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

2026-07-22·1분 읽기·

[🥈 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 — crt rsa 분류: Crypto 난이도: 🥈 Silver 3 FLAG: DH{B45ic_Crt_R54_w1th_A_bit_0f_f3rmat_fact0r}

RSA 문제인데 주는 값이 평소와 다르다. 모듈러스 N, 그리고 dp, dq, qinv 세 개, 마지막으로 암호문 하나. 공개 지수 e 도, 개인키 d 도, 소수 p·q 도 없다. 이름이 "crt rsa"인 만큼 이 세 값은 RSA를 CRT(중국인의 나머지 정리)로 빠르게 복호할 때 쓰는 파라미터들이다.

포인트는 두 가지다. 하나는 이 dp·dq·qinv 가 소인수만 알면 그대로 복호에 쓸 수 있는 값이라는 것, 다른 하나는 출제자가 소수를 p = nextprime(q + 1) 로 뽑아 두 소수를 붙여 놓은 것. 2048비트 모듈러스라 겁을 주지만, 이 두 번째 사실이 인수분해를 통째로 열어 준다.


문제 개요

항목내용
문제명crt rsa
난이도🥈 Silver 3
분류Crypto
제공 파일prob.py, output.txt (N·dp·dq·qinv·encrypted flag)
핵심 취약점연속 소수 → Fermat 인수분해 + CRT 파라미터 직접 복호

prob.py 는 flag 를 RSA 로 암호화하고 output.txt 에 공개값을 뱉는다. 소수 p·q 와 개인키 d 는 물론 숨겨져 있다. 우리가 해야 할 건 (1) N 을 인수분해하고, (2) 주어진 CRT 파라미터로 복호하는 것.



🧩 배경 — RSA-CRT 복호란

일반 RSA 복호는 m = c^d mod N 한 방이지만, d 가 2048비트라 이 거듭제곱은 느리다. 그래서 실제 구현은 모듈러스를 소수 단위로 쪼개는 CRT 최적화를 쓴다. 미리 다음 세 값을 준비해 둔다.

  • dp = d mod (p - 1) — p 쪽 지수
  • dq = d mod (q - 1) — q 쪽 지수
  • qinv = q⁻¹ mod p — 두 결과를 합칠 때 쓰는 계수

복호는 각 소수에서 따로 거듭제곱한 뒤(모듈러가 절반 크기라 훨씬 빠르다) Garner 공식으로 합친다.

RSA-CRT 복호 흐름 — c 를 p, q 각각에서 거듭제곱한 뒤 qinv 로 합쳐 평문 m 을 얻는다. e 나 d 는 필요 없다
RSA-CRT 복호 흐름 — c 를 p, q 각각에서 거듭제곱한 뒤 qinv 로 합쳐 평문 m 을 얻는다. e 나 d 는 필요 없다

여기서 중요한 사실 하나. 이 복호 과정에는 공개 지수 e 도, 원래 개인키 d 도 등장하지 않는다. 필요한 건 소수 p·q 와 이미 주어진 dp·dq·qinv 뿐이다. 즉 이 문제는 "복호에 필요한 재료를 이미 다 줬는데, 딱 하나 소인수만 안 준" 상태다. 그렇다면 남은 일은 N 을 인수분해하는 것뿐이다.


🔬 코드 정찰

prob.py 부터 본다.

이 글의 명령은 모두 문제 폴더 최상위(extracted/ 의 부모) 에서 실행한다.

cat extracted/prob.py
prob.py 소스 — getPrime 로 q 를 뽑고 p = nextprime(q + 1) 로 두 번째 소수를 만든다
prob.py 소스 — getPrime 로 q 를 뽑고 p = nextprime(q + 1) 로 두 번째 소수를 만든다

핵심은 소수를 만드는 세 줄이다.

q = getPrime(1024)
p = nextprime(q + 1)     # ← q 바로 다음 소수. p 와 q 가 붙어 있다
N = p * q

getPrime(1024) 로 q 를 뽑은 뒤, p 를 nextprime(q + 1) — 즉 q 바로 다음 소수 — 로 잡는다. 1024비트 근처에서 연속한 소수 사이 간격은 평균 수백 정도라, p 와 q 는 사실상 딱 붙어 있다. 이게 이 문제의 급소다.

그리고 출력부는 N, dp, dq, qinv, encrypted flag 만 찍는다. e 와 d 는 화면에 없다. output.txt 가 실제로 무엇을 주는지 크기부터 확인해 봤다.

# inspect_params.py — output.txt 가 주는 값의 정체 확인
import re
raw = open("extracted/output.txt").read()
for k in ("N", "dp", "dq", "qinv"):
    v = int(re.search(rf"{k}\s*=\s*(\d+)", raw).group(1))
    print(f"{k:>4} : {v.bit_length():>4} bits")
c = int(re.search(r"encrypted flag\s*=\s*(\d+)", raw).group(1))
print(f"{'c':>4} : {c.bit_length():>4} bits  (encrypted flag)")
inspect_params.py 실행 — N 2048비트, dp/dq/qinv 각 1024비트급, e 와 d 는 없음
inspect_params.py 실행 — N 2048비트, dp/dq/qinv 각 1024비트급, e 와 d 는 없음

N 은 2048비트, dp·dq·qinv 는 소수 크기(1024비트급). 공개 지수도 개인키도 없다. 배경에서 정리한 대로, 주어진 세 값은 CRT 복호 파라미터 그 자체다.


🐛 삽질

▶🐛 삽질 — 그냥 인수분해부터 하려다 2048비트 벽에 막힘

CRT 파라미터가 복호에 쓰인다는 건 금방 보였지만, 그러려면 p·q 가 필요하다. 그래서 "일단 N 을 인수분해하자"로 달려들었다. 문제는 N 이 2048비트 반소수(semiprime)라는 것. 작은 소인수라도 있나 훑어봤다.

# naive_factor.py — 작은 소인수 스캔 후 일반 인수분해로 넘어가려던 시도
import re, time
from sympy import primerange
N = int(re.search(r"N\s*=\s*(\d+)", open("extracted/output.txt").read()).group(1))
print(f"N : {N.bit_length()} bits")
t = time.time()
small = None
for pr in primerange(2, 300000):
    if N % pr == 0:
        small = pr
        break
print(f"작은 소인수 (< 300000) : {small}")
print(f"스캔 시간 : {time.time() - t:.2f}s")
naive_factor.py 실행 — 작은 소인수 없음. 일반 인수분해로는 2048비트 반소수가 안 갈라진다
naive_factor.py 실행 — 작은 소인수 없음. 일반 인수분해로는 2048비트 반소수가 안 갈라진다

작은 소인수는 없다. trial division 도, Pollard rho·p-1 도 2048비트 반소수 앞에서는 사실상 멈춘다. 여기서 "e 가 없으니 dp·dq 로 d 를 복원한 다음 표준 복호를 하자"로 방향을 틀어 봤지만, d 를 복원하려 해도 결국 p-1, q-1 을 알아야 하니 다시 인수분해로 되돌아온다. 뱅뱅 돌다 prob.py 의 p = nextprime(q + 1) 한 줄로 돌아왔다 — 소수가 붙어 있으면 굳이 일반 인수분해가 필요 없다.



💣 핵심 — 연속 소수라 Fermat이 0스텝에 뚫린다

p 와 q 가 붙어 있다는 건 Fermat 인수분해가 딱 맞는 상황이라는 뜻이다. Fermat 인수분해는 홀수 N 을 두 제곱수의 차로 본다.

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

여기서 a = (p + q) / 2, b = (p − q) / 2 다. 두 소수가 가까우면 b 가 아주 작고, a 는 √N 바로 위에 있다. 그래서 a 를 ⌈√N⌉ 부터 하나씩 올리며 a² − N 이 완전제곱(= b²)이 되는 순간을 찾으면 된다. 소수가 붙어 있을수록 그 순간이 빨리 온다.

Fermat 인수분해 — 두 소수가 가까우면 a = ceil(sqrt N) 부터 몇 스텝 안에 a squared minus N 이 완전제곱이 된다
Fermat 인수분해 — 두 소수가 가까우면 a = ceil(sqrt N) 부터 몇 스텝 안에 a squared minus N 이 완전제곱이 된다

이 문제에선 얼마나 가까운지 직접 확인해 봤다. sympy 로 나온 두 소수가 실제 소수인지, 그리고 정말 연속 소수(p == nextprime(q + 1))인지까지 검증했다.

# factor_check.py — Fermat 으로 갈라 보고 연속 소수인지 확인
from math import isqrt
from sympy import isprime, nextprime
import re
 
N = int(re.search(r"N\s*=\s*(\d+)", open("extracted/output.txt").read()).group(1))
a = isqrt(N)
if a * a < N:
    a += 1
steps = 0
while True:
    b2 = a * a - N
    b = isqrt(b2)
    if b * b == b2:
        break
    a += 1
    steps += 1
p, q = a + b, a - b
print(f"Fermat steps      : {steps}")
print(f"p * q == N        : {p * q == N}")
print(f"p, q 가 소수인가   : {isprime(p)}, {isprime(q)}")
print(f"p == nextprime(q+1): {p == nextprime(q + 1)}")
print(f"p - q             : {p - q}")
factor_check.py 실행 — Fermat steps 0, p*q==N True, 둘 다 소수, p==nextprime(q+1) True, p-q=672
factor_check.py 실행 — Fermat steps 0, p*q==N True, 둘 다 소수, p==nextprime(q+1) True, p-q=672

Fermat steps = 0. 즉 a = ⌈√N⌉ 그 값에서 이미 a² − N 이 완전제곱이었다. 두 소수 차이는 겨우 672. 2048비트 모듈러스가 단 한 번의 제곱근 계산으로 갈라진 것이다. p == nextprime(q + 1) 도 True 라, 우리가 복원한 소수가 출제자가 만든 그 소수 쌍이 맞다.

이제 소인수를 얻었으니 배경의 CRT 복호를 그대로 돌리면 된다.

m1 = c^dp mod p
m2 = c^dq mod q
h  = qinv · (m1 − m2) mod p     # qinv = q⁻¹ mod p
m  = m2 + h · q

🎯 Full Exploit

output.txt 에서 값을 파싱해 Fermat 인수분해 → CRT 복호까지 한 번에 하는 스크립트다. 값은 하드코딩하지 않고 문제 파일에서 그대로 읽는다.

# solve.py
from math import isqrt
from Crypto.Util.number import long_to_bytes
import re, os
 
here = os.path.dirname(os.path.abspath(__file__))
raw = open(os.path.join(here, "extracted", "output.txt")).read()
vals = {}
for key in ("N", "dp", "dq", "qinv"):
    vals[key] = int(re.search(rf"{key}\s*=\s*(\d+)", raw).group(1))
c = int(re.search(r"encrypted flag\s*=\s*(\d+)", raw).group(1))
N, dp, dq, qinv = vals["N"], vals["dp"], vals["dq"], vals["qinv"]
 
# 1) Fermat factorization: N = a^2 - b^2 = (a+b)(a-b)
a = isqrt(N)
if a * a < N:
    a += 1
steps = 0
while True:
    b2 = a * a - N
    b = isqrt(b2)
    if b * b == b2:
        break
    a += 1
    steps += 1
p, q = a + b, a - b
assert p * q == N and p > q
print(f"[+] Fermat steps = {steps}   (p-q = {p - q})")
print(f"[+] p = {p}")
print(f"[+] q = {q}")
 
# 2) RSA-CRT 복호 (e, d 불필요)
m1 = pow(c, dp, p)
m2 = pow(c, dq, q)
h = (qinv * (m1 - m2)) % p
m = m2 + h * q
 
flag = long_to_bytes(m)
print(f"[+] FLAG = {flag.decode()}")
solve.py 실행 — Fermat steps 0, p 와 q(끝 세 자리만 다른 연속 소수) 출력, 마지막 줄에 FLAG
solve.py 실행 — Fermat steps 0, p 와 q(끝 세 자리만 다른 연속 소수) 출력, 마지막 줄에 FLAG

출력된 p 와 q 를 보면 앞자리가 전부 같고 끝 세 자리만 다르다(...798109 vs ...797437). 눈으로 봐도 두 소수가 붙어 있다는 게 보인다. 그리고 마지막 줄에 플래그가 떨어진다.

DH{B45ic_Crt_R54_w1th_A_bit_0f_f3rmat_fact0r}

✅ 검증

플래그 문자열 자체가 풀이를 그대로 요약한다 — B45ic_Crt_R54_w1th_A_bit_0f_f3rmat_fact0r, 즉 "약간의 Fermat 인수분해를 곁들인 기본 CRT-RSA". 우리가 쓴 두 도구(연속 소수 Fermat 인수분해 + CRT 파라미터 복호)가 의도한 정답 경로였다는 확인이다. 이 문제는 needs_vm 이 없는 오프라인 문제라, 서버 없이 로컬에서 값을 복원해 그대로 제출했다.



📝 결론 — 무엇이 문제였나

RSA의 안전성은 "큰 모듈러스는 인수분해가 어렵다"에 기댄다. 하지만 그건 두 소수가 무작위로 멀리 떨어져 있을 때 얘기다. 이 문제는 두 곳에서 그 전제를 무너뜨렸다.

  • 연속 소수: p = nextprime(q + 1) 은 편의상 그럴듯해 보이지만, 두 소수를 붙여 놓으면 √N 근처에 소인수가 몰려 Fermat 인수분해가 즉시 통한다. 비트 수(2048)는 방어가 되지 못한다. 소수는 반드시 독립적으로, 충분히 떨어지게 뽑아야 한다(그래서 표준 키 생성은 |p − q| 하한을 둔다).
  • CRT 파라미터 노출: dp·dq·qinv 는 개인키의 일부다. 이 값들은 소인수만 확보되면 e·d 없이도 곧장 복호로 이어진다. CRT 최적화 값은 개인키와 동급으로 취급해 절대 공개하지 않아야 한다.

두 실수 중 하나만 있어도 위험한데, 이 문제는 둘을 겹쳐 놨다. 방어자의 교훈은 단순하다 — 소수는 독립·충분한 간격으로, CRT 파라미터는 개인키처럼.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 4] e 가 (p-1) 을 나누는 RSA — AMM 으로 e차근 구하기 — DreamHack special_rsa_parameter 풀이
blog

