문제: DreamHack — Test Your Luck 분류: Web 난이도: 🥉 Bronze 4 FLAG:
DH{ec5762d9893b04b4:HeNvDqBAku7pRP66ZGLeXw==}
숫자 하나를 맞히면 flag를 주는 게임이다. 범위는 0부터 10000까지, 총 10001가지. 화면에는 "결과는 10초 뒤에 공개됩니다"라는 문구가 큼직하게 붙어 있어서, 얼핏 보면 요청 자체에 어떤 지연이나 제한이 걸려 있을 것 같은 인상을 준다. 실제로 그런지, 아니면 그냥 사용자 경험을 위한 연출인지는 서버에 직접 물어보기 전까지는 알 수 없다.
난이도는 Bronze 4로 낮게 매겨져 있고 실제로도 익스플로잇 코드 자체는 단 몇 줄이면 충분하다. 하지만 이 문제가 재미있는 이유는 따로 있다 — "화면에 적힌 제약을 그대로 믿을 것이냐, 서버에 직접 확인할 것이냐"라는 아주 기본적인 태도의 차이가 풀이 시간을 몇 분과 27시간 사이 어딘가로 갈라놓는다는 점이다. 코드가 짧다고 정찰을 대충 하면 오히려 더 오래 걸리는, 역설적인 구조를 갖고 있다.
문제 개요
docker-compose.yml이 띄우는 건 Flask 앱 하나뿐이다. 라우트도 /(입력 폼)와 /guess(POST로 숫자 제출) 딱 두 개다. 소스가 통째로 공개돼 있으니 정찰은 짧게 끝난다.
services:
web:
build: ./deploy
ports:
- "5000:5000"
container_name: test-your-luck배포 구성도 최소한이다 — 리버스 프록시도, 별도의 인증 계층도 없이 Flask 개발 서버(flask run)를 그대로 외부에 노출한다. 이 점이 뒤에서 "서버가 정말 단일 프로세스로만 도는가"를 확인하는 실험의 전제가 된다.
폼은 단순하다. 숫자 하나 입력하고 Submit을 누르면 된다. 문제는 정답을 어떻게 맞히느냐인데, 소스를 열어보면 답이 바로 보인다.
🔬 정찰 — 전역변수 하나가 게임의 전부다
app.py를 읽어보면 이 문제의 성격이 한 줄로 요약된다.
핵심은 7번째 줄이다.
NUMBER_RANGE = (0, 10000)
TARGET_NUMBER = random.randint(*NUMBER_RANGE)이게 @app.route 데코레이터가 붙은 함수 안이 아니라 모듈 최상단, 즉 Flask 프로세스가 처음 임포트될 때 딱 한 번만 실행되는 위치에 있다. /guess 핸들러는 이 값을 매 요청마다 다시 뽑는 게 아니라, 프로세스가 뜰 때 이미 확정된 하나의 상수를 계속 참조만 한다.
@app.route('/guess', methods=['POST'])
def guess_number():
user_guess = int(request.form['guess'])
if user_guess == TARGET_NUMBER:
return jsonify({"result": "Correct", "flag": flag()})
else:
return jsonify({"result": "Incorrect", "flag": "Try again~!"})비교 로직도 그냥 == 하나다. 타입 캐스팅 함정도, 문자열 비교 트릭도 없다. int(request.form['guess'])가 예외를 던질 수 있는 입력(문자열, 빈 값)을 보내면 500 에러가 날 수는 있지만, 그건 정답 탐색과는 무관한 별개의 이야기다. 정답이 결정론적으로 고정돼 있고 범위가 10001가지뿐이라면, 남은 선택지는 하나다 — 전부 물어보면 된다. 다만 그 전에 화면에 박혀 있는 "10초 뒤 공개"라는 문구가 정말 이 전략을 방해하는지부터 확인해야 했다.
여기서 잠깐 짚고 넘어갈 부분이 있다. flag() 함수는 매 요청마다 ./flag 파일을 새로 읽는다.
def flag():
try:
FLAG = open("./flag", "r").read()
except:
FLAG = "[**FLAG**]"
return FLAG즉 flag 값 자체는 파일 시스템에 그대로 존재하고, TARGET_NUMBER를 맞히는 순간 그 내용을 그대로 읽어서 응답에 실어준다. 별도의 암호화나 인코딩 계층이 없으니, 이 문제의 전부는 결국 "TARGET_NUMBER와 같은 정수를 서버에 제출할 수 있는가" 하나로 수렴한다.
▶🕳️ 삽질 — 이 10초, 서버가 강제하는 거 맞아?
index.html의 제출 로직을 보면 의심이 생긴다.
form.onsubmit = async (e) => {
e.preventDefault();
const formData = new FormData(form);
await new Promise(resolve => setTimeout(resolve, 10000));
const response = await fetch('/guess', { method: 'POST', body: formData });
...
};setTimeout으로 10초를 기다린 다음에야 fetch를 호출한다. 만약 서버 쪽에도 "같은 세션에서 10초 안에 재요청하면 거부"하는 로직이 있다면, 10001번을 순서대로 물어보는 데 10001 × 10초 = 대략 27시간이 걸린다. 브루트포스 자체는 여전히 가능하지만 현실적인 시간 안에 끝내려면 다른 전략(세션을 수백 개로 병렬화한다거나)이 필요해진다.
그런데 /guess 라우트 코드 전체를 다시 훑어봐도 time.sleep도, 요청 시각을 기록하는 세션 값도, 어떤 종류의 rate-limit 미들웨어도 보이지 않는다. 코드에 없다는 것과 실제로 서버가 그렇게 동작하지 않는다는 것은 다른 이야기이므로, 로컬에 챌린지를 직접 띄워서 확인했다.

