Step 185. 스택 완전 이해 — 함수 호출 시 스택에 쌓이는 것들의 완전한 지도
Level 3 — Pwn 트랙 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 184를 마쳤다. call이 리턴 주소를 스택에 남긴다는 것을 gdb로 확인했고,
disas와x/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),%rax— buf의 주소 = 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 호출).
gcc -g -O0 -fno-stack-protector -no-pie로 컴파일하고disas f2에서sub $0x..,%rsp값과 buf의 rbp 오프셋을 읽어 기록한다- f2에 멈춰 rbp, rsp, buf의 주소를 기록하고
$rbp - buf를 계산한다 x/2gx $rbp와info symbol *(long*)($rbp+8)로 saved rbp와 RET의 정체를 확인한다- buf에서 RET까지의 거리(패딩 길이)를 계산한다
- 높은 주소가 위로 오게 스택 지도를 그리고, RET / saved rbp / token / buf 네 칸의 주소와 내용을 채운다
- 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 확인 → disas로 sub $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 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.