Step 290. CTF 복기 블록 A + 블록 C: 익스플로잇 코드 정리 — 급조를 자산으로 바꾼다

Step 290. CTF 복기 블록 A + 블록 C: 익스플로잇 코드 정리 — 급조를 자산으로 바꾼다

Level 3 — CTF 대회 사이클 | 난이도 ★★★☆☆ | 예상 소요 시간 2일 (복기 반일 + 라이브러리 구축 1.5일)

전제: Step 289(중간 평가 대회) 완료, 지금까지 대회·머신에서 급조한 익스플로잇 스크립트들, 파이썬 3.

  • 준비물: 지금까지 쓴 익스플로잇 스크립트 전부(대회·머신·연습), Git 저장소 1개, 파이썬 3. 이 챕터의 라이브러리 뼈대 작성·목차 생성·로컬 데모 실행은 실측(2026-09-09, Python 3.12)입니다.
  • ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
  • 이 챕터는 자산 정리 챕터입니다 — 블록 A(못 푼 문제 끝까지 파기)에 블록 C(코드 자산화)를 붙인 복기입니다.

대회마다 스크립트를 새로 짜고 계십니까? 그러면 여섯 번의 대회 동안 같은 뼈대를 여섯 번 짠 셈입니다. 소켓을 열고, 프롬프트까지 받고, 페이로드를 보내고 — 그 30줄은 매번 같고, 문제마다 다른 것은 주소와 값 몇 줄입니다.

상위 팀들의 비밀은 실력만큼 라이브러리입니다. 비슷한 유형이 나왔을 때 처음부터 짜는 팀과, 템플릿을 복사해 입출력만 바꾸는 팀의 30분 차이가 순위를 가릅니다. 오늘은 급조의 흔적을 모아 재사용 가능한 개인 익스플로잇 라이브러리 v1을 만듭니다.


1. 학습 목표

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

  • 복기 블록 A(못 푼 문제 끝까지 파기)의 루틴을 중간 평가 대회에 적용한다
  • 급조 스크립트를 "입출력만 바꾸면 되는" 템플릿으로 다듬는 기준을 안다
  • 템플릿·페이로드·유틸의 3층 라이브러리 구조를 만든다
  • 각 파일에 "사용 조건" 주석을 달아 대회 중 10초 검색을 가능하게 한다
  • Git으로 라이브러리를 버전 관리하고 팀 자산으로 공유한다

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

오늘의 도구 한눈에 보기

구분 내용
언어·환경 파이썬 3(표준 라이브러리만 — pwntools는 선택), Git, 마크다운
오늘의 명령 git init/add/commit, python build_index.py, 템플릿 실행
필요한 개념 템플릿화(80% 재사용), 사용 조건 주석, 3층 구조, 팀 자산화
오늘의 산출물 익스플로잇 라이브러리 v1 (템플릿 10종 목표) + 목차 README

2-1. 블록 C — 복기의 세 번째 축

복기 블록 A가 "못 푼 문제를 끝까지 파는 것", 블록 B가 "Write-up으로 기록하는 것"이라면, 블록 C는 코드를 자산으로 바꾸는 것입니다. 풀이를 이해하고(블록 A) 기록했어도(블록 B), 코드가 대회 당일의 임시 파일로 남아 있으면 다음 대회에서 다시 짭니다.

블록 C의 판정 질문은 하나입니다 — "이 코드, 다음 대회에서 그대로 꺼내 쓸 수 있는가?" 문제 전용 하드코딩이 섞여 있으면 불가입니다. 그래서 오늘의 작업은 분리입니다 — 문제마다 바뀌는 것(주소·포트·페이로드 값)과 매번 같은 것(접속·수신·전송 뼈대).

2-2. 3층 구조 — 템플릿 / 페이로드 / 유틸

라이브러리는 세 층으로 나눕니다. 역할이 다르면 폴더를 나누는 것이 검색 속도입니다.

