Step 185. 스택 완전 이해 — 함수 호출 시 스택에 쌓이는 것들의 완전한 지도

Step 185. 스택 완전 이해 — 함수 호출 시 스택에 쌓이는 것들의 완전한 지도

Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 4시간

전제: Step 184를 마쳤다. call이 리턴 주소를 스택에 남긴다는 것을 gdb로 확인했고, disasx/gx를 쓸 수 있다.

⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.

  • 준비물: WSL 우분투 터미널, gcc, gdb. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
  • 주의: 오늘 등장하는 gets는 실무에서 절대 쓰면 안 되는 함수입니다. 컴파일러가 경고까지 띄우는 이유를 오늘 몸으로 배웁니다. 일부러 취약한 코드를 만드는 것은 관찰용 실험실에서만입니다.

Step 184에서 우리는 call이 리턴 주소를 스택에 남기는 것을 봤습니다. 그런데 스택에는 리턴 주소만 있는 게 아닙니다. 지역 변수, 이전 함수의 기준점, 그리고 그 리턴 주소까지 — 이 모든 것이 정해진 순서로 나란히 쌓입니다. 오늘은 그 배치도를 완성합니다. char buf[16]에 스물네 바이트를 넘어 서른두 바이트를 쓰면 무슨 일이 벌어지는지, 버퍼에서 리턴 주소까지가 몇 바이트인지를 자로 잰 것처럼 정확히 재는 날입니다. 이 지도 하나가 버퍼 오버플로우 공격과 방어의 전부입니다.


1. 학습 목표

이 챕터를 끝내면 다음을 할 수 있습니다:

  • 함수 호출 시 스택에 쌓이는 것들의 순서(지역 변수 → saved rbp → RET)를 그린다
  • gdb에서 rbp와 rsp의 관계, 지역 변수의 위치(rbp 기준 오프셋)를 읽는다
  • $rbp+8에 리턴 주소가 있다는 것을 확인하고 값의 정체를 대조한다
  • buf에서 RET까지의 거리(패딩 길이)를 계산한다
  • 긴 입력으로 스택이 채워지는 장면을 관찰하고 어느 바이트가 어디에 닿는지 지목한다

2. 배경 지식 — 오늘의 도구와 개념

오늘의 도구 한눈에 보기

구분 내용
언어·환경 C 언어, WSL 우분투 bash, gcc 13.3.0, gdb 15.1 (x86-64)
오늘의 명령어·옵션 gcc -g -O0 -fno-stack-protector -no-pie(관찰용 컴파일), gdb 안에서 info registers rbp rsp, p/x $rbp - (long)buf(거리 계산), x/2gx $rbp(기준점 주변 보기), info symbol 주소(주소의 이름 찾기), b *주소(주소로 멈춤)
필요한 개념 스택 프레임, saved rbp(SFP), RET(리턴 주소), 스택의 성장 방향, gets의 위험성

2-1. 스택 프레임 — 함수마다 하나씩 쌓이는 층

함수가 호출될 때마다 스택에는 그 함수의 작업 공간인 스택 프레임(stack frame)이 하나씩 쌓입니다. 프레임에는 지역 변수와 버퍼가 들어가고, 함수가 끝나면 그 층이 통째로 걷힙니다. Step 60에서 배운 "함수의 임시 서랍"의 실물입니다.

문제는 이 층에 함수의 데이터만 있는 게 아니라, 제어 정보(돌아갈 주소)도 함께 있다는 것입니다. 데이터와 제어가 한 칸씩 이웃해 삽니다.

2-2. 스택의 성장 방향 — 아래로 자라는 탑

x86-64에서 스택은 높은 주소에서 낮은 주소로 자랍니다. 뭔가 쌓이면(push) rsp는 작아지고, 빠지면(pop) 커집니다. 처음엔 "거꾸로" 느껴지는 게 정상입니다.

그런데 버퍼 안에서의 쓰기 방향은 반대입니다. buf[0], buf[1] … 순으로 쓸 때 주소는 커지는 쪽으로 갑니다. 이 두 방향의 교차 — 스택은 아래로 자라는데 버퍼 쓰기는 위로 번진다 — 가 오버플로우가 "버퍼를 넘어 프레임의 윗부분을 덮는" 사고로 이어지는 이유입니다.

