문제: DreamHack — multipoint2 분류: reversing + crypto 난이도: 💎 Platinum 3 FLAG:
POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!}
문제 설명은 두 줄이다. "예전 multipoint 풀어봤냐, 이번엔 업그레이드판이다." 받은 건 스트립된 PIE 바이너리 하나. 실행하면 Input : 을 찍고 한 줄을 받아 Correct 아니면 Wrong 을 뱉는 평범한 크랙미처럼 보인다. 그런데 안을 열면 입력을 251로 나눈 나머지 위에서 곱하고 제곱하기를 열여섯 번 반복하는, 유한체 거듭제곱이 통째로 들어 있었다. 결론부터 말하면 이 바이너리는 다항식환 위에서 RSA 암호화를 수행하는 장치였고, 플래그를 되찾는 일은 곧 지수 역원을 구해 복호하는 일이었다.
문제 개요
먼저 바이너리의 생김새부터 본다.
file extracted/multipoint2
readelf -h extracted/multipoint2 | grep -E "Type:|Entry"
objdump -T extracted/multipoint2 | grep UND | awk '{print $NF}'
| 항목 | 값 |
|---|---|
| 아키텍처 | x86-64, PIE, dynamically linked |
| 심볼 | stripped (main 은 _start 의 lea rdi 로 추적) |
| 임포트 | read write memset memcpy memcmp 뿐 |
| 입력 | 정확히 39바이트(0x27), 앞 38바이트만 사용 |
| 판정 | 변환 결과를 내장 상수 38바이트와 memcmp |
쓰는 함수가 memcmp 하나로 끝난다는 건 "입력을 뭔가로 바꾼 뒤 정답 버퍼와 통째로 비교"하는 전형적인 크랙미 구조라는 뜻이다. 입출력 문자열도 그 그림에 맞는다.
strings -n 4 extracted/multipoint2 | grep -nE "Input|Correct|Wrong"
핵심은 두 덩어리 데이터에 있다. .data 안에 거대한 정수 배열(곱셈 계수표)과, .bss 직전에 38바이트짜리 정답 암호문이 박혀 있다. 이 둘의 정체를 밝히는 게 이 문제의 전부다.
main 읽기 — 입력을 어떻게 다루나
_start 가 lea rdi,[rip+0xc8] 로 넘기는 0x1180 이 main 이다. 앞부분만 보면 흐름이 명확하다.
objdump -d -M intel --start-address=0x11ec --stop-address=0x1253 extracted/multipoint2
정리하면 이렇다.
read(0, buf_in, 0x100)으로 입력을 받고, 반환값이0x27(39)이 아니면 즉시Wrong.- 입력 앞 38바이트(
0x26)를memcpy로 전역 상태 버퍼로 옮긴다. 이 버퍼를 앞으로 STATE 라 부른다(.bss의0x1ed90). - 그다음이 본론이다. 16번 도는 바깥 루프와, 그 안에서 계수표를 훑는 안쪽 루프.
39바이트를 요구하지만 쓰는 건 38바이트다. 나머지 한 바이트는 printf '%s\n' 의 개행이다. 즉 플래그 본문은 38글자다.
핵심 연산 — 251 위의 이차형식
안쪽 루프가 이 문제의 심장이다. 계수표에서 네 바이트 (b0, b1, b2, b3) 를 꺼내 다음을 계산한다.
objdump -d -M intel --start-address=0x129a --stop-address=0x1314 extracted/multipoint2
읽어낸 연산은 이것이다.
TMP[b0] = ( TMP[b0] + STATE[b1] * STATE[b2] * b3 ) mod 2510xfb = 251 은 소수다. 각 항이 STATE[b1] * STATE[b2] 라는 두 상태 원소의 곱이라는 데 주목한다. 출력 한 칸(TMP[b0])은 입력 여러 쌍의 곱을 계수와 함께 더한 값, 곧 251 위의 이차형식이다. 이런 모양은 하나뿐이다 — 다항식 두 개를 곱하고 어떤 다항식으로 나눈 나머지. 계수표를 꺼내 확인해 보자.
#!/usr/bin/env python3
# multipoint2 - .data 의 COEF 곱셈표와 TARGET(정답 암호문) 추출
import struct, os
BIN = 'extracted/multipoint2' if os.path.exists('extracted/multipoint2') else 'multipoint2'
data = open(BIN, 'rb').read()
def off(v): return v - 0x1000 # .data: vaddr - 0x1000 = 파일 오프셋
# COEF 테이블 @0x4050 : dword=0 센티넬까지. dword 1개 = (b0,b1,b2,b3)
base = off(0x4050); coefs = []; p = base
while True:
dw = struct.unpack_from('<I', data, p)[0]
if dw == 0:
break
coefs.append((dw & 0xff, (dw >> 8) & 0xff, (dw >> 16) & 0xff, (dw >> 24) & 0xff))
p += 4
print("[COEF] 항목 수 =", len(coefs))
print("[COEF] 처음 6개 (b0=출력idx, b1,b2=입력idx, b3=계수):")
for c in coefs[:6]:
print(" out[%2d] += s[%2d]*s[%2d]*%3d" % c)
b0 = [c[0] for c in coefs]; b1 = [c[1] for c in coefs]
b2 = [c[2] for c in coefs]; b3 = [c[3] for c in coefs]
print("[COEF] b0 범위 %d..%d / b1 %d..%d / b2 %d..%d / 계수 %d..%d"
% (min(b0), max(b0), min(b1), max(b1), min(b2), max(b2), min(b3), max(b3)))
# TARGET 38바이트 @0x1ed50 (memcmp 비교 대상 = FLAG^65537 의 바이트열)
TARGET = list(data[off(0x1ed50):off(0x1ed50) + 38])
print("[TARGET] 38바이트 =", TARGET)
print("[TARGET] hex =", bytes(TARGET).hex())
print("[MOD] idiv 상수 0xfb =", 0xfb, "(= 251, 소수)")
27455개 항목, 인덱스는 전부 037, 계수는 1250. 출력·입력 인덱스가 모두 38칸 안에서 움직인다. 38칸짜리 벡터를 곱하는 쌍선형 연산, 즉 길이 38 다항식끼리의 곱셈표로 보면 딱 맞는다.
배경 — 이게 왜 다항식 곱셈인가
계수표가 정의하는 연산을 MUL(u, v)[k] = Σ u[i]·v[j]·c 로 두고, 이게 정말 환(ring)의 곱셈인지 성질로 확인한다. 환이라면 가환·결합·항등원을 가져야 하고, 표준 다항식 표현이라면 x = [0,1,0,…] 의 거듭제곱이 단항식 기저와 맞아떨어져야 한다.
#!/usr/bin/env python3
# multipoint2 - COEF 가 정의하는 곱셈 MUL 이 F_251[x]/(p(x)) 환(ring)인지 실증한다.
# MUL(u,v)[k] = sum over (k,i,j,c) in COEF u[i]*v[j]*c (mod 251)
import struct, os, random
BIN = 'extracted/multipoint2' if os.path.exists('extracted/multipoint2') else 'multipoint2'
data = open(BIN, 'rb').read()
def off(v): return v - 0x1000
MOD, N = 251, 38
base = off(0x4050); coefs = []; p = base
while True:
dw = struct.unpack_from('<I', data, p)[0]
if dw == 0: break
coefs.append((dw & 0xff, (dw >> 8) & 0xff, (dw >> 16) & 0xff, (dw >> 24) & 0xff)); p += 4
from collections import defaultdict
T = defaultdict(list)
for (k, i, j, c) in coefs:
T[k].append((i, j, c))
def MUL(u, v):
r = [0] * N
for k in range(N):
s = 0
for (i, j, c) in T[k]:
s += u[i] * v[j] * c
r[k] = s % MOD
return r
rnd = lambda: [random.randrange(MOD) for _ in range(N)]
# 1) 가환성
comm = all(MUL(a, b) == MUL(b, a) for a, b in [(rnd(), rnd()) for _ in range(5)])
print("[1] 가환(commutative) :", comm)
# 2) 결합성
assoc = all(MUL(MUL(a, b), c) == MUL(a, MUL(b, c))
for a, b, c in [(rnd(), rnd(), rnd()) for _ in range(3)])
print("[2] 결합(associative) :", assoc)
# 3) 항등원 e = [1,0,0,...]
e = [0] * N; e[0] = 1
ident = all(MUL(e, a) == a for a in [rnd() for _ in range(5)])
print("[3] e=[1,0,..] 가 항등원 :", ident)
# 4) x=[0,1,0,..] 의 거듭제곱이 단항식 기저와 일치 (표준 다항식 표현)
x = [0] * N; x[1] = 1
cur = e[:]; mono = True
for k in range(N):
ek = [0] * N; ek[k] = 1
if cur != ek: mono = False; break
cur = MUL(cur, x)
print("[4] x^k == 기저 e_k (k<38) :", mono)
# 5) x^38 의 환원 => 모듈러스 다항식 p(x) = x^38 - (x^38 환원식)
print("[5] x^38 환원 계수(= p의 상수부분들) =", cur)![클릭하여 확대 analyze_ring.py 실행 결과. 가환 결합 항등원이 모두 True 이고, x=[0,1,0,…] 의 거듭제곱이 38차 전까지 단항식 기저와 정확히 일치한다. 마지막 줄은 x^38 을 환원한 38개 계수로, 이게 곧 모듈러스 다항식 p(x) 를 준다.](/_next/image?url=%2Fimages%2Fblog%2Fmultipoint2%2F08_ring_props.png&w=3840&q=75)
네 성질이 전부 성립한다. 이 연산은 F_251[x]/(p(x)) 의 곱셈이고, 입력 38바이트는 차수 37 이하 다항식의 계수다. x^38 을 환원한 값이 모듈러스 다항식 p(x) 를 알려준다(x^38 = Σ (그 계수)·x^i, 그러니 p(x) = x^38 − Σ(…)). 바닥이 깔렸으니 이제 바깥 루프가 무엇을 하는지가 보인다.
돌파구 — 16번 제곱과 마지막 곱은 65537제곱
바깥 루프는 16번 돈다. 매 라운드 STATE = MUL(STATE, STATE), 곧 제곱이다. 제곱을 16번 하면 지수가 2씩 열여섯 번 곱해지니 STATE = IN^(2^16) 이 된다. 루프가 끝난 뒤 마지막에 한 번 더, 이번엔 원본 입력과 곱한다.
objdump -d -M intel --start-address=0x13ac --stop-address=0x1409 extracted/multipoint2
앞선 제곱 루프가 두 피연산자 모두 STATE 에서 읽던 것과 달리, 이 마지막 루프는 한쪽을 원본 입력 IN(rbp-0x110)에서, 다른 쪽을 16번 제곱된 STATE(rbp-0x210)에서 읽는다. 그러니 결과는 IN · IN^(2^16) = IN^(2^16+1) = IN^65537. 이 값을 내장 암호문과 비교한다.
objdump -d -M intel --start-address=0x144b --stop-address=0x149d extracted/multipoint2
세 줄로 요약된다.
STATE16 = IN^(2^16) # 제곱 16회
OUT = IN · STATE16 # 마지막 곱
OUT = IN^65537 # 즉 입력의 65537제곱
Correct ⟺ OUT == TARGET65537 = 2^16 + 1. 공개 지수 e = 65537 로 거듭제곱해 암호문을 만드는 것, 바로 RSA의 그 지수다. 다만 정수 모듈러가 아니라 유한체 GF(251^38) 위에서 돌린다. 플래그 문자열이 POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!} — Rivest·Shamir·Adleman, 이름부터 "다항식(Poly) RSA"였다.

