문제: 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"
PIE에 Full RELRO, 게다가 stripped다. 다만 이건 pwn 문제가 아니라 reversing 문제라 보호 기법 자체는 별 의미가 없다. 중요한 건 심볼이 없으니 함수 이름 없이 주소로만 읽어야 한다는 점.
문자열부터 훑으면 게임의 뼈대가 거의 다 드러난다.
strings -a -n 5 extracted/prob | sed -n "/Basilisk/,/flag: /p"
읽히는 것들:
-
몬스터 이름 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
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 | %5hu | val >> 48 (16비트 전부) |
| STR | %5hhu | val & 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
killgdb -q -batch -iex "set debuginfod enabled off" -x verify.gdb ./local/prob
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
주석을 얹은 규칙 파일은 이렇다.
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
부분 점수는 없다. 문자열을 다 소비한 시점의 누산기가 목표값과 정확히 같아야 통과다.
세 가지 거절 조건은 바이너리에게 직접 물어봤다. 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
🧩 주문 만들기 — 결국 이진수다
목표값 n 을 +1 과 ×2 만으로 만들라는 건, 0에서 시작해 n 까지 가는 연산 순서를 짜라는 뜻이다. 여기에 "+1 을 연달아 쓸 수 없다"가 붙는다.
+1 을 연속으로 못 쓴다는 건 곧 각 자리에 1은 한 번씩만 얹을 수 있다는 말이고, 자리를 올리는 수단은 ×2 뿐이다. 그래서 답은 이진 표기 그대로다. 최상위 비트를 A 로 세우고, 그 아래 비트를 하나씩 내려가며 B 로 자리를 올린 뒤 비트가 1이면 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
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
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
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
주문 길이가 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
여기까지 확인하고서야 라이브 서버를 켰다.
python3 solve.py host3.dreamhack.games 8875
DH{ddfd394275e80d059f04cee16667ad40e81820a0b105e2f80f0d17544a274a62}한 번에 열 스테이지, 왕복 20번쯤을 5초 안에 끝낸다.
📝 결론
"보여주기용"과 "판정용"이 같은 변수면 그건 노출이다
이 문제의 버그는 알고리즘이 아니라 설계에 있다. 게임을 그럴듯하게 만들려고 붙인 스탯 출력이, 하필 정답으로 쓰는 난수를 인코딩만 바꿔 그대로 내보낸다. HP만 16비트인 것도 우연이 아니라 64비트를 남김없이 채우려다 보니 그렇게 된 것이다.
서버가 판정에 쓰는 값은 클라이언트에게 어떤 형태로도 보내지 않아야 한다. 요약본이든, 해시든, "일부만" 이든 마찬가지다. 여기서는 일곱 조각으로 나눠 예쁘게 포장했을 뿐 원본 그대로였다.
제약을 적을 때는 그게 답을 좁히는지 확인해야 한다
AA 금지는 난이도를 올리려고 넣은 규칙처럼 보인다. 그런데 실제로는 정답 표현을 이진 전개 하나로 못 박아 버린다. 제약이 없었다면 AAAA... 같은 표현도 가능해 탐색 공간이 오히려 넓어졌을 것이다.
로컬에서 배포 형태까지 돌려 보고 서버를 켠다
드림핵 워게임 서버는 VM 크레딧을 쓴다. 이번에는 맨몸 바이너리 → 배포 Dockerfile 컨테이너 → 라이브 순서로 올라가서 라이브 접속은 한 번으로 끝났다. socat 이 표준입출력을 그대로 물려 주는 구조라 로컬 컨테이너와 원격의 동작이 완전히 같고, 그래서 로컬에서 통과하면 원격도 통과한다는 확신을 미리 살 수 있다.
stripped 라도 objdump 로 충분히 읽힌다
심볼이 없어도 엔트리에서 main 을 찾고, 문자열의 참조 주소로 함수 경계를 잡으면 14KB짜리 바이너리는 손으로 다 읽힌다. 정적 분석으로 세운 가설(rbp-0x1b0 이 목표값)은 gdb 배치 한 방으로 확인했다. 이 두 장이 있으면 "그럴 것이다"가 "그렇다"가 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…