2-3. 프레임의 배치도 — 오늘의 주인공 그림

함수 하나의 프레임은 높은 주소(위)에서 낮은 주소(아래)로 이렇게 생겼습니다.

높은 주소 ┌──────────────────┐
          │  RET (리턴 주소)  │ ← rbp + 8 : call이 남긴 쪽지
          ├──────────────────┤
          │  saved rbp (SFP) │ ← rbp     : 이전 함수의 기준점
          ├──────────────────┤
          │  지역 변수/버퍼   │ ← rbp - N : buf는 여기
낮은 주소 └──────────────────┘ ← rsp (꼭대기)

saved rbp(SFP)는 함수가 시작될 때 push %rbp로 저장하는 "이전 함수의 rbp"입니다. 현재 rbp는 이 값을 가리키고, 리턴 주소는 그 위칸(rbp + 8)에 있습니다. 그래서 rbp 하나만 알면 프레임의 모든 것의 위치가 계산됩니다.

버퍼에서 위로 쓰기가 번지면(오버플로우): 지역 변수 영역 → saved rbp → RET 순으로 덮습니다. RET를 덮으면, 함수가 ret하는 순간 rip는 내가 쓴 값으로 갑니다. 오늘 배우는 거리 계산이 내일의 공격 설계도입니다.

2-4. gets — 상한이 없는 입력 함수

gets(buf)는 엔터를 칠 때까지 입력을 길이 제한 없이 buf에 씁니다. 버퍼가 몇 칸인지 묻지도 않습니다. 그래서 C11 표준에서 폐기됐고, 링커가 "위험하다"는 경고를 띄우지만, 아직 실행은 됩니다.

오늘 우리는 일부러 이 폐기된 함수를 씁니다. 경계 없는 쓰기가 스택 지도를 어떻게 유린하는지 관찰하기 위해서입니다.


3. 따라 하기

3-1. 실험용 프로그램 — 16칸 버퍼와 gets

입력 (stack.c)

#include <stdio.h>

void f(void) {
    char buf[16];
    printf("buf 주소: %p\n", (void *)buf);
    printf("문자열 입력: ");
    fflush(stdout);
    gets(buf);
    printf("입력받은 값: %s\n", buf);
}

int main(void) {
    f();
    printf("무사히 돌아옴\n");
    return 0;
}

컴파일

