Step 177. CTF 맛보기 2: Pwn 문제 도전 (pwnable.kr) — 바이너리와의 첫 대면
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 6시간
전제: Step 62~65(버퍼 오버플로우와 메모리 실험), Step 176(CTF 형식). C 기초 문법을 읽을 수 있습니다.
⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. pwnable.kr은 풀라고 만들어진 합법 학습 플랫폼입니다.
- 준비물: WSL 우분투(gcc, gdb), 파이썬. 실측 환경은 Ubuntu 24.04, gcc 13.3.0, gdb 15.1, x86-64입니다.
- 주의: 외부 플랫폼(pwnable.kr)에는 이 환경에서 접속하지 않습니다. 플랫폼 접속 장면은 "화면 예시"로 안내하고, 문제의 뼈대가 되는 두 프로그램은 소스를 재현해 WSL에서 직접 컴파일·풀이 실측합니다.
Pwn은 실행 파일(바이너리)의 메모리 취약점을 공격하는 CTF 종목입니다. 문제로 실행 파일과 접속할 서버가 주어지고, 파일을 분석해 허점을 찾은 뒤 서버에 공격을 성공시키면 플래그가 나옵니다. 어제의 웹이 "문서를 파고드는 종목"이라면, Pwn은 "기계 그 자체를 파고드는 종목"입니다. 오늘은 입문 문제 두 개로 이 종목의 규칙을 익힙니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- Pwn 문제의 구조(분석용 파일 + 공격 대상 서버)를 설명한다
- C 소스에서 "출제자가 노린 허점"을 읽어 내는 접근법을 안다
- 파일 디스크립터(fd)와 stdin의 관계를 이용해 조건을 만족시킨다
- 리틀 엔디안으로 정수를 바이트로 변환해 정확한 길이의 입력을 만든다
- 파이썬으로 바이너리에 정밀한 입력을 전달한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | C 읽기 + WSL 우분투 bash + 파이썬 (실측: Ubuntu 24.04, gcc 13.3.0, gdb 15.1) |
| 오늘의 명령어 | gcc -o 프로그램 소스.c, gdb -batch -ex "disassemble main", struct.pack("<5i", ...) |
| 필요한 개념 | 파일 디스크립터, read/strcmp, 리틀 엔디안, 포인터 캐스팅, 정수 오버플로 감각 |
| 오늘의 산출물 | 두 문제의 풀이 기록 + "출제자가 노린 허점" 한 줄씩 |
2-1. Pwn 문제의 구조 — 파일과 서버
웹 문제가 "주소 하나"를 준다면, Pwn 문제는 보통 세 개를 줍니다 (화면 예시 — pwnable.kr의 문제 페이지 형태):
# 화면 예시 — 플랫폼 문제 페이지의 전형적 구성
fd
Mommy! I think I know what a file descriptor is!!
ssh fd@pwnable.kr -p2222 (pw:guest)
파일(소스 또는 바이너리)은 분석용이고, 서버는 공격 대상입니다. 로컬에서 소스를 읽고 허점을 찾은 뒤, 서버에 접속해 같은 논리로 플래그를 읽는 구조입니다. 오늘 우리는 서버 대신 WSL에 같은 프로그램을 컴파일해 그 논리를 실측합니다.
2-2. 파일 디스크립터 — 번호로 여는 문
리눅스에서 프로그램이 파일·키보드·네트워크를 다룰 때 쓰는 손잡이가 파일 디스크립터(file descriptor, fd) — 그냥 정수 번호입니다. 그리고 처음 세 개는 약속돼 있습니다:
| 번호 | 이름 | 연결된 것 |
|---|---|---|
| 0 | stdin | 키보드(표준 입력) |
| 1 | stdout | 화면(표준 출력) |
| 2 | stderr | 화면(표준 오류) |
read(0, buf, 32)는 "0번 문(stdin)에서 32바이트 읽어라"입니다. 이 약속이 오늘 첫 문제의 열쇠입니다.
2-3. 리틀 엔디안 — 정수가 뒤집혀 저장되는 이유
x86-64는 정수를 메모리에 리틀 엔디안(little-endian) — 낮은 자리 바이트부터 — 로 저장합니다. 정수 0x12345678은 메모리에서 78 56 34 12 순서입니다. Step 62~65에서 주소와 값을 다룰 때 만났던 그 규칙이, 오늘 두 번째 문제에서는 "입력을 어떤 바이트 순서로 만들어야 하는가"를 결정합니다.
2-4. 출제자의 허점 읽기 — Pwn의 문법
Pwn 입문 문제의 소스를 읽는 순서는 정해져 있습니다. ① 입력이 어디로 들어오나(argv? stdin?) → ② 어떤 조건을 검사하나 → ③ 조건을 만족하는 입력을 거꾸로 계산할 수 있나. 공격은 폭력이 아니라 역산입니다 — 오늘 두 문제 모두 "조건을 보고 입력을 계산하는" 문제입니다.
3. 따라 하기
3-1. 실험실 준비 — 두 문제를 로컬에 재현
pwnable.kr의 입문 문제 fd와 collision은 소스가 공개된 고전입니다. WSL의 /tmp에 재현해 직접 풀어 봅니다 (실제 서버 접속은 화면 예시로만 안내합니다).
입력 (fd.c — fd 문제의 재현)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char buf[32];
int main(int argc, char* argv[], char* envp[]){
if(argc<2){ printf("pass argv[1] a number\n"); return 0; }
int fd = atoi( argv[1] ) - 0x1234;
int len = 0;
len = read(fd, buf, 32);
if(!strcmp("LETMEWIN\n", buf)){
printf("good job :)\n");
system("/bin/cat flag");
exit(0);
}
printf("learn about Linux file IO\n");
return 0;
}
입력 (col.c — collision 문제의 재현)
#include <stdio.h>
#include <string.h>
unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
int* ip = (int*)p;
int i; int res=0;
for(i=0; i<5; i++){ res += ip[i]; }
return res;
}
int main(int argc, char* argv[]){
if(argc<2){ printf("usage : %s [passcode]\n", argv[0]); return 0; }
if(strlen(argv[1]) != 20){ printf("passcode length should be 20 bytes\n"); return 0; }
if(hashcode == check_password( argv[1] )){ system("/bin/cat flag"); return 0; }
else printf("wrong passcode.\n");
return 0;
}
컴파일과 준비
mkdir -p /tmp/s177 && cd /tmp/s177
echo "FLAG{fd_4nd_c0ll1s10n_cl34r3d}" > flag
gcc -o fd fd.c
gcc -o col col.c
fd.c:12:11: warning: implicit declaration of function 'read'; did you mean 'fread'?
col.c:23:9: warning: implicit declaration of function 'system'
(2026-09-09 실측. 경고는 원본 소스에 헤더 선언이 빠진 탓이며, 오늘의 목적에는 지장이 없습니다. 실제 플랫폼의 바이너리도 이런 소스에서 만들어졌습니다.)
예측: fd.c의 조건을 보세요.
read가buf에 채운 값이"LETMEWIN\n"과 같으면 flag가 나옵니다.fd번호를 우리가 고를 수 있다면, 어떤 번호가 "키보드에서 읽기"를 뜻할까요? 2-2의 표를 보고 답을 예측한 뒤 다음으로.
3-2. 문제 1: fd — 파일 디스크립터의 약속
먼저 실패하는 모습부터 봅니다:
./fd
pass argv[1] a number
echo hello | ./fd 1
learn about Linux file IO
(2026-09-09 실측.)
읽는 법: 소스의 조건식은 int fd = atoi(argv[1]) - 0x1234;입니다. 우리가 넣은 숫자에서 0x1234를 뺀 값이 fd가 됩니다. 1을 넣으면 fd는 1 - 0x1234 = 음수 — 그런 문은 없으니 read는 실패하고 buf는 빈 채로 비교에 집니다.
이제 역산합니다. 우리가 원하는 것: read가 키보드(stdin, fd 0번)에서 읽는 것. 필요한 조건: atoi(argv[1]) - 0x1234 == 0. 따라서 넣을 숫자는 0x1234 = 십진수 4660입니다.
echo LETMEWIN | ./fd 4660
FLAG{fd_4nd_c0ll1s10n_cl34r3d}
good job :)
(2026-09-09 실측. 출력 순서가 소스의 printf 순서와 바뀌어 보이는 것은 버퍼링 때문입니다 — 벽 3 참고.)
출력 읽는 법: echo LETMEWIN이 표준 입력으로 "LETMEWIN\n"을 보내고, fd가 0이 된 read(0, buf, 32)가 그것을 읽어 strcmp를 통과했습니다. 우리는 메모리를 하나도 건드리지 않았습니다 — 약속(fd 0번 = stdin)을 알고 조건을 역산했을 뿐입니다.
출제자가 노린 허점 한 줄: "fd 번호를 사용자가 정할 수 있고, 0번은 항상 stdin이다."
3-3. 문제 2: collision — 다섯 정수의 합
col.c의 조건을 읽어 봅시다:
- 입력은 정확히 20바이트여야 한다 (
strlen(argv[1]) != 20이면 퇴짜) - 그 20바이트를 4바이트짜리 int 다섯 개로 잘라(
(int*)p) 합산한다 - 합이
0x21DD09EC와 같으면 flag
먼저 실패를 확인합니다:
./col
usage : ./col [passcode]
./col AAAAAAAAAAAAAAAAAAAA
wrong passcode.
(2026-09-09 실측. A 20개 — 길이는 맞지만 다섯 개의 0x41414141의 합이 목표와 다릅니다.)
역산 시간입니다. 목표 합은 0x21DD09EC = 십진수 568134124. 다섯 개로 나눕니다:
568134124 ÷ 5 = 113626824 ... 나머지 4
→ 네 개는 113626824 (0x06C5CEC8), 마지막 하나는 113626828 (0x06C5CECC)
→ 합 검산: 113626824×4 + 113626828 = 568134124 ✓
이 다섯 정수를 리틀 엔디안 바이트 열로 만들어 argv로 전달하면 됩니다. 손으로 칠 수 없는 바이트(제어 문자 포함)라 파이썬으로 만듭니다:
import struct, subprocess
target = 0x21DD09EC
q, r = divmod(target, 5)
nums = [q, q, q, q, q + r]
payload = struct.pack("<5i", *nums) # 리틀 엔디안 int 5개 = 20바이트
print(f"payload {len(payload)} bytes: {payload!r}")
out = subprocess.run([b"./col", payload], capture_output=True)
print(out.stdout.decode(), end="")
payload 20 bytes: b'\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xcc\xce\xc5\x06'
FLAG{fd_4nd_c0ll1s10n_cl34r3d}
(2026-09-09 실측.)
새 도구 읽는 법: struct.pack("<5i", ...)의 <가 리틀 엔디안, 5i가 int 다섯 개입니다. 0x06C5CEC8이 바이트 열 c8 ce c5 06으로 뒤집혀 들어간 것이 보입니다 — 이것이 리틀 엔디안의 실물입니다. 그리고 subprocess.run([b"./col", payload])처럼 argv를 바이트로 넘겼습니다. 문자열로 넘기면 인코딩이 끼어 길이가 깨집니다 — 이 챕터에서 실제로 겪은 실패이며 벽 2에서 다룹니다.
출제자가 노린 허점 한 줄: "문자열 포인터를 int 배열로 캐스팅하면, 입력 20바이트는 정수 다섯 개의 합산이 된다 — 합만 맞추면 되는 해시는 충돌이 공짜다."
3-4. gdb로 들여다보기 — 소스가 없을 때의 연습
실제 Pwn 문제는 소스를 안 주고 바이너리만 주는 경우가 많습니다. 그때의 첫 도구가 gdb입니다. 방금 만든 fd 바이너리를 소스 없이 해부해 봅니다:
gdb -batch -ex "disassemble main" ./fd | head -18
Dump of assembler code for function main:
0x00000000000011e9 <+0>: endbr64
0x00000000000011ed <+4>: push %rbp
0x00000000000011ee <+5>: mov %rsp,%rbp
0x00000000000011f1 <+8>: sub $0x30,%rsp
...
0x0000000000001200 <+23>: cmpl $0x1,-0x14(%rbp)
0x0000000000001204 <+27>: jg 0x121f <main+54>
...
(2026-09-09 실측, gdb 15.1.)
출력 읽는 법: <+23>의 cmpl $0x1, ...은 "인자 개수가 1보다 큰가" 비교 — 소스의 if(argc<2)입니다. 소스 없이도 조건의 자리가 보입니다. 어셈블리는 Step 182부터 본격적으로 배우니, 오늘은 "바이너리도 열어 볼 수 있다"는 것만 확인하면 충분합니다. 참고로 바이너리의 보호 기법을 보는 checksec 도구가 실전에서는 표준인데, 이 환경에는 설치돼 있지 않습니다 — 화면 예시로만 봅니다:
# 화면 예시 — checksec이 보여 주는 것들
RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE
각 항목이 "이 바이너리에 어떤 방어막이 있는가"입니다. Step 62의 카나리가 Stack 줄에 해당합니다 — 방어막의 유무가 공격 난이도를 결정합니다.
3-5. 실제 플랫폼에서의 모습 — 화면 예시
로컬에서 익힌 논리를 실제 pwnable.kr에 적용하면 이런 흐름입니다 (화면 예시 — 이 환경에서는 접속하지 않았습니다):
# 화면 예시 — 실제 플랫폼 접속 시나리오
$ ssh fd@pwnable.kr -p2222
fd@pwnable.kr's password: guest
fd@ubuntu:~$ ls
fd fd.c flag
fd@ubuntu:~$ cat fd.c # 소스가 서버에도 있다 — 분석은 여기서
fd@ubuntu:~$ echo LETMEWIN | ./fd 4660
good job :)
Mommy! the file descriptor is dangerous...
로컬 재현과 다른 점은 두 가지뿐입니다 — flag의 내용과, 서버의 fd 바이너리에 SUID가 걸려 있어 우리가 읽을 권한 없는 flag 파일을 대신 읽어 준다는 것(Step 106의 SUID가 여기서 이렇게 쓰입니다).
4. 미션과 연습문제
미션 — "출제자의 허점" 노트
오늘 푼 두 문제에 더해, 아래 세 번째 미니 문제를 스스로 만들고 풀어 보세요:
- col.c를 변형해 목표 합을
0x21DD09EC가 아닌 여러분이 고른 값(예:0x2A2A2A2A)으로 바꾼 col2.c를 만든다 - 컴파일하고, 파이썬으로 새 목표에 맞는 20바이트를 계산해 푼다
- 세 문제(fd, col, col2) 각각에 대해 "입력 경로 / 검사 조건 / 역산 방법 / 출제자가 노린 허점 한 줄"을 노트에 정리한다
연습문제
문제 1. fd 문제에서 ./fd 4661을 넣으면 어떻게 되나요? 어느 fd가 선택되며, 왜 실패하나요?
문제 2. collision에서 ./col AAAAAAAAAAAAAAAAAAAA는 길이가 20으로 맞는데 왜 틀리나요? A 다섯 묶음의 합을 16진수로 계산해 보세요.
문제 3. struct.pack("<i", 0x06C5CEC8)의 결과 바이트가 c8 ce c5 06 순서인 이유를 리틀 엔디안으로 설명하세요.
문제 4. 실제 pwnable.kr에서는 flag 파일을 직접 cat으로 읽을 수 없는데 ./fd 4660 성공으로는 읽힙니다. 어떤 권한 장치가 이것을 가능하게 하나요?
5. 모범 답안과 완료 기준
미션 모범 답안
col2.c의 목표를 0x2A2A2A2A(= 707406378)로 바꿨다면:
707406378 ÷ 5 = 141481275 ... 나머지 3
nums = [141481275, 141481275, 141481275, 141481275, 141481278]
payload = struct.pack("<5i", *nums) → 20바이트
검산: 141481275×4 + 141481278 = 707406378 ✓
노트 정리의 예:
[fd] 입력: argv → atoi - 0x1234 → read(fd) → strcmp 조건
역산: fd=0(stdin)이 되려면 입력 = 0x1234 = 4660
허점: fd 번호를 사용자가 고르고, 0번은 언제나 stdin
[col] 입력: argv(20B) → int 5개 합산 → 0x21DD09EC와 비교
역산: 목표 ÷ 5의 몫 4개 + (몫+나머지) 1개, 리틀 엔디안으로 포장
허점: 문자열→int 캐스팅, 합만 맞추면 되는 약한 검증
[col2] 같은 구조 — 목표값만 바뀜: 역산 절차가 재사용됨을 확인
검증하는 법: ① col2가 스스로 만든 payload로 flag를 출력했는가, ② 노트의 "허점 한 줄"이 기법명이 아니라 조건의 구멍을 말하고 있는가.
연습문제 해답
문제 1 해답. 4661 - 0x1234(4660) = 1이라 fd 1번, 즉 stdout이 선택됩니다. stdout은 "쓰는 문"이라 read는 실패하고 buf는 빈 문자열인 채로 비교에 지므로 learn about Linux file IO가 뜹니다. fd 문제의 복습 포인트는 "번호마다 연결된 것이 다르다"는 것입니다.
문제 2 해답. A는 0x41이라 20바이트는 int 다섯 개 0x41414141로 읽힙니다. 합은 0x41414141 × 5 = 0x145050505인데, int(32비트)로는 넘치는 윗자리가 잘려 0x45050505가 됩니다. 어느 쪽으로 계산해도 목표 0x21DD09EC와 다르니 wrong passcode.입니다. 길이 조건은 통과 조건의 절반일 뿐입니다.
문제 3 해답. 리틀 엔디안은 정수의 낮은 자리 바이트부터 메모리에 놓는 방식이기 때문입니다. 0x06C5CEC8의 낮은 자리부터 나열하면 C8, CE, C5, 06 — 그래서 바이트 열은 c8 ce c5 06이 됩니다. x86-64가 이 방식이라, 우리가 만든 바이트 열이 프로그램 안에서 다시 int로 읽힐 때 원래 수로 복원됩니다.
문제 4 해답. SUID(Set User ID, Step 106)입니다. fd 바이너리에 소유자 권한으로 실행되는 SUID 비트가 걸려 있어, 실행하는 순간 flag 파일 소유자의 권한을 빌립니다. 우리에게 읽기 권한이 없는 flag를 프로그램이 대신 읽어 주는 구조 — pwnable.kr의 Toddler’s Bottle 전 문제가 이 장치로 성립합니다. SUID가 왜 강력하고 왜 감사 대상인지(Step 174, 175)의 실전 사례이기도 합니다.
완료 기준 체크리스트
- [ ] Pwn 문제의 구조(분석용 파일 + 공격 대상 서버)를 설명할 수 있다
- [ ] 파일 디스크립터 0/1/2가 각각 무엇에 연결됐는지 안다
- [ ] fd 문제를 로컬에서 재현하고 4660으로 풀었다
- [ ] 목표값 ÷ 5 역산으로 collision payload를 만들어 풀었다
- [ ]
struct.pack("<5i", ...)의<가 리틀 엔디안임을 설명할 수 있다 - [ ] gdb
disassemble main으로 바이너리의 조건 자리를 확인했다 - [ ] 미션: col2 변형 문제를 만들어 풀고, 세 문제의 허점 노트를 정리했다
6. 흔한 실수와 해결
벽 1. 4660이 어디서 나온 숫자인지 모르겠다
증상: 0x1234와 4660의 관계가 안 잡힙니다.
원인: 16진수→십진수 변환 문제입니다. 0x1234 = 1×4096 + 2×256 + 3×16 + 4 = 4660.
해결: 파이썬에 물어보세요 — python3 -c "print(0x1234)" → 4660. 반대는 hex(4660) → '0x1234'. 진수 변환은 외우는 것이 아니라 도구에 시키는 것입니다.
벽 2. payload가 20바이트인데 passcode length should be 20 bytes가 뜬다
증상: 분명 20바이트를 만들었는데 길이 검사에서 걸립니다. 이 챕터를 실측하는 동안 실제로 겪은 실패입니다.
원인: payload를 문자열로 argv에 넣으면 파이썬이 UTF-8로 인코딩하면서 \xc8 같은 바이트가 2바이트로 부풀어, 20바이트가 25바이트가 됩니다.
해결: argv 자체를 바이트로 넘기세요 — subprocess.run([b"./col", payload], ...)처럼 프로그램 이름까지 b"..." 바이트 리스트로. 쉘에서 $(python3 -c ...)로 넘기는 방법도 같은 인코딩 함정이 있으니, Pwn에서는 파이썬이 직접 실행까지 맡는 이 패턴을 기본으로 씁니다.
벽 3. good job :)보다 flag가 먼저 출력된다
증상: 소스상 printf("good job :)")이 먼저인데 실제 출력은 flag가 위에 옵니다 (2026-09-09 실측).
원인: 파이프로 연결된 stdout은 버퍼링됩니다. printf 출력이 버퍼에 머무는 사이 system("cat flag")의 출력이 먼저 파이프로 나갑니다.
해결: 고칠 것은 없습니다 — 정상 동작입니다. 다만 "출력 순서 = 코드 순서"가 아닐 수 있다는 것, 디버깅할 때 기억해 두세요.
벽 4. int의 합이 엉뚱한 값이 된다
증상: 계산한 다섯 수의 합이 목표보다 작게 나옵니다.
원인: col.c의 res는 32비트 int입니다. 합이 0xFFFFFFFF를 넘으면 윗자리가 잘립니다(정수 오버플로). 문제의 목표값은 그 범위 안이라 괜찮지만, 다른 값으로 변형할 때 걸립니다.
해결: 미션에서 목표값을 고를 때 0x7FFFFFFF 이하로 고르세요. 넘치는 값을 노리는 문제도 실전에 있으니, "합산은 32비트 안에서 일어난다"를 기억해 두면 됩니다.
벽 5. gdb 출력이 외계어다
증상: 디스어셈블리를 봐도 한 줄도 모르겠습니다.
원인: 정상입니다. 어셈블리는 Step 182에서 체계적으로 배웁니다.
해결: 오늘은 세 글자만 찾는 연습을 하세요 — cmp(비교), jmp/jXX(점프), call(호출). 이 세 종류만 눈에 들어와도 "조건이 어디 있는가"는 찾을 수 있습니다. 나머지는 그때 배우면 됩니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| Pwn | 바이너리의 메모리·논리 취약점을 공격해 플래그를 얻는 종목 |
| 파일 디스크립터 | 파일·입출력을 가리키는 정수 번호 — 0 stdin, 1 stdout, 2 stderr |
| 리틀 엔디안 | 정수를 낮은 자리 바이트부터 저장하는 x86의 규칙 |
| 포인터 캐스팅 | 같은 바이트를 다른 타입으로 읽기 — 문자열 20B = int 5개 |
| 역산 풀이 | 조건을 읽고 "그 조건을 만족하는 입력"을 거꾸로 계산하기 |
| SUID와 플래그 | 읽을 권한 없는 flag를 SUID 바이너리가 대신 읽어 주는 구조 |
오늘의 명령어
| 명령 | 하는 일 |
|---|---|
gcc -o fd fd.c |
C 소스를 실행 파일로 컴파일 |
echo LETMEWIN | ./fd 4660 |
파이프로 stdin에 정답 문자열 공급 |
struct.pack("<5i", a,b,c,d,e) |
int 다섯 개를 리틀 엔디안 20바이트로 포장 |
subprocess.run([b"./col", payload]) |
바이트 argv로 정밀 실행 (인코딩 함정 회피) |
gdb -batch -ex "disassemble main" ./fd |
소스 없이 main의 어셈블리 열기 |
명령어보다 중요한 감각
오늘 두 문제에서 메모리는 하나도 터뜨리지 않았습니다. 번호의 약속(fd 0번)과 바이트의 순서(리틀 엔디안)를 알고, 조건을 역산했을 뿐입니다. Pwn의 입구가 "공격"이 아니라 "정확한 읽기와 계산"이라는 것 — 이것이 이 종목의 첫인상이자 평생 가는 자세입니다.
반대로 출제자 입장에서 보면, 두 문제는 "검증을 게을리한 코드"의 표본입니다. fd는 사용자가 문 번호를 고르게 했고, col은 합만 맞으면 통과하는 약한 검증을 썼습니다. Step 175의 대응표에 "Pwn" 행을 추가할 시간입니다 — 공격: 조건 역산, 방어: 입력 출처 제한·강한 검증. 표는 이렇게 Level 3에서도 계속 자랍니다.
전부 체크되면 Step 177 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.