폴더 내용
템플릿 templates/ 문제 유형별 실행 뼈대 — 복사 후 입출력만 수정 TCP 프롬프트 풀이, BOF 뼈대
페이로드 payloads/ 유형이 정해졌을 때 골라 넣는 재료 모음 — ‘사전’ SSTI 탐지 문자열, SQLi 판별문
유틸 utils/ 모든 템플릿이 공유하는 함수 소켓 래퍼, 인코딩 헬퍼

템플릿과 페이로드의 차이가 중요합니다. 템플릿은 실행 가능한 파일, 페이로드는 복사해 넣는 문자열 모음입니다. SSTI 페이로드 20개를 템플릿 20개로 만들면 관리가 무너집니다 — 한 파일의 사전(dict)으로 모으는 것이 정답입니다.

2-3. 사용 조건 주석 — 10초 검색의 비밀

대회 중 라이브러리에 허락된 시간은 파일당 10초입니다. 그 10초에 답해야 할 질문은 "이 파일을 이 문제에 쓸 수 있는가"입니다. 그래서 모든 파일 첫머리에 두 줄을 강제합니다.

"""templates/tcp_prompt_template.py — TCP 프롬프트형 문제 범용 템플릿

사용 조건: 접속하면 메뉴/프롬프트가 뜨고, 특정 입력에 플래그를 반환하는 서비스.
수정 지점: HOST, PORT, TRIGGER(답으로 보낼 값), EXPECT(프롬프트 끝 문자열).
"""

사용 조건은 "어떤 문제에서 쓰는가", 수정 지점은 "어디를 고치는가"입니다. 이 두 줄이 없는 파일은 대회 중에 열어보고 버리는 시간 5분의 주인이 됩니다.

2-4. 80% 재사용 — 범용화의 함정

소재의 경고입니다 — 템플릿은 "급조의 시작점"일 뿐 만능이 아닙니다. 모든 경우를 처리하려고 if를 늘리면, 정작 대회 중에 그 파일을 읽을 수 없게 됩니다.

기준은 80%입니다. 자주 보는 문제 구조의 80%를 커버하는 단순한 뼈대로 멈추고, 나머지 20%는 대회 중에 손으로 짭니다. 완벽한 범용 라이브러리는 읽는 시간이 새로 짜는 시간을 넘는 순간 자산이 아니라 부채입니다.


3. 따라 하기

3-1. 블록 A 먼저 — 중간 평가의 못 푼 문제 정리

라이브러리 작업 전에 블록 A 루틴부터 돌립니다. Step 289 대회에서 못 푼 문제 3개를 골라 끝까지 파되, 오늘의 관점이 하나 추가됩니다 — "이 풀이 코드, 템플릿이 될 수 있는가?"

블록 A 기록 양식 (블록 C 확장판):
문제: (이름/분야/배점)
막힌 지점: (한 문장)
정답 풀이: (공개 Write-up 대조)
우리 코드와의 차이: (한 문장)
템플릿 후보? □ 예 — 유형: ___  □ 아니오 — 일회성 문제

마지막 줄이 오늘의 추가 항목입니다. 정답 코드가 우리 라이브러리에 없는 유형이면, 그것이 오늘 만들 템플릿의 후보입니다.

3-2. 유틸 층 — 소켓 래퍼부터 만든다

모든 템플릿의 공통 분모부터 정리합니다. pwntools가 깔린 환경이면 그것을 쓰면 되지만, 의존성 없이 어디서든 도는 최소 소켓 래퍼를 하나 갖추면 든든합니다. 아래를 ctf_lib/utils/net.py로 저장하세요.

"""ctf_lib/utils/net.py — pwntools 없이 쓰는 최소 소켓 래퍼

사용 조건: 대회 서버가 TCP(raw socket) 서비스일 때.
수정 지점: remote()의 host/port, sendline 후 기대 응답 문자열.
"""
import socket

