[💠 Platinum 3] 신기루 뒤의 선형 연립방정식 — DreamHack Mirage 풀이

2026-10-11·1분 읽기·

[💠 Platinum 3] 신기루 뒤의 선형 연립방정식 — DreamHack Mirage 풀이

겉보기엔 한 글자씩 비교하는 평범한 검증 같지만, 그 관문은 size만 맞으면 통과하는 신기루였다. 진짜 검증은 입력 50자를 5자씩 10개의 big-endian 정수로 바꿔 base-128 bignum 곱셈으로 섞은 뒤 10×10 상수 행렬과 맞춰보는 선형 연립방정식이었다. gdb로 상수를 뽑아 가우스 소거로 풀었다.

문제: 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 = 1

getauxval(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 심볼 없음)")
pwntools로 확인한 Mirage 바이너리 보호기법 — amd64 stripped PIE, Full RELRO, NX, Canary 전부 적용
pwntools로 확인한 Mirage 바이너리 보호기법 — amd64 stripped PIE, Full RELRO, NX, Canary 전부 적용

stripped PIE라 심볼이 없다. 보호기법은 전부 켜져 있지만 서버가 없는 로컬 리버싱이라 그 자체는 중요하지 않다. 다음으로 문자열을 보면 검증의 골격이 드러난다.

strings extracted/chall | grep -E 'Input|Correct|Wrong|Flag is|read error'
strings로 추출한 Mirage의 핵심 문자열 — Input:, Correct!, Wrong!, Flag is DH 로 시작하는 출력 문자열이 보인다
strings로 추출한 Mirage의 핵심 문자열 — Input:, Correct!, Wrong!, Flag is DH 로 시작하는 출력 문자열이 보인다

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
objdump로 본 fcn_1644의 반환 로직 — size가 50이 아니면 0, 그 외에는 청크 비교 결과와 무관하게 무조건 1을 반환한다
objdump로 본 fcn_1644의 반환 로직 — size가 50이 아니면 0, 그 외에는 청크 비교 결과와 무관하게 무조건 1을 반환한다

길이가 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
objdump로 본 main의 검증 디스패치 — 세 개의 lea로 변환입력·exp1·exp2 주소를 싣고 진짜 검증 함수 fcn_1ac7을 호출한다
objdump로 본 main의 검증 디스패치 — 세 개의 lea로 변환입력·exp1·exp2 주소를 싣고 진짜 검증 함수 fcn_1ac7을 호출한다

fcn_1ac7(변환된 입력, exp1, exp2). 안을 보면 10×10 구조로 변환된 입력과 상수 exp1을 곱해 합친 뒤 exp2와 비교하는데, 첫 비교가 틀리는 순간 바로 빠져나온다(단락 평가). exp1·exp2는 입력과 무관한 고정 상수다.

Mirage 검증 구조 다이어그램 — 입력 10청크를 transform 후 신기루(fcn_1644)와 진짜 검증(fcn_1ac7)으로 갈리고, 진짜 검증은 선형 연립방정식 M·X=b로 귀결된다
Mirage 검증 구조 다이어그램 — 입력 10청크를 transform 후 신기루(fcn_1644)와 진짜 검증(fcn_1ac7)으로 갈리고, 진짜 검증은 선형 연립방정식 M·X=b로 귀결된다
▶🐛 삽질 — '이건 곱셈이겠지'로 몇 번을 헛짚었나

변환된 입력(각 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 잡음/
objdump로 본 fcn_6304의 base-128 곱셈 루프 — imul로 자릿수를 곱하고 and 0x7f로 하위 7비트만 limb에 저장, shr 7로 올림수를 다음 자리에 넘긴다
objdump로 본 fcn_6304의 base-128 곱셈 루프 — imul로 자릿수를 곱하고 and 0x7f로 하위 7비트만 limb에 저장, shr 7로 올림수를 다음 자리에 넘긴다

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)
gdb 런타임으로 확인한 combine — X0 × exp1[0][0]과 combine 결과 dest가 base-128 곱셈으로 정확히 일치한다(True)

