[💎 Platinum 3] 다항식 안에 숨은 RSA — DreamHack multipoint2 풀이

2026-10-20·1분 읽기·

[💎 Platinum 3] 다항식 안에 숨은 RSA — DreamHack multipoint2 풀이

스트립된 크랙미가 입력 38바이트를 F_251[x]/(p(x)) 의 원소로 보고 e=2^16+1 로 거듭제곱해 내장 암호문과 비교한다. COEF 테이블이 환의 곱셈이라는 걸 밝히고, 16번의 제곱과 마지막 곱이 65537제곱이라는 구조를 읽어, 지수 역원 d=65537^-1 로 암호문을 되돌려 플래그를 복원한다.

문제: 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}'
file 과 readelf 로 본 multipoint2 바이너리의 기본 정보와 objdump -T 로 추린 임포트 심볼 목록. x86-64 PIE, stripped 이며 가져다 쓰는 libc 함수는 read write memset memcpy memcmp 다섯 개뿐이다.
file 과 readelf 로 본 multipoint2 바이너리의 기본 정보와 objdump -T 로 추린 임포트 심볼 목록. x86-64 PIE, stripped 이며 가져다 쓰는 libc 함수는 read write memset memcpy memcmp 다섯 개뿐이다.
항목값
아키텍처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"
strings 출력에서 Input 과 Correct, Wrong 세 문자열이 잡힌 모습. 입력 프롬프트 한 개와 성공, 실패 메시지 두 개만 있어 전형적인 단일 비교 크랙미임을 보여준다.
strings 출력에서 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
objdump 로 본 main 앞부분. read 로 입력을 받은 뒤 길이를 0x27 과 cmp 해서 정확히 39바이트가 아니면 Wrong 으로 빠지고, 맞으면 입력 38바이트를 memcpy 로 상태 버퍼 STATE 로 복사한다.
objdump 로 본 main 앞부분. read 로 입력을 받은 뒤 길이를 0x27 과 cmp 해서 정확히 39바이트가 아니면 Wrong 으로 빠지고, 맞으면 입력 38바이트를 memcpy 로 상태 버퍼 STATE 로 복사한다.

정리하면 이렇다.

  • 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
objdump 로 본 안쪽 루프의 산술. TMP 에 STATE 의 두 원소를 곱하고 계수 b3 를 또 곱해 더한 뒤, mov esi,0xfb 와 idiv esi 로 251 로 나눈 나머지를 남긴다. 251 위에서 돌아가는 이차형식 누적이다.
objdump 로 본 안쪽 루프의 산술. TMP 에 STATE 의 두 원소를 곱하고 계수 b3 를 또 곱해 더한 뒤, mov esi,0xfb 와 idiv esi 로 251 로 나눈 나머지를 남긴다. 251 위에서 돌아가는 이차형식 누적이다.

읽어낸 연산은 이것이다.

TMP[b0] = ( TMP[b0] + STATE[b1] * STATE[b2] * b3 ) mod 251

0xfb = 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, 소수)")
parse_data.py 실행 결과. COEF 테이블이 27455개 항목이고 b0 b1 b2 는 모두 0부터 37, 계수는 1부터 250 범위다. 아래에 내장 TARGET 38바이트와 그 16진수, 그리고 나눗셈 상수 251이 소수임을 찍어 준다.
parse_data.py 실행 결과. COEF 테이블이 27455개 항목이고 b0 b1 b2 는 모두 0부터 37, 계수는 1부터 250 범위다. 아래에 내장 TARGET 38바이트와 그 16진수, 그리고 나눗셈 상수 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) 를 준다.
analyze_ring.py 실행 결과. 가환 결합 항등원이 모두 True 이고, x=[0,1,0,…] 의 거듭제곱이 38차 전까지 단항식 기저와 정확히 일치한다. 마지막 줄은 x^38 을 환원한 38개 계수로, 이게 곧 모듈러스 다항식 p(x) 를 준다.

