[🥉 Bronze 2] 스탯 창에 목표값이 통째로 적혀 있었다 — DreamHack dungeon-in-1983 풀이

2026-08-12·1분 읽기·

[🥉 Bronze 2] 스탯 창에 목표값이 통째로 적혀 있었다 — DreamHack dungeon-in-1983 풀이

스테이지마다 /dev/urandom 에서 뽑은 64비트 난수를 맞춰야 하는데, 몬스터 스탯 7개가 그 난수를 바이트 단위로 쪼개 그대로 출력해 준다. 되조립한 값을 A(+1)·B(x2) 두 버튼으로 만들면 되고, 연속 A 금지 규칙 때문에 답은 자연히 이진 전개가 된다.

문제: DreamHack — dungeon-in-1983 분류: reversing 난이도: 🥉 Bronze 2 FLAG: DH{ddfd394275e80d059f04cee16667ad40e81820a0b105e2f80f0d17544a274a62}

문제 설명은 딱 한 줄이다. "Back in 1983, when you only had A and B on your controller..."

1983년이면 패미컴이 나온 해다. 컨트롤러에 버튼이 A와 B밖에 없던 시절 얘기고, 실제로 이 문제에서 우리가 서버에 보낼 수 있는 글자도 그 둘뿐이다.


문제 개요

항목내용
문제명dungeon-in-1983
난이도🥉 Bronze 2
분류reversing
제공 파일prob(ELF64 PIE, stripped, 14KB) · Dockerfile · deploy/flag(더미)
서버socat TCP-LISTEN:31337 EXEC:/home/dungeon/prob (제한시간 5초)
핵심 취약점정답으로 쓸 난수를 화면에 스탯으로 전부 출력한다 (정보 노출)

풀이 흐름은 짧다. 스테이지마다 /dev/urandom 에서 8바이트를 읽어 목표값으로 삼는데, 그 직후 출력되는 [INFO] 줄이 같은 8바이트를 HP·STR·AGI·VIT·INT·END·DEX 로 잘라 전부 보여준다. 목표값을 되조립한 뒤, A(더하기 1)와 B(곱하기 2) 두 연산만으로 그 수를 만드는 순서를 짜면 끝이다.

여기에 "A를 연속으로 누를 수 없다"는 제약이 붙는데, 이 제약이 오히려 정답을 하나로 좁혀 준다.



🔬 정찰 — 무엇이 들어 있나

먼저 파일 형태와 보호 기법부터 본다.

file extracted/prob && readelf -h -d extracted/prob | grep -E "Type:|Entry|NEEDED|BIND_NOW|Flags"
file 과 readelf 로 확인한 prob 의 기본 정보 — ELF 64비트 PIE 실행 파일이고 심볼이 stripped 되어 있으며 동적 섹션 FLAGS 가 BIND_NOW, FLAGS_1 이 NOW PIE 로 Full RELRO 구성이다
file 과 readelf 로 확인한 prob 의 기본 정보 — ELF 64비트 PIE 실행 파일이고 심볼이 stripped 되어 있으며 동적 섹션 FLAGS 가 BIND_NOW, FLAGS_1 이 NOW PIE 로 Full RELRO 구성이다

PIE에 Full RELRO, 게다가 stripped다. 다만 이건 pwn 문제가 아니라 reversing 문제라 보호 기법 자체는 별 의미가 없다. 중요한 건 심볼이 없으니 함수 이름 없이 주소로만 읽어야 한다는 점.

문자열부터 훑으면 게임의 뼈대가 거의 다 드러난다.

strings -a -n 5 extracted/prob | sed -n "/Basilisk/,/flag: /p"
strings 로 뽑은 prob 의 문자열 구간 — Basilisk 부터 Roc 까지 몬스터 이름과 INFO 포맷 문자열, 세 종류의 거절 메시지, dev urandom 경로, 그리고 flag 를 건네는 마지막 문장이 순서대로 보인다
strings 로 뽑은 prob 의 문자열 구간 — Basilisk 부터 Roc 까지 몬스터 이름과 INFO 포맷 문자열, 세 종류의 거절 메시지, dev urandom 경로, 그리고 flag 를 건네는 마지막 문장이 순서대로 보인다

읽히는 것들:

  • 몬스터 이름 9개가 보이고(Basilisk ~ Manticor), 코드에는 Roc 까지 열 마리가 박혀 있다.

  • [INFO] HP: %5hu, STR: %5hhu, ... — 스탯 출력 포맷. HP만 %hu(16비트)이고 나머지 여섯은 %hhu(8비트)다. 16 + 8×6 = 64비트. 이 산수가 이 문제의 전부다.

  • 거절 메시지 세 개: A button stucked! Retreat..., Lore says the spell should start with A..., Invalid button!

  • /dev/urandom 과 ./flag

  • It's dangerous to go alone! Take the flag: — 젤다 첫 던전 대사다. 여기까지 가면 이긴 것.