gcc -g -O0 -fno-stack-protector -no-pie stack.c -o stack
stack.c: In function ‘f’:
stack.c:8:5: warning: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
/usr/bin/ld: /root/lab185/stack.c:8:(.text+0x57): warning: the `gets' function is dangerous and should not be used.

(2026-09-09 실측.)

출력 읽는 법: 경고 두 개가 뜨지만 바이너리는 만들어집니다. 특히 두 번째 — 링커가 직접 "이 함수는 위험하다, 쓰지 마라"라고 말하는 장면을 기억하세요. 도구가 이렇게까지 말리는 함수는 드뭅니다. 오늘은 그 이유를 증명하는 날입니다. -fno-stack-protector는 Step 62에서 만난 넘침 감시 장치를 끄는 것(관찰용), -no-pie는 주소 고정입니다.

3-2. f의 프레임 구조 — disas로 읽기

gdb -q ./stack
(gdb) b f
Breakpoint 1 at 0x4011a2: file stack.c, line 5.
(gdb) r
Starting program: .../stack

Breakpoint 1, f () at stack.c:5
5	    printf("buf 주소: %p\n", (void *)buf);
(gdb) disas f
Dump of assembler code for function f:
   0x0000000000401196 <+0>:	endbr64
   0x000000000040119a <+4>:	push   %rbp
   0x000000000040119b <+5>:	mov    %rsp,%rbp
   0x000000000040119e <+8>:	sub    $0x10,%rsp
=> 0x00000000004011a2 <+12>:	lea    -0x10(%rbp),%rax
   ...
   0x00000000004011ec <+86>:	call   0x401090 <gets@plt>
   ...
   0x000000000040120d <+119>:	leave
   0x000000000040120e <+120>:	ret

(2026-09-09 실측.)

출력 읽는 법: 함수의 첫 세 명령이 프레임을 짓는 공사입니다.

  • push %rbp — 이전 함수(main)의 rbp를 스택에 저장. 이것이 saved rbp입니다.
  • mov %rsp,%rbp — 지금의 rsp를 새 기준점(rbp)으로. 여기서 rbp가 태어납니다.
  • sub $0x10,%rsp — 0x10 = 16바이트를 내립니다. buf[16]의 자리입니다.
  • lea -0x10(%rbp),%raxbuf의 주소 = rbp – 0x10. 어셈블리가 직접 알려 줍니다.

3-3. 지도에 숫자 입히기 — rbp, rsp, 그리고 RET의 정체

멈춰 있는 상태에서 네 걸음(ni ×4) 더 가서 프레임 공사가 끝난 뒤를 봅니다.

(gdb) info registers rbp rsp
rbp            0x7fffffffe670      0x7fffffffe670
rsp            0x7fffffffe660      0x7fffffffe660
(gdb) p/x $rbp - (long)buf
$1 = 0x10
(gdb) x/2gx $rbp
0x7fffffffe670:	0x00007fffffffe680	0x000000000040121c
(gdb) info symbol *(long*)($rbp+8)
main + 13 in section .text of /root/lab185/stack

(2026-09-09 실측. 0x7fff... 주소는 실행마다 달라집니다. p/x는 gdb 안의 계산기 명령입니다.)

출력 읽는 법: 지도의 모든 좌표가 채워졌습니다.

  • rbp = 0x7fffffffe670, rsp = 0x7fffffffe660 — 차이는 0x10(16). 프레임의 크기입니다.
  • buf = rbp – 0x10 = 0x7fffffffe660 — buf가 프레임의 맨 아래(rsp와 같은 곳)에 있습니다.
  • x/2gx $rbp의 첫 칸 0x7fffffffe680 — saved rbp, main의 기준점입니다.
  • 두 번째 칸 0x40121c — 이것이 RET, f가 끝나면 돌아갈 주소입니다. info symbol이 이름을 확인해 줍니다: main + 13.

그리고 main의 disas를 보면 call f가 0x401217(main+8)에, 그 다음 명령이 0x40121c(main+13)에 있습니다 (2026-09-09 실측). Step 184의 규칙 — "리턴 주소 = call 다음 명령" — 이 여기서도 정확합니다.

3-4. 거리 계산 — 오늘의 핵심 숫자

이제 버퍼에서 RET까지의 거리를 셉니다. 그림으로 정리하면:

주소              내용
0x7fffffffe678    RET (main+13 = 0x40121c)   ← rbp + 8
0x7fffffffe670    saved rbp (0x7fffffffe680) ← rbp
0x7fffffffe660    buf 시작 (16바이트)          ← rbp - 0x10

buf 시작(0x…660)에서 RET 칸(0x…678)까지: 0x678 – 0x660 = 0x18 = 24바이트.

16바이트(buf) + 8바이트(saved rbp) = 24. 입력 24바이트까지는 프레임의 데이터만 덮고, 25번째 바이트부터가 RET를 덮기 시작합니다. 이 "24"가 공격 설계에서 말하는 패딩(padding) 길이입니다. 거리는 컴파일러와 옵션에 따라 달라지므로, 실전에서는 매번 이렇게 gdb로 잽니다.

예측: A를 32개 넣으면 buf(16) + saved rbp(8) + RET(8)이 전부 0x41로 덮일 겁니다. 맞을까요? 다음 절에서 확인합니다.

3-5. 채워지는 스택 — 0x41의 범람 관찰

gets가 끝난 직후(주소 0x4011f1, disas에서 call <gets@plt> 다음 명령)에 멈춤을 걸고, A 서른두 개를 넣어 봅니다.

python3 -c 'print("A"*32)' > input32.txt
gdb -q ./stack
(gdb) b *0x4011f1
Breakpoint 1 at 0x4011f1: file stack.c, line 9.
(gdb) r < input32.txt
...
Breakpoint 1, f () at stack.c:9
9	    printf("입력받은 값: %s\n", buf);
(gdb) x/6gx $rbp-0x10
0x7fffffffe660:	0x4141414141414141	0x4141414141414141
0x7fffffffe670:	0x4141414141414141	0x4141414141414141
0x7fffffffe680:	0x00007fffffffe700	0x00007ffff7c2a1ca
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x000000000040120e in f () at stack.c:10
10	}

(2026-09-09 실측. 멈춤 주소는 여러분의 disas 결과로 맞추세요.)

출력 읽는 법: 예측 그대로입니다. buf 두 칸(0x660, 0x668), saved rbp 칸(0x670), RET 칸(0x678) 까지 네 칸 전부 0x4141414141414141 — ‘A’ 여덟 개씩. 그리고 c로 계속 달리자 f는 ret하는 순간 죽었습니다. ret이 스택 맨 위에서 꺼낸 값이 0x4141414141414141이었고, 그런 주소는 이 프로그램에 존재하지 않으니까요.

멈춘 곳이 f+120, 즉 ret 명령 그 자체라는 것에 주목하세요. 흉기(A 서른두 개)와 치명상(RET 칸)과 사망 지점(ret)이 한 화면에 다 있습니다.

: 방금 본 것이 버퍼 오버플로우의 완전한 메커니즘입니다. "버퍼에서 24바이트를 채우고, 그다음 8바이트가 RET를 덮는다." 만약 그 8바이트가 0x4141414141414141이 아니라 존재하는 주소라면 — 프로그램은 죽는 대신 그 주소로 갑니다. 이 문장이 Step 186의 전부입니다.


4. 미션과 연습문제

미션 — 스택 지도 정밀 측량

stack.c의 buf를 char buf[32]로 바꾸고, 지역 변수 int token = 777;을 buf 위에 추가한 f2 함수를 만드세요 (프로토타입: void f2(void), main에서 f2 호출).

  1. gcc -g -O0 -fno-stack-protector -no-pie로 컴파일하고 disas f2에서 sub $0x..,%rsp 값과 buf의 rbp 오프셋을 읽어 기록한다
  2. f2에 멈춰 rbp, rsp, buf의 주소를 기록하고 $rbp - buf를 계산한다
  3. x/2gx $rbpinfo symbol *(long*)($rbp+8)로 saved rbp와 RET의 정체를 확인한다
  4. buf에서 RET까지의 거리(패딩 길이)를 계산한다
  5. 높은 주소가 위로 오게 스택 지도를 그리고, RET / saved rbp / token / buf 네 칸의 주소와 내용을 채운다
  6. token이 buf보다 위에 있는지 아래에 있는지, 그리고 그 이유(선언 순서? 컴파일러 판단?)를 조사해 한 줄로 적는다

연습문제

문제 1. x86-64에서 스택이 "아래로 자란다"는 말의 뜻을 rsp의 변화로 설명해 보세요. 그런데 오버플로우는 왜 buf를 넘어 위쪽(saved rbp, RET)을 덮나요?

문제 2. f의 프레임에서 RET는 rbp + 8에 있었습니다. 왜 하필 +8인가요? rbp 자리(rbp + 0)에는 무엇이 있나요?

문제 3. 3-4의 계산에서 패딩 길이는 24였습니다. buf가 char buf[24]였다면 패딩은 얼마가 될까요? (구조가 같다고 가정)

문제 4. 3-5에서 프로그램이 죽은 곳은 ret 명령이었습니다. "gets에서 죽지 않고 ret에서 죽는" 이유를 설명해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

측량 기록의 예 (2026-09-09, Ubuntu 24.04, gcc 13.3.0, -g -O0 -fno-stack-protector -no-pie 기준):

[disas f2]  sub $0x30,%rsp   (buf 32 + token 4 + 정렬 패딩)
            buf = lea -0x30(%rbp) 근처

[f2 진입 후]  rbp = 0x7fffffffe650, rsp = 0x7fffffffe620
             buf = rbp - 0x30

[saved rbp]  x/2gx $rbp 첫 칸 = 이전(main) rbp 값
[RET]        x/2gx $rbp 둘째 칸 = main의 call 다음 주소 → info symbol로 "main + N" 확인

[패딩 계산]  0x30 + 0x8 = 0x38 = 56 바이트
            (buf 32 + saved rbp 8 ... token의 위치는 컴파일러 배치에 따름)

[지도] 높은 주소 → RET(rbp+8) / saved rbp(rbp) / token / buf 순으로 기록

검증하는 법: ① 패딩 계산의 근거(버퍼 크기 + saved rbp 8)가 명시되어 있어야 합니다. ② RET 칸의 값이 info symbol로 "main + N"으로 확인되어야 합니다. ③ 지도의 주소가 위로 갈수록 커지게 그려져 있어야 합니다. 숫자가 책의 예와 달라도, 계산 과정과 대조 방법이 맞으면 정답입니다.

연습문제 해답

문제 1 해답. push나 sub가 일어나면 rsp는 작아집니다 — 낮은 주소 쪽으로 공간이 생기니 "아래로 자란다"입니다. 반면 buf에 대한 쓰기는 buf[0] → buf[1] → … 순으로 주소가 커지는 쪽으로 진행됩니다. 프레임에서 buf보다 높은 주소에 saved rbp와 RET가 있으므로, 상한 없는 쓰기는 buf를 넘어 그것들을 차례로 덮습니다.

문제 2 해답. rbp + 0 자리에는 saved rbp(이전 함수의 rbp)가 있고, 함수를 부른 call이 남긴 리턴 주소는 그 위칸에 쌓이기 때문입니다. 64비트 환경에서 주소는 8바이트이므로 saved rbp 한 칸(8)을 건너뛴 rbp + 8이 RET의 자리입니다.

문제 3 해답. 24(buf) + 8(saved rbp) = 32바이트입니다. 단, 실제로는 컴파일러가 정렬을 위해 프레임을 16바이트 단위로 맞추는 경우가 많아 gdb로 확인해야 정확합니다. "구조식으로 추정하고 gdb로 검증한다"가 정석입니다.

문제 4 해답. gets는 그저 스택에 바이트를 쓸 뿐이고, 엉뚱한 곳에 "쓰는" 것 자체는 즉시 사고가 아니기 때문입니다. 사고는 그 값을 "읽어 쓰는" 순간에 일어납니다. ret가 덮인 리턴 주소를 꺼내 rip에 넣고 점프하려는 그 순간 — 0x4141414141414141은 실행 가능한 주소가 아니므로 여기서 세그폴트가 납니다. 덮는 것과 죽는 것의 시차가 중요합니다.

완료 기준 체크리스트

  • [ ] 스택 프레임의 배치(지역 변수 → saved rbp → RET)를 백지에 그릴 수 있다
  • [ ] 스택이 낮은 주소로 자라고, 버퍼 쓰기는 높은 주소로 번진다는 것을 설명할 수 있다
  • [ ] gdb에서 buf의 위치(rbp 오프셋)를 disas로 읽을 수 있다
  • [ ] $rbp+8의 RET 값을 info symbol로 정체 확인까지 해 봤다
  • [ ] 패딩 길이(버퍼 크기 + 8)를 계산하고 gdb 측정과 대조했다
  • [ ] 32바이트 입력으로 RET 칸이 0x41로 덮이는 장면을 관찰했다
  • [ ] gets의 위험성과 컴파일러·링커 경고의 의미를 설명할 수 있다
  • [ ] 미션: buf[32] + token 버전의 스택 지도를 완성했다

6. 흔한 실수와 해결

벽 1. gets가 선언되지 않았다는 경고가 나온다

증상: 컴파일 시 이런 경고가 뜹니다 (2026-09-09 실측):

warning: implicit declaration of function ‘gets’; did you mean ‘fgets’?
warning: the `gets' function is dangerous and should not be used.