네 성질이 전부 성립한다. 이 연산은 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
objdump 로 본 마지막 곱셈 루프. 한쪽 피연산자는 rbp-0x110 의 원본 입력 IN 에서, 다른 쪽은 rbp-0x210 의 16번 제곱된 STATE 에서 바이트를 읽어 곱한다. 즉 MUL(IN, STATE16) 을 계산한다.
objdump 로 본 마지막 곱셈 루프. 한쪽 피연산자는 rbp-0x110 의 원본 입력 IN 에서, 다른 쪽은 rbp-0x210 의 16번 제곱된 STATE 에서 바이트를 읽어 곱한다. 즉 MUL(IN, STATE16) 을 계산한다.

앞선 제곱 루프가 두 피연산자 모두 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
objdump 로 본 main 끝부분. 계산된 출력 버퍼와 0x1ed50 의 내장 TARGET 을 38바이트 memcmp 하고, 반환이 0이면 Correct, 아니면 Wrong 을 write 한다.
objdump 로 본 main 끝부분. 계산된 출력 버퍼와 0x1ed50 의 내장 TARGET 을 38바이트 memcmp 하고, 반환이 0이면 Correct, 아니면 Wrong 을 write 한다.

세 줄로 요약된다.

STATE16 = IN^(2^16)       # 제곱 16회
OUT     = IN · STATE16     # 마지막 곱
OUT     = IN^65537         # 즉 입력의 65537제곱
Correct ⟺ OUT == TARGET

65537 = 2^16 + 1. 공개 지수 e = 65537 로 거듭제곱해 암호문을 만드는 것, 바로 RSA의 그 지수다. 다만 정수 모듈러가 아니라 유한체 GF(251^38) 위에서 돌린다. 플래그 문자열이 POKA{Poly_c0w_R1vest-Sham1r-Adl3man~!} — Rivest·Shamir·Adleman, 이름부터 "다항식(Poly) RSA"였다.

multipoint2 의 전체 구조 다이어그램. 위쪽은 암호화로 입력 FLAG 를 16번 제곱해 IN^(2^16) 을 만들고 원본과 곱해 IN^65537=TARGET 을 얻는 흐름이고, 아래쪽은 복호로 곱셈군 위수 N=251^38-1 에서 d=65537^-1 을 구해 TARGET^d 로 플래그를 되찾는 흐름이다.
multipoint2 의 전체 구조 다이어그램. 위쪽은 암호화로 입력 FLAG 를 16번 제곱해 IN^(2^16) 을 만들고 원본과 곱해 IN^65537=TARGET 을 얻는 흐름이고, 아래쪽은 복호로 곱셈군 위수 N=251^38-1 에서 d=65537^-1 을 구해 TARGET^d 로 플래그를 되찾는 흐름이다.

삽질 기록

▶🐛 삽질 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)
solve.py 실행 결과. p(x) 가 기약이 아니라고 나오지만 gcd(65537, q-1)=1 이라 지수 역원이 존재하고, 복원한 38바이트가 POKA 로 시작하는 ASCII 플래그로 디코드된다. forward(FLAG)==TARGET 이 True 라 정방향 재암호화 검증까지 통과했다.
solve.py 실행 결과. p(x) 가 기약이 아니라고 나오지만 gcd(65537, q-1)=1 이라 지수 역원이 존재하고, 복원한 38바이트가 POKA 로 시작하는 ASCII 플래그로 디코드된다. forward(FLAG)==TARGET 이 True 라 정방향 재암호화 검증까지 통과했다.