Dockerfile 은 별다른 게 없다. ubuntu 22.04를 digest로 고정해 놓고 socat -T 25 TCP-LISTEN:31337,reuseaddr,fork EXEC:/home/dungeon/prob 로 띄운다. 즉 서버 쪽은 그냥 이 바이너리를 표준입출력에 물린 것뿐이고, 로컬에서 돌린 것과 원격이 완전히 같다.



🧠 main 읽기 — 난수 하나에 스테이지 하나

심볼이 없으니 main 은 엔트리에서 넘겨받는 주소로 찾는다. 0x1200 의 _start 가 lea rdi,[rip+0x2ea] 로 0x1509 를 잡아 __libc_start_main 에 넘기므로 main = 0x1509 다.

거기서 하는 일을 정리하면 이렇다.

// main @ 0x1509 (재구성)
char monsters[10][20] = {"Basilisk", ..., "Roc"};
FILE *rnd = fopen("/dev/urandom", "r");
 
for (int i = 0; i <= 9; i++) {
    unsigned long val;
    fread(&val, 8, 1, rnd);                    // 목표값 8바이트
    printf("[STAGE %2d]: %s\n", i + 1, monsters[i]);
    print_info(val);                           // ← 0x138d
    printf("Cast your spell!: ");
    fgets(buf, 200, stdin);                    // 개행 잘라내기
    if (!check(buf, val))                      // ← 0x1407
        { puts("You were defeated. Retreat!"); exit(-1); }
    printf("%s defeated. STAGE %2d cleared!\n", monsters[i], i + 1);
}
// 열 마리 다 잡으면 ./flag 를 getline 으로 읽어 출력

스테이지 10개, 각각 독립된 난수. alarm(5) 이 걸려 있어서 손으로 칠 시간은 없다. 스크립트로 붙어야 한다.

여기서 볼 함수는 둘이다. 목표값을 보여주는 print_info@0x138d, 그리고 우리 입력을 채점하는 check@0x1407.


💣 핵심 — 목표값이 스탯 창에 통째로 있다

print_info 를 디스어셈블해 보면 하는 일이 노골적이다. 인자로 받은 64비트 값을 8비트씩 잘라서 printf 인자로 늘어놓는다. 어떤 시프트가 어떤 스탯이 되는지는 sed 로 주석을 얹어 뒀다(주석은 내가 붙인 것이고, objdump 원본 출력은 그대로다).

objdump -d -M intel --no-show-raw-insn --start-address=0x138d --stop-address=0x1407 extracted/prob | sed -f annot_info.sed | tail -n +7
objdump 로 뽑은 print_info 함수의 디스어셈블 — 인자 val 을 0x28, 0x20, 0x18, 0x10, 0x8 만큼 시프트해 DEX, END, INT, VIT, AGI 를 만들고 하위 바이트를 STR, 0x30 시프트한 16비트를 HP 로 쓴다
objdump 로 뽑은 print_info 함수의 디스어셈블 — 인자 val 을 0x28, 0x20, 0x18, 0x10, 0x8 만큼 시프트해 DEX, END, INT, VIT, AGI 를 만들고 하위 바이트를 STR, 0x30 시프트한 16비트를 HP 로 쓴다

annot_info.sed 는 이 한 장을 위해 만든 치환 규칙이다.

s|shr    rax,0x28|&           <-- val >> 40  = DEX|
s|shr    rax,0x20|&           <-- val >> 32  = END|
s|shr    rax,0x18|&           <-- val >> 24  = INT|
s|shr    rax,0x10|&           <-- val >> 16  = VIT|
s|shr    rax,0x8|&            <-- val >>  8  = AGI|
s|movzx  edx,al|&             <-- val \& 0xff = STR|
s|shr    rax,0x30|&           <-- val >> 48  = HP (16비트)|

정리하면 이렇다. 인자 배치가 헷갈리는데, 13e8: push rdi / 13e9: push rsi 로 여섯 번째·일곱 번째 인자를 스택에 밀어 넣기 때문에 END와 DEX가 뒤에 붙는다.

스탯서식값
HP%5huval >> 48 (16비트 전부)
STR%5hhuval & 0xff
AGI%5hhu(val >> 8) & 0xff
VIT%5hhu(val >> 16) & 0xff
INT%5hhu(val >> 24) & 0xff
END%5hhu(val >> 32) & 0xff
DEX%5hhu(val >> 40) & 0xff

64비트가 한 비트도 남김없이 화면에 나온다. 목표값을 맞히는 문제가 아니라, 화면에 적힌 목표값을 읽어 되조립하는 문제였던 것.

정적 분석만으로 단정하기는 찜찜하니 실행 중에 확인했다. fread 로 받은 8바이트가 담긴 스택 슬롯(rbp-0x1b0)을 print_info 호출 직전에 덤프하고, 그다음 프로그램이 스스로 찍는 [INFO] 줄과 눈으로 대조하는 gdb 배치 스크립트다. PIE라 로드 베이스는 관례로 가정하지 않고 info proc mappings 에서 직접 읽는다.

