● CWE-362 · CWE-367

경쟁 조건과 TOCTOU(CWE-362, CWE-367): 병렬 요청이 검사를 무너뜨리는 방식과 해결 방법

값을 읽고, 확인한 뒤, 다시 쓰는 코드에서는 같은 순간에 도착한 두 요청이 모두 검사를 통과해 기프트 카드가 두 번 사용되거나 잔액이 마이너스가 됩니다. 확인과 실행 사이의 시간 창이 어떻게 생기는지, 애플리케이션 수준의 검사로는 왜 이를 닫을 수 없는지, 그리고 조건부 UPDATE ... RETURNING 한 문장, 행 잠금, 데이터베이스 제약 조건이 작업을 어떻게 원자적으로 만드는지 Python과 PostgreSQL 예제로 설명합니다.

알기 쉬운 설명 (ELI5)

어느 영화관에 매표소가 두 곳 있고 F열에는 빈자리가 하나뿐입니다. 두 직원이 같은 순간 좌석표를 보고 둘 다 F7이 비었다고 확인한 뒤 둘 다 팔아 버립니다. 둘 다 팔기 전에 확인은 했습니다. 문제는 확인과 판매가 서로 다른 순간이었고, 그 사이에 다른 직원이 움직였다는 점입니다. 해결책은 두 사람이 모두 한 권의 표 묶음에서 뜯어 가게 하는 것입니다. 마지막 F7 표를 뜯은 사람이 가져가고, 다른 사람은 묶음이 비었다는 것을 알게 됩니다. 확인과 가져가기가 하나의 동작이 됩니다.

핵심 개념 및 용어

TOCTOU(검사 시점과 사용 시점의 차이)
검사한 상태가 사용하기 전에 바뀔 수 있습니다. 웹 코드에서 검사는 보통 SELECT이고 사용은 그 뒤의 UPDATE이며, 그 사이에 다른 요청이 실행됩니다.
경쟁 창과 병렬 요청
창은 보통 몇 밀리초에 불과하지만, 공격자는 수십 개의 요청을 한꺼번에 도착하도록 맞출 수 있습니다. PortSwigger의 2023년 연구는 여러 HTTP/2 요청의 마지막 바이트들을 한 패킷에 담아 보내면 약 1밀리초 안에 모두 도착한다는 것을 보여 주었습니다.
원자적 조건부 업데이트
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...은 한 문장에서 행을 검사하고 바꿉니다. 행을 돌려받는 호출자는 하나뿐이고, 나머지는 아무것도 받지 못합니다.
Read Committed에서의 행 잠금
PostgreSQL에서 같은 행에 대한 두 번째 UPDATE는 첫 번째 트랜잭션을 기다린 다음, 커밋된 행을 기준으로 WHERE 절을 다시 평가합니다. 이 재검사 덕분에 격리 수준을 올리지 않아도 조건부 업데이트가 안전합니다.
제약 조건과 멱등성 키
UNIQUE 인덱스와 CHECK (balance_cents >= 0)는 경쟁에서 진 요청이 두 번 쓰이는 대신 명확히 실패하게 만듭니다. 고유 인덱스로 저장한 Idempotency-Key는 클라이언트가 결제를 중복 실행하지 않고 재시도할 수 있게 합니다.

단계별 공격 실행 흐름

단계 1

공격자가 유효한 기프트 카드 한 장을 가짐

정해진 금액의 카드 한 장과 적립할 계정이 있습니다.

단계 2

동시에 많은 사용 요청을 보냄

같은 코드의 요청 20개가 1밀리초 안에 도착합니다. 테스트 코드 TEST-0001을 쓰는 병렬 요청 두 개가 이 경우를 보여 줍니다.

단계 3

모든 요청이 검사를 통과함

어떤 요청도 카드를 사용 완료로 표시하기 전에 각 SELECT가 실행되므로, 모두 redeemed = false를 봅니다.

단계 4

잔액이 여러 번 적립됨

각 요청이 플래그가 기록되기 전에 카드 금액을 더합니다. 2015년 Egor Homakov는 스타벅스 기프트 카드 사이의 병렬 이체로 없던 잔액을 만들어 낼 수 있음을 보였습니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

취약한 구현
# gift_cards.py: 검사와 사용이 서로 다른 문장이다
from .db import pool


# 누가 쓰기 전에 병렬 요청마다 redeemed = false를 볼 수 있다
def redeem_gift_card(user_id: int, code: str) -> int:
    with pool.connection() as conn:
        card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
        if card is None or card[2]:
            raise ValueError("invalid or used card")
        conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
        conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
        return card[1]
보안 강화 패치
# gift_cards.py: 검사와 사용을 한 문장에서 처리한다
from .db import pool


# 행을 돌려받는 호출자는 하나뿐; 나머지는 잠금을 기다린 뒤 NOT redeemed에서 실패한다
def redeem_gift_card(user_id: int, code: str) -> int:
    with pool.connection() as conn, conn.transaction():
        card = conn.execute(
            "UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
            "WHERE code = %s AND NOT redeemed RETURNING balance_cents",
            (user_id, code),
        ).fetchone()
        if card is None:
            raise ValueError("invalid or used card")
        # 같은 트랜잭션에서 적립하므로 두 변경이 함께 커밋되거나 둘 다 되지 않는다
        conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
        return card[0]

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← 전체 보안 디렉터리 보기 모든 취약점 가이드 →