curl -X POST /guess -d guess=0을 연속으로 세 번 날려도 매번 2~3밀리초 안에 응답이 온다. 서버는 정말로 아무 지연도, 아무 제한도 걸지 않는다. setTimeout은 순전히 "긴장감을 주는 로딩 연출"이었을 뿐, 어떤 서버측 규칙도 강제하지 않는다.
한 가지 더 확인하고 싶었던 건 프로세스 모델이었다. flask run이 내부적으로 워커를 여러 개 띄우거나(gunicorn의 --workers처럼), 요청이 들어올 때마다 새 프로세스로 처리한다면 TARGET_NUMBER가 워커마다 다른 값일 수 있고, 그러면 브루트포스 도중 "이전에 시도했던 값인데 이번엔 다른 워커가 받아서 결과가 널뛰는" 상황이 생길 수 있다. 로컬 컨테이너 안에서 실행 중인 프로세스를 직접 들여다봤다.

/proc를 순회해서 실행 중인 명령줄을 전부 나열해보니 flask run --host=0.0.0.0 프로세스가 pid 1 딱 하나뿐이다. 워커도, 리로더 자식 프로세스도 없다. TARGET_NUMBER는 컨테이너가 살아있는 한 정말로 단 하나의 값으로 고정된다.
이 확인이 왜 필요했는지 조금 더 풀어보면 — 만약 배포에 gunicorn을 --workers 4로 얹었다면 얘기가 완전히 달라졌을 것이다. 워커마다 별도의 Python 인터프리터 프로세스가 뜨고, 각 프로세스가 모듈을 임포트할 때마다 random.randint를 독립적으로 호출하므로 워커 개수만큼 서로 다른 TARGET_NUMBER가 동시에 존재하게 된다. 그러면 브루트포스 요청이 로드밸런서(또는 OS 스케줄러)에 의해 매번 다른 워커로 튈 수 있고, "방금 그 숫자는 아까 이미 틀렸다고 나왔는데 왜 다시 물어보면 다른 결과가 나오지?" 같은 혼란스러운 상황이 생긴다. 이 경우엔 워커 하나당 정답이 따로 있으므로 이론적으로는 각 워커에 대해 별도로 10001가지를 전수조사해야 하고, 어떤 워커가 응답했는지조차 알 수 없다면 운에 맡기는 수밖에 없다. flask run이 기본값(디버그 모드 아님, 워커 지정 없음)으로 켜져 있었기에 이런 복잡성 없이 단일 프로세스·단일 정답이 보장된 것이고, 이는 코드만 읽어서는 100% 확신할 수 없는 부분이라 직접 컨테이너 내부를 들여다봐야 했다.
두 가정 모두 사실로 확인됐으니, 이제 망설일 이유가 없다 — 그냥 순서대로 다 물어보면 된다.
💣 핵심 — 10001가지뿐인 정답을 순서대로 다 물어본다
정리하면 이렇다.
TARGET_NUMBER는 프로세스가 뜰 때 한 번 고정되고 다시는 바뀌지 않는다.- 화면의 10초 대기는 브라우저 JS의
setTimeout일 뿐, 서버는 아무 지연도 강제하지 않는다. - 비교는 단순
==이고 정답 후보는 10001가지뿐이다.
이 세 가지가 합쳐지면 공격이랄 것도 없는 전략이 성립한다 — requests.Session() 하나로 /guess에 0부터 10000까지 순서대로 POST를 날리고, 응답 JSON의 result 필드가 "Correct"로 바뀌는 순간을 기다리면 된다. 병렬화도 필요 없다. 순차로 돌려도 수 분이면 끝난다.
CTF에서 자주 나오는 "숫자 맞히기" 류 문제는 보통 random.seed()가 예측 가능한 시드를 쓰거나, PRNG의 내부 상태를 역산해야 하는 형태가 많다. 이 문제는 그런 암호학적 장치가 전혀 없다 — 그냥 후보 공간이 작고(10001가지), 서버가 몇 번이든 재시도를 허용하고, 클라이언트 쪽 UI 제한을 서버가 실제로는 강제하지 않는다는 세 가지 설계 실수가 겹친 결과다.
🧪 증거 — 브라우저에서 그대로 재현
실제로 오답과 정답을 각각 폼에 입력해서 결과가 어떻게 갈리는지 확인했다.
1234처럼 무작위로 고른 값은 당연히 Incorrect가 뜬다. 브루트포스로 찾아낸 실제 정답인 2881을 같은 폼에 입력하면 결과가 바뀐다.
애플리케이션 자체 UI로 재현했을 때도 스크립트로 얻은 것과 동일한 flag가 나온다. 서버 프로세스가 재시작되지 않는 한 이 값은 계속 유효하다.
🎯 자체 완결 solve
라이브 서버(정답 하나뿐이고 재시도 페널티도 없는 채점 VM)에 10001번을 통째로 쏟아붓기 전에, 먼저 로컬에서 같은 Dockerfile로 챌린지를 직접 빌드해 동작을 재현했다. 이렇게 하면 (1) 브루트포스 로직 자체에 버그가 없는지 부담 없이 반복 검증할 수 있고, (2) 앞선 삽질에서 확인한 "서버측 지연 없음"이 우연이 아니라 코드 구조상 원천적으로 그런지 다시 한번 못박을 수 있다.
빌드는 몇 초 안에 끝나고, 컨테이너가 뜨자마자 curl로 찔러본 응답도 즉시 돌아온다. 로컬에서 뽑힌 TARGET_NUMBER는 당연히 라이브 서버와 다른 값이지만(각자 독립적으로 random.randint를 호출하므로), 동작 방식 자체는 완전히 동일함을 확인했다. 이 상태에서 로컬 컨테이너를 상대로 브루트포스 스크립트를 먼저 한 번 돌려 로직을 검증한 뒤에야 실제 채점 서버로 넘어갔다.
브루트포스 스크립트는 아주 짧다. requests.Session()을 재사용해서 커넥션을 매번 새로 맺지 않게 하고, 500번마다 진행상황을 찍어서 어디까지 왔는지 확인할 수 있게만 했다.
라이브 서버를 상대로 실행한 결과다.
guess=2881에서 result=Correct가 뜨고, 그 자리에서 flag가 그대로 딸려온다. 전체 10001가지를 처음부터 순차로 돌리면 대략 5분 안팎(서버 응답 지연에 따라 다름)에 끝난다 — 항목 수가 애초에 작아서 정렬이나 이분탐색 같은 최적화조차 필요 없다.
📝 결론
이 문제가 주는 교훈은 사실 단순하다. 클라이언트 쪽 코드에 적힌 제약은 서버가 강제하지 않는 한 아무 의미가 없다. setTimeout(10000)은 사람이 보기엔 "10초마다 한 번씩만 시도할 수 있구나"라는 인상을 주지만, 그건 어디까지나 브라우저 안에서 실행되는 자바스크립트일 뿐이다. 공격자는 fetch를 거치지 않고 /guess에 직접 요청을 보낼 수 있고, 서버는 그 요청이 브라우저에서 왔는지 curl에서 왔는지 구분할 방법이 없다.
방어 관점에서 보면 최소 두 가지가 빠져 있었다.
-
서버측 rate-limit 부재: 실제로 시도 횟수나 간격을 제한하고 싶었다면 Flask-Limiter 같은 미들웨어로 IP나 세션 단위 쿨다운을 서버에서 강제했어야 한다. 예를 들면 이렇게 라우트 데코레이터 한 줄로 끝나는 수준이다.
from flask_limiter import Limiter from flask_limiter.util import get_remote_address limiter = Limiter(app, key_func=get_remote_address) @app.route('/guess', methods=['POST']) @limiter.limit("1 per 10 seconds") def guess_number(): ...클라이언트 JS의
setTimeout은 사용자 경험 연출이지 보안 통제가 아니다. 이 한 줄이 있었다면 10001가지를 순차로 다 시도하는 데 정말로 27시간 넘게 걸렸을 것이고, 그마저도 세션/IP당 시도 횟수 제한이 함께 있었다면 브루트포스 자체가 성립하지 않았을 것이다. -
작은 후보 공간 + 무제한 재시도: 정답 후보가 10001가지뿐이고 오답에 아무 페널티도 없다면, 시도 횟수 제한이나 실패 시 세션 초기화 같은 장치 없이는 전수조사를 막을 방법이 없다. 애초에 후보 공간을 넓히거나(예: UUID 기반 토큰), 몇 회 이상 오답 시 아예 새 라운드로 강제 전환하는 설계가 필요했다.
-
동일 프로세스 내 전역 정답 재사용:
TARGET_NUMBER가 요청마다 다시 뽑히는 구조였다면(예: 세션별로 별도 정답을 부여하고 세션이 끝나면 폐기), 한 번 브루트포스로 뚫린 값이 다른 시도에 재사용될 수도 없었을 것이다. 전역 상태를 여러 사용자가 공유하는 구조 자체가 "한 번만 계산하면 영구히 유효한 답"을 만들어낸 셈이다.
이 세 가지는 각각 독립적으로도 공격을 무력화할 수 있는 장치들이었다. 그런데 셋 다 빠져 있었기 때문에, 정찰 단계에서 "서버가 정말 아무것도 강제하지 않는다"는 사실 하나만 확인하면 나머지는 단순 반복 작업으로 끝나버렸다.
"UI에 그렇게 적혀 있으니 서버도 그렇게 동작하겠지"라는 가정은 공격자 입장에서는 검증 없이 믿을 이유가 없는 가정이고, 방어자 입장에서는 절대 의존해서는 안 되는 가정이다. 클라이언트 코드는 언제나 공격자가 읽고, 수정하고, 우회할 수 있는 신뢰 경계 바깥의 영역이라는 원칙은 이런 사소해 보이는 문제에서도 그대로 적용된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…