set pagination off
set confirm off
set debuginfod enabled off
 
# PIE 라 로드 베이스를 관례로 가정하지 않고 매핑에서 직접 읽는다
starti
python
import gdb
base = None
for line in gdb.execute("info proc mappings", to_string=True).splitlines():
    f = line.split()
    if len(f) >= 5 and f[-1].endswith("/prob"):
        base = int(f[0], 16)
        break
gdb.execute("set $base = %#x" % base)
end
printf "[gdb] PIE base = %#lx\n", $base
 
# 0x177d = fread 로 받은 8바이트를 print_info 로 넘기기 직전
break *($base + 0x177d)
continue
 
set $v = *(unsigned long *)($rbp - 0x1b0)
printf "[gdb] /dev/urandom 8B = %#018lx\n", $v
printf "[gdb] 되조립 HP=%d DEX=%d END=%d INT=%d VIT=%d AGI=%d STR=%d\n", (int)(($v>>48)&0xffff), (int)(($v>>40)&0xff), (int)(($v>>32)&0xff), (int)(($v>>24)&0xff), (int)(($v>>16)&0xff), (int)(($v>>8)&0xff), (int)($v&0xff)
 
# print_info 호출을 지나 프로그램이 찍는 [INFO] 줄과 대조한다
tbreak *($base + 0x178c)
continue
kill
gdb -q -batch -iex "set debuginfod enabled off" -x verify.gdb ./local/prob
gdb 배치 실행 결과 — PIE base 를 매핑에서 읽어 0x555555554000 을 얻고, dev urandom 이 준 8바이트 0xd203273629d56622 를 덤프한 뒤 프로그램이 찍는 INFO 줄의 HP 53763 STR 34 AGI 102 와 일치함을 보여준다
gdb 배치 실행 결과 — PIE base 를 매핑에서 읽어 0x555555554000 을 얻고, dev urandom 이 준 8바이트 0xd203273629d56622 를 덤프한 뒤 프로그램이 찍는 INFO 줄의 HP 53763 STR 34 AGI 102 와 일치함을 보여준다

0xd203273629d56622 를 두 자리씩 끊어 보면 d203(HP 53763), 27(DEX 39), 36(END 54), 29(INT 41), d5(VIT 213), 66(AGI 102), 22(STR 34). 프로그램이 찍은 [INFO] 줄과 한 글자도 다르지 않다.



🎯 채점 함수 — 버튼 둘로 수를 만드는 규칙

이제 check@0x1407. 입력 문자열을 앞에서부터 한 글자씩 보며 누산기 하나를 굴린다.

objdump -d -M intel --no-show-raw-insn --start-address=0x143d --stop-address=0x14bc extracted/prob | sed -f annot_check.sed | tail -n +7
objdump 로 뽑은 check 함수의 A 와 B 분기 — cmp eax 0x41 과 0x42 로 글자를 가르고, A 쪽은 acc 에 1 을 더한 뒤 직전 글자 플래그를 검사해 연속 A 면 puts 후 exit, B 쪽은 A 를 누른 적 있는지 확인하고 acc 를 shl 로 두 배 한다
objdump 로 뽑은 check 함수의 A 와 B 분기 — cmp eax 0x41 과 0x42 로 글자를 가르고, A 쪽은 acc 에 1 을 더한 뒤 직전 글자 플래그를 검사해 연속 A 면 puts 후 exit, B 쪽은 A 를 누른 적 있는지 확인하고 acc 를 shl 로 두 배 한다

주석을 얹은 규칙 파일은 이렇다.

s|cmp    eax,0x41|&                <-- 'A' 인가|
s|cmp    eax,0x42|&                <-- 'B' 인가|
s|add    QWORD PTR \[rbp-0x8\],0x1|&   <-- acc += 1|
s|shl    QWORD PTR \[rbp-0x8\],1|&     <-- acc *= 2|
s|cmp    DWORD PTR \[rbp-0x14\],0x0|&  <-- 직전 글자가 A 였나 (연속 A 금지)|
s|cmp    DWORD PTR \[rbp-0x10\],0x0|&  <-- A 를 한 번이라도 눌렀나|
s|cmp    rax,QWORD PTR \[rbp-0x30\]|&  <-- acc == 목표값?|

스택 슬롯 넷의 역할을 정리하면 함수 전체가 이 정도로 줄어든다.