class Tube:
    """recvuntil / sendline 만 갖춘 최소 튜브. pwntools의 tube를 흉내 낸다."""

    def __init__(self, host, port, timeout=5.0):
        self.sock = socket.create_connection((host, port), timeout=timeout)
        self.buf = b""

    def recvuntil(self, delim, timeout=5.0):
        self.sock.settimeout(timeout)
        while delim not in self.buf:
            chunk = self.sock.recv(4096)
            if not chunk:
                raise ConnectionError("서버가 연결을 닫았습니다")
            self.buf += chunk
        idx = self.buf.index(delim) + len(delim)
        out, self.buf = self.buf[:idx], self.buf[idx:]
        return out

    def recvline(self, timeout=5.0):
        return self.recvuntil(b"\n", timeout)

    def sendline(self, data):
        self.sock.sendall(data + b"\n")

    def close(self):
        self.sock.close()

def remote(host, port):
    return Tube(host, port)

recvuntil이 핵심입니다 — 소켓 수신은 경계를 몰라서, "프롬프트 문자열까지 받기"를 직접 버퍼로 구현해야 프롬프트형 문제를 다룰 수 있습니다.

3-3. 템플릿 층 — 사용 조건과 수정 지점을 단다

유틸 위에 템플릿을 올립니다. 아래가 실제로 작성한 뼈대입니다 — ctf_lib/templates/tcp_prompt_template.py로 저장하세요.