이 눈으로 다시 보면 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
gdb 런타임으로 확인한 transform — "ABCDE"가 base-128 limb 45080d121408, 정수 0x4142434445, 즉 bytes b'ABCDE'로 환원된다
gdb 런타임으로 확인한 transform — "ABCDE"가 base-128 limb 45080d121408, 정수 0x4142434445, 즉 bytes b'ABCDE'로 환원된다

"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다.

선형 연립방정식 다이어그램 — base-128 bignum으로 저장된 exp1·exp2 상수와 미지수 X_i가 M·X=b 형태를 이루고, 상수 추출과 가우스 소거 두 단계로 푼다
선형 연립방정식 다이어그램 — base-128 bignum으로 저장된 exp1·exp2 상수와 미지수 X_i가 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 로 시작하는 플래그가 출력된다
solve.py 실행 결과 — 10개 청크 ['fca5c','7c52a',...]가 복원되고 flag DH 로 시작하는 플래그가 출력된다

🚀 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
reproduce.sh 전체 실행 — 상수 덤프 후 선형계를 풀어 복원한 입력을 바이너리에 넣자 Input: Correct! 와 Flag is DH 로 시작하는 플래그가 그대로 출력된다
reproduce.sh 전체 실행 — 상수 덤프 후 선형계를 풀어 복원한 입력을 바이너리에 넣자 Input: Correct! 와 Flag is DH 로 시작하는 플래그가 그대로 출력된다

덤프한 상수로 푼 입력을 바이너리에 그대로 먹이니 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[💠 Platinum 3] 니블 교환·AES S-box·std::sort 로 짠 로그인 검사 되돌리기 — DreamHack rev-login 풀이
blog

[💠 Platinum 3] 니블 교환·AES S-box·std::sort 로 짠 로그인 검사 되돌리기 — DreamHack rev-login 풀이

아이디와 비밀번호를 세 단계로 검사하는 C++ 바이너리. 아이디 검사는 글자를 니블 단위로 뒤집어 옆 글자와 엮은 뒤 AES S-box 를 통과시키고, 비밀번호 검사는 S-box 를 한 번 더 누산한 다음 std::sort 로 자리 정보를 지워 버린다. 플래그는 어디에도 없고 정답 문자열의 SHA-1 이 곧 키다.
#dreamhack#ctf#reversing+6
2026-08-04#dreamhack +4
[🥈 Silver 3] 시리얼 검증 함수 5개는 사실 연립방정식 5개였다 — DreamHack verify 풀이
blog

[🥈 Silver 3] 시리얼 검증 함수 5개는 사실 연립방정식 5개였다 — DreamHack verify 풀이

스트립된 keygen류 리버싱 바이너리. "DH 뒤에 8자리-10자리-10자리-8자리 형식의 hex 세그먼트"를 가진 시리얼을 입력받아 형식을 검사하고, 4조각을 각각 64비트 정수로 바꾼 뒤 5개의 검증 함수를 체인으로 통과시킨다. 함수를 하나씩 디스어셈블해보면 그 5개가 전부 덧셈과 XOR로 이루어진 식이라는 게 드러난다 — a+b, b+c, c+d, a+d, b^c^d. 겉보기엔 복잡한 검증 로직이지만 결국 미지수 4개짜리 연립방정식 5개일 뿐이고, numpy로 66초 만에 유일해를 찾아낸다.
#dreamhack#ctf#reversing+5
2026-07-21#dreamhack +4
[🥇 Gold 2] 화면에 한 자리씩 뜨는 실행파일 — DreamHack captain-hook 풀이
blog

[🥇 Gold 2] 화면에 한 자리씩 뜨는 실행파일 — DreamHack captain-hook 풀이

클릭할 때마다 16진수 한 자리를 그리는 창. 그 18,432자리를 다 모으면 UPX로 패킹된 또 하나의 실행파일이 나온다. 풀어서 실행하면 flag가 뜨는데 굵기 10.0 펜으로 그은 가림막이 글자를 덮는다. 상수 하나를 0.3으로 바꾸면 덮개가 걷힌다.
#dreamhack#ctf#reversing+5
2026-07-31#dreamhack +4