// check @ 0x1407 (재구성)
int check(char *s, unsigned long target) {
    unsigned long acc = 0;      // rbp-0x8
    int prev_a = 0;             // rbp-0x14  직전 글자가 A였나
    int seen_a = 0;             // rbp-0x10  A를 한 번이라도 눌렀나
    for (int i = 0; s[i]; i++) {
        if (s[i] == 'A') {
            seen_a = 1; acc += 1;
            if (prev_a) { puts("A button stucked! Retreat..."); exit(-1); }
            prev_a = 1;
        } else if (s[i] == 'B') {
            if (!seen_a) { puts("Lore says the spell should start with A..."); exit(-1); }
            acc <<= 1;
            prev_a = 0;
        } else { puts("Invalid button!"); exit(-1); }
    }
    return acc == target;
}

루프를 빠져나온 뒤의 판정도 확인해 둔다.

objdump -d -M intel --no-show-raw-insn --start-address=0x14bc --stop-address=0x1509 extracted/prob | sed -f annot_check.sed | tail -n +7
objdump 로 뽑은 check 함수의 뒷부분 — 문자열 끝에 닿으면 루프를 빠져나와 rbp-0x8 의 누산기를 rbp-0x30 의 목표값과 cmp 로 비교하고 같으면 1 다르면 0 을 반환한다
objdump 로 뽑은 check 함수의 뒷부분 — 문자열 끝에 닿으면 루프를 빠져나와 rbp-0x8 의 누산기를 rbp-0x30 의 목표값과 cmp 로 비교하고 같으면 1 다르면 0 을 반환한다

부분 점수는 없다. 문자열을 다 소비한 시점의 누산기가 목표값과 정확히 같아야 통과다.

세 가지 거절 조건은 바이너리에게 직접 물어봤다. STAGE 1에서 일부러 규칙을 어긴 주문을 넣고 마지막 줄만 뽑는 스크립트다.

#!/usr/bin/env python3
"""주문에 걸린 세 가지 제약을 바이너리에게 직접 확인받는다.
 
STAGE 1 에서 일부러 규칙을 어긴 입력을 넣고, 마지막 줄에 어떤 거절 메시지가
나오는지 본다. 세 메시지 모두 .rodata 에 박혀 있던 것들이다.
"""
import os
import subprocess
 
HERE = os.path.dirname(os.path.abspath(__file__))
LOCAL = os.path.join(HERE, "local")
 
CASES = [
    ("BA",  "B 를 먼저 눌렀다"),
    ("AAB", "A 를 연속으로 눌렀다"),
    ("ABC", "A/B 가 아닌 글자를 섞었다"),
]
 
for spell, why in CASES:
    out = subprocess.run(
        [os.path.join(LOCAL, "prob")], cwd=LOCAL, input=spell + "\n",
        capture_output=True, text=True, timeout=10,
    ).stdout.strip().splitlines()
    print(f"[{spell:<4}] {why:<22} -> {out[-1].split(': ')[-1]}")
python3 guards.py
guards.py 실행 결과 — BA 는 Lore says the spell should start with A, AAB 는 A button stucked Retreat, ABC 는 Invalid button 이라는 거절 메시지를 각각 받아 세 제약이 실제로 동작함을 확인한다
guards.py 실행 결과 — BA 는 Lore says the spell should start with A, AAB 는 A button stucked Retreat, ABC 는 Invalid button 이라는 거절 메시지를 각각 받아 세 제약이 실제로 동작함을 확인한다


🧩 주문 만들기 — 결국 이진수다

목표값 n 을 +1 과 ×2 만으로 만들라는 건, 0에서 시작해 n 까지 가는 연산 순서를 짜라는 뜻이다. 여기에 "+1 을 연달아 쓸 수 없다"가 붙는다.

+1 을 연속으로 못 쓴다는 건 곧 각 자리에 1은 한 번씩만 얹을 수 있다는 말이고, 자리를 올리는 수단은 ×2 뿐이다. 그래서 답은 이진 표기 그대로다. 최상위 비트를 A 로 세우고, 그 아래 비트를 하나씩 내려가며 B 로 자리를 올린 뒤 비트가 1이면 A 를 더한다.

13 을 A 와 B 두 버튼으로 만드는 과정을 그린 도식 — 이진수 1101 의 각 비트가 A 는 더하기 1, B 는 곱하기 2 로 대응되어 누산기가 1, 2, 3, 6, 12, 13 순으로 올라가며 A 사이에는 항상 B 가 끼어 연속 A 가 생기지 않는다
13 을 A 와 B 두 버튼으로 만드는 과정을 그린 도식 — 이진수 1101 의 각 비트가 A 는 더하기 1, B 는 곱하기 2 로 대응되어 누산기가 1, 2, 3, 6, 12, 13 순으로 올라가며 A 사이에는 항상 B 가 끼어 연속 A 가 생기지 않는다

제약 두 개가 저절로 지켜진다는 점이 편하다. A 는 반드시 B 다음에만 나오니 AA 가 생길 수 없고, 첫 글자는 항상 A 다.

길이도 확인해 둘 필요가 있다. fgets 버퍼가 200바이트라 그 안에 들어가야 한다. 64비트가 전부 1인 최악의 경우가 A 하나 + BA 63쌍 = 127자다.