[🥈 Silver 4] e 가 (p-1) 을 나누는 RSA — AMM 으로 e차근 구하기 — DreamHack special_rsa_parameter 풀이

공개지수 e=257 이 (p-1) 을 딱 한 번 나눈다. gcd(e, φ(N)) 이 1 이 아니라 d=e⁻¹ mod φ 가 존재하지 않아 평범한 RSA 복호가 막힌다. p·q 가 이미 주어졌으니 CRT 로 mod p / mod q 로 쪼갠 뒤, e차근이 유일한 mod q 는 그대로 풀고, e차근이 257개인 mod p 는 AMM(Adleman-Manders-Miller) 으로 전부 구해 CRT 로 다시 합친다. 257개 후보 중 BISC 로 시작하는 평문이 flag.
#dreamhack#ctf#crypto+6
2026-07-23#dreamhack +5
[🥈 Silver 1] sorted() 가 지운 것과 못 지운 것 — DreamHack Amo's gift 풀이
blog

[🥈 Silver 1] sorted() 가 지운 것과 못 지운 것 — DreamHack Amo's gift 풀이

800 미만 소수 139개마다 sorted([p % i, q % i]) 를 흘려 주는 2048비트 RSA 문제. 정렬 때문에 어느 쪽이 p 인지는 사라지지만 두 값의 합은 그대로 남는다. 139개 소수에서 (p+q) mod i 를 모아 CRT 로 p+q 를 복원하고, 이차방정식 근으로 N 을 분해했다.
#dreamhack#ctf#crypto+3
2026-08-20#dreamhack +5
[🥉 Bronze 1] q = nextPrime(p) 한 줄로 무너진 자작 PGP — DreamHack Pretty Good Privacy 풀이
blog

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

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