[🥉 Bronze 1] q = nextPrime(p) 한 줄로 무너진 자작 PGP — DreamHack Pretty Good Privacy 풀이

2026-08-18·1분 읽기·

[🥉 Bronze 1] q = nextPrime(p) 한 줄로 무너진 자작 PGP — DreamHack Pretty Good Privacy 풀이

주말에 PGP를 직접 구현했다는 사람이 RSA 키의 두 소수를 붙여서 뽑았다. 그 한 줄 때문에 1023비트 모듈러스가 Fermat 인수분해 한 번에 쪼개진다. 개인키를 복원한 뒤 RFC 4880을 직접 구현해 세션키와 본문을 차례로 벗기고, 같은 키를 gpg에 물려 교차검증했다.

문제: DreamHack — Pretty Good Privacy 분류: crypto 난이도: 🥉 Bronze 1 FLAG: acsc{RSA is fine, unless you implement it badly}

문제 설명이 사실상 답을 말해 준다. "주말에 PGP를 후딱 만들어 봤다. 전부 직접 구현했다. 문제라면 내 RSA 키가 q = nextPrime(p)를 쓴다는 것 정도?"

암호 문제에서 파라미터 생성 방식을 이렇게 대놓고 알려 주는 경우는 드물다. 그래서 이 문제의 난이도는 "무엇이 취약한가"가 아니라 "그걸 알고도 봉투를 끝까지 벗길 수 있는가"에 걸려 있다. 인수분해는 세 줄이면 끝나지만, 그 뒤에 OpenPGP 패킷 포맷이 그대로 남는다.

드림핵 Pretty Good Privacy 문제 페이지 — ACSC CTF 출제작 안내와 q = nextPrime(p) 힌트가 담긴 문제 설명, crypto 태그와 B1 난이도 배지, 125명 해결 표시가 보인다
드림핵 Pretty Good Privacy 문제 페이지 — ACSC CTF 출제작 안내와 q = nextPrime(p) 힌트가 담긴 문제 설명, crypto 태그와 B1 난이도 배지, 125명 해결 표시가 보인다


문제 개요

항목내용
문제명Pretty Good Privacy
난이도🥉 Bronze 1
분류crypto
제공 파일pub.asc (PGP 공개키), enc.asc (PGP 암호문)
서버없음 — 완전 오프라인
핵심 취약점RSA 두 소수를 q = nextPrime(p)로 뽑아 Fermat 인수분해에 그대로 노출
사용 도구GnuPG 2.4.8(--list-packets), Python(pycryptodome), sympy
출제ACSC 2025 (드림핵 이식)

받은 건 ASCII armor 두 장뿐이다. 공개키에서 n을 꺼내 쪼개고, 그 개인키로 암호문의 세션키를 풀고, 세션키로 본문을 풀면 끝난다. 말로는 세 단계지만 각 단계가 서로 다른 규격을 요구한다.



🧩 배경 — PGP는 무엇을 어떻게 감싸는가

PGP는 본문을 RSA로 직접 암호화하지 않는다. RSA는 느리고 모듈러스 길이를 넘는 데이터를 못 담기 때문에, 매번 임의의 대칭키(세션키)를 만들어 본문을 그 키로 암호화하고, 세션키만 수신자의 공개키로 감싼다. 이걸 하이브리드 암호화라고 한다.

그래서 enc.asc 안에는 서로 성격이 다른 패킷 두 개가 나란히 들어 있다. 하나는 RSA로 감싼 세션키(PKESK), 다른 하나는 그 세션키로 암호화한 본문(SEIPD)이다.

OpenPGP 하이브리드 봉투 구조 도식 — PKESK 패킷을 RSA로 풀어 얻은 세션키로 SEIPD 패킷을 복호하고 그 안의 Literal 패킷에서 평문이 나오는 4단계 흐름
OpenPGP 하이브리드 봉투 구조 도식 — PKESK 패킷을 RSA로 풀어 얻은 세션키로 SEIPD 패킷을 복호하고 그 안의 Literal 패킷에서 평문이 나오는 4단계 흐름

공격 지점이 어디인지가 이 그림에서 바로 보인다. 봉투 구조 자체는 튼튼하고, 두 번째 상자를 통과할 수 있느냐가 전부다. 그 상자를 여는 열쇠가 개인키 d이고, d는 n의 인수를 알아야 계산된다. 문제 설명은 바로 그 인수를 어떻게 뽑았는지 알려 준 것이다.


🔬 정찰 — armor 안에 뭐가 들어 있나

먼저 받은 파일이 뭔지부터 확인한다.