인코딩이 맞는지, 길이가 정말 127을 안 넘는지는 check 를 그대로 옮긴 에뮬레이터로 확인했다.

#!/usr/bin/env python3
"""주문 인코딩이 정말 맞는지, 그리고 200바이트 버퍼 안에 들어가는지 확인한다.
 
emulate() 는 prob 의 check() 를 그대로 옮긴 것이다 —
'A' 는 acc+=1, 'B' 는 acc*=2, 연속 'A' 금지, 첫 A 이전의 'B' 금지.
"""
from solve import spell
 
 
def emulate(s: bytes) -> int:
    """check@0x1407 의 재구현. 규칙을 어기면 ValueError 를 던진다."""
    acc, seen_a, prev_a = 0, False, False
    for c in s.decode():
        if c == "A":
            if prev_a:
                raise ValueError("A button stucked! Retreat...")
            seen_a, prev_a = True, True
            acc += 1
        elif c == "B":
            if not seen_a:
                raise ValueError("Lore says the spell should start with A...")
            acc <<= 1
            prev_a = False
        else:
            raise ValueError("Invalid button!")
    return acc
 
 
CASES = [1, 2, 3, 5, 10, 255, 0xDEADBEEF, 2**64 - 1]
 
for n in CASES:
    s = spell(n)
    ok = emulate(s) == n
    shown = s.decode() if len(s) <= 40 else s[:18].decode() + "..." + s[-18:].decode()
    print(f"[{'OK' if ok else '!!'}] n={n:<20} len={len(s):>3}  {shown}")
 
worst = max(len(spell(2**k - 1)) for k in range(1, 65))
print(f"\n[+] 64비트 최악의 경우 주문 길이 = {worst}자  (fgets 버퍼 200바이트 안)")
print(f"[+] 'AA' 부분문자열 등장 횟수 = {sum(b'AA' in spell(n) for n in CASES)}")
python3 spell_demo.py
spell_demo.py 실행 결과 — 1, 3, 5, 255, 0xDEADBEEF, 2의 64승 마이너스 1 까지 여덟 개 사례가 전부 OK 로 검증되고 최악의 경우 주문 길이가 127자, AA 부분문자열 등장 횟수는 0 으로 나온다
spell_demo.py 실행 결과 — 1, 3, 5, 255, 0xDEADBEEF, 2의 64승 마이너스 1 까지 여덟 개 사례가 전부 OK 로 검증되고 최악의 경우 주문 길이가 127자, AA 부분문자열 등장 횟수는 0 으로 나온다

5 가 ABBA 로 나오는 게 마음에 든다. 1 → 2 → 4 → 5.


▶🐛 삽질 — flag 보기 전에 걸린 두 가지

풀이 자체는 막힌 데가 없었는데, 바이너리를 처음 돌리는 데서 두 번 헛짚었다.

1. 배포본을 풀었더니 실행 권한이 없다

./prob 를 돌리려는데 timeout: failed to execute process: Permission denied 가 났다. 문구가 "실행 실패"라 바이너리가 깨졌거나 아키텍처가 안 맞는 줄 알고 file 을 다시 봤다. 원인은 훨씬 단순했다. zip 안에는 755로 기록돼 있는데, 자동화가 Python zipfile.extractall 로 풀어서 external_attr 의 모드가 통째로 날아간 것이다. unzip 으로 풀면 755 그대로 나온다.

#!/usr/bin/env bash
# 삽질 재현 — 압축 안에서는 755 인데 풀어 놓은 파일은 실행 권한이 없다.
# 자동화가 python zipfile 로 풀면 external_attr 의 모드가 그대로 죽는다.
cd "$(dirname "$(readlink -f "$0")")"
 
echo "--- zip 안에 기록된 모드 ---"
python3 -c "
import zipfile
for i in zipfile.ZipFile('challenge_1212.zip').infolist():
    print(f'{i.filename:<14} {oct(i.external_attr >> 16)}')
"
 
echo "--- zipfile.extractall 로 풀린 실물 ---"
ls -l extracted/prob
 
echo "--- 그래서 이렇게 죽는다 ---"
timeout 5 ./extracted/prob
echo "exit=$?  <-- 126 = 실행 권한 없음"
./sapjil_perm.sh
sapjil_perm.sh 실행 결과 — zip 메타데이터에는 prob 이 0o100755 로 기록돼 있지만 zipfile.extractall 로 풀린 실물은 rw-rw-r 이고, 실행하면 Permission denied 와 함께 종료코드 126 이 나온다
sapjil_perm.sh 실행 결과 — zip 메타데이터에는 prob 이 0o100755 로 기록돼 있지만 zipfile.extractall 로 풀린 실물은 rw-rw-r 이고, 실행하면 Permission denied 와 함께 종료코드 126 이 나온다

