문제: 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 |
| 난이도 | 🥉 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)이다.

공격 지점이 어디인지가 이 그림에서 바로 보인다. 봉투 구조 자체는 튼튼하고, 두 번째 상자를 통과할 수 있느냐가 전부다. 그 상자를 여는 열쇠가 개인키 d이고, d는 n의 인수를 알아야 계산된다. 문제 설명은 바로 그 인수를 어떻게 뽑았는지 알려 준 것이다.
🔬 정찰 — armor 안에 뭐가 들어 있나
먼저 받은 파일이 뭔지부터 확인한다.
file extracted/* && cat extracted/enc.asc
armor는 그냥 base64 포장이다. 마지막 =YWyz 줄은 CRC24 체크섬이고 데이터가 아니다. 이걸 벗기면 248바이트짜리 바이너리 패킷 스트림이 나온다.
패킷 구조를 직접 파싱하기 전에, 표준 구현이 이 파일을 어떻게 읽는지 먼저 본다. GnuPG의 --list-packets가 정확히 그 용도다.
gpg --list-packets extracted/enc.asc; gpg --list-packets extracted/pub.asc
여기서 확인되는 것들.
- 공개키는 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
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비트 근처에서 수백 정도로 줄어든다.

코드로는 이게 전부다.
#!/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
반복 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
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
두 번째 블록이 특히 볼 만하다. 양쪽 다 프리픽스 검사 통과로 찍히는데, 정답 쪽은 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
플래그는 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
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
로컬 환경: 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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…