Step 215. Ghidra 심화: 함수 분석, 구조체 복원 — 기계가 지은 이름을 사람의 언어로
Level 3 — Reversing 트랙 | 난이도 ★★★☆☆ | 예상 소요 시간 5시간
전제: Step 178(CTF 맛보기 Reversing)을 마쳤다. strings, objdump, gdb로 crackme를 풀어 봤고, Ghidra가 무엇을 보여 주는 도구인지 안다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 분석 대상은 직접 컴파일한 연습용 바이너리입니다.
- 준비물: WSL 우분투 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1). Ghidra는 이 환경에 설치돼 있지 않으므로 화면 예시로 안내하고, 같은 원리를 objdump·gdb로 실측합니다. Ghidra를 설치했다면 화면 예시를 그대로 따라 할 수 있습니다.
- 주의: 스트립된 바이너리(심볼 없음)도 다룹니다. 주소는 여러분의 환경과 다를 수 있으며, 절차가 같으면 됩니다.
Step 178에서 Ghidra를 처음 열어 디컴파일 결과를 "읽어" 봤습니다. 실전 리버싱은 읽기가 아니라 정리입니다. FUN_00101169, param_1, local_48 같은 기계 이름을 의미 있는 이름으로 바꾸고, 변수의 타입을 고치고, 바이트 오프셋의 나열에서 구조체를 복원해 내는 작업 — 이 워크플로우가 몸에 배면 분석 속도가 배가 됩니다. 오늘은 디컴파일러를 "보는 도구"에서 "생각하는 책상"으로 바꾸는 날입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- Ghidra에서 함수 이름 바꾸기(L)와 시그니처 편집(Edit Function Signature)을 한다
param_1 + 0x14같은 오프셋 접근 패턴에서 구조체 필드를 추론한다- 추론한 구조체를 Ghidra의 Data Type으로 만들어 변수에 적용한다
- 디컴파일이 이상할 때 어셈블리 뷰로 돌아가 진실을 확인한다
- 같은 바이너리를 objdump/gdb와 대조해 디컴파일러의 출력을 검증한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C(분석 대상 제작용) + WSL 우분투 bash (실측: gcc 13.3.0, gdb 15.1), Ghidra는 화면 예시 |
| 오늘의 명령어·키 | objdump -d, strip, nm, gdb disassemble / Ghidra: L(이름 바꾸기), Ctrl+Shift+E 또는 우클릭(Edit Function Signature), Data Type Manager |
| 필요한 개념 | 심볼과 스트립, 호출 규약(rdi = 첫 인자), 구조체 필드 오프셋, 디컴파일의 한계 |
| 오늘의 산출물 | 구조체 실습 바이너리의 "복원 노트" — 함수 이름표 + 구조체 설계도 |
2-1. 디컴파일러는 추측기다
디컴파일러는 기계어에서 C 코드를 "복원"하지만, 이것은 번역이 아니라 추측입니다. 변수의 타입, 함수의 인자 개수, 배열과 포인터의 구분은 원본에 없는 정보입니다 — 컴파일 과정에서 사라졌기 때문입니다. 그래서 디컴파일러는 undefined4, param_1 같은 중립적 이름을 붙여 둡니다.
분석가의 일은 이 추측을 교정하는 것입니다. "이 변수는 매번 4바이트 정수처럼 쓰이네", "이 포인터는 항상 +0x14를 더해 쓰네" — 관찰을 이름과 타입으로 고정해 가는 작업이 리버싱의 본체입니다.
2-2. 심볼과 스트립 — 이름이 있는 세계와 없는 세계
심볼(symbol)은 함수·변수의 이름표입니다. 개발자가 컴파일하면 남지만, 배포용 바이너리는 대부분 스트립(strip) — 이름표를 뜯어 냅니다. 스트립된 바이너리에서 Ghidra는 함수를 주소로 부릅니다: FUN_00101169.
오늘 실습에서 같은 바이너리의 "심볼 있음/없음" 두 판을 만들어 비교합니다. 실전에서 만날 모습은 후자입니다.
2-3. 구조체 복원의 단서 — 고정 오프셋
C의 구조체 필드는 베이스 주소 + 고정 오프셋으로 접근됩니다. p->level은 컴파일 후 0x14(%rdi) 같은 기계어가 됩니다. 반대로, 어셈블리에서 "같은 포인터에 대해 반복적으로 등장하는 오프셋들"을 모으면 구조체의 설계도가 됩니다.
0x14(%rdi)에 4바이트 덧셈 → 오프셋 0x14에 4바이트 정수 필드0x18(%rdi)에 8바이트 뺄셈 → 오프셋 0x18에 8바이트 정수 필드0x4(%rdi)를 문자열 함수에 전달 → 오프셋 0x4에 문자 배열
이것이 구조체 복원의 전부입니다. 도구가 달라도(objdump든 Ghidra든) 읽는 것은 같은 오프셋입니다.
2-4. Ghidra의 세 작업대
화면 예시로 미리 봅니다. ① Symbol Tree(왼쪽) — 함수·레이블 목록. ② Listing(가운데) — 어셈블리. ③ Decompile(오른쪽) — 의사 C 코드. 이름 바꾸기는 어느 창에서든 대상을 고르고 L. 시그니처 편집은 Decompile 창에서 함수명 우클릭 → Edit Function Signature. 구조체는 Data Type Manager 창에서 우클릭 → New → Structure.
중요한 습관: Listing과 Decompile은 항상 함께 봅니다. 의사 코드가 이상하면 진실은 어셈블리에 있습니다.
2-5. 왜 정리에 시간을 쓰는가
이름을 바꾸는 작업은 겉치레가 아닙니다. FUN_00101169를 level_up으로 바꾸는 순간, 그 함수를 부르는 모든 곳의 의사 코드가 같이 읽히기 시작합니다. 정리는 복리로 쌓입니다 — 큰 바이너리일수록 처음 한 시간의 정리가 나중 열 시간을 줄입니다.
3. 따라 하기
3-1. 실험실 — 구조체를 쓰는 프로그램
분석 대상을 직접 만듭니다. 출제자의 눈으로 구조를 알고 만든 뒤, 분석가의 눈으로 잊는 훈련입니다.
입력 (structlab.c)
#include <stdio.h>
#include <string.h>
struct player {
int id; /* +0x00 */
char name[16]; /* +0x04 */
int level; /* +0x14 */
long gold; /* +0x18 */
};
void level_up(struct player *p) {
p->level += 1;
p->gold -= 100;
}
void print_player(struct player *p) {
printf("id=%d name=%s level=%d gold=%ld\n",
p->id, p->name, p->level, p->gold);
}
int main(void) {
struct player p = {7, "daimon", 3, 5000};
level_up(&p);
print_player(&p);
return 0;
}
컴파일 — 두 판을 만듭니다:
cd ~/lab214_218
gcc -O1 -o structlab structlab.c
strip -o structlab_stripped structlab
./structlab
id=7 name=daimon level=4 gold=4900
(2026-09-09 실측.)
strip은 심볼을 뜯은 사본을 만듭니다. 확인해 보면:
nm structlab_stripped
nm: structlab_stripped: no symbols
(2026-09-09 실측.) 이름표가 전부 사라졌습니다. 실전에서 받는 바이너리의 모습입니다.
3-2. 분석가의 눈 — 오프셋에서 구조체 읽기 (objdump 실측)
이제 소스를 잊고, 스트립된 쪽을 봅니다. 함수 이름이 없으니 주소로 찾습니다 — 스트립 전 바이너리와 대조해 가며 읽는 연습을 겸합니다.
objdump -d structlab | sed -n "/<level_up>:/,/ret/p"
0000000000001169 <level_up>:
1169: f3 0f 1e fa endbr64
116d: 83 47 14 01 addl $0x1,0x14(%rdi)
1171: 48 83 6f 18 64 subq $0x64,0x18(%rdi)
1176: c3 ret
(2026-09-09 실측.)
출력 읽는 법 — 심볼이 없었다면 이 함수는 FUN_00101169입니다. 내용을 읽습니다:
addl $0x1, 0x14(%rdi)— 첫 인자(rdi)가 가리키는 곳의 +0x14에 있는 4바이트(addl = long, 32비트) 정수에 1을 더합니다.subq $0x64, 0x18(%rdi)— +0x18의 8바이트(subq = quad, 64비트) 정수에서 100(0x64)을 뺍니다.
이 함수 하나만으로 구조체의 두 필드가 드러났습니다. 이름을 지어 봅시다: "+0x14에 1을 더하는 함수" — 레벨업처럼 보입니다.
3-3. 두 번째 함수 — 필드 지도 완성 (objdump 실측)
objdump -d structlab | sed -n "/<print_player>:/,/ret/p"
0000000000001177 <print_player>:
117f: 48 8d 4f 04 lea 0x4(%rdi),%rcx
1183: 8b 17 mov (%rdi),%edx
1185: 4c 8b 4f 18 mov 0x18(%rdi),%r9
1189: 44 8b 47 14 mov 0x14(%rdi),%r8d
118d: 48 8d 35 74 0e 00 00 lea 0xe74(%rip),%rsi # 2008
119e: e8 cd fe ff ff call 1070 <__printf_chk@plt>
(2026-09-09 실측.)
출력 읽는 법: 같은 포인터(rdi)에서 네 필드를 읽어 printf에 나열합니다.
| 오프셋 | 접근 | 추론 |
|---|---|---|
| +0x00 | mov (%rdi),%edx — 4바이트 |
int 필드 (id?) |
| +0x04 | lea — 주소를 넘김 |
문자 배열 (name? 포인터를 넘기니까) |
| +0x14 | 4바이트 | int (3-2에서 1 증가하던 그 자리 — level) |
| +0x18 | 8바이트 | long (100을 빼던 자리 — gold) |
+0x04에서 +0x14까지의 간격은 16바이트 — name[16]이 들어갈 자리입니다. 두 함수의 관찰을 합치면 구조체 설계도가 완성됩니다. 원본 소스와 정확히 일치합니다 — 여러분은 방금 구조체 복원을 손으로 한 것입니다.
3-4. 같은 일을 Ghidra로 — 화면 예시
이 환경에는 Ghidra가 없어 화면 예시로 안내합니다. structlab_stripped를 Import하고 분석(Analyze)하면, Symbol Tree의 Functions에 주소 이름들이 나옵니다. FUN_00101169를 더블클릭하면 Decompile 창에 대략 이런 의사 코드가 뜹니다:
# 화면 예시 — Ghidra Decompile 창 (FUN_00101169)
void FUN_00101169(long *param_1)
{
*(int *)(param_1 + 2) = *(int *)(param_1 + 2) + 1; /* +0x14 = long[2] */
param_1[3] = param_1[3] - 100; /* +0x18 = long[3] */
return;
}
읽는 법: 디컴파일러는 param_1을 long *로 추측했습니다. +0x14를 param_1 + 2(long 기준 2×8=16=0x10이 아니라… 정확히는 바이트 오프셋 환산 결과)로, +0x18을 param_1[3]으로 표현했네요. 읽기 어색합니다 — 타입 추측이 틀렸기 때문입니다. 진실은 Listing 창의 어셈블리(addl $0x1,0x14(%rdi))에 있고, 3-2에서 우리는 이미 진실을 알고 있습니다.
3-5. 정리 워크플로우 — 화면 예시
이제 추측을 교정하는 네 동작입니다:
# 화면 예시 — Ghidra 정리 절차
1. 함수 이름: FUN_00101169 선택 → L 키 → level_up 으로 변경
FUN_00101177 → print_player 로 변경
2. 시그니처: Decompile 창에서 level_up 우클릭 → Edit Function Signature
→ 인자 타입을 struct player * 로 바꿀 준비
3. 구조체 생성: Data Type Manager 창 우클릭 → New → Structure
→ 필드 추가: +0x00 int id / +0x04 char name[16] / +0x14 int level / +0x18 long gold
(오프셋은 3-2, 3-3의 objdump 실측과 같은 값)
4. 타입 적용: Edit Function Signature에서 param_1의 타입을 player * 로 지정
타입을 적용하는 순간 Decompile 창이 이렇게 바뀝니다:
# 화면 예시 — 구조체 적용 후
void level_up(player *p)
{
p->level = p->level + 1;
p->gold = p->gold - 100;
return;
}
기계어 한 줄도 바뀌지 않았는데, 코드가 사람의 언어가 됐습니다. 이것이 정리의 힘입니다. 그리고 이 결과가 3-1의 원본 소스와 같다는 것 — 복원이 정확했다는 증거입니다.
3-6. 디컴파일이 틀릴 때 — 어셈블리가 진실
3-4에서 봤듯 디컴파일러는 타입을 틀립니다. 판단 기준은 하나입니다: 타입을 바꿨는데 코드가 더 이상해지면, 그 타입이 아닙니다. 반대로 바꾸자마자 사방의 코드가 깔끔해지면 맞습니다.
의심스러울 때의 확인 절차: Decompile 창에서 이상한 줄을 클릭하면 Listing 창의 해당 어셈블리로 커서가 같이 움직입니다. 어셈블리의 opcode(addl이냐 addq냐, mov냐 lea냐)가 타입의 진실을 말해 줍니다. l은 4바이트, q는 8바이트, lea는 "주소를 계산" — 이 세 힌트만으로 오늘 구조체의 전부를 읽었습니다.
3-7. 복원 노트 — 산출물 정리
| 주소 | 기계 이름 | 붙인 이름 | 근거 |
|---|---|---|---|
| 0x1169 | FUN_00101169 | level_up | +0x14 증가, +0x18 감소 |
| 0x1177 | FUN_00101177 | print_player | 4필드를 printf로 출력 |
| 오프셋 | 크기 | 필드명 | 근거 |
|---|---|---|---|
| +0x00 | 4 | id | mov (%rdi) — 4바이트 읽기 |
| +0x04 | 16 | name | lea로 주소 전달 — 문자 배열 |
| +0x14 | 4 | level | addl $0x1 — 1 증가 |
| +0x18 | 8 | gold | subq $0x64 — 100 감소 |
이 두 표가 오늘의 산출물, "복원 노트"입니다. 실전에서는 이 노트가 수십 함수·수십 필드로 커집니다.
4. 미션과 연습문제
미션 — 미지의 함수 하나를 C 수준으로 복원
- structlab.c에 함수를 하나 추가합니다 — 예:
void buy_item(struct player *p, int price) { if (p->gold >= price) p->gold -= price; }(필드 구성은 바꾸지 마세요) - 다시 컴파일하고 스트립합니다
- 심볼 없는 상태에서 새 함수를 찾아 objdump로 읽고, 하는 일을 C 코드 한 단락으로 복원합니다 — 조건 분기(
jge/jl류)까지 읽어야 합니다 - Ghidra가 있다면 같은 함수를 열어 이름과 타입을 적용하고, 내 손 복원과 디컴파일 결과를 비교합니다
연습문제
문제 1. 3-2의 addl과 subq에서 끝글자 l과 q는 무엇을 뜻하며, 이것이 구조체 복원에서 왜 중요한가요?
문제 2. 3-3에서 +0x04 필드는 mov가 아니라 lea로 접근됐습니다. 이 차이가 "정수 필드가 아니라 배열"이라는 추론에 어떻게 쓰이나요?
문제 3. 스트립된 바이너리에서 Ghidra가 함수를 FUN_00101169로 부르는 이유를, 심볼의 관점에서 설명하세요.
문제 4. "디컴파일러는 추측기"라고 했습니다. 추측이 틀렸는지 판별하는 실용적인 기준은 무엇이며, 최종적인 진실은 어디서 확인하나요?
5. 모범 답안과 완료 기준
미션 모범 답안
buy_item을 추가해 컴파일·스트립한 뒤의 복원 과정 예:
[발견] main 디스어셈블리에서 모르는 call 주소 확인 → 해당 주소 함수를 objdump로 읽기
[관찰 — 출력 예시, 환경에 따라 주소는 다름]
mov 0x18(%rdi),%rax ; +0x18 (8바이트)을 읽고
cmp %rsi,%rax ; 두 번째 인자(rsi)와 비교
jl 리턴 ; 작으면 아무것도 안 함
movsxd %rsi,%rsi
sub %rsi,0x18(%rdi) ; 크거나 같으면 +0x18에서 rsi만큼 뺌
ret
[복원한 C]
void buy_item(player *p, long price) {
if (p->gold >= price) p->gold -= price;
}
검증하는 법: ① 복원한 C의 필드 접근(+0x18, 8바이트)이 3-3의 구조체 지도와 일치하는가. ② 분기 방향이 맞는가 — jl은 "작으면 점프"이니, 빼기를 하지 않는 쪽으로 가는 분기입니다. 방향이 반대면 "돈이 모자랄 때 결제되는" 반전된 코드가 됩니다. ③ 원본과 대조했을 때 의미가 같은가 (문법이 달라도 동작이 같으면 복원 성공).
연습문제 해답
문제 1 해답. AT&T 문법의 크기 접미사입니다 — l(long)은 4바이트, q(quad)는 8바이트 연산. 같은 오프셋이라도 접미사가 필드의 크기를 결정해 주므로, 구조체에서 int와 long을 구분하는 직접적 근거가 됩니다. 크기를 모르면 구조체의 경계를 그을 수 없습니다.
문제 2 해답. mov는 그 자리의 값을 가져오고, lea는 그 자리의 주소를 계산합니다. printf 계열에 문자열을 넘길 때는 값이 아니라 주소가 필요하므로, lea 0x4(%rdi)는 "+0x04부터 시작하는 연속 공간(배열)"이 있다는 뜻입니다. 값 읽기가 아니라 주소 넘기기 — 이 한 opcode 차이로 "정수 필드"와 "문자 배열"을 구분합니다.
문제 3 해답. 심볼은 함수 이름과 주소의 대응표인데, strip이 이 표를 제거하기 때문입니다. Ghidra는 분석으로 코드의 시작점(함수 경계)은 찾을 수 있지만 이름은 복구할 수 없어, 주소를 넣은 임시 이름 FUN_<주소>를 붙입니다. 이름표가 사라졌을 뿐 함수 자체는 그대로라는 것 — 그래서 분석가가 동작을 읽고 이름을 다시 지어 주는 작업이 가능합니다.
문제 4 해답. 실용적 기준은 "타입을 적용했을 때 관련 코드 전체가 더 읽기 쉬워지는가"입니다 — 한 곳만 예뻐지고 다른 곳이 깨지면 그 타입이 아닐 가능성이 높습니다. 최종 진실은 어셈블리(Listing)에서 확인합니다. 디컴파일은 편의를 위한 번역이고, 실제로 CPU가 실행하는 것은 어셈블리이므로 둘이 어긋나면 어셈블리가 맞습니다.
완료 기준 체크리스트
- [ ] 디컴파일러의 출력이 "추측"임을 설명할 수 있다
- [ ]
strip전후 바이너리의 차이(nm 결과)를 확인했다 - [ ] objdump 출력에서 오프셋 접근 패턴으로 구조체 필드 네 개를 추론했다
- [ ] opcode 접미사(l/q)와 mov/lea 차이로 필드 크기와 종류를 구분할 수 있다
- [ ] Ghidra의 이름 바꾸기(L), 시그니처 편집, 구조체 생성 절차를 말할 수 있다
- [ ] 디컴파일이 이상할 때 어셈블리로 돌아가 확인하는 습관을 안다
- [ ] 미션: 새 함수를 스트립 상태에서 C로 복원했다
6. 흔한 실수와 해결
벽 1. Ghidra 분석(Analyze)이 끝나지 않는다
증상: Import 후 분석이 수 분째 돌아갑니다.
원인: 파일이 크거나 분석 옵션을 많이 켰을 때 정상입니다.
해결: 기다리면 됩니다. 연습용 작은 바이너리는 수 초면 끝납니다. 너무 오래 걸리면 분석 옵션에서 Decompiler Switch Analysis 등 무거운 항목을 끄고 다시 시도하세요.
벽 2. 의사 코드에 param_1 + 2처럼 이상한 오프셋이 나온다
증상: 우리가 아는 +0x14가 param_1 + 2로 표시됩니다.
원인: 디컴파일러가 인자를 long *로 추측하면 바이트 오프셋이 포인터 산술(8바이트 단위)로 표현됩니다. 추측이 틀린 것입니다 (3-4, 3-6).
해결: Listing의 어셈블리에서 진짜 바이트 오프셋(0x14)을 확인하고, 구조체 타입을 만들어 적용하세요. 적용 후에도 이상하면 그 구조체가 틀린 것입니다.
벽 3. 이름을 바꿨는데 다른 창에서는 그대로다
증상: Listing에서 바꿨는데 Decompile의 호출부가 예전 이름입니다.
원인: 커서를 잘못된 대상(레이블이 아닌 주소 리터럴 등)에 두고 바꿨거나, 함수가 아니라 함수 안 레이블을 바꾼 경우입니다.
해결: Symbol Tree의 Functions에서 함수를 골라 L로 바꾸는 것이 가장 확실합니다. 바꾼 뒤 Decompile 창은 자동으로 갱신됩니다.
벽 4. 스트립한 바이너리에서 main을 못 찾겠다
증상: nm이 no symbols라고 하고, 어디가 main인지 모르겠습니다.
원인: 이름표가 없는 것이 정상 상태입니다.
해결: 진입점(entry)에서 시작하세요 — ELF의 entry는 _start이고, 그곳에서 __libc_start_main에 넘기는 인자가 main입니다. objdump 실측에서는 entry 디스어셈블리의 lea ...(%rip),%rdi가 main의 주소를 가리킵니다. Ghidra는 entry를 자동 분석하므로 그 호출 인자를 따라가면 됩니다.
벽 5. 구조체 필드 경계를 어디까지로 잡아야 할지 모르겠다
증상: +0x04가 배열인 건 알겠는데 길이를 모르겠습니다.
원인: 배열 길이 정보는 기계어에 없는 경우가 많습니다.
해결: 다음 필드의 오프셋이 곧 경계입니다. +0x04 다음 필드가 +0x14에서 접근되므로 배열은 최대 16바이트. 이렇게 "관찰된 오프셋들의 간격"으로 경계를 긋는 것이 구조체 복원의 기본기입니다. 정렬 패딩(보이지 않는 틈)이 있을 수 있음도 기억하세요 — 오늘의 구조체에서 +0x14와 +0x18 사이가 그 예입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 심볼 / 스트립 | 함수 이름표 / 그 이름표를 뜯어 낸 배포 상태 |
FUN_0010xxxx |
심볼 없는 함수에 Ghidra가 붙이는 주소 기반 임시 이름 |
| 구조체 복원 | 고정 오프셋 접근 패턴을 모아 필드 설계도를 되찾는 작업 |
| opcode 접미사 | l=4바이트, q=8바이트 — 필드 크기의 증거 |
| mov vs lea | 값 읽기 vs 주소 계산 — 정수와 배열의 구분선 |
| 시그니처 편집 | 함수의 인자 타입을 교정해 의사 코드 전체를 사람 언어로 바꾸는 일 |
오늘의 명령어·키
| 명령/키 | 하는 일 |
|---|---|
strip -o 출력 입력 |
심볼을 뜯은 사본 만들기 |
nm 바이너리 |
심볼 유무 확인 (no symbols = 스트립됨) |
objdump -d 바이너리 |
오프셋 접근 패턴 수집 — 구조체 복원의 원료 |
Ghidra L |
함수·변수 이름 바꾸기 |
| Ghidra 우클릭 → Edit Function Signature | 인자 타입 교정 |
| Data Type Manager → New → Structure | 추론한 구조체 만들기 |
명령어보다 중요한 감각
리버싱 실력은 "읽는 속도"가 아니라 "정리의 습관"에서 갈립니다. 수상한 함수를 만나면 세 가지를 바로 합니다 — 이름을 짓고, 인자 타입을 정하고, 반복되는 오프셋을 구조체로 묶는다. 한 번 정리한 것은 그 바이너리 어디에서든 다시 읽을 필요가 없습니다.
그리고 디컴파일러를 믿되 의심하세요. 의사 코드가 어색하면 그것은 분석가에게 보내는 신호입니다 — "내 추측이 틀렸으니 고쳐 달라". 고치는 재료는 언제나 어셈블리에 있습니다. 오늘 objdump로 손수 복원해 본 것은, 내일 Ghidra가 틀렸을 때 흔들리지 않기 위한 근육입니다.
전부 체크되면 Step 215 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.