복원된 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
continue
gdb -q -batch -x cmds.gdb --args extracted/multipoint2 < flag_input.txt
gdb 배치 실행 결과. memcmp 진입 시점에서 입력으로 계산한 OUT 버퍼가 6b 82 5a a3 … 로 시작하고, 바이너리 내장 TARGET 도 같은 주소열에서 동일하게 6b 82 5a a3 … 다. 두 버퍼가 38바이트 전부 일치해 memcmp 반환값 eax 가 0 이고 Correct 가 출력된다.
gdb 배치 실행 결과. memcmp 진입 시점에서 입력으로 계산한 OUT 버퍼가 6b 82 5a a3 … 로 시작하고, 바이너리 내장 TARGET 도 같은 주소열에서 동일하게 6b 82 5a a3 … 다. 두 버퍼가 38바이트 전부 일치해 memcmp 반환값 eax 가 0 이고 Correct 가 출력된다.

계산된 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
바이너리를 직접 실행한 결과. 복호한 플래그를 넣으면 Input : Correct 가 나오고, 길이만 맞춘 엉뚱한 입력을 넣으면 Input : Wrong 이 나온다. 복원한 38글자가 이 크랙미를 통과하는 유일한 입력임을 보여준다.
바이너리를 직접 실행한 결과. 복호한 플래그를 넣으면 Input : Correct 가 나오고, 길이만 맞춘 엉뚱한 입력을 넣으면 Input : Wrong 이 나온다. 복원한 38글자가 이 크랙미를 통과하는 유일한 입력임을 보여준다.

복호한 문자열에서 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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[💠 Platinum 4] 이진체 위의 RSA는 다항식 인수분해로 무너진다 — DreamHack Red-Black Christmas Tree 풀이
blog

[💠 Platinum 4] 이진체 위의 RSA는 다항식 인수분해로 무너진다 — DreamHack Red-Black Christmas Tree 풀이

정수가 아니라 GF(2) 위 4096차 다항식을 법으로 쓰는 RSA. "Red or Black, 0 or 1, binary fields"라는 힌트 그대로 곱셈은 carryless 다항식 곱이고 암호문은 flag^65537이다. 정수 인수분해는 어렵지만 유한체 위 다항식 인수분해는 다항시간이라, n을 0.16초 만에 8개 기약다항식으로 쪼개 유닛군 위수를 구하고 d=e⁻¹로 flag를 복원한다.
#dreamhack#ctf#crypto+6
2026-10-11#dreamhack +5
[🥇 Gold 3] 교과서 RSA 실수 9종 세트로 그림 퍼즐 풀기 — DreamHack EZ-RSA-PZ 풀이
blog

[🥇 Gold 3] 교과서 RSA 실수 9종 세트로 그림 퍼즐 풀기 — DreamHack EZ-RSA-PZ 풀이

오프라인 HTML 퍼즐 게임 하나에 RSA 공격 아홉 개가 숨어 있다. 개인키 노출·소수 노출·서명 악용·저지수·공통 소인수·공통 모듈러스·CRT 정수근·Coppersmith·Franklin-Reiter 로 아홉 서브플래그를 뽑고, 각각을 SHA256 해 AES-CBC 키로 직소 조각을 복호한 뒤 9장을 겹쳐 그림 속 최종 플래그를 읽는다.
#dreamhack#ctf#crypto+6
2026-10-10#dreamhack +4
[💠 Platinum 4] 35%만 남긴 RSA 소수를, 플래그가 문자열이라서 복원한다 — DreamHack pprintable 풀이
blog

[💠 Platinum 4] 35%만 남긴 RSA 소수를, 플래그가 문자열이라서 복원한다 — DreamHack pprintable 풀이

플래그 256자를 그대로 1024비트 소수 두 개로 쓴 RSA 문제다. p, q 의 비트 중 35.16%만 공개돼 있어 Heninger-Shacham 부분키 복원이 요구하는 57%에 한참 못 미친다. 모자란 22%를 메운 건 암호가 아니라 플래그 자체의 성질이었다 — 모든 바이트가 영문자와 밑줄, 중괄호뿐인 55종 안에 있어야 한다는 것.
#dreamhack#ctf#crypto+5
2026-08-06#dreamhack +4