"""templates/tcp_prompt_template.py — TCP 프롬프트형 문제 범용 템플릿

사용 조건: 접속하면 메뉴/프롬프트가 뜨고, 특정 입력에 플래그를 반환하는 서비스.
수정 지점: HOST, PORT, TRIGGER(답으로 보낼 값), EXPECT(프롬프트 끝 문자열).
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from utils.net import remote

# ── 여기만 문제마다 수정 ──────────────────
HOST = "127.0.0.1"
PORT = 31337
EXPECT = b"> "
TRIGGER = b"open"
# ──────────────────────────────────────────

def solve():
    io = remote(HOST, PORT)
    banner = io.recvuntil(EXPECT)
    print("[*] 배너:", banner.decode(errors="replace").strip())
    io.sendline(TRIGGER)
    resp = io.recvline().decode(errors="replace").strip()
    print("[*] 응답:", resp)
    io.close()
    if "flag{" in resp:
        print("[+] 플래그 획득:", resp)
    else:
        print("[-] 플래그 형식이 아님 — 응답을 보고 TRIGGER 수정")

if __name__ == "__main__":
    solve()

수정 지점을 파일 중간의 주석 블록으로 시각적으로 격리한 것이 포인트입니다. 대회 중에는 이 블록만 고치고 나머지는 읽지도 않습니다.

3-4. 페이로드 층과 로컬 검증 — 돌려보고 넣는다

페이로드는 사전 형태로 모읍니다 (ctf_lib/payloads/web_payloads.py):

"""payloads/web_payloads.py — 웹 페이로드 모음 (주석 = 사용 조건)"""

SSTI_PROBES = {
    "detect": "{{7*7}}",            # 49가 나오면 SSTI 의심
    "jinja2_id": "{{7*'7'}}",       # 7777777이면 Jinja2, 49면 Twig 계열
}
SQLI_BOOL = {
    "true":  "' OR '1'='1' -- ",
    "false": "' OR '1'='2' -- ",    # 응답 차이가 나면 블라인드 SQLi
    "time":  "' OR SLEEP(3) -- ",   # 3초 지연이면 time-based
}

그리고 규칙 하나 — 라이브러리에 넣는 코드는 넣기 전에 로컬에서 한 번 돌립니다. 검증용 가짜 서비스(demo_server.py, localhost 전용)를 띄워 템플릿이 실제로 플래그를 뽑는지 확인했습니다.

실측 (2026-09-09 — 데모 서버 + 템플릿 실행):

[*] 데모 서버 대기 중 127.0.0.1:31337
[*] 배너: === demo vault ===
명령을 입력하세요 >
[*] 응답: flag{template_works_local_only}
[+] 플래그 획득: flag{template_works_local_only}

읽는 법: 템플릿이 배너를 받고, 입력을 보내고, 플래그를 잡아냈습니다. "대충 되겠지"로 저장한 코드는 대회 중에 깨집니다. 5초짜리 로컬 검증이 그 사고를 막습니다 — 다음 스텝(291)은 이 검증을 대회에서 해보는 이야기입니다.

3-5. 목차 자동 생성과 Git — 자산의 마감

마지막으로 목차입니다. 손으로 쓰는 목차는 금방 낡으므로, 각 파일의 "사용 조건" 주석을 긁어 README를 만드는 생성기(build_index.py)를 만들었습니다.

실측 (2026-09-09 — python build_index.py):

# CTF 익스플로잇 라이브러리 목차

### templates/
- `tcp_prompt_template.py` — 접속하면 메뉴/프롬프트가 뜨고, 특정 입력에 플래그를 반환하는 서비스.

### payloads/
- `web_payloads.py` — (사용 조건 주석 없음 — 추가 필요)

### utils/
- `net.py` — 대회 서버가 TCP(raw socket) 서비스일 때.

목차 파일 생성: ...\ctf_lib\README.md

읽는 법: web_payloads.py에 "(사용 조건 주석 없음)"이 찍혔습니다 — 생성기가 주석 누락 파일을 탐지합니다. 목차가 문서인 동시에 검사기가 되는 구조입니다. 이 출력대로 주석을 보강한 뒤, 저장소에 커밋합니다.

cd ctf_lib
git init
git add .
git commit -m "exploit library v1: templates 10종, payloads, utils"

팀과 공유할 때는 저장소를 팀 원격에 올리고, 규칙 하나를 합의합니다 — 대회 중 급조로 고친 코드는 대회 후 정리해서 커밋한다. 정리 없이 쌓이는 공유 저장소는 반년 뒤 아무도 못 읽는 쓰레기장이 됩니다.


4. 미션과 연습문제

미션 — 익스플로잇 라이브러리 v1 완성

  1. 블록 A 루틴으로 중간 평가 대회의 못 푼 문제 3개를 정리합니다 — "템플릿 후보?" 항목 포함.
  2. 지금까지 쓴 익스플로잇 스크립트를 전부 한 폴더에 모읍니다.
  3. 3층 구조(templates/·payloads/·utils/)로 템플릿 10종을 정리합니다 — 각 파일에 사용 조건·수정 지점 주석 필수.
  4. 템플릿 전부를 로컬 데모 서비스로 한 번씩 돌려 검증합니다.
  5. 목차 생성기로 README를 만들고, Git 커밋 후 팀과 공유합니다.

연습문제

문제 1. 템플릿과 페이로드를 다른 폴더에 두는 이유는 무엇인가요? SSTI 페이로드 20개를 템플릿 20개로 만들면 어떤 일이 생기나요?

문제 2. "사용 조건"과 "수정 지점" 주석이 답하는 질문이 각각 무엇이며, 왜 대회 중에는 이 두 줄이 파일 전체보다 중요한가요?

문제 3. 소재는 "범용화를 과하게 하면 오히려 안 읽힌다 — 80% 재사용 수준에서 멈추기"라고 했습니다. 완벽한 범용 템플릿이 오히려 손해인 이유를 "대회 중의 읽기 시간" 관점에서 설명해 보세요.

문제 4. 라이브러리에 넣기 전 로컬 검증을 규칙으로 정한 이유는 무엇인가요? 검증 없이 저장된 코드가 대회에서 만드는 구체적 사고 하나를 상상해 보세요.


5. 모범 답안과 완료 기준

미션 모범 답안

검증 기준으로 확인하세요.

  1. 블록 A의 선행: 못 푼 문제 3개의 기록에 "템플릿 후보?" 판정이 있는가 — 라이브러리가 복기에서 자라야지, 복기와 따로 놀면 안 됩니다.
  2. 10종의 구성: 템플릿이 유형별로 퍼져 있는가 — 비슷한 문제 10종은 1종입니다. BOF·포맷 스트링·SQLi·SSTI·JWT·프롬프트형 등 분야가 섞여야 합니다.
  3. 주석의 전수: 모든 파일 첫머리에 사용 조건·수정 지점이 있는가 — 목차 생성기가 "(주석 없음)"을 하나라도 찍으면 미완입니다.
  4. 로컬 검증 흔적: 각 템플릿을 실제로 실행한 기록(터미널 캡처 등)이 있는가.
  5. Git 이력: v1 커밋이 있고, 팀원이 clone해서 읽을 수 있는 상태인가.

연습문제 해답

문제 1 해답. 역할이 다르기 때문입니다 — 템플릿은 실행 가능한 뼈대, 페이로드는 골라 넣는 재료. 페이로드를 템플릿으로 만들면 파일 수가 폭증하고, 대회 중 20개 파일을 열어 비교하는 시간이 생깁니다. 한 파일의 사전에 이름표를 달아 모으면 목차 한 줄로 검색이 끝납니다. 폴더 분리는 정리 미학이 아니라 검색 속도의 설계입니다.

문제 2 해답. 사용 조건은 "이 파일을 이 문제에 쓸 수 있는가", 수정 지점은 "어디를 고치는가"에 답합니다. 대회 중 라이브러리에 허락된 시간은 파일당 10초인데, 이 두 줄이 없으면 파일 전체를 읽어 판단해야 하고, 그 시간이 5분이면 처음부터 짜는 것과 다를 바 없습니다. 주석 두 줄이 "꺼내 쓰는 자산"과 "다시 읽어야 하는 옛 파일"을 가릅니다.

문제 3 해답. 템플릿의 가치는 "안 읽고 고치는 부분만 고쳐도 도는 것"인데, 범용화가 진행되면 분기와 옵션을 이해해야만 고칠 수 있게 되기 때문입니다. 읽기 시간이 새로 짜는 시간을 넘는 순간 템플릿은 부채입니다. 80% 커버의 단순한 뼈대는 수정 지점만 읽으면 되지만, 100%를 노린 코드는 저자 본인도 대회 중에는 못 읽습니다. 나머지 20%는 손으로 짜는 것이 총량 기준으로 빠릅니다.

문제 4 해답. 검증 없는 저장은 "된다"는 거짓 기억을 만들기 때문입니다. 대회 중에 꺼낸 템플릿이 오타·경로·의존성 문제로 죽으면, 팀은 템플릿 고치기와 문제 풀기를 동시에 하게 됩니다 — 급조보다 나쁜 상황입니다. 로컬 검증 5초는 "이 파일은 마지막으로 확인된 시점에 돌았다"는 보증을 자산에 새기는 일입니다.

완료 기준 체크리스트

  • [ ] 중간 평가 대회의 못 푼 문제 3개를 블록 A 양식으로 정리했다
  • [ ] 과거 익스플로잇 스크립트를 한 폴더에 수집했다
  • [ ] templates/payloads/utils 3층 구조를 만들었다
  • [ ] 템플릿 10종에 사용 조건·수정 지점 주석을 달았다
  • [ ] 템플릿 전부를 로컬에서 한 번씩 실행해 검증했다
  • [ ] 목차 생성기로 README를 만들고 주석 누락을 0건으로 만들었다
  • [ ] Git 커밋을 만들고 팀과 공유했다

6. 흔한 실수와 해결

벽 1. 바이트 리터럴에 한글을 넣었다가 스크립트가 안 돌아요

증상: 서버/클라이언트 코드에서 실행도 전에 문법 오류가 납니다.

실측 (2026-09-09 — 실제로 작성 중 발생한 오류):

conn.sendall(b"=== demo vault ===\n명령을 입력하세요 > ")
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
SyntaxError: bytes can only contain ASCII literal characters

원인: b"..." 바이트 리터럴은 ASCII만 허용됩니다. 네트워크 코드에서 메시지에 한글을 넣으면 이 오류가 납니다.

해결: 문자열로 쓰고 .encode()를 붙이세요 — "명령을 입력하세요 > ".encode(). 반대로 받은 바이트를 찍을 때는 .decode(errors="replace"). 송신은 encode, 수신은 decode — 이 방향만 기억하면 됩니다.

벽 2. 템플릿을 돌렸는데 연결 거부가 떠요

실측 (2026-09-09 — 서버 없이 템플릿만 실행한 경우):

ConnectionRefusedError: [WinError 10061] 대상 컴퓨터에서 연결을 거부했으므로 연결하지 못했습니다

원인: 접속 대상(서버)이 떠 있지 않거나, 포트가 틀렸습니다. 대회라면 VPN 미접속이나 문제 인스턴스 미시작이 같은 오류를 냅니다.

해결: 순서대로 확인하세요 — ① 대상 주소·포트 오타 ② 로컬 검증이라면 데모 서버를 먼저 띄웠는가 ③ 대회라면 문제 인스턴스 시작 버튼·VPN. 이 오류는 코드의 문제가 아니라 대상의 문제이므로, 코드를 고치기 전에 대상 상태부터 봅니다.

벽 3. 10종을 모으려니 자꾸 비슷한 템플릿만 나와요

증상: 웹 문제 템플릿 8개, 나머지 분야 2개.

원인: 팀이 푼 문제가 웹에 치중했다는 뜻이기도 합니다 — 라이브러리 편향은 팀 역량 편향의 거울입니다.

해결: 빈 분야의 템플릿은 블록 A에서 가져오세요. 못 푼 문제의 정답 코드를 정리해 템플릿화하는 것이 공백 메우기의 정석입니다. 그래서 3-1에서 "템플릿 후보?"를 물은 것입니다.

벽 4. 템플릿이 점점 비대해져요

증상: "이 경우도 처리해야지" 하며 if를 추가하다가 200줄이 됐습니다.

원인: 2-4의 함정 — 범용화의 유혹입니다.

해결: 80% 선에서 자르세요. 예외 케이스는 코드가 아니라 주석으로 남깁니다 — "이 템플릿은 X 경우에 깨진다, 그때는 Y로" 한 줄이면 충분합니다. 코드를 늘리는 대신 주석을 늘리는 것이 자산 관리입니다.

벽 5. 팀원이 공유 저장소에 정리 안 된 코드를 push해요

증상: 대회 직후 급조 코드가 그대로 올라와 읽을 수 없는 파일이 쌓입니다.

원인: 커밋 규칙의 합의 부재입니다.

해결: 브랜치로 분리하세요 — wip/ 폴더 또는 브랜치에 급조 코드를 두고, 정리(사용 조건 주석 + 로컬 검증)를 거친 것만 메인에 올립니다. 규칙은 한 문장이면 됩니다 — "주석 없는 코드는 메인에 없다."


7. 정리

오늘의 개념

개념 한 줄 설명
복기 블록 C 풀이 코드를 다음 대회에서 꺼내 쓸 수 있는 자산으로 다듬는 복기 축
3층 구조 templates(뼈대) / payloads(재료 사전) / utils(공용 함수)
사용 조건 주석 "어떤 문제에 쓰는가" — 10초 검색의 답
수정 지점 주석 "어디를 고치는가" — 안 읽고 고치기의 좌표
80% 재사용 범용화를 멈추는 선 — 나머지는 대회 중에 손으로 짠다
로컬 검증 저장 전 5초 실행 — "돌았다"는 보증을 새기는 절차

오늘의 도구·명령

도구·명령 하는 일
utils/net.pyremote() 표준 라이브러리만으로 TCP 프롬프트 다루기
build_index.py 사용 조건 주석 → 목차 README 자동 생성 + 주석 누락 탐지
demo_server.py 템플릿 로컬 검증용 가짜 서비스(localhost 전용)
git init/add/commit 라이브러리의 버전 관리와 팀 자산화
.encode() / .decode() 네트워크 송수신의 문자-바이트 경계 처리

명령어보다 중요한 감각

라이브러리의 첫 번째 사용자는 팀원이 아니라 대회 중의 나 자신입니다. 지친 상태의 내가 10초 안에 찾고, 안 읽고, 고쳐 쓸 수 있어야 자산입니다. 정리할 때 그 사람을 상상하세요 — 주석 두 줄은 과거의 내가 미래의 나에게 남기는 쪽지입니다.

그리고 라이브러리는 만들어지는 순간부터 낡기 시작합니다. 다음 대회(Step 291)에서 실제로 꺼내 쓰며 무너지는 지점을 찾고, 그 패치가 v2가 됩니다. 자산의 가치는 보관이 아니라 실전 투입으로 확인됩니다.


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