[🥉 Bronze 4] 화면의 "10초 뒤 공개"는 클라이언트만의 약속이었다 — DreamHack Test Your Luck 풀이

2026-07-21·1분 읽기·

[🥉 Bronze 4] 화면의 "10초 뒤 공개"는 클라이언트만의 약속이었다 — DreamHack Test Your Luck 풀이

0부터 10000 사이의 숫자를 맞히면 flag를 준다는 게임이다. 정답은 서버 프로세스가 뜰 때 딱 한 번 뽑혀 전역변수에 박히고, 그 뒤로는 절대 바뀌지 않는다. 화면에는 "결과는 10초 뒤에 공개됩니다"라고 적혀 있지만, 그 10초는 서버가 강제하는 규칙이 아니라 브라우저 자바스크립트의 setTimeout 한 줄일 뿐이다. 직접 요청을 찔러보면 서버는 밀리초 단위로 즉시 대답한다 — 그러면 10001가지 뿐인 정답을 그냥 처음부터 끝까지 다 물어보면 된다.

문제: 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)를 그대로 외부에 노출한다. 이 점이 뒤에서 "서버가 정말 단일 프로세스로만 도는가"를 확인하는 실험의 전제가 된다.

브라우저로 연 Test Your Luck 메인 페이지 — 0부터 10000 사이 숫자를 넣는 입력 폼과 '결과는 10초 뒤에 공개됩니다' 라는 안내 문구가 나란히 보인다

폼은 단순하다. 숫자 하나 입력하고 Submit을 누르면 된다. 문제는 정답을 어떻게 맞히느냐인데, 소스를 열어보면 답이 바로 보인다.


🔬 정찰 — 전역변수 하나가 게임의 전부다

app.py를 읽어보면 이 문제의 성격이 한 줄로 요약된다.

app.py 소스코드 — TARGET_NUMBER가 모듈 최상단 전역변수로 random.randint(0, 10000)에 고정되는 부분과 /guess 라우트의 단순 비교 로직

핵심은 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 -w 로 동일한 guess=0 요청을 연속 3회 보낸 결과, 매번 http=200에 응답시간 0.002초 내외로 10초 지연이 전혀 없음을 확인

curl -X POST /guess -d guess=0을 연속으로 세 번 날려도 매번 2~3밀리초 안에 응답이 온다. 서버는 정말로 아무 지연도, 아무 제한도 걸지 않는다. setTimeout은 순전히 "긴장감을 주는 로딩 연출"이었을 뿐, 어떤 서버측 규칙도 강제하지 않는다.

한 가지 더 확인하고 싶었던 건 프로세스 모델이었다. flask run이 내부적으로 워커를 여러 개 띄우거나(gunicorn의 --workers처럼), 요청이 들어올 때마다 새 프로세스로 처리한다면 TARGET_NUMBER가 워커마다 다른 값일 수 있고, 그러면 브루트포스 도중 "이전에 시도했던 값인데 이번엔 다른 워커가 받아서 결과가 널뛰는" 상황이 생길 수 있다. 로컬 컨테이너 안에서 실행 중인 프로세스를 직접 들여다봤다.

docker exec로 컨테이너 안 /proc 를 순회해 실행 중인 프로세스를 나열한 결과, flask run 프로세스가 pid 1 하나뿐임을 확인

/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 전역 고정 확인, setTimeout이 클라이언트뿐임을 코드로 확인, curl로 서버측 rate-limit 부재 실측, 0..10000 전수 브루트포스로 flag 획득까지 4단계
  1. TARGET_NUMBER는 프로세스가 뜰 때 한 번 고정되고 다시는 바뀌지 않는다.
  2. 화면의 10초 대기는 브라우저 JS의 setTimeout일 뿐, 서버는 아무 지연도 강제하지 않는다.
  3. 비교는 단순 ==이고 정답 후보는 10001가지뿐이다.

이 세 가지가 합쳐지면 공격이랄 것도 없는 전략이 성립한다 — requests.Session() 하나로 /guess에 0부터 10000까지 순서대로 POST를 날리고, 응답 JSON의 result 필드가 "Correct"로 바뀌는 순간을 기다리면 된다. 병렬화도 필요 없다. 순차로 돌려도 수 분이면 끝난다.

CTF에서 자주 나오는 "숫자 맞히기" 류 문제는 보통 random.seed()가 예측 가능한 시드를 쓰거나, PRNG의 내부 상태를 역산해야 하는 형태가 많다. 이 문제는 그런 암호학적 장치가 전혀 없다 — 그냥 후보 공간이 작고(10001가지), 서버가 몇 번이든 재시도를 허용하고, 클라이언트 쪽 UI 제한을 서버가 실제로는 강제하지 않는다는 세 가지 설계 실수가 겹친 결과다.


🧪 증거 — 브라우저에서 그대로 재현

실제로 오답과 정답을 각각 폼에 입력해서 결과가 어떻게 갈리는지 확인했다.

브라우저에서 1234를 제출한 결과, Result: Incorrect, flag: Try again~! 이 표시됨