원인: 정상입니다. gets는 C11에서 표준 헤더(stdio.h) 선언이 빠졌지만 라이브러리에는 남아 있어, 경고와 함께 실행 파일이 만들어집니다.
해결: 경고가 아니라 error면서 바이너리가 안 만들어졌다면 그때만 원인을 찾으세요. 오늘 실습에서는 이 경고 자체가 수업 내용의 일부입니다.

벽 2. 주소 방향이 머릿속에서 뒤집힌다

증상: "buf가 RET보다 위인가 아래인가"가 계속 헷갈립니다.
원인: 스택 그림의 위아래와 주소의 대소가 반대라 뇌가 혼란을 겪습니다. 처음엔 누구나 그렇습니다.
해결: 숫자로만 생각하세요. buf = 0x…660, RET = 0x…678. 큰 숫자가 위입니다. 그리고 쓰기는 작은 숫자에서 큰 숫자로 번집니다. 이 두 문장이면 방향 싸움은 끝납니다.

벽 3. 패딩 길이가 계산과 다르다

증상: buf[16]이니 16 + 8 = 24일 줄 알았는데, 덮어 보니 다른 길이에서 반응합니다.
원인: 컴파일러가 정렬을 위해 프레임에 패딩을 끼우거나 변수 배치를 바꿀 수 있습니다. 카나리(보호 장치)가 켜져 있으면 구조 자체가 달라집니다.
해결: 그래서 실전에서는 추정이 아니라 측정입니다. -fno-stack-protector 확인 → disassub $0x..,%rsp와 buf의 오프셋 확인 → x/2gx $rbp로 RET 위치 확인. 이 세 걸음이면 어떤 환경에서도 거리가 잡힙니다.