삽질 기록
▶🐛 삽질 1 — 기약이 아닌 모듈러스, 그래도 역원은 먹혔다
처음엔 당연히 p(x) 가 F_251 위에서 기약일 거라 생각했다. 기약이면 환이 바로 체 GF(251^38) 이 되고, 곱셈군 위수는 깔끔하게 251^38 − 1, 지수 역원 한 번이면 끝이다. 그런데 sympy 로 돌려 보니 p(x) irreducible over F_251: False. 기약이 아니다. 엄밀히 말하면 이 환은 체가 아니라 여러 체의 곱(CRT 분해)이고, 65537제곱 사상이 전단사라는 보장이 그 자리에서 사라진다.
그래도 멈추지 않은 건, gcd(65537, 251^38 − 1) = 1 이 성립했기 때문이다. 공개 지수가 전체 위수와 서로소면 d = 65537^{-1} mod (251^38 − 1) 로 역원을 잡을 수 있고, 플래그가 단원(unit)인 한 TARGET^d = FLAG 가 복원된다. 다만 "이론상 맞다"로 넘기지 않았다. 복원한 값을 다시 정방향으로 암호화해서 TARGET 과 바이트 단위로 일치하는지 확인하는 것을 최종 관문으로 삼았다. 이게 통과하면 모듈러스가 기약이든 아니든 이 암호문에 대해서는 정답이 확정된다.
결과적으로 복원값은 그대로 출력 가능한 ASCII였고 POKA{…} 형식이었으며, 정방향 재암호화가 TARGET 과 완전히 일치했다. 수론적 염려는 실측 검증 앞에서 접었다.
▶🐛 삽질 2 — 어느 피연산자가 입력이고 어느 게 상태인가
제곱 루프와 마지막 곱 루프는 어셈블리가 거의 똑같이 생겼다. 둘 다 계수표를 훑고, 두 바이트를 곱하고, 251로 나눈다. 차이는 바이트를 어디서 읽느냐 하나뿐이다. 제곱 루프는 양쪽 다 rbp-0x210(STATE)에서 읽지만, 마지막 루프는 한쪽이 rbp-0x110(원본 입력 IN)이다. 이 한 글자 차이(-0x110 vs -0x210)를 놓치면 전체를 IN^(2^17) 로 오해하게 된다. IN^65537 과 IN^131072 는 전혀 다른 복호 지수를 준다. disassembly 에서 메모리 오프셋을 끝까지 따라가야 했던 이유다.
복호 — 지수 역원으로 되돌리기
구조가 확정됐으니 복호는 RSA 그대로다. 위수 N = 251^38 − 1, 비밀 지수 d = 65537^{-1} mod N, 그리고 FLAG = TARGET^d. 환의 거듭제곱은 square-and-multiply 로, 이때 곱은 바이너리에서 그대로 뽑아낸 MUL(이미 환원까지 포함한다)을 쓴다. 아래가 실제로 플래그를 뽑은 solve.py 전문이다.
#!/usr/bin/env python3
# multipoint2 (DreamHack, Platinum 3, reversing+crypto) solver
#
# 바이너리 분석 결과:
# - 입력 39바이트(0x27)를 읽고, 앞 38바이트(0x26)를 상태 STATE 로 복사한다.
# - COEF 테이블(.data@0x4050)은 F_251[x]/(p(x)) 위의 곱셈 MUL 을 정의한다:
# MUL(u,v)[k] = sum over (k,i,j,c) in COEF u[i]*v[j]*c (mod 251)
# - loop1: STATE <- MUL(STATE,STATE) 를 16회 => STATE16 = IN^(2^16)
# - loop2: OUT = MUL(IN, STATE16) = IN^(2^16+1) = IN^65537
# - memcmp(OUT, TARGET@0x1ed50, 38) => "Correct"
# 즉 TARGET = FLAG^65537 (GF(251^38) 위의 RSA 구조).
#
# 복호: p(x) 가 F_251 위에서 기약 => R = GF(q), q = 251^38.
# d = 65537^{-1} mod (q-1), FLAG = TARGET^d.
import struct, sys
MOD = 251
N = 38
E = 65537
data = open('extracted/multipoint2', 'rb').read() if __import__('os').path.exists('extracted/multipoint2') else open('multipoint2','rb').read()
def off(v): return v - 0x1000 # .data: vaddr - 0x1000 = file offset
# --- COEF 테이블 파싱 (dword=0 센티넬까지) ---
base = off(0x4050); coefs = []; p = base
while True:
dw = struct.unpack_from('<I', data, p)[0]
if dw == 0: break
coefs.append((dw & 0xff, (dw >> 8) & 0xff, (dw >> 16) & 0xff, (dw >> 24) & 0xff)); p += 4
from collections import defaultdict
T = defaultdict(list)
for (k, i, j, c) in coefs:
T[k].append((i, j, c))
def MUL(u, v):
r = [0] * N
for k in range(N):
s = 0
for (i, j, c) in T[k]:
s += u[i] * v[j] * c
r[k] = s % MOD
return r
# TARGET 38바이트
TARGET = list(data[off(0x1ed50):off(0x1ed50) + 38])
# --- 이진 모델 정확 재현(검증용): OUT = IN^65537 via 16 squarings + 1 mul ---
def encrypt(IN):
st = IN[:]
for _ in range(16):
st = MUL(st, st) # STATE <- STATE^2 => IN^(2^16)
return MUL(IN, st) # IN * IN^(2^16) = IN^(2^16+1)
# --- 모듈러스 다항식 p(x): x^38 의 환원 ---
e = [0]*N; e[0] = 1
x = [0]*N; x[1] = 1
cur = e[:]
for _ in range(N):
cur = MUL(cur, x)
x38 = cur # x^38 = sum x38[i] x^i
# p(x) = x^38 - sum x38[i] x^i (monic)
from sympy import Poly, symbols, GF
X = symbols('X')
pcoeffs = [1] + [(-x38[N-1-i]) % MOD for i in range(N)] # 최고차부터
ppoly = Poly(pcoeffs, X, modulus=MOD)
print("p(x) monic deg", ppoly.degree())
print("p(x) irreducible over F_251:", ppoly.is_irreducible)
q = MOD ** N
order = q - 1
from math import gcd
print("gcd(65537, q-1) == 1 :", gcd(E, order) == 1)
d = pow(E, -1, order)
# --- 환(=체) 거듭제곱: square-and-multiply (MUL 이 이미 환원 포함) ---
def ring_pow(base_v, exp):
result = e[:] # identity
b = base_v[:]
while exp > 0:
if exp & 1:
result = MUL(result, b)
b = MUL(b, b)
exp >>= 1
return result
FLAG_vec = ring_pow(TARGET, d)
# vec -> bytes (단항식 계수가 곧 바이트)
flag_bytes = bytes(FLAG_vec)
print("recovered 38 bytes:", FLAG_vec)
print("as ascii:", flag_bytes)
# --- 검증: 복원값을 정방향 암호화하면 TARGET 과 일치해야 함 ---
chk = encrypt(FLAG_vec)
print("forward(FLAG) == TARGET :", chk == TARGET)
# 39번째 바이트는 개행(read 0x27=39, 사용 38). 플래그 출력
try:
print("FLAG =", flag_bytes.decode())
except Exception as ex:
print("decode err", ex)
복원된 38바이트가 그대로 POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!} 로 디코드되고, forward(FLAG) == TARGET : True 로 정방향 재암호화가 내장 암호문과 완전히 맞아떨어졌다. 복호 지수가 맞았다는 가장 확실한 증거다.
검증 — gdb 로 비교 직전을 들여다보기
스크립트 말고 바이너리 자신에게도 물어본다. 복호한 플래그를 입력으로 넣고, memcmp 진입 직전에서 멈춰 계산된 출력 버퍼와 내장 TARGET 을 나란히 덤프한다. ASLR 은 gdb 기본값대로 끄고(로드 베이스 0x555555554000), memcmp@plt(0x1060)에 중단점을 건다.
# cmds.gdb
set pagination off
set disable-randomization on
break *0x555555555060 # memcmp@plt = 0x1060 + PIE base
run
printf "길이 rdx = %d\n", $rdx
x/38bx $rdi # 입력으로 계산한 OUT
x/38bx $rsi # 바이너리 내장 TARGET
finish
printf "memcmp 반환 eax = %d\n", $eax
continuegdb -q -batch -x cmds.gdb --args extracted/multipoint2 < flag_input.txt
계산된 OUT(rdi)과 내장 TARGET(rsi)이 38바이트 전부 같다. memcmp 가 0을 반환하고 Correct 가 찍힌다. 마지막으로 실제 바이너리를 평범하게 실행해 복호한 플래그와 엉뚱한 입력을 각각 넣어 본다.
printf '%s\n' 'POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!}' | ./multipoint2
printf '%s\n' 'POKA{wrong_flag_123456789012345678}' | ./multipoint2
복호한 문자열에서 Correct, 엉뚱한 입력에서 Wrong. 이 38글자가 FLAG = POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!} 다. DreamHack 제출도 정답 처리됐다.
마무리
multipoint2 는 리버싱과 크립토가 반반씩 걸쳐 있다. 리버싱 쪽 일은 "251로 나누고 두 바이트를 곱하는 루프"가 사실은 다항식환의 곱셈이고, 16번 제곱 뒤 원본과 한 번 더 곱하는 흐름이 e = 2^16 + 1 거듭제곱이라는 걸 어셈블리에서 읽어내는 것이었다. 그걸 읽고 나면 크립토 쪽은 교과서 RSA 복호 — 위수와 서로소인 공개 지수의 역원을 구해 암호문을 되돌리면 된다.
몇 가지 남길 점:
- 반복되는 산술은 대수 구조를 의심한다. mod 소수 위에서 두 원소를 곱해 누적하는 패턴이 나오면 다항식/유한체 곱셈일 가능성이 높다. 가환·결합·항등원·기저 네 가지를 실측으로 확인하면 추측이 사실이 된다.
- 제곱 n회 + 곱 1회 = 지수 2^n+1. 이 조합은 RSA 공개 지수 65537(=2^16+1)을 하드코딩하지 않고 "제곱 반복"으로 숨기는 흔한 수법이다. 루프 횟수를 지수로 환산하는 눈이 필요하다.
- 이론은 실측으로 닫는다. 모듈러스가 기약이 아니어서 "체가 아니다"라는 경고가 떴지만, 복원값을 정방향으로 재암호화해 암호문과 바이트 단위로 대조하는 것으로 정답을 확정했다. 수론적 전제가 흔들릴 때일수록 재현 가능한 검증이 답을 지킨다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…