문제: DreamHack — Mirage 분류: Reversing 난이도: 💠 Platinum 3 FLAG:
DH{fca5c7c52a86459f10ef963921a164d31c714328fd6de9f5fe}
문제 설명은 짧다. "It looks clear at first glance, but nothing is as it seems. Find what's real before it fades away." 한눈엔 깔끔해 보이지만 보이는 게 전부가 아니라는 말. 실제로 이 바이너리는 입력을 한 글자씩 비교하는 것처럼 생긴 검증 루틴을 하나 보여주는데, 그게 바로 사라질 신기루였다.
문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | Mirage |
| 난이도 | 💠 Platinum 3 |
| 분류 | Reversing |
| 제공 파일 | chall (단일 ELF, 서버 없음) |
| 스택 | C++ (libstdc++) · x86-64 · stripped PIE |
| 핵심 기법 | base-128 bignum 곱셈 + 10×10 선형 연립방정식 복원 |
입력 50자를 받아 맞으면 Flag is DH{ 입력 }을 뱉는, 전형적인 "정답 입력을 찾아라" 리버싱이다. 즉 입력 자체가 곧 플래그 본문이다. 풀이 흐름은 세 단계다. ① 보이는 검증 루틴이 가짜(신기루)임을 간파하고, ② 진짜 검증이 입력을 base-128 bignum으로 섞는 구조임을 디스어셈블로 확인한 뒤, ③ 그 섞임이 사실은 선형 연립방정식이라는 걸 깨닫고 상수만 뽑아 풀었다.
🧩 배경 — C++ 바이너리와 "보이지 않는 산술"
이 문제가 까다로운 건 알고리즘이 어려워서가 아니라, C++ STL(std::vector)로 큰 수 연산을 손수 구현해 놓고 그 위에 anti-analysis 잡음을 뿌려놨기 때문이다. 모든 "큰 수"는 std::vector<uint8_t>로 표현되고, 벡터끼리 곱하고 더하는 함수가 따로 있다. 디스어셈블만 봐서는 이게 곱셈인지 append인지 구분이 안 간다.
게다가 함수 곳곳에 이런 패턴이 박혀 있다.
mov edi, 6
call getauxval ; AT_PAGESZ (=4096)
sub rax, 1
and rax, [pagesz] ; 4096 & 4095 == 0 → 항상 참
test rax, rax
sete al ; al = 1getauxval(AT_PAGESZ)로 페이지 크기를 받아 "2의 거듭제곱인가"를 검사한다. 페이지 크기는 언제나 4096이라 결과는 항상 참 — 아무 의미 없는 **불투명 술어(opaque predicate)**다. 디스어셈블을 읽는 사람 눈을 분기와 잡음으로 흐리려는 장치다. 분석할 땐 이런 줄은 머릿속에서 지우고 봐야 한다.
🔬 정찰
먼저 바이너리의 기본 성격부터 본다.
# recon.py — 바이너리 기본 정보 + 보호기법
from pwn import ELF, context
context.log_level = 'error'
e = ELF('extracted/chall', checksec=False)
print(f"arch : {e.arch} {e.bits}-bit")
print(f"PIE : {e.pie}")
print(f"RELRO : {e.relro}")
print(f"NX : {e.nx}")
print(f"Canary : {e.canary}")
print(f"stripped : {not bool(e.symbols.get('main'))} (main 심볼 없음)")
stripped PIE라 심볼이 없다. 보호기법은 전부 켜져 있지만 서버가 없는 로컬 리버싱이라 그 자체는 중요하지 않다. 다음으로 문자열을 보면 검증의 골격이 드러난다.
strings extracted/chall | grep -E 'Input|Correct|Wrong|Flag is|read error'
getline으로 한 줄 입력받아(Input: ), 검증 후 Correct! 또는 Wrong!을 찍고, 맞으면 Flag is DH{ 뒤에 입력을 그대로 이어 붙인다. 그러니 할 일은 하나다. Wrong!이 아니라 Correct!가 나오게 하는 50자를 찾는 것.
🕵️ 구조 — 신기루와 진짜 검증
main을 따라가면 입력 문자열을 들고 두 뭉치의 검증을 거친다. 첫 번째는 fcn_1644. 입력을 5자씩 끊어 10개 청크로 나누고, 각 청크를 어떤 함수로 변환해 가며 정성스럽게 비교하는 것처럼 생겼다. 그런데 반환값을 끝까지 따라가 보면 허무하다.
; fcn_1644 — 입력 size 검사 후 ...
call std::__cxx11::...::size()
cmp rax, 0x32 ; size == 50 ?
setne al
... ; (청크를 변환하며 뭔가 하지만)
mov eax, 1 ; size만 50이면 끝까지 eax = 1
ret변환 루프가 돌긴 하는데, 그 결과는 반환값에 전혀 반영되지 않는다. 함수의 출구를 디스어셈블로 확인하면 분명하다.
objdump -d --start-address=0x1685 --stop-address=0x1815 extracted/chall | sed -nE -f mirage.sed# mirage.sed — fcn_1644 에서 반환값(eax)을 정하는 줄만 골라 주석
/cmp \$0x32,%rax/{s/$/ <-- size == 50 ?/;p}
/mov \$0x0,%eax/{s/$/ <-- size 가 50 아니면 0 반환/;p}
/mov \$0x1,%eax/{s/$/ <-- 그 외에는 무조건 1 (= 통과, 신기루)/;p}
/ ret *$/p
길이가 50이기만 하면 무조건 "통과"로 끝난다. 이게 제목 그대로의 신기루다 — 열심히 들여다볼수록 시간만 녹는, 사라질 관문. 진짜 판정은 그 뒤 fcn_1ac7에서 세 인자로 이뤄진다.
lea rdx, [exp2_table] ; 상수 ③
lea rcx, [transformed] ; 변환된 입력 (신기루가 채워둔 배열)
lea rax, [exp1_table] ; 상수 ②
call fcn_1ac7 ; → 여기가 진짜 Correct/Wrong 을 가른다objdump -d --start-address=0x2054 --stop-address=0x2074 extracted/chall | sed -E -f dispatch.sed
fcn_1ac7(변환된 입력, exp1, exp2). 안을 보면 10×10 구조로 변환된 입력과 상수 exp1을 곱해 합친 뒤 exp2와 비교하는데, 첫 비교가 틀리는 순간 바로 빠져나온다(단락 평가). exp1·exp2는 입력과 무관한 고정 상수다.

▶🐛 삽질 — '이건 곱셈이겠지'로 몇 번을 헛짚었나
변환된 입력(각 6바이트)과 exp1(5바이트)을 합치면 11바이트가 나오고, 비교 대상 exp2도 11바이트였다. 6 + 5 = 11. 바로 "정수 곱셈이구나" 싶었다. 그래서 gdb로 한 쌍의 결과(DEST)를 떠서 온갖 가설을 때려봤다.
transform × exp1(정수 곱) — 엔디안 4조합 전부 불일치.(transform × exp1) mod 2^88— 불일치.exp2 ÷ exp1이 나누어떨어지나 — 나머지가 남음.- 자리별 convolution(mod 256 / XOR) · GF(2^8) 다항식 곱(기약다항식 28종) — 전부 불일치.
transform × exp1 + 상수 C— C가 샘플마다 다름.
결정적 단서는 결과의 최하위 바이트였다. 곱셈이라면 DEST[0] = transform[0] × exp1[0] mod 256 = 0x41 × 0x06 = 0x86이어야 하는데 실제로는 0x06, 즉 exp1[0] 그 자체였다. 곱셈의 LSB가 저렇게 나올 리 없다. "곱셈 맞는데 표현이 뭔가 다르다"는 쪽으로 방향을 틀고 나서야 base-128을 의심하게 됐다.
💣 핵심 — 모든 수는 base-128 bignum이었다
곱셈 가설이 번번이 깨진 이유는 자릿수의 밑(base)이 256이 아니라 128이었기 때문이다. 벡터를 곱하는 함수 fcn_6304 안을 보면 스쿨북 곱셈 그대로다.
objdump -d --start-address=0x65b4 --stop-address=0x662b extracted/chall | sed -E -f b128.sed# b128.sed — 디스어셈블 실제 출력에 설명 주석만 얹는다(합성 아님)
s/(imul .*%eax)/\1 <-- 자릿수 x 곱수/
s/(and \$0x7f,%eax)/\1 <-- base-128: 하위 7비트만 남긴다(limb)/
s/(mov %bl,\(%rax\))/\1 <-- limb 저장/
s/(shr \$0x7,%eax)/\1 <-- 올림수 = 값 >> 7/
s/(call 12f0 <getauxval@plt>)/\1 <-- anti-analysis 잡음/
and 0x7f로 하위 7비트만 한 자리에 저장하고, shr 7로 나머지를 올림수로 다음 자리에 넘긴다. 즉 한 limb가 0~127인 base-128 큰 수다. 그래서 바이너리에 찍힌 바이트들이 하나같이 0x7f 이하였던 것이고, 내가 base-256으로 가정하고 돌린 곱셈이 다 어긋났던 것이다. 값은 val = Σ limbₖ · 128ᵏ로 복원한다.
밑을 128로 맞추고 나면 "곱셈"이라는 가설이 비로소 들어맞는다. 검증 함수 안의 첫 combine 결과를 gdb로 떠서 X₀ × exp1[0][0]과 대조하면 정확히 일치한다.
# combine_probe.py 핵심 — combine 직후 dest 를 읽어 base-128 곱셈인지 확인
X0 = val(vec($rdi)) # 변환된 chunk0
e00 = val(vec($rsi)) # exp1[0][0]
# ... combine(fcn_69a8) 직후로 continue ...
dest = val(vec($rbp - 0x30))
print(f"X0 * exp1[0][0] = {X0*e00:#x}")
print(f"combine dest = {dest:#x}")
print(f"combine == base-128 곱셈 ? {X0*e00==dest}")gdb -q -nx -batch -ex 'source combine_probe.py' -ex 'starti < probe_in.txt' -ex 'py setup()' -ex 'continue' -ex 'cp' extracted/chall![클릭하여 확대 gdb 런타임으로 확인한 combine — X0 × exp1[0][0]과 combine 결과 dest가 base-128 곱셈으로 정확히 일치한다(True)](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-mirage-writeup%2F08_combine_gdb.png&w=3840&q=75)
이 눈으로 다시 보면 transform도 정체가 드러난다. 5자 청크를 받아 output = output·256 + 글자를 다섯 번 반복한 것 — 즉 5자를 big-endian 40비트 정수로 만든 뒤 base-128으로 저장한 것뿐이다. gdb로 변환 결과를 떠서 확인하면 깔끔하다.
# transform_probe.py — 변환된 입력 배열을 읽어 "5자 = BE 정수"임을 확인
import gdb
CHECK_OFF = 0x1ac7
def rd(a,n): return bytes(gdb.selected_inferior().read_memory(a,n))
def vec(p):
raw=rd(p,24); p0=int.from_bytes(raw[0:8],'little'); p1=int.from_bytes(raw[8:16],'little')
return list(rd(p0,p1-p0)) if p0 and 0<=p1-p0<64 else []
def base():
return int(gdb.execute("info proc mappings",to_string=True).splitlines()[4].split()[0],16)
class T(gdb.Command):
def __init__(self): super().__init__("tp", gdb.COMMAND_USER)
def invoke(self,a,ft):
rdi=int(gdb.parse_and_eval("$rdi")) # 변환된 입력 배열(10 x vector)
for i in range(3):
limbs=vec(rdi+i*24)
v=sum(d*(128**k) for k,d in enumerate(limbs))
print(f"chunk[{i}] transform(limbs base128)={bytes(limbs).hex()} -> int={v:#014x} bytes={v.to_bytes(5,'big')}")
gdb.execute("kill")
T()
def setup(): gdb.execute("break *"+hex(base()+CHECK_OFF))gdb -q -nx -batch -ex 'source transform_probe.py' -ex 'starti < probe_in.txt' -ex 'py setup()' -ex 'continue' -ex 'tp' extracted/chall
"ABCDE"의 변환값이 0x4142434445, 정확히 'A''B''C''D''E'의 ASCII를 이어 붙인 40비트 정수다. 이제 검증 전체를 식으로 쓸 수 있다. 청크 i의 정수값을 X_i라 하면, 진짜 검증 fcn_1ac7은 10개의 출력 슬롯 j마다 이걸 요구한다.
for j = 0 .. 9:
X_0*exp1[j][0] + X_1*exp1[j][1] + ... + X_9*exp1[j][9] == exp2[j]exp1[j][i]와 exp2[j]는 base-128으로 저장된 상수. 미지수는 X_0..X_9 열 개뿐이고 방정식도 열 개다. 곱셈과 덧셈으로만 엮인 선형 연립방정식 M·X = b다.

🎯 풀이 — 상수만 뽑으면 끝
exp1·exp2가 입력과 무관한 상수이므로, gdb로 검증 함수 진입 시점에 exp1(10×10)과 exp2(10)를 메모리에서 한 번만 떠 오면 된다. 그다음은 유리수 가우스 소거로 X를 정확히 풀고, 각 Xᵢ를 5바이트 big-endian으로 되돌리면 그게 청크 문자다.
# solve.py — 핵심부: base-128 복원 + 유리수 가우스 소거
from fractions import Fraction
def val(limbs): # base-128 bignum(LSB first) -> integer
v = 0
for k, d in enumerate(limbs):
v += d * (128 ** k)
return v
def solve(M, b):
n = len(M)
A = [M[j][:] + [b[j]] for j in range(n)] # 증강행렬
for c in range(n):
piv = next(r for r in range(c, n) if A[r][c] != 0)
A[c], A[piv] = A[piv], A[c]
pv = A[c][c]
A[c] = [x / pv for x in A[c]]
for r in range(n):
if r != c and A[r][c] != 0:
f = A[r][c]
A[r] = [A[r][k] - f * A[c][k] for k in range(n + 1)]
return [A[i][n] for i in range(n)]각 해를 5바이트로 풀어 쓰면 전부 출력 가능한 문자, 그것도 16진 문자열로 떨어진다.
python3 solve.py![클릭하여 확대 solve.py 실행 결과 — 10개 청크 ['fca5c','7c52a',...]가 복원되고 flag DH 로 시작하는 플래그가 출력된다](/_next/image?url=%2Fimages%2Fblog%2Fdreamhack-mirage-writeup%2F05_solve.png&w=3840&q=75)
🚀 Full Exploit
전체 재현은 세 조각이다. gdb 스크립트로 상수를 덤프하고(dump_consts.py), 선형계를 풀고(solve.py), 복원한 플래그를 바이너리에 다시 먹여 Correct!를 확인한다(reproduce.sh).
# dump_consts.py — gdb로 검증 루틴의 상수(exp1 10x10, exp2 10)를 /tmp/mirage_mat.json 에 덤프
import gdb, json
CHECK_OFF = 0x1ac7
def rd(a, n): return bytes(gdb.selected_inferior().read_memory(a, n))
def vec(p): # std::vector<uint8_t> [begin,end,cap]
raw = rd(p, 24)
p0 = int.from_bytes(raw[0:8], 'little'); p1 = int.from_bytes(raw[8:16], 'little')
return list(rd(p0, p1 - p0)) if p0 and 0 <= p1 - p0 < 256 else []
def pie_base():
return int(gdb.execute("info proc mappings", to_string=True).splitlines()[4].split()[0], 16)
def setup():
gdb.execute("break *" + hex(pie_base() + CHECK_OFF))
class DumpConsts(gdb.Command):
def __init__(self): super().__init__("dc", gdb.COMMAND_USER)
def invoke(self, arg, ft):
e1 = int(gdb.parse_and_eval("$rsi")) # exp1 : 10 x 10 (outer stride 240)
e2 = int(gdb.parse_and_eval("$rdx")) # exp2 : 10 (stride 24)
M = [[vec(e1 + j*240 + i*24) for i in range(10)] for j in range(10)]
B = [vec(e2 + j*24) for j in range(10)]
json.dump({"M": M, "B": B}, open("/tmp/mirage_mat.json", "w"))
gdb.write("dumped exp1(10x10) + exp2(10) -> /tmp/mirage_mat.json\n")
gdb.execute("kill")
DumpConsts()# solve.py — 전문: 상수 json을 읽어 선형계를 풀고 flag 출력
import json
from fractions import Fraction
def val(limbs):
v = 0
for k, d in enumerate(limbs):
v += d * (128 ** k)
return v
def solve(M, b):
n = len(M)
A = [M[j][:] + [b[j]] for j in range(n)]
for c in range(n):
piv = next(r for r in range(c, n) if A[r][c] != 0)
A[c], A[piv] = A[piv], A[c]
pv = A[c][c]
A[c] = [x / pv for x in A[c]]
for r in range(n):
if r != c and A[r][c] != 0:
f = A[r][c]
A[r] = [A[r][k] - f * A[c][k] for k in range(n + 1)]
return [A[i][n] for i in range(n)]
def main():
d = json.load(open("/tmp/mirage_mat.json"))
M = [[Fraction(val(d["M"][j][i])) for i in range(10)] for j in range(10)]
b = [Fraction(val(d["B"][j])) for j in range(10)]
X = solve(M, b)
chunks = []
for i, x in enumerate(X):
assert x.denominator == 1, f"X[{i}] 가 정수가 아님: {x}"
xi = int(x)
bs = xi.to_bytes(5, "big")
assert all(0x20 <= c < 0x7f for c in bs), f"청크 {i} 가 비출력문자: {bs!r}"
chunks.append(bs)
inner = b"".join(chunks).decode()
print("chunks :", [c.decode() for c in chunks])
print("input :", inner)
print("FLAG : DH{" + inner + "}")
if __name__ == "__main__":
main()# reproduce.sh — 상수 덤프 → 선형계 풀이 → flag → 바이너리 검증
set -e
cd "$(dirname "$0")"
BIN=./extracted/chall
chmod +x "$BIN"
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n' > /tmp/mi.txt
gdb -q -nx -batch -ex 'source dump_consts.py' -ex 'starti < /tmp/mi.txt' \
-ex 'py setup()' -ex 'continue' -ex 'dc' "$BIN" 2>/dev/null | grep -E 'dumped'
FLAG=$(python3 solve.py | sed -n 's/^FLAG : //p')
echo "solved: $FLAG"
INNER=$(printf '%s' "$FLAG" | sed -E 's/^DH\{(.*)\}$/\1/')
echo "$INNER" | "$BIN"bash reproduce.sh
덤프한 상수로 푼 입력을 바이너리에 그대로 먹이니 Correct!가 떨어진다. 플래그는 DH{fca5c7c52a86459f10ef963921a164d31c714328fd6de9f5fe}.
📝 결론
신기루에 시간을 쏟지 말 것.
검증 함수를 처음부터 끝까지 정직하게 읽으면 fcn_1644의 청크 변환 루프에 눈이 오래 머문다. 하지만 반환값까지 따라가 보면 그게 size == 50에만 의존하는 가짜였다. 리버싱에서 "결과에 영향을 주는가"를 먼저 확인하는 습관이 반나절을 아낀다.
자릿수의 밑을 의심하라.
곱셈 가설이 전부 깨졌던 건 로직이 틀려서가 아니라 base가 256이 아니라 128이어서였다. 바이트가 전부 0x7f 이하거나 and 0x7f / shr 7이 보이면 base-128(혹은 다른 비표준 밑)을 떠올려야 한다. 커스텀 bignum은 밑만 맞추면 그다음은 그냥 산수다.
비선형처럼 보여도 선형일 수 있다.
벡터 곱·합으로 뒤섞인 10×10 구조가 겁을 주지만, 미지수(각 청크의 정수값)에 대해선 1차식이었다. anti-analysis 잡음과 STL 추상화를 걷어내고 나면 남는 건 미지수 10개짜리 연립방정식 하나. 상수는 입력과 무관하니 gdb로 한 번 떠 오면 끝난다. 공격자 관점에선 "검증식을 입력에 대한 방정식으로 다시 쓸 수 있는가"가 항상 첫 질문이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…