벽 4. x/2gx $rbp의 두 칸 중 뭐가 뭔지 모르겠다

증상: 0x00007fffffffe680과 0x000000000040121c 중 어느 것이 RET인지 헷갈립니다.
원인: 둘 다 "주소처럼" 생겼지만 대역이 다릅니다.
해결: 스택 주소는 0x7fff…로 시작하고, 코드 주소는(-no-pie 기준) 0x40…입니다. 0x40으로 시작하는 둘째 칸이 RET입니다. 확실하게 하려면 info symbol 0x40121c — "main + 13"처럼 함수 이름이 나오면 코드 주소, 즉 RET가 맞습니다.

*벽 5. b 주소를 찍었는데 멈추지 않는다

증상: gets 직후에 멈추려고 책의 주소로 b를 걸었는데 그냥 지나칩니다.
원인: 책의 주소(0x4011f1)는 실측 환경의 것입니다. 소스가 조금이라도 다르면 주소가 밀립니다.
해결: 반드시 내 환경에서 disas f를 먼저 하고, call <gets@plt> 바로 다음 줄의 주소를 읽어 b *그주소로 찍으세요.


7. 정리

오늘의 개념

개념 한 줄 설명
스택 프레임 함수마다 쌓이는 작업 공간 — 데이터와 제어 정보의 공동 주택
saved rbp (SFP) 이전 함수의 기준점 — rbp + 0 자리의 주민
RET (리턴 주소) 돌아갈 곳의 쪽지 — rbp + 8 자리의 주민, 공격의 표적
스택의 성장 방향 push되면 rsp가 작아진다 — 아래로 자라는 탑
패딩 buf에서 RET까지의 거리 = 버퍼 크기 + 8 (환경마다 측정 필수)
gets 상한 없는 입력 함수 — 지도 유린의 도구, 실무 금지