chmod 755 prob 한 줄이면 끝나는 일인데, 에러 문구가 원인을 안 가리켜서 몇 분을 썼다.

2. pwntools process(cwd=...) 의 경로 해석

로컬 모드는 prob 사본과 더미 flag 를 local/ 에 두고 거기서 돌리게 했다. 그래서 process("./prob", cwd="./local") 이라고 썼는데 파일이 멀쩡히 있는데도 못 찾는다.

#!/usr/bin/env python3
"""삽질 재현 — solve.py 의 로컬 모드를 처음엔 이렇게 썼다가 죽었다.
 
pwntools 는 `./prob` 를 현재 디렉토리 기준으로 합쳐 놓고, subprocess 는 그 경로를
`cwd` 로 옮긴 **뒤에** 다시 찾는다. 그래서 파일이 멀쩡히 있는데도 못 찾는다.
"""
from pwn import context, process
 
context.log_level = "error"
 
try:
    process("./prob", cwd="./local")
except Exception as e:
    print(f"{type(e).__name__}: {e}")
 
print("→ cwd 를 바꿀 거면 실행 파일은 절대경로로 줘야 한다.")
python3 sapjil_cwd.py
sapjil_cwd.py 실행 결과 — pwntools 가 상대경로를 현재 디렉토리 기준으로 합쳐 놓은 탓에 FileNotFoundError 가 나며 존재하지도 않는 경로 local 슬래시 점 슬래시 prob 을 찾다가 실패한다
sapjil_cwd.py 실행 결과 — pwntools 가 상대경로를 현재 디렉토리 기준으로 합쳐 놓은 탓에 FileNotFoundError 가 나며 존재하지도 않는 경로 local 슬래시 점 슬래시 prob 을 찾다가 실패한다

pwntools가 ./prob 를 현재 디렉토리 기준으로 합쳐 ./local/./prob 를 만들고, subprocess 는 그 상대경로를 다시 새 cwd 기준으로 찾는다. 결국 local/local/prob 를 뒤지는 셈이라 없다. 실행 파일은 절대경로로 넘기면 된다.



🚀 Full Exploit

[INFO] 줄을 정규식으로 파싱해 목표값을 되조립하고, 그 수의 이진 전개를 주문으로 보낸다. 인자를 주면 원격, 안 주면 로컬 사본으로 붙는다.

#!/usr/bin/env python3
"""dungeon-in-1983 (Dreamhack, Bronze 2 / reversing) solver.
 
스테이지마다 /dev/urandom 에서 읽은 64비트 값이 목표치인데,
[INFO] 줄이 그 값을 HP/DEX/END/INT/VIT/AGI/STR 로 쪼개 전부 출력해 준다.
목표치를 복원한 뒤 'A'(+1) 'B'(x2) 두 버튼만으로 그 수를 만드는 주문을 짠다.
연속 'A' 가 금지되므로 A 사이에 항상 B 가 들어가는 이진 전개가 곧 정답이다.
 
    로컬:  python3 solve.py
    원격:  python3 solve.py host3.dreamhack.games 12345
"""
import os
import re
import sys
 
from pwn import context, log, process, remote
 
HERE = os.path.dirname(os.path.abspath(__file__))
 
STAGES = 10
INFO_RE = re.compile(
    rb"HP:\s*(\d+), STR:\s*(\d+), AGI:\s*(\d+), VIT:\s*(\d+), "
    rb"INT:\s*(\d+), END:\s*(\d+), DEX:\s*(\d+)"
)
 
 
def target_from_info(line: bytes) -> int:
    """[INFO] 한 줄에서 64비트 목표치를 원래대로 조립한다."""
    m = INFO_RE.search(line)
    if not m:
        raise ValueError(f"INFO 파싱 실패: {line!r}")
    hp, st, ag, vi, it, en, dx = (int(v) for v in m.groups())
    return (hp << 48) | (dx << 40) | (en << 32) | (it << 24) | (vi << 16) | (ag << 8) | st
 
 
def spell(n: int) -> bytes:
    """A(+1)/B(x2) 로 n 을 만드는 주문. 'AA' 없이, 항상 A 로 시작한다."""
    if n == 0:
        return b""
    bits = bin(n)[2:]              # 최상위 비트는 반드시 1
    out = ["A"]                    # 그 1 을 A 로 세운다
    for b in bits[1:]:
        out.append("B")            # 자리 하나 올리고
        if b == "1":
            out.append("A")        # 필요하면 1 을 더한다
    return "".join(out).encode()
 
 
