Step 201. 레이스 컨디션, HTTP 요청 스머글링 개념 — 찰나와 어긋남의 공격
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★★☆ | 예상 소요 시간 4시간
전제: Step 78에서 스레드와 레이스 컨디션을 재현해 봤고, Step 131 구간에서 HTTP 요청의 구조(헤더·본문)를 안다.
- 준비물: 파이썬 3(표준 라이브러리만 사용), 터미널 두 개, Step 78의
race2.py기억. - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다. 오늘의 레이스 컨디션 실습은 100% 내 컴퓨터(127.0.0.1) 안에서 일어납니다.
지금까지의 웹 공격이 "무엇을 보낼까"의 싸움이었다면, 오늘의 두 공격은 "언제 보낼까"와 "경계를 어디로 읽을까"의 싸움입니다. 레이스 컨디션(race condition)은 확인과 사용 사이의 찰나를 동시 요청으로 비집는 공격이고, HTTP 요청 스머글링(request smuggling)은 프록시와 웹서버가 요청의 끝을 다르게 읽는 틈을 이용하는 고급 공격입니다.
둘 다 페이로드 하나로는 설명이 안 되는, 구조의 취약점입니다. 그래서 오늘은 개념이 주인공이고, 실습은 레이스 컨디션의 로컬 재현 하나에 집중합니다. 스머글링은 그림으로 원리를 잡고, 실습은 PortSwigger의 공식 랩에 맡깁니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- TOCTOU(확인-사용 시점 차이)의 구조를 그림으로 설명한다
- 쿠폰 1회 사용 서버에 동시 요청을 보내 레이스 컨디션을 로컬에서 재현한다
- 레이스 공격의 성공 확률을 올리는 요인(요청 수, 처리 지연)을 말한다
- HTTP 요청 스머글링의 CL.TE / TE.CL 두 형태를 해석 불일치로 설명한다
- 두 공격의 방어 원리(원자적 처리, 해석 통일)를 말한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 표준 라이브러리 (http.server, threading, urllib) — 설치 불필요 |
| 오늘의 코드 | ThreadingHTTPServer(요청마다 스레드), 스레드 20개 동시 요청, time.sleep으로 틈 벌리기 |
| 필요한 개념 | TOCTOU, 원자적(atomic) 처리, Content-Length와 Transfer-Encoding, 프록시-서버 해석 차이 |
| 오늘의 산출물 | 레이스 컨디션 재현 기록 + 스머글링 구조 도식 이해 |
2-1. TOCTOU — 확인과 사용 사이의 찰나
TOCTOU(Time-of-check to time-of-use)는 "확인한 시점"과 "사용하는 시점"이 다른 데서 오는 취약점입니다. 코드는 이렇게 생겼습니다.
① 확인: 이 쿠폰은 아직 안 쓰였는가? → 예
← 이 사이에 시간이 흐른다
② 사용: 쿠폰을 사용 처리한다
①과 ② 사이에 다른 요청이 끼어들면 어떻게 될까요? 두 요청이 모두 ①을 통과한 뒤 ②를 실행하면, 1회용 쿠폰이 두 번 적용됩니다. Step 78에서 카운터 덧셈이 증발하던 것과 같은 뼈대 — 읽기와 쓰기 사이에 다른 실행 흐름이 끼어드는 문제입니다.
2-2. Step 78의 실측 복기
Step 78에서 우리는 이 현상을 이미 봤습니다 (2026-09-09 실측):
race.py— 스레드 10개가 각각 10만 번씩 더했는데 결과는 계속 정답 1000000. 충돌 창이 좁아서 붕괴가 안 보였습니다.race2.py— 읽기와 쓰기 사이에time.sleep(0)로 틈을 벌리자 결과가 20575, 20489, 20607로 붕괴했습니다 (2026-09-09 실측).
여기서 얻은 법칙 두 개가 오늘 그대로 쓰입니다. 레이스 컨디션은 "잘 안 재현되는 것"이 특징이고, 틈을 벌려 주면 터진다. 오늘은 그 틈을 HTTP 서버에 만듭니다.
2-3. 웹에서의 레이스 — 왜 위험한가
웹 서버는 요청마다 별도의 실행 흐름(스레드나 프로세스)으로 처리합니다. "잔액 확인 → 출금", "쿠폰 확인 → 사용", "재고 확인 → 주문"처럼 확인과 사용이 나뉜 기능은 전부 후보입니다. 공격자는 같은 요청을 수십 개 동시에 보내, 전부가 확인을 통과하게 만듭니다.
방어는 "확인과 사용을 나눌 수 없게 만드는 것" — 데이터베이스의 원자적 연산(트랜잭션, 조건부 UPDATE)이나 잠금으로, 두 단계를 한 덩어리로 묶습니다.
2-4. HTTP 요청 스머글링 — 두 개의 자
HTTP/1.1에서 요청의 끝을 알리는 방법은 두 가지입니다. Content-Length 헤더(본문의 바이트 수)와 Transfer-Encoding: chunked(덩어리 단위 전송)입니다. 규격상 둘이 같이 오면 Transfer-Encoding이 우선이지만, 모든 서버가 이 규칙을 같게 지키지는 않습니다.
문제는 요청이 보통 프록시(앞문) → 웹서버(본진) 두 관문을 지난다는 것입니다. 앞문과 본진이 서로 다른 자를 쓰면, 요청의 경계가 어긋납니다.
[CL.TE 형태] 앞문은 Content-Length를, 본진은 Transfer-Encoding을 신뢰
POST / HTTP/1.1
Content-Length: 13 ← 앞문: "본문은 13바이트"
Transfer-Encoding: chunked ← 본진: "chunked로 읽자"
0 ← 본진은 여기서 요청이 끝났다고 판단
← 그 뒤에 숨긴 다음 요청이 본진의 큐에 슬쩍 끼어든다
[TE.CL 형태] 앞문은 Transfer-Encoding을, 본진은 Content-Length를 신뢰
앞문은 chunked를 다 읽어 넘기지만, 본진은 Content-Length만큼만 읽고
남은 바이트를 "다음 요청의 시작"으로 오독한다
2-5. 스머글링이 여는 사고
어긋난 경계에 숨겨 둔 요청은 본진 기준으로 "다음 사용자의 요청 앞에 붙은" 형태가 됩니다. 이것으로 다른 사용자의 요청을 가로채거나, 캐시를 오염시키거나, 앞문의 접근 제어를 우회할 수 있습니다. 페이로드가 아니라 경계 해석 자체가 공격 지점이라, WAF 같은 방어도 앞문과 본진의 해석이 갈리면 뚫립니다.
방어 원리는 단순합니다. 양쪽의 해석을 통일하거나(HTTP/2 사용, 모호한 요청 거부), 헤더가 충돌하는 요청을 아예 받지 않는 것입니다.
3. 따라 하기
3-1. 실험 장치 — 쿠폰 1회 사용 서버
race_coupon.py를 만듭니다. 표준 라이브러리만으로 된, 확인과 사용 사이에 0.2초의 틈이 있는 서버입니다.
입력 (race_coupon.py)
"""레이스 컨디션 실측: 쿠폰 1회 사용 서버에 동시 요청 20발."""
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
state = {"coupon_used": False} # 쿠폰은 단 1회만 사용 가능해야 함
DELAY = 0.2 # 확인 → 사용 처리 사이의 인위적 틈
class CouponHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path.startswith("/use-coupon"):
# 1) 확인(check): 쿠폰이 아직 안 쓰였는가?
if not state["coupon_used"]:
time.sleep(DELAY) # ← 이 틈에 다른 요청들이 확인을 통과한다
# 2) 사용 처리(use)
state["coupon_used"] = True
body = b"OK coupon applied"
else:
body = b"FAIL already used"
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass
def attack(port, n=20):
results = []
threads = []
def fire():
with urllib.request.urlopen(f"http://127.0.0.1:{port}/use-coupon") as r:
results.append(r.read().decode())
for _ in range(n):
t = threading.Thread(target=fire)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
return results
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", 0), CouponHandler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
results = attack(port, n=20)
ok = sum(1 for r in results if r.startswith("OK"))
fail = sum(1 for r in results if r.startswith("FAIL"))
print(f"동시 요청: 20개")
print(f"쿠폰 적용 성공(OK): {ok}개")
print(f"거부(FAIL): {fail}개")
print(f"결론: 1회용 쿠폰이 {ok}번 적용됨" + (" — 레이스 컨디션 발생" if ok > 1 else ""))
server.shutdown()
읽는 법: ThreadingHTTPServer는 요청마다 새 스레드를 띄웁니다. do_GET 안의 if not state[...]가 ① 확인, 그 뒤 state[...] = True가 ② 사용입니다. 그 사이의 time.sleep(0.2)가 TOCTOU의 창입니다 — 실제 서비스에서는 DB 조회, 외부 API 호출 등이 이 틈을 만듭니다.
3-2. 동시 요청 20발 — 레이스 발생
실행합니다. 같은 명령을 세 번 실행한 결과입니다 (2026-09-09 파이썬 3.12.14 실측):
python race_coupon.py
동시 요청: 20개
쿠폰 적용 성공(OK): 11개
거부(FAIL): 9개
결론: 1회용 쿠폰이 11번 적용됨 — 레이스 컨디션 발생
(2026-09-09 실측, 세 번 실행 모두 OK 11 / FAIL 9.)
읽는 법: 1회용 쿠폰이 열한 번 적용됐습니다. 스레드 스무 개가 거의 동시에 출발해, 그중 열한 개가 0.2초의 창 안에 ① 확인을 통과한 것입니다. 서버 로직은 "1회만"을 검사했는데도 결과는 11회입니다. 검사 코드가 거짓말을 한 것이 아니라, 검사와 처리 사이의 시간이 배신한 것입니다.
왜: 이것이 쇼핑몰 쿠폰 중복 사용, 잔액 이상 출금, 좌석 중복 예매의 축소 모형입니다. 실전에서는 Burp의 단일 패킷 공격(single-packet attack)으로 스무 개 요청을 진짜로 같은 순간에 도착시킵니다.
3-3. 대조 실험 — 틈이 없으면 안 터진다
Step 78의 법칙을 확인합니다. 같은 코드에서 지연만 0으로 바꿉니다.
import race_coupon, threading
from http.server import ThreadingHTTPServer
race_coupon.DELAY = 0.0 # 확인과 사용 사이의 틈 제거
server = ThreadingHTTPServer(("127.0.0.1", 0), race_coupon.CouponHandler)
port = server.server_address[1]
threading.Thread(target=server.serve_forever, daemon=True).start()
results = race_coupon.attack(port, n=20)
ok = sum(1 for r in results if r.startswith("OK"))
fail = sum(1 for r in results if r.startswith("FAIL"))
print(f"틈 없음(DELAY=0): OK {ok}개 / FAIL {fail}개")
틈 없음(DELAY=0): OK 1개 / FAIL 19개
(2026-09-09 실측.)
읽는 법: 틈이 0이면 정확히 한 번만 적용됩니다. 파이썬의 스레드 교체 간격보다 확인-처리가 빨라서 충돌이 안 일어난 것입니다. 이 대조가 말해 주는 교훈은 Step 78과 같습니다 — "재현이 안 된다"와 "안전하다"는 다릅니다. 틈이 좁을 뿐인 취약점은 테스트를 통과하고 실전에서 터집니다.
왜: 공격자 관점에서는 반대로 읽습니다. 틈을 벌리는 조건 — 요청 수를 늘리고, 서버가 느려지는 시간대를 노리고, 처리가 무거운 기능을 고르는 것 — 이 곧 성공 확률을 올리는 손잡이입니다.
3-4. 스머글링 구조 도식 — 두 개의 자가 만드는 어긋남
로컬 재현은 생략하고(실습은 PortSwigger 랩 권장), 구조를 그림으로 정밀하게 봅니다. CL.TE 형태의 요청 흐름입니다.
공격자가 보낸 하나의 요청 덩어리:
POST / HTTP/1.1
Host: example.com
Content-Length: 13 ← 앞문(프록시)이 보는 자: 13바이트까지가 본문
Transfer-Encoding: chunked ← 본진(웹서버)이 보는 자: chunked 규칙
0 ← 본진: "chunk 종료. 요청 끝!" (앞문은 이걸 본문의 일부로 셈)
GET /admin HTTP/1.1 ← 본진: 이건 "다음 요청의 시작"으로 처리된다
Host: example.com
읽는 법: 앞문은 Content-Length대로 읽어서 이 전체를 "무해한 요청 하나"로 본진에 넘깁니다. 본진은 chunked 규칙으로 읽다가 0에서 요청이 끝났다고 판단하고, 그 뒤의 GET /admin을 새 요청으로 처리합니다. 앞문의 접근 제어(/admin 차단)는 앞문 기준으로는 존재하지 않는 요청이라 검사하지 못합니다.
TE.CL은 이 신뢰가 뒤집힌 형태입니다 — 앞문이 chunked로 읽어 전부 넘기는데 본진이 Content-Length만큼만 읽어, 남은 바이트가 다음 요청과 이어 붙습니다.
3-5. 방어의 눈으로 다시 보기
두 공격의 방어를 한 줄씩 정리합니다.
- 레이스 컨디션: 확인과 사용을 원자적 연산으로 묶는다. DB라면
UPDATE ... WHERE coupon_used = 0처럼 조건과 변경을 한 문장으로. 파일이라면 잠금. 파이썬이라면threading.Lock()으로 ①②를 감싼다. - 스머글링: 앞문과 본진의 해석을 통일한다(둘 다 HTTP/2, 또는 둘 다 같은 파서). Content-Length와 Transfer-Encoding이 공존하는 모호한 요청은 거부한다. 본진으로 가는 연결을 재사용하지 않는 설정도 완충이 된다.
4. 미션과 연습문제
미션 — 레이스 재현과 스머글링 설명
- 3-1의 서버를 실행해 쿠폰이 2번 이상 적용되는 장면을 재현하고, 실행 결과를 기록합니다
DELAY값을 0.2 → 0.05 → 0으로 바꿔 가며 OK 개수가 어떻게 변하는지 표로 기록합니다- 요청 수를 20 → 40으로 늘렸을 때의 변화도 함께 기록합니다
- CL.TE 형태의 스머글링을 "앞문이 본 것 / 본진이 본 것" 두 줄의 그림으로 직접 그려 설명합니다
연습문제
문제 1. TOCTOU의 네 글자가 뜻하는 두 시점은 무엇이며, 그 사이의 시간이 길어질수록 어떤 일이 생기나요?
문제 2. 3-2에서 1회용 쿠폰이 11번 적용됐습니다. 서버 코드의 어느 부분이 "1회만"을 보장하려 했고, 왜 실패했나요?
문제 3. 3-3 대조 실험에서 DELAY=0이면 OK가 1개였습니다. 그렇다면 "우리 서비스는 테스트에서 중복 적용이 안 나왔으니 안전하다"는 주장의 어디가 틀렸나요?
문제 4. CL.TE 스머글링에서 앞문(프록시)과 본진(웹서버)은 각각 어느 헤더를 신뢰하며, 그 불일치가 만드는 결과는 무엇인가요?
5. 모범 답안과 완료 기준
미션 모범 답안
기록 표의 예시입니다 (2026-09-09 실측값과 여러분의 값은 다를 수 있습니다 — 확률의 문제라 정상입니다):
[레이스 컨디션 재현 기록]
DELAY=0.2, 요청 20개 → OK 11개, FAIL 9개 (3회 반복 동일)
DELAY=0, 요청 20개 → OK 1개, FAIL 19개
→ 틈이 넓을수록, 요청이 많을수록 성공률 상승 확인
스머글링 그림의 핵심은 두 줄입니다: "앞문은 Content-Length: 13을 믿어 전체를 하나의 본문으로 봤다 / 본진은 Transfer-Encoding: chunked를 믿어 0에서 끊고 나머지를 새 요청으로 처리했다."
검증하는 법: ① 재현 기록에 OK 2개 이상이 찍혔는가. ② DELAY 변화와 OK 개수의 상관이 표에 드러나는가. ③ 스머글링 그림에 "누가 어떤 헤더를 믿었는가"가 명시됐는가.
연습문제 해답
문제 1 해답. 확인 시점(time-of-check)과 사용 시점(time-of-use)입니다. 사이 시간이 길어질수록 다른 실행 흐름이 끼어들 확률이 커져, 확인 결과가 사용 시점에는 이미 낡은 정보가 됩니다. 3-2에서 OK 11개는 0.2초의 창에 열한 개의 흐름이 끼어든 결과입니다.
문제 2 해답. if not state["coupon_used"] 검사가 "1회만"을 보장하려 했습니다. 그러나 검사와 state["coupon_used"] = True 처리 사이에 time.sleep으로 표현된 시간 간격이 있고, 요청마다 별도 스레드가 돌기 때문에 여러 요청이 동시에 검사를 통과합니다. 검사는 정직했지만 원자적이지 않았습니다.
문제 3 해답. 테스트 환경에서는 충돌 창이 좁아 우연히 안 터졌을 뿐이라는 점입니다. 실전에서는 느린 DB, 많은 동시 사용자, 네트워크 지연이 그 창을 넓힙니다. Step 78의 race.py(백만 정답)와 race2.py(붕괴)의 차이가 바로 이 이야기입니다 — "재현 안 됨"은 "안전"이 아닙니다.
문제 4 해답. 앞문은 Content-Length를, 본진은 Transfer-Encoding을 신뢰합니다. 그 결과 본진은 본문 중간의 chunk 종료 표시(0)에서 요청이 끝났다고 판단하고, 앞문이 본문으로 본 나머지 바이트를 새로운 요청으로 처리합니다. 숨겨 둔 요청이 본진의 큐에 끼어드는 것이 스머글링입니다.
완료 기준 체크리스트
- [ ] TOCTOU의 구조(확인 → 틈 → 사용)를 그림으로 설명할 수 있다
- [ ] 쿠폰 서버에 동시 요청을 보내 레이스 컨디션을 재현했다
- [ ] 틈의 크기·요청 수와 성공률의 관계를 실험으로 확인했다
- [ ] CL.TE와 TE.CL의 차이를 "누가 무엇을 믿는가"로 설명할 수 있다
- [ ] 레이스의 방어(원자적 처리)와 스머글링의 방어(해석 통일)를 말할 수 있다
- [ ] "재현이 안 된다 ≠ 안전하다"를 예를 들어 설명할 수 있다
- [ ] 미션: 재현 기록 표와 스머글링 도식을 완성했다
6. 흔한 실수와 해결
벽 1. 레이스가 재현이 안 된다 (OK가 1개만 나온다)
증상: 스무 개를 보냈는데 한 번만 적용됩니다.
원인: 틈이 좁거나, 스레드가 순차적으로 처리되고 있습니다. 파이썬은 스레드를 아주 짧은 간격으로만 교체합니다 (Step 78의 벽 5와 같은 원리).
해결: DELAY를 키우고(0.2 → 0.5), 요청 수를 늘리세요 (2026-09-09 실측: DELAY=0.2, 20개에서 OK 11개). 실험에서 틈을 벌리는 것은 부정이 아니라 구조를 보기 위한 확대경입니다.
벽 2. 파이썬 GIL 때문에 진짜 동시가 아니라는 말을 들었다
증상: "GIL이 있어서 스레드는 동시에 안 돌지 않나?"라는 의문.
원인: 반은 맞습니다 — CPU 계산은 한 번에 한 스레드만 돕니다.
해결: 그러나 time.sleep과 네트워크 대기 중에는 GIL이 풀려 다른 스레드가 돕니다. 오늘의 레이스는 바로 그 "기다리는 동안"에 일어납니다. 실제 서버도 I/O 대기가 대부분이라 같은 구조입니다 (2026-09-09 실측에서 OK 11개가 그 증거).
벽 3. 서버 코드를 고쳤는데 변화가 없다
증상: DELAY를 바꿨는데 결과가 같습니다.
원인: 수정한 파일이 아니라 예전 프로세스가 돌고 있거나, import 캐시를 보고 있습니다.
해결: 서버 프로세스를 완전히 끄고 다시 실행하세요. 모듈로 import해 실험한다면 새 파이썬 프로세스에서 다시 import합니다.
벽 4. 스머글링을 로컬에서 재현해 보고 싶은데 구조가 안 잡힌다
증상: 프록시-본진 구조를 집에 만들기가 버겁습니다.
원인: 정상입니다. 프록시 두 층 구조는 입문 랩의 범위를 넘습니다.
해결: 오늘은 3-4의 도식을 손으로 그려 보는 것으로 충분합니다. 실습은 PortSwigger Web Security Academy의 request smuggling 랩(무료)에 맡기고, 탐지 도구로 http-request-smuggler(Burp 확장)가 있다는 것만 확인해 두세요.
벽 5. chunked 인코딩의 0이 뭔지 모르겠다
증상: 도식의 0 한 줄이 왜 "끝"인지 이해가 안 됩니다.
원인: chunked 전송은 각 덩어리 앞에 크기를 16진수로 적는 규약이고, 크기 0이 "더 이상 덩어리 없음"의 약속입니다.
해결: 0\r\n\r\n이 chunked 메시지의 마침표라고만 기억하면 됩니다. 본진은 그 마침표를 보고 요청을 끊고, 그 뒤의 바이트를 다음 요청으로 읽기 시작합니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| 레이스 컨디션 | 실행 순서의 타이밍에 따라 결과가 달라지는 취약점 |
| TOCTOU | 확인 시점과 사용 시점의 차이 — 그 사이가 공격의 창 |
| 원자적(atomic) 처리 | 확인과 사용을 나눌 수 없는 한 덩어리로 묶는 방어 |
| Content-Length | 본문의 바이트 수로 끝을 알리는 헤더 |
| Transfer-Encoding: chunked | 크기를 앞에 적은 덩어리들로 보내는 전송 방식 |
| HTTP 요청 스머글링 | 앞문과 본진의 요청 경계 해석 차이를 이용하는 공격 (CL.TE / TE.CL) |
오늘의 코드와 도구
| 코드·도구 | 하는 일 |
|---|---|
ThreadingHTTPServer |
요청마다 스레드를 띄우는 실험용 웹서버 |
time.sleep(DELAY) |
TOCTOU의 창을 인위적으로 벌리는 확대경 |
스레드 N개 + urllib.request.urlopen |
동시 요청 공격 시뮬레이션 |
threading.Lock() |
확인-처리 구간을 묶는 방어 장치 |
| Burp 단일 패킷 공격 | 실전에서 요청들을 같은 순간에 도착시키는 기법 |
| http-request-smuggler | 스머글링 해석 불일치를 탐지하는 Burp 확장 |
명령어보다 중요한 감각
오늘의 두 공격은 공통점이 있습니다. 둘 다 코드 한 줄의 버그가 아니라 구조의 틈입니다 — 하나는 시간의 틈, 하나는 해석의 틈. 그래서 발견도 "페이로드"가 아니라 "이 기능은 확인과 처리가 나뉘어 있는가", "이 요청은 관문을 두 개 지나는가"라는 질문에서 시작합니다. 그리고 대조 실험이 보여 준 마지막 교훈: 테스트에서 안 터지는 버그가 실전에서도 안 터지란 법은 없습니다. 틈의 존재를 아는 눈이 타이밍보다 먼저입니다.
전부 체크되면 Step 201 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.