오늘의 명령어

명령 하는 일
gcc -g -O0 -fno-stack-protector -no-pie 프레임 관찰용 컴파일
info registers rbp rsp 프레임의 위와 아래 좌표
p/x $rbp - (long)buf gdb 안에서 거리 계산
x/2gx $rbp saved rbp와 RET 두 칸 보기
info symbol 주소 주소의 정체(함수 이름) 찾기
x/6gx $rbp-0x10 프레임 전체를 한눈에 보기

명령어보다 중요한 감각

오늘 여러분의 노트에는 지도 한 장이 남았을 겁니다. buf, saved rbp, RET — 세 칸의 상자와 24라는 숫자. 이 지도는 단순해 보이지만, 앞으로 Pwn의 모든 챕터가 이 그림 위에서 놀게 됩니다. 공격자도 방어자도 같은 지도를 봅니다. 공격자는 "24바이트 채우고 주소를 쓰면 된다"를 보고, 방어자는 "저 쪽지를 지키려면 어디에 경비를 세울까"를 봅니다.

그리고 잊지 마세요. 이 거리 24는 자연법칙이 아니라 오늘 내 컴파일러의 측정값입니다. 실전에서 손이 가는 순서는 언제나 같습니다: disas로 구조를 읽고, gdb로 재고, 그 숫자로 설계한다.


전부 체크되면 Step 185 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.