def main() -> None:
    context.log_level = "info"
    if len(sys.argv) >= 3:
        io = remote(sys.argv[1], int(sys.argv[2]))
    else:
        local = os.path.join(HERE, "local")   # prob 사본 + 더미 flag 를 둔 곳
        io = process([os.path.join(local, "prob").encode()], cwd=local)
 
    for stage in range(1, STAGES + 1):
        name = io.recvline_startswith(b"[STAGE").split(b":")[-1].strip()
        target = target_from_info(io.recvline_startswith(b"[INFO]"))
        payload = spell(target)
        log.info("STAGE %2d %-10s target=%-20d len=%d", stage, name.decode(), target, len(payload))
        io.sendlineafter(b"Cast your spell!: ", payload)
 
    io.recvuntil(b"Take the flag: ")
    print(io.recvline().decode().strip())
    io.close()
 
 
if __name__ == "__main__":
    main()

먼저 맨몸 바이너리로 돌려 열 스테이지를 통과하는지 본다. local/flag 에는 내가 넣어 둔 더미 문자열이 들어 있다.

python3 solve.py
solve.py 를 로컬 바이너리에 붙여 실행한 결과 — Basilisk 부터 Roc 까지 열 스테이지의 목표값과 주문 길이가 89 에서 97 사이로 찍히고 마지막에 로컬 더미 플래그가 출력된다
solve.py 를 로컬 바이너리에 붙여 실행한 결과 — Basilisk 부터 Roc 까지 열 스테이지의 목표값과 주문 길이가 89 에서 97 사이로 찍히고 마지막에 로컬 더미 플래그가 출력된다

주문 길이가 89~97자 근처에서 논다. 64비트 난수면 1인 비트가 평균 32개라 대략 1 + 63 + 32 = 96 자가 나오는 게 맞다.

라이브에 붙기 전에 배포 형태 그대로도 확인했다. 문제가 준 Dockerfile 을 그대로 빌드해 socat 컨테이너를 띄우고 원격 모드로 접속한다. 이 경로로 나오는 flag는 배포본에 들어 있는 더미(DH{test_flag})라 진짜 flag가 아니고, 익스 체인이 소켓 너머에서도 도는지만 본다.

#!/usr/bin/env bash
# dungeon-in-1983 — 한 방 재현.
#
#   ./reproduce.sh                         배포 Dockerfile 그대로 빌드해 로컬에서 체인 검증
#                                          (배포본 flag 는 더미 DH{test_flag} 다 — 진짜 flag 아님)
#   ./reproduce.sh host3.dreamhack.games 8875
#                                          라이브 서버에 붙어 진짜 flag 를 뽑고 문제.json 과 대조
set -u
cd "$(dirname "$(readlink -f "$0")")"
 
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
 
if [ "$#" -ge 2 ]; then
  MODE="라이브"
  WANT="$EXPECT"
  OUT=$(TERM=xterm timeout 120 python3 solve.py "$1" "$2" 2>&1) || true
else
  MODE="로컬 도커"
  WANT="DH{test_flag}"
  docker build -q -t dungeon1983 ./extracted >/dev/null || { echo "❌ docker build 실패"; exit 1; }
  docker rm -f dungeon1983 >/dev/null 2>&1
  docker run -d --name dungeon1983 -p 31337:31337 dungeon1983 >/dev/null || { echo "❌ docker run 실패"; exit 1; }
  sleep 2
  OUT=$(TERM=xterm timeout 120 python3 solve.py 127.0.0.1 31337 2>&1) || true
  docker rm -f dungeon1983 >/dev/null 2>&1
fi
 
echo "$OUT" | tail -12
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
 
echo
if [ -n "$FLAG" ] && { [ -z "$WANT" ] || [ "$FLAG" = "$WANT" ]; }; then
  if [ "$MODE" = "로컬 도커" ]; then
    echo "✅ PASS  ($MODE — 익스 체인 검증. $FLAG 는 배포본 더미다)"
  else
    echo "✅ PASS  ($MODE) $FLAG"
  fi
  exit 0
fi
echo "❌ FAIL ($MODE / 얻은 값: '${FLAG:-없음}' / 기대: '${WANT:-미지정}')"
exit 1
./reproduce.sh
reproduce.sh 실행 결과 — 배포 Dockerfile 로 빌드한 socat 컨테이너에 127.0.0.1 포트 31337 로 붙어 열 스테이지를 통과하고 배포본 더미 플래그를 회수한 뒤 로컬 도커 체인 검증 PASS 로 끝난다
reproduce.sh 실행 결과 — 배포 Dockerfile 로 빌드한 socat 컨테이너에 127.0.0.1 포트 31337 로 붙어 열 스테이지를 통과하고 배포본 더미 플래그를 회수한 뒤 로컬 도커 체인 검증 PASS 로 끝난다

여기까지 확인하고서야 라이브 서버를 켰다.

python3 solve.py host3.dreamhack.games 8875
solve.py 를 드림핵 라이브 서버 host3 포트 8875 에 붙여 실행한 결과 — 열 스테이지의 목표값을 차례로 맞춰 통과하고 마지막 줄에 진짜 플래그가 출력된다
solve.py 를 드림핵 라이브 서버 host3 포트 8875 에 붙여 실행한 결과 — 열 스테이지의 목표값을 차례로 맞춰 통과하고 마지막 줄에 진짜 플래그가 출력된다
DH{ddfd394275e80d059f04cee16667ad40e81820a0b105e2f80f0d17544a274a62}