1234처럼 무작위로 고른 값은 당연히 Incorrect가 뜬다. 브루트포스로 찾아낸 실제 정답인 2881을 같은 폼에 입력하면 결과가 바뀐다.

브라우저 폼에 브루트포스로 찾아낸 정답 2881 을 제출한 결과, Result: Correct 로 바뀌면서 flag 문자열이 응답에 그대로 실려 화면에 노출된다

애플리케이션 자체 UI로 재현했을 때도 스크립트로 얻은 것과 동일한 flag가 나온다. 서버 프로세스가 재시작되지 않는 한 이 값은 계속 유효하다.


🎯 자체 완결 solve

라이브 서버(정답 하나뿐이고 재시도 페널티도 없는 채점 VM)에 10001번을 통째로 쏟아붓기 전에, 먼저 로컬에서 같은 Dockerfile로 챌린지를 직접 빌드해 동작을 재현했다. 이렇게 하면 (1) 브루트포스 로직 자체에 버그가 없는지 부담 없이 반복 검증할 수 있고, (2) 앞선 삽질에서 확인한 "서버측 지연 없음"이 우연이 아니라 코드 구조상 원천적으로 그런지 다시 한번 못박을 수 있다.

deploy/Dockerfile로 로컬 이미지를 빌드하고 컨테이너를 띄운 뒤 curl로 즉시 응답을 받는 과정, 로컬 재현 확인

빌드는 몇 초 안에 끝나고, 컨테이너가 뜨자마자 curl로 찔러본 응답도 즉시 돌아온다. 로컬에서 뽑힌 TARGET_NUMBER는 당연히 라이브 서버와 다른 값이지만(각자 독립적으로 random.randint를 호출하므로), 동작 방식 자체는 완전히 동일함을 확인했다. 이 상태에서 로컬 컨테이너를 상대로 브루트포스 스크립트를 먼저 한 번 돌려 로직을 검증한 뒤에야 실제 채점 서버로 넘어갔다.

브루트포스 스크립트는 아주 짧다. requests.Session()을 재사용해서 커넥션을 매번 새로 맺지 않게 하고, 500번마다 진행상황을 찍어서 어디까지 왔는지 확인할 수 있게만 했다.

solve.py 전체 소스코드 — 0부터 10000까지 순차 POST로 /guess 를 찔러보고 result가 Correct가 되는 지점을 찾는 로직

라이브 서버를 상대로 실행한 결과다.

실제 라이브 서버에 guess=2881을 보낸 결과 result=Correct와 flag 값이 그대로 출력됨

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

댓글

0

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3
[🥉 Bronze 4] 부분 일치 누출로 4자리 사물함 번호 복원 — DreamHack random-test 풀이
blog

[🥉 Bronze 4] 부분 일치 누출로 4자리 사물함 번호 복원 — DreamHack random-test 풀이

로그인 폼이 전체 값이 아니라 입력 길이만큼의 prefix만 비교하면, 한 글자씩 정답을 떠보는 oracle이 된다. 2.1억 조합짜리 사물함 번호+비밀번호를 176번의 요청으로 복원해 플래그를 얻는 과정을 정리했다.
#dreamhack#ctf#web+5
2026-06-04#dreamhack +5
[🥈 Silver 2] 연결은 미리 다 맺어두고 마지막 1바이트만 아꼈다 — DreamHack chocoshop 레이스 컨디션 풀이
blog

[🥈 Silver 2] 연결은 미리 다 맺어두고 마지막 1바이트만 아꼈다 — DreamHack chocoshop 레이스 컨디션 풀이

쿠폰 하나로 1000원, 플래그는 2000원. 세션당 쿠폰은 딱 한 번만 발급된다는 제약이 있었지만, "이미 받았는지 확인"과 "받았다고 기록"하는 두 Redis 명령 사이에는 짧은 틈이 있었다. requests + ThreadPoolExecutor로 30개를 동시에 쏴봤지만 결과는 1장뿐이었다 — 스레드마다 새로 여는 connect() 오버헤드가 도착 시각을 흩어놓은 탓이었다. 소켓을 전부 미리 열어두고, 헤더의 마지막 1바이트만 아껴뒀다가 한 번에 흘려보내는 single-packet 기법까지 가서야 100건 중 90건 넘는 쿠폰을 한 번에 받아냈다.
#dreamhack#ctf#web+5
2026-07-20#dreamhack +5
[🥈 Silver 4] 5번 제한을 100번으로 — DreamHack login-1 풀이
blog

[🥈 Silver 4] 5번 제한을 100번으로 — DreamHack login-1 풀이

회원가입은 항상 guest 등급만 만든다. /user/idx의 IDOR로 관리자 계정(Apple, level=1)을 먼저 찾아내고, 비밀번호 재설정의 2자리 백업코드는 5회 오답이면 잠기지만 그 카운트 확인과 증가 사이에 TOCTOU 레이스가 있다. 100개 값을 동시에 쏘면 잠기기 전에 정답이 섞여 나간다.
#dreamhack#ctf#web+5
2026-07-06#dreamhack +5