file extracted/* && cat extracted/enc.asc

file 명령이 enc.asc를 PGP message Public-Key Encrypted Session Key로, pub.asc를 PGP public key block으로 인식했고 cat으로 연 enc.asc는 base64 여섯 줄과 CRC24 체크섬 한 줄로 된 ASCII armor 블록이다
file 명령이 enc.asc를 PGP message Public-Key Encrypted Session Key로, pub.asc를 PGP public key block으로 인식했고 cat으로 연 enc.asc는 base64 여섯 줄과 CRC24 체크섬 한 줄로 된 ASCII armor 블록이다

armor는 그냥 base64 포장이다. 마지막 =YWyz 줄은 CRC24 체크섬이고 데이터가 아니다. 이걸 벗기면 248바이트짜리 바이너리 패킷 스트림이 나온다.

패킷 구조를 직접 파싱하기 전에, 표준 구현이 이 파일을 어떻게 읽는지 먼저 본다. GnuPG의 --list-packets가 정확히 그 용도다.

gpg --list-packets extracted/enc.asc; gpg --list-packets extracted/pub.asc

GnuPG의 list-packets 출력 — enc.asc는 tag 1 PKESK와 tag 18 암호문으로, pub.asc는 tag 6 공개키와 tag 13 UserID로 분해되고 양쪽 keyid가 FB762C0BE303F5AF로 같다
GnuPG의 list-packets 출력 — enc.asc는 tag 1 PKESK와 tag 18 암호문으로, pub.asc는 tag 6 공개키와 tag 13 UserID로 분해되고 양쪽 keyid가 FB762C0BE303F5AF로 같다

여기서 확인되는 것들.

  • 공개키는 v4 RSA, pkey[0]이 1023비트다. 1024가 아니다. 나중에 RSA 복호 결과를 바이트로 되돌릴 때 이 값이 그대로 길이가 되므로 넘겨짚으면 안 된다.
  • UserID는 ACSC 2025 Challenge이고, 자체 서명 패킷이 하나 붙어 있다.
  • 암호문의 PKESK keyid FB762C0BE303F5AF가 공개키의 keyid와 같다. 즉 이 암호문은 우리가 받은 그 키로 봉해졌다.
  • SEIPD 패킷에 mdc_method: 2가 붙어 있다. 본문 끝에 SHA-1 무결성 태그가 따라온다는 뜻이다.
  • 그리고 gpg: decryption failed: No secret key — 당연하지만, 지금은 못 연다.

이제 같은 파일을 직접 파싱한다. gpg는 비트 길이만 알려 주고 실제 정수값은 안 보여 주는데, n을 손에 쥐어야 인수분해를 하니 직접 뜯는 편이 빠르다. RFC 4880의 패킷 헤더는 구식(old format)과 신식(new format) 두 가지가 있고, 길이 필드 인코딩이 서로 다르다.

▸📄 parse_pgp.py 전문 — RFC 4880 패킷 파서
#!/usr/bin/env python3
"""pub.asc / enc.asc 의 OpenPGP 패킷을 RFC 4880 규격대로 직접 뜯어본다."""
import base64, sys, datetime
 
def unarmor(path):
    body, on = [], False
    for line in open(path):
        line = line.rstrip("\n")
        if line.startswith("-----BEGIN"):
            on = True; continue
        if line.startswith("-----END"):
            break
        if not on:
            continue
        if not line.strip():          # 헤더와 본문 사이 빈 줄
            continue
        if line.startswith("="):      # CRC24
            continue
        body.append(line)
    return base64.b64decode("".join(body))
 
TAGS = {1: "PKESK(공개키 암호화 세션키)", 2: "Signature(서명)", 5: "SecretKey",
        6: "PublicKey(공개키)", 13: "UserID", 18: "SEIPD(무결성보호 암호문)"}
 
def packets(buf):
    i = 0
    while i < len(buf):
        h = buf[i]; i += 1
        assert h & 0x80, f"bad packet header {h:#x}"
        if h & 0x40:                                   # new format
            tag = h & 0x3f
            l = buf[i]; i += 1
            if l < 192:
                ln = l
            elif l < 224:
                ln = ((l - 192) << 8) + buf[i] + 192; i += 1
            else:
                ln = int.from_bytes(buf[i:i+4], "big"); i += 4
        else:                                          # old format
            tag = (h >> 2) & 0x0f
            lt = h & 0x03
            ln = [1, 2, 4][lt] if lt < 3 else 0
            ln = int.from_bytes(buf[i:i+ln], "big"); i += ln and [1,2,4][lt]
        yield tag, buf[i:i+ln]
        i += ln
 
def mpi(buf, off):
    bits = int.from_bytes(buf[off:off+2], "big")
    nbytes = (bits + 7) // 8
    val = int.from_bytes(buf[off+2:off+2+nbytes], "big")
    return val, off + 2 + nbytes, bits
 
for path in sys.argv[1:]:
    raw = unarmor(path)
    print(f"### {path}  ({len(raw)} bytes)")
    for tag, data in packets(raw):
        print(f"  [tag {tag}] {TAGS.get(tag,'?')}  len={len(data)}")
        if tag == 6:
            ver = data[0]
            ts = int.from_bytes(data[1:5], "big")
            algo = data[5]
            n, off, nb = mpi(data, 6)
            e, off, eb = mpi(data, off)
            print(f"      version={ver} created={datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S')}Z algo={algo}(RSA)")
            print(f"      n({nb} bits) = {n}")
            print(f"      e({eb} bits) = {e}")
        elif tag == 13:
            print(f"      userid = {data.decode()}")
        elif tag == 1:
            ver = data[0]; keyid = data[1:9].hex(); algo = data[9]
            c, off, cb = mpi(data, 10)
            print(f"      version={ver} keyid={keyid} algo={algo}")
            print(f"      c({cb} bits) = {c}")
        elif tag == 18:
            print(f"      version={data[0]} ciphertext={len(data)-1} bytes")
            print(f"      head = {data[1:33].hex()}")

MPI(Multi-Precision Integer)는 앞 2바이트에 비트 수를 담고 그다음에 빅엔디언 정수가 오는 형식이다. 바이트 수가 아니라 비트 수라서, n의 길이 필드가 1024가 아닌 1023으로 찍히는 것도 여기서 확인된다.

python3 parse_pgp.py extracted/pub.asc extracted/enc.asc

parse_pgp.py 실행 화면 — pub.asc에서 1023비트 모듈러스 n의 십진 전체 값과 e가 65537임을 뽑아내고 enc.asc에서는 PKESK의 1019비트 암호문 c와 SEIPD의 103바이트 암호문 앞부분 헥사를 출력했다
parse_pgp.py 실행 화면 — pub.asc에서 1023비트 모듈러스 n의 십진 전체 값과 e가 65537임을 뽑아내고 enc.asc에서는 PKESK의 1019비트 암호문 c와 SEIPD의 103바이트 암호문 앞부분 헥사를 출력했다

n과 e = 65537을 손에 넣었다. 여기까지가 정찰이고, 이제 인수분해다.


💣 핵심 — q = nextPrime(p)가 무너뜨리는 것

RSA 모듈러스 n = p · q를 쪼개는 고전 기법 중 하나가 Fermat 인수분해다. 홀수 합성수는 언제나 두 제곱수의 차로 쓸 수 있다는 성질을 쓴다.

n = a² − b² = (a − b)(a + b)

a를 ⌈√n⌉부터 1씩 올리면서 a² − n이 완전제곱이 되는 순간을 찾으면, 그때 p = a − b, q = a + b가 된다. 여기서 a = (p+q)/2이고 b = (q−p)/2이므로, 탐색 횟수는 두 소수가 얼마나 떨어져 있는지에 정비례한다.

정상적인 RSA 키는 512비트 소수 두 개를 독립적으로 뽑으므로 간격이 2^500 규모다. a를 그만큼 올릴 방법은 없다. 그런데 q = nextPrime(p)로 만들면 간격이 소수 사이의 평균 간격, 즉 512비트 근처에서 수백 정도로 줄어든다.

Fermat 인수분해 원리 도식 — 정상 키에서는 p와 q가 수직선 양 끝에 떨어져 있어 탐색이 불가능하지만 q가 nextPrime(p)이면 두 점이 √N 주변에 붙어 있어 첫 후보에서 바로 완전제곱이 나온다
Fermat 인수분해 원리 도식 — 정상 키에서는 p와 q가 수직선 양 끝에 떨어져 있어 탐색이 불가능하지만 q가 nextPrime(p)이면 두 점이 √N 주변에 붙어 있어 첫 후보에서 바로 완전제곱이 나온다

코드로는 이게 전부다.

#!/usr/bin/env python3
"""q = nextPrime(p) 라 |p-q| 가 극히 작다 → Fermat 인수분해."""
from math import isqrt
import time
 
N = 78574622397097820678949935904930701355539503053568778574333582718520010791986457848131234051799716988383894120456993071770989011651678060403273301126723277989383156012898531112838853848487707804983112615122507562047566366611384489740128147950638551395991406841390176005369328690645540213230474384906309452863
 
t0 = time.time()
a = isqrt(N)
if a * a < N:
    a += 1
it = 0
while True:
    it += 1
    b2 = a * a - N
    b = isqrt(b2)
    if b * b == b2:
        break
    a += 1
p, q = a - b, a + b
assert p * q == N and p != 1
print(f"반복 횟수 = {it}   소요 = {time.time()-t0:.3f}s")
print(f"a = ceil(sqrt(N)) + {it-1}")
print(f"p = {p}")
print(f"q = {q}")
print(f"q - p = {q - p}")
print(f"p 비트수 = {p.bit_length()}, q 비트수 = {q.bit_length()}")

math.isqrt는 파이썬 3.8부터 표준에 있는 정수 제곱근이고, 임의 정밀도라서 1023비트에서도 오차가 없다. 부동소수 **0.5로 하면 여기서 바로 틀린다.

python3 factor.py

factor.py 실행 화면 — Fermat 반복 횟수가 1이고 소요 시간 0.000초, a는 ceil sqrt N 그 자체이며 512비트 소수 p와 q의 십진값이 출력되고 두 값의 차이가 1022로 찍혔다
factor.py 실행 화면 — Fermat 반복 횟수가 1이고 소요 시간 0.000초, a는 ceil sqrt N 그 자체이며 512비트 소수 p와 q의 십진값이 출력되고 두 값의 차이가 1022로 찍혔다

반복 1회. a = ⌈√n⌉를 계산한 그 첫 후보에서 이미 a² − n이 완전제곱이었다. 두 소수 차이는 1022고, b = 511이다.

문제 설명을 그대로 믿고 넘어가지 않고, 정말 nextPrime 관계인지 sympy로 확인해 둔다. 인수분해가 우연히 맞은 게 아니라 설명대로였다는 걸 못 박는 셈이다.

#!/usr/bin/env python3
"""문제 설명의 'q = nextPrime(p)' 가 실제로 그러한지 sympy 로 확인한다."""
from math import isqrt
from sympy import isprime, nextprime
from solve import read_pubkey, fermat
from pathlib import Path
 
n, e = read_pubkey(Path(__file__).resolve().parent / "extracted/pub.asc")
p, q, it = fermat(n)
print(f"p 소수?          {isprime(p)}")
print(f"q 소수?          {isprime(q)}")
print(f"p * q == n ?     {p * q == n}")
print(f"nextprime(p)==q ? {nextprime(p) == q}")
print(f"q - p            = {q - p}")
print(f"isqrt(n) - p     = {isqrt(n) - p}   (p 와 q 사이 딱 중간)")
print(f"Fermat 반복 횟수  = {it}")
python3 check_primes.py

sympy로 검증한 결과 화면 — p와 q가 모두 소수이고 곱이 n과 일치하며 nextprime(p)가 정확히 q이고 두 소수의 차는 1022, isqrt(n)에서 p까지의 거리가 510으로 두 소수의 정확히 중간임을 보여 준다
sympy로 검증한 결과 화면 — p와 q가 모두 소수이고 곱이 n과 일치하며 nextprime(p)가 정확히 q이고 두 소수의 차는 1022, isqrt(n)에서 p까지의 거리가 510으로 두 소수의 정확히 중간임을 보여 준다

isqrt(n) − p = 510이라는 값이 반복 1회의 이유를 정확히 설명한다. √n이 두 소수 사이 한복판에 앉아 있으니, 올림한 ⌈√n⌉이 곧 (p+q)/2다. 탐색할 것이 없다.



🔓 세션키 꺼내기 — PKESK와 PKCS#1 v1.5

인수를 알았으니 개인키는 한 줄이다.

d = pow(e, -1, (p - 1) * (q - 1) // gcd(p - 1, q - 1))   # λ(n) 기준

φ(n) = (p−1)(q−1)을 써도 결과는 같다. RSA는 d가 λ(n)의 배수만큼 달라도 동작하기 때문이다. 다만 OpenPGP 비밀키 패킷에 넣을 때는 표준 구현이 기대하는 값과 맞추는 편이 안전하다.

PKESK 패킷 본문은 version(1) ‖ keyid(8) ‖ algo(1) ‖ MPI(c) 구조다. c를 복호하면 EME-PKCS1-v1_5로 인코딩된 블록이 나온다.

00 02 [0이 아닌 랜덤 패딩 …] 00 ‖ algo(1) ‖ session_key ‖ checksum(2)

앞의 00 02는 블록 타입, 그다음 랜덤 패딩이 최소 8바이트 이상 이어지다가 00 하나로 끊기고, 그 뒤가 알맹이다. 알맹이는 다시 대칭 알고리즘 ID 1바이트 + 세션키 + 체크섬 2바이트인데, 체크섬은 세션키 바이트를 전부 더해 65536으로 나눈 나머지다. 이 체크섬이 맞아떨어지면 d가 맞다는 강한 증거가 된다.

이 문제에서는 알고리즘 ID가 9, 즉 AES-256이 나온다.


🧱 SEIPD 복호 — OpenPGP-CFB와 MDC

본문 패킷(tag 18)은 버전 바이트 하나 뒤부터 전부 암호문이다. 복호 규격은 이렇다.

  • CFB 모드, IV는 전부 0. 진짜 IV 역할은 평문 맨 앞에 붙는 랜덤 프리픽스가 대신한다.
  • 평문 앞 블록크기 + 2바이트가 프리픽스다. 마지막 2바이트가 그 앞 2바이트의 복사본이라, 이게 맞는지 보면 키가 맞는지 즉시 알 수 있다.
  • 평문 맨 뒤 22바이트는 MDC 패킷이다. D3 14 두 바이트 뒤에 SHA-1 해시 20바이트가 오고, 해시 대상은 프리픽스부터 D3 14까지 전부다.
  • 남은 가운데가 실제 패킷 스트림이고, 여기서 Literal 패킷(tag 11)을 꺼내면 파일명과 본문이 나온다. 압축이 걸려 있으면 Compressed 패킷(tag 8)이 한 겹 더 있다.

🐛 손으로 구현할 때 갈리는 두 지점

두 곳 모두 "예외 없이 잘 돌면서 결과만 조용히 틀리는" 부류다. 검산을 안 걸어 두면 어디서 어긋났는지 알 방법이 없어서, 두 경우를 실제로 돌려 차이를 눈으로 확인했다.

▶🐛 함정 1 — PKCS#1 언패딩에서 구분자 0x00을 블록 처음부터 찾으면

RSA 복호 결과 m의 **첫 바이트가 이미 0x00**이다. 그래서 m.index(b"\x00")으로 구분자를 찾으면 인덱스 0이 잡히고, 알맹이가 92바이트만큼 앞으로 밀린다. 실제로 돌려 보면 알고리즘 ID 자리에 0x02(3DES)가 들어앉고 체크섬이 어긋난다.

0x00 0x02를 건너뛴 m[2:]에서 찾아야 인덱스 92가 나오고 알고리즘 ID가 0x09(AES-256)로 떨어진다. 세션키 체크섬을 검사하지 않으면 이 실수가 다음 단계까지 그대로 굴러간다.

▶🐛 함정 2 — SEIPD에 옛 tag 9 방식의 CFB resync를 적용하면

RFC 4880에는 대칭 암호 패킷이 두 종류 있다. 옛 Symmetrically Encrypted Data(tag 9)는 프리픽스를 복호한 뒤 CFB 레지스터를 암호문 쪽으로 재동기화(resync) 하는데, 무결성 보호가 붙은 SEIPD(tag 18)는 그 재동기화를 하지 않는다.

문제는 이 차이가 프리픽스 뒤에서만 드러난다는 것이다. resync를 잘못 적용해도 프리픽스 2바이트 반복 검사는 그대로 통과한다. 키가 맞다는 신호를 받고서 본문만 난수로 나오니, 세션키를 의심하며 앞 단계로 되돌아가기 쉽다.

이 두 갈래를 한 스크립트에서 나란히 돌려 봤다.

▸📄 traps.py 전문 — 두 갈래를 나란히 돌려 비교
#!/usr/bin/env python3
"""OpenPGP 를 손으로 구현할 때 갈리는 두 지점을 실제로 돌려 확인한다.
 
둘 다 "돌기는 도는데 결과만 조용히 틀린" 부류라 어서션을 안 걸면 못 알아챈다.
"""
from pathlib import Path
from Crypto.Cipher import AES
from solve import unarmor, packets, mpi, read_pubkey, fermat, session_key
 
HERE = Path(__file__).resolve().parent
n, e = read_pubkey(HERE / "extracted/pub.asc")
p, q, _ = fermat(n)
d = pow(e, -1, (p - 1) * (q - 1))
pkesk = seipd = None
for tag, data in packets(unarmor(HERE / "extracted/enc.asc")):
    if tag == 1:  pkesk = data
    if tag == 18: seipd = data
 
print("── 함정 1. EME-PKCS1-v1_5 구분자 0x00 을 블록 처음부터 찾으면 ──")
c, _ = mpi(pkesk, 10)
m = pow(c, d, n).to_bytes((n.bit_length() + 7) // 8, "big")
print(f"  m 앞 6바이트      : {m[:6].hex()}")
wrong = m[m.index(b'\x00') + 1:]                 # m[0] 이 0x00 이라 여기서 걸린다
right = m[2:][m[2:].index(b'\x00') + 1:]
print(f"  처음부터 찾은 위치 : {m.index(bytes(1))}  → 알맹이 첫 바이트 {wrong[0]:#04x} (알고리즘 ID로 해석됨)")
print(f"  0x00 0x02 건너뛴 뒤: {2 + m[2:].index(bytes(1))}  → 알맹이 첫 바이트 {right[0]:#04x} = 9 (AES-256)")
print(f"  잘못된 키 체크섬   : {sum(wrong[1:-2]) % 65536} vs 기록값 {int.from_bytes(wrong[-2:],'big')} → 불일치")
 
print()
print("── 함정 2. SEIPD(tag 18) 에 옛 tag 9 방식의 CFB resync 를 적용하면 ──")
algo, key = session_key(pkesk, n, d)
ct, bs = seipd[1:], 16
 
def cfb_no_resync(ct):                            # SEIPD v1 이 쓰는 방식
    return AES.new(key, AES.MODE_CFB, iv=b"\x00" * bs, segment_size=bs * 8).decrypt(ct)
 
def cfb_resync(ct):                               # 옛 Symmetrically Encrypted Data(tag 9) 방식
    head = AES.new(key, AES.MODE_CFB, iv=b"\x00" * bs, segment_size=bs * 8).decrypt(ct[:bs + 2])
    tail = AES.new(key, AES.MODE_CFB, iv=ct[2:bs + 2], segment_size=bs * 8).decrypt(ct[bs + 2:])
    return head + tail
 
for label, fn in (("resync 없음(정답)", cfb_no_resync), ("resync 적용(오답)", cfb_resync)):
    pt = fn(ct)
    ok = pt[bs - 2:bs] == pt[bs:bs + 2]
    print(f"  {label}: 프리픽스 검사 {'통과' if ok else '실패'} | 본문 앞 16바이트 {pt[bs+2:bs+18].hex()}")
    print(f"      → {pt[bs+2:bs+40]!r}")
python3 traps.py

traps.py 실행 화면 — 구분자를 처음부터 찾으면 위치 0이 잡혀 알고리즘 ID가 0x02로 오독되고 체크섬이 16527 대 4081로 어긋나며, CFB resync를 적용한 쪽은 프리픽스 검사를 통과하고도 본문이 난수 바이트로 나온다
traps.py 실행 화면 — 구분자를 처음부터 찾으면 위치 0이 잡혀 알고리즘 ID가 0x02로 오독되고 체크섬이 16527 대 4081로 어긋나며, CFB resync를 적용한 쪽은 프리픽스 검사를 통과하고도 본문이 난수 바이트로 나온다

두 번째 블록이 특히 볼 만하다. 양쪽 다 프리픽스 검사 통과로 찍히는데, 정답 쪽은 msg.txt라는 파일명과 플래그 앞부분이 그대로 보이고 오답 쪽은 의미 없는 바이트다. 프리픽스 검사만 신뢰하면 안 되는 이유가 여기 있다. MDC 검증까지 걸어야 이 갈림길이 실패로 드러난다.


🚀 Full Exploit

지금까지의 단계를 하나로 묶었다. 외부 PGP 라이브러리를 쓰지 않고 armor 해제부터 Literal 패킷까지 직접 처리한다.

#!/usr/bin/env python3
# Pretty Good Privacy (DreamHack Bronze 1, crypto) — 자체 완결 풀이
#
#   pub.asc 에서 n·e 를 뜯어내고 q = nextPrime(p) 라는 힌트대로 Fermat 으로 쪼갠 뒤,
#   PKESK 의 세션키를 RSA 로 풀고 SEIPD 를 OpenPGP-CFB 로 복호해 flag 를 꺼낸다.
#   외부 PGP 라이브러리를 쓰지 않고 RFC 4880 을 직접 구현했다.
import base64, hashlib, sys, zlib
from math import isqrt
from pathlib import Path
from Crypto.Cipher import AES, DES3
from Crypto.Cipher import CAST
 
HERE = Path(__file__).resolve().parent
 
# ── 1. ASCII Armor 해제 ──────────────────────────────────────────────
def unarmor(path):
    body, on = [], False
    for line in Path(path).read_text().splitlines():
        if line.startswith("-----BEGIN"):
            on = True; continue
        if line.startswith("-----END"):
            break
        if on and line.strip() and not line.startswith("="):
            body.append(line)
    return base64.b64decode("".join(body))
 
# ── 2. 패킷 분해 (new/old 두 형식 모두) ──────────────────────────────
def packets(buf):
    i = 0
    while i < len(buf):
        h = buf[i]; i += 1
        if not h & 0x80:
            raise ValueError(f"패킷 헤더가 아니다: {h:#x}")
        if h & 0x40:                                    # new format
            tag = h & 0x3f
            l = buf[i]; i += 1
            if l < 192:
                ln = l
            elif l < 224:
                ln = ((l - 192) << 8) + buf[i] + 192; i += 1
            elif l == 255:
                ln = int.from_bytes(buf[i:i+4], "big"); i += 4
            else:
                raise ValueError("부분 길이(partial body)는 다루지 않는다")
        else:                                           # old format
            tag = (h >> 2) & 0x0f
            nl = [1, 2, 4][h & 0x03]
            ln = int.from_bytes(buf[i:i+nl], "big"); i += nl
        yield tag, buf[i:i+ln]
        i += ln
 
def mpi(buf, off):
    bits = int.from_bytes(buf[off:off+2], "big")
    nb = (bits + 7) // 8
    return int.from_bytes(buf[off+2:off+2+nb], "big"), off + 2 + nb
 
# ── 3. 공개키에서 n, e ──────────────────────────────────────────────
def read_pubkey(path):
    for tag, d in packets(unarmor(path)):
        if tag == 6:
            assert d[0] == 4 and d[5] == 1, "v4 RSA 키가 아니다"
            n, off = mpi(d, 6)
            e, _ = mpi(d, off)
            return n, e
    raise ValueError("공개키 패킷 없음")
 
# ── 4. Fermat 인수분해 (p, q 가 붙어 있을 때만 성립) ────────────────
def fermat(n, rounds=1 << 20):
    a = isqrt(n)
    if a * a < n:
        a += 1
    for k in range(rounds):
        b2 = a * a - n
        b = isqrt(b2)
        if b * b == b2:
            return a - b, a + b, k + 1
        a += 1
    raise ValueError("Fermat 실패 — p, q 가 충분히 가깝지 않다")
 
# ── 5. PKESK → 세션키 (EME-PKCS1-v1_5 언패딩) ───────────────────────
def session_key(pkesk, n, d):
    assert pkesk[0] == 3 and pkesk[9] == 1, "v3 RSA PKESK 가 아니다"
    c, _ = mpi(pkesk, 10)
    m = pow(c, d, n).to_bytes((n.bit_length() + 7) // 8, "big")
    assert m[0] == 0x00 and m[1] == 0x02, f"PKCS#1 v1.5 블록타입 불일치: {m[:2].hex()}"
    body = m[2:][m[2:].index(b"\x00") + 1:]          # 0x00 구분자 이후가 알맹이
    algo, key, chk = body[0], body[1:-2], int.from_bytes(body[-2:], "big")
    assert sum(key) % 65536 == chk, "세션키 체크섬 불일치"
    return algo, key
 
# ── 6. SEIPD 복호 (OpenPGP-CFB, IV=0, resync 없음) ──────────────────
BLOCK = {2: 8, 3: 8, 7: 16, 8: 16, 9: 16}
NAME  = {2: "3DES", 3: "CAST5", 7: "AES-128", 8: "AES-192", 9: "AES-256"}
 
def sym_decrypt(algo, key, seipd):
    assert seipd[0] == 1, "SEIPD v1 이 아니다"
    bs = BLOCK[algo]
    if algo == 2:
        c = DES3.new(key, DES3.MODE_CFB, iv=b"\x00" * bs, segment_size=bs * 8)
    elif algo == 3:
        c = CAST.new(key, CAST.MODE_CFB, iv=b"\x00" * bs, segment_size=bs * 8)
    else:
        c = AES.new(key, AES.MODE_CFB, iv=b"\x00" * bs, segment_size=bs * 8)
    pt = c.decrypt(seipd[1:])
    assert pt[bs-2:bs] == pt[bs:bs+2], "CFB 프리픽스 검사 실패 — 세션키가 틀렸다"
    body, mdc = pt[bs+2:-22], pt[-22:]
    assert mdc[:2] == b"\xd3\x14", "MDC 패킷이 아니다"
    assert hashlib.sha1(pt[:-20]).digest() == mdc[2:], "MDC(SHA-1) 불일치 — 변조됨"
    return body
 
# ── 7. 압축 해제 + Literal 패킷에서 본문 ────────────────────────────
def literal(body):
    for tag, d in packets(body):
        if tag == 8:                                    # Compressed Data
            algo = d[0]
            raw = d[1:]
            plain = (zlib.decompressobj(-15).decompress(raw) if algo == 1 else
                     zlib.decompress(raw) if algo == 2 else raw)
            return literal(plain)
        if tag == 11:                                   # Literal Data
            nl = d[1]
            fname = d[2:2+nl].decode(errors="replace")
            return fname, d[2+nl+4:]
    raise ValueError("Literal 패킷 없음")
 
# ── 실행 ────────────────────────────────────────────────────────────
def main():
    n, e = read_pubkey(HERE / "extracted/pub.asc")
    print(f"[+] n = {n}")
    print(f"[+] e = {e}  ({n.bit_length()} bit)")
 
    p, q, it = fermat(n)
    print(f"[+] Fermat {it}회 만에 분해, q - p = {q - p}")
    print(f"[+] p = {p}")
    print(f"[+] q = {q}")
 
    d = pow(e, -1, (p - 1) * (q - 1) // __import__("math").gcd(p - 1, q - 1))
    print(f"[+] d = {d}")
 
    pkesk = seipd = None
    for tag, data in packets(unarmor(HERE / "extracted/enc.asc")):
        if tag == 1:  pkesk = data
        if tag == 18: seipd = data
 
    algo, key = session_key(pkesk, n, d)
    print(f"[+] 세션 대칭키: {NAME[algo]} / {key.hex()}")
 
    fname, data = literal(sym_decrypt(algo, key, seipd))
    print(f"[+] 원본 파일명 = {fname!r}")
    print()
    print(data.decode(errors="replace").strip())
 
if __name__ == "__main__":
    main()
python3 solve.py

solve.py 실행 화면 — 1023비트 n과 e 65537을 읽어 Fermat 1회로 분해하고 개인키 d를 계산한 뒤 AES-256 세션키를 복원해 원본 파일명 msg.txt와 플래그 문자열을 출력했다
solve.py 실행 화면 — 1023비트 n과 e 65537을 읽어 Fermat 1회로 분해하고 개인키 d를 계산한 뒤 AES-256 세션키를 복원해 원본 파일명 msg.txt와 플래그 문자열을 출력했다

플래그는 acsc{RSA is fine, unless you implement it badly}. 드림핵 문제지만 ACSC CTF에서 이식된 것이라 접두사가 DH가 아니다. 제출할 때 형식을 맞추려고 손대면 오답이 된다.


🔁 교차검증 — 재조립한 비밀키를 진짜 gpg에 물리기

직접 구현한 복호가 맞다는 걸 스스로 증명하기는 어렵다. 그래서 같은 p, q로 OpenPGP 비밀키를 조립해 GnuPG에 넘겨 봤다. gpg가 그 키를 정상 키로 받아들이고 같은 평문을 뱉는다면, 인수분해와 패킷 해석이 둘 다 맞았다는 뜻이다.

v4 비밀키 패킷(tag 5)은 공개키 패킷 본문 뒤에 S2K 사용 옵션 1바이트를 붙이고, 그다음 d, p, q, u = p⁻¹ mod q를 MPI로 이어 붙인 뒤 2바이트 합 체크섬으로 끝난다. S2K 옵션을 0x00으로 두면 암호 없이 평문으로 저장한다는 뜻이다.

여기서 한 가지 요령이 있다. UserID와 자체 서명 패킷은 pub.asc에 있는 것을 그대로 재사용한다. 서명 대상이 키 재료와 UserID인데 그 둘이 바뀌지 않았으므로 서명이 계속 유효하고, gpg가 키를 온전한 것으로 받아들인다.

#!/usr/bin/env python3
"""쪼갠 p, q 로 OpenPGP v4 비밀키 패킷을 조립해 sec.asc 를 만든다.
 
pub.asc 의 UserID·자체서명 패킷은 그대로 두고 공개키 패킷(tag 6)만
비밀키 패킷(tag 5)으로 바꿔 끼운다 — 서명 대상(키 재료 + UserID)이
그대로라 자체서명이 계속 유효하고, gpg 가 정상 키로 받아 준다.
"""
import base64
from pathlib import Path
from solve import unarmor, packets, mpi, read_pubkey, fermat
 
HERE = Path(__file__).resolve().parent
 
def to_mpi(x):
    b = x.to_bytes((x.bit_length() + 7) // 8, "big")
    return x.bit_length().to_bytes(2, "big") + b
 
def new_packet(tag, body):
    """new-format 헤더로 패킷 하나를 감싼다."""
    if len(body) < 192:
        ln = bytes([len(body)])
    elif len(body) < 8384:
        v = len(body) - 192
        ln = bytes([(v >> 8) + 192, v & 0xFF])
    else:
        ln = b"\xff" + len(body).to_bytes(4, "big")
    return bytes([0xC0 | tag]) + ln + body
 
CRC24_INIT, CRC24_POLY = 0xB704CE, 0x1864CFB
def crc24(data):
    crc = CRC24_INIT
    for b in data:
        crc ^= b << 16
        for _ in range(8):
            crc <<= 1
            if crc & 0x1000000:
                crc ^= CRC24_POLY
    return crc & 0xFFFFFF
 
def armor(kind, data):
    b64 = base64.b64encode(data).decode()
    lines = [b64[i:i+64] for i in range(0, len(b64), 64)]
    chk = base64.b64encode(crc24(data).to_bytes(3, "big")).decode()
    return (f"-----BEGIN PGP {kind}-----\n\n" + "\n".join(lines) +
            f"\n={chk}\n-----END PGP {kind}-----\n")
 
n, e = read_pubkey(HERE / "extracted/pub.asc")
p, q, _ = fermat(n)
assert p < q                                   # OpenPGP 는 p < q, u = p^-1 mod q
d = pow(e, -1, (p - 1) * (q - 1))
u = pow(p, -1, q)
 
out = b""
for tag, body in packets(unarmor(HERE / "extracted/pub.asc")):
    if tag == 6:
        secret = to_mpi(d) + to_mpi(p) + to_mpi(q) + to_mpi(u)
        secret += (sum(secret) % 65536).to_bytes(2, "big")   # S2K usage 0 → 단순 합 체크섬
        out += new_packet(5, body + b"\x00" + secret)        # 0x00 = 암호화하지 않음
    else:
        out += new_packet(tag, body)
 
(HERE / "sec.asc").write_text(armor("PRIVATE KEY BLOCK", out))
print(f"sec.asc 생성 완료 ({len(out)} bytes, 패킷 tag 5/13/2)")

armor 끝의 =xxxx 줄은 장식이 아니라 CRC24 체크섬이라, 직접 만들 때는 이것도 계산해 붙여야 gpg가 받아 준다. 키링은 홈 디렉토리를 건드리지 않도록 문제 폴더 안에 따로 만든다.

rm -rf gnupg && mkdir -m 700 gnupg && python3 make_seckey.py && GNUPGHOME=$PWD/gnupg gpg --batch --import sec.asc && GNUPGHOME=$PWD/gnupg gpg --batch --decrypt extracted/enc.asc

재구성한 비밀키를 GnuPG에 임포트한 화면 — secret key imported로 받아들여지고 이어진 gpg decrypt가 rsa1023 키 ID FB762C0BE303F5AF로 복호했다며 같은 플래그 문자열을 출력했다
재구성한 비밀키를 GnuPG에 임포트한 화면 — secret key imported로 받아들여지고 이어진 gpg decrypt가 rsa1023 키 ID FB762C0BE303F5AF로 복호했다며 같은 플래그 문자열을 출력했다

gpg: secret key imported가 뜨고, 이어진 --decrypt가 파이썬 구현과 똑같은 평문을 뱉었다. gpg는 MDC까지 스스로 검증하므로, 이 한 줄로 복호 경로 전체가 검증된 셈이다.

중간의 cipher algorithm AES256 not found in recipient preferences 경고는 키에 선호 알고리즘 서브패킷이 없어서 뜨는 것이다. 우리가 서명 패킷을 손대지 않고 그대로 옮겼기 때문에 원래 키의 서명에 그 정보가 없다는 뜻이지, 복호가 잘못됐다는 신호는 아니다.


✅ 재현

문제 폴더만 있으면 두 경로가 모두 자동으로 돌아가도록 묶어 뒀다.

#!/usr/bin/env bash
# Pretty Good Privacy (DreamHack Bronze 1, crypto) — 한 방 재현.
#   ① 순수 파이썬 풀이(solve.py)로 flag 를 뽑고
#   ② 재구성한 비밀키를 진짜 gpg 에 물려 같은 값이 나오는지 교차검증한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
# 기대값: sync_nas.sh 가 만든 문제.json 이 있으면 그걸, 없으면 제출로 확인된 값을 쓴다.
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null \
         || echo 'acsc{RSA is fine, unless you implement it badly}')
 
echo "=== ① 순수 파이썬 (RFC 4880 직접 구현) ==="
OUT=$(timeout 300 python3 solve.py 2>&1) || true
echo "$OUT" | tail -6
FLAG=$(printf '%s' "$OUT" | grep -aoE 'acsc\{[^}]+\}' | head -1)
 
echo
echo "=== ② gpg 교차검증 (재구성한 비밀키) ==="
rm -rf gnupg && mkdir -m 700 gnupg
python3 make_seckey.py
GNUPGHOME=$PWD/gnupg gpg --batch --quiet --import sec.asc
GOUT=$(GNUPGHOME=$PWD/gnupg gpg --batch --decrypt extracted/enc.asc 2>&1) || true
echo "$GOUT" | tail -4
GFLAG=$(printf '%s' "$GOUT" | grep -aoE 'acsc\{[^}]+\}' | head -1)
 
echo
if [ -n "$FLAG" ] && [ "$FLAG" = "$EXPECT" ] && [ "$GFLAG" = "$EXPECT" ]; then
  echo "✅ PASS  $FLAG   (파이썬·gpg 양쪽 일치)"; exit 0
fi
echo "❌ FAIL (python='${FLAG:-없음}' / gpg='${GFLAG:-없음}' / 기대='$EXPECT')"; exit 1
./reproduce.sh

reproduce.sh 실행 화면 — 파이썬 풀이가 플래그를 출력하고 이어서 재구성한 비밀키를 gpg에 임포트해 복호한 결과가 같은 값으로 나와 마지막 줄이 PASS 파이썬 gpg 양쪽 일치로 끝났다
reproduce.sh 실행 화면 — 파이썬 풀이가 플래그를 출력하고 이어서 재구성한 비밀키를 gpg에 임포트해 복호한 결과가 같은 값으로 나와 마지막 줄이 PASS 파이썬 gpg 양쪽 일치로 끝났다

로컬 환경: Ubuntu 25.10, Python 3.13.7(pycryptodome 3.23.0, sympy 1.13.3), GnuPG 2.4.8 / libgcrypt 1.11.0. sympy는 nextprime 검증에만 쓰이므로 없어도 solve.py는 그대로 돌아간다.



📝 결론

소수 두 개를 어떻게 뽑느냐가 RSA의 절반이다

n = p · q라는 식만 보면 p와 q가 큰 소수이기만 하면 될 것 같지만, 실제로는 그 둘의 간격이 안전성의 절반을 진다. q = nextPrime(p)는 코드로는 한 줄이고 "소수 두 개를 뽑는다"는 요구를 문자 그대로 만족한다. 그런데 그 한 줄이 1023비트 인수분해를 0.000초짜리 계산으로 바꿔 놓았다. Fermat 인수분해는 1643년 기법이다.

같은 이유로 부분 유출·작은 지수·공통 소인수처럼 파라미터 생성 단계의 실수는 알고리즘을 아무리 잘 골라도 복구가 안 된다. 라이브러리의 키 생성 함수를 쓰라는 조언이 지루하게 반복되는 이유가 이것이다. openssl genrsa나 언어별 표준 라이브러리는 두 소수를 독립적으로 뽑고 간격까지 검사한다.

직접 구현한 프로토콜은 규격의 예외 조항에서 무너진다

이 문제에서 시간을 쓰게 되는 지점은 인수분해가 아니라 그 뒤였다. PKCS#1 언패딩의 구분자 위치, SEIPD와 옛 tag 9의 CFB resync 차이, MPI가 바이트 수가 아니라 비트 수를 담는다는 것 — 전부 규격에는 명시돼 있지만 상식으로는 반대로 짐작하기 쉬운 것들이다. 문제 설명의 "전부 직접 구현했다"는 문장이 출제자의 농담만은 아닌 셈이다.

검산을 걸어 두지 않으면 어디서 틀렸는지 알 수 없다

세션키 체크섬, CFB 프리픽스 2바이트 반복, MDC의 SHA-1. OpenPGP는 단계마다 검산 장치를 넣어 뒀고, 그걸 다 확인하도록 짜면 함정 2처럼 "프리픽스는 통과하는데 본문만 틀린" 상황이 곧바로 드러난다. 반대로 검산을 생략하면 난수 바이트를 손에 쥐고 인수분해부터 다시 의심하게 된다. 복호기를 짤 때 어서션을 아끼지 않는 게 결국 시간을 아낀다.

방어 쪽에서 정리하면

키 생성은 직접 만들지 않는다. 굳이 검증해야 한다면 두 소인수의 차가 n의 네제곱근보다 큰지 확인하는 것만으로 Fermat 계열은 막힌다(FIPS 186-5도 소수 간 최소 거리를 요구한다). 그리고 이미 만들어진 키를 점검할 때는 ⌈√n⌉ 근처를 몇천 번만 훑어봐도 이런 사고는 걸러진다. 검사 비용이 사실상 공짜라서, 키 감사 도구에 넣어 둘 값어치가 있다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 3] 2048비트 RSA가 0스텝에 갈라지는 이유 — DreamHack crt rsa 풀이
blog

[🥈 Silver 3] 2048비트 RSA가 0스텝에 갈라지는 이유 — DreamHack crt rsa 풀이

공개 지수 e 도 개인키 d 도 주지 않고 dp, dq, qinv 와 암호문만 주는 RSA-CRT 문제. 두 소수를 p = nextprime(q + 1) 로 만들어 사실상 연속 소수라, 2048비트 모듈러스인데도 Fermat 인수분해로 0스텝에 갈라진다. dp·dq·qinv 는 그 자체가 CRT 복호 파라미터라, 소인수만 알면 e·d 복원 없이 곧장 평문을 얻는다.
#dreamhack#ctf#crypto+4
2026-07-22#dreamhack +5
[🥉 Bronze 4] RSA-2048 을 아홉 가지로 두들기고 돌아오니 답은 소스에 있었다 — DreamHack fakeday revenge 풀이
blog

[🥉 Bronze 4] RSA-2048 을 아홉 가지로 두들기고 돌아오니 답은 소스에 있었다 — DreamHack fakeday revenge 풀이

만우절에 올라온 문제. 배포본 prob.py 가 2048비트 RSA 로 flag 를 암호화하고 N, e, c 만 남기는데 소스의 flag 자리에는 가짜로 보이는 리터럴이 박혀 있다. Fermat, Pollard p-1, rho, 저지수, factordb 까지 아홉 항목을 돌려도 흠집이 없고, 설명이 가리킨 Shor 는 4099 큐빗이 필요하다. 답은 그 리터럴 자체였다.
#dreamhack#ctf#crypto+7
2026-08-22#dreamhack +4
[🥉 Bronze 3] 8827비트 RSA가 0.004초에 갈라지는 이유 — DreamHack many primes 풀이
blog

[🥉 Bronze 3] 8827비트 RSA가 0.004초에 갈라지는 이유 — DreamHack many primes 풀이

모듈러스 N 이 2658자리(8827비트)라 겁을 주지만, 실은 11부터 8293 사이의 자잘한 소수 777개를 곱해 만든 값이다. 소인수가 전부 8296 미만이라 8296까지 시행나눗셈만 돌려도 통째로 갈라진다. 서로 다른 소수의 곱이라 φ(N)=∏(pᵢ−1) 로 바로 구해지고, d=e⁻¹ mod φ 로 개인키를 복원해 복호한다.
#dreamhack#ctf#crypto+5
2026-07-23#dreamhack +4