한 번에 열 스테이지, 왕복 20번쯤을 5초 안에 끝낸다.



📝 결론

"보여주기용"과 "판정용"이 같은 변수면 그건 노출이다

이 문제의 버그는 알고리즘이 아니라 설계에 있다. 게임을 그럴듯하게 만들려고 붙인 스탯 출력이, 하필 정답으로 쓰는 난수를 인코딩만 바꿔 그대로 내보낸다. HP만 16비트인 것도 우연이 아니라 64비트를 남김없이 채우려다 보니 그렇게 된 것이다.

서버가 판정에 쓰는 값은 클라이언트에게 어떤 형태로도 보내지 않아야 한다. 요약본이든, 해시든, "일부만" 이든 마찬가지다. 여기서는 일곱 조각으로 나눠 예쁘게 포장했을 뿐 원본 그대로였다.

제약을 적을 때는 그게 답을 좁히는지 확인해야 한다

AA 금지는 난이도를 올리려고 넣은 규칙처럼 보인다. 그런데 실제로는 정답 표현을 이진 전개 하나로 못 박아 버린다. 제약이 없었다면 AAAA... 같은 표현도 가능해 탐색 공간이 오히려 넓어졌을 것이다.

로컬에서 배포 형태까지 돌려 보고 서버를 켠다

드림핵 워게임 서버는 VM 크레딧을 쓴다. 이번에는 맨몸 바이너리 → 배포 Dockerfile 컨테이너 → 라이브 순서로 올라가서 라이브 접속은 한 번으로 끝났다. socat 이 표준입출력을 그대로 물려 주는 구조라 로컬 컨테이너와 원격의 동작이 완전히 같고, 그래서 로컬에서 통과하면 원격도 통과한다는 확신을 미리 살 수 있다.

stripped 라도 objdump 로 충분히 읽힌다

심볼이 없어도 엔트리에서 main 을 찾고, 문자열의 참조 주소로 함수 경계를 잡으면 14KB짜리 바이너리는 손으로 다 읽힌다. 정적 분석으로 세운 가설(rbp-0x1b0 이 목표값)은 gdb 배치 한 방으로 확인했다. 이 두 장이 있으면 "그럴 것이다"가 "그렇다"가 된다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] 분기 미로 뒤에 숨은 상수 한 줄 — DreamHack Labyrinth 풀이
blog

[🥉 Bronze 2] 분기 미로 뒤에 숨은 상수 한 줄 — DreamHack Labyrinth 풀이

14KB 짜리 stripped PIE 하나가 전부인 리버싱 문제. ptrace 안티디버깅과 시드 %7 로 갈라지는 분기 미로가 겹겹이 깔려 있지만, 정답 입력은 .rodata 의 32바이트를 0x30 으로 XOR 한 것이 전부였다. flag 생성기까지 파이썬으로 재구현해 바이너리 출력과 바이트 단위로 대조했다.
#dreamhack#ctf#reversing+6
2026-08-20#dreamhack +5
[🥉 Bronze 2] .rodata 247바이트가 스택 VM 바이트코드였다 — DreamHack SelfStatus 풀이
blog

[🥉 Bronze 2] .rodata 247바이트가 스택 VM 바이트코드였다 — DreamHack SelfStatus 풀이

프로그램이 자기 /proc/self/status 를 읽어 TracerPid 로 디버거를 알아채고 조용히 죽는다. 진짜 검사는 .rodata 에 박힌 247바이트를 도는 스택 VM 이 맡는데, 그 VM 은 사용자 입력을 아예 쳐다보지 않고 정답 문자열을 스스로 조립한다. 그래서 역산할 것이 없다 — VM 만 옮겨 적으면 끝난다.
#dreamhack#ctf#reversing+6
2026-08-19#dreamhack +5
[🥉 Bronze 2] 쓴 값이 다음 쓰기 주소가 된다 — DreamHack p_rho 풀이
blog

[🥉 Bronze 2] 쓴 값이 다음 쓰기 주소가 된다 — DreamHack p_rho 풀이

숫자를 하나 넣으면 배열에 저장하고, 그 값을 그대로 다음 인덱스로 삼는 무한 루프. 임의 주소 쓰기인데 주소를 직접 못 고르고 "직전에 쓴 값"이 정한다는 제약이 붙는다. buf 를 원점으로 보면 GOT 가 음수 인덱스에 있고, Partial RELRO 라 잠겨 있지도 않다. 정수 두 개로 printf@got 를 win 으로 덮어 셸을 잡았다.
#dreamhack#ctf#pwnable+6
2026-08-12#dreamhack +5