문제: DreamHack — the present 분류: crypto 난이도: 🥈 Silver 4 FLAG:
DH{M3rry_ChrI5tM4s_And_4_h4pPy_N3w_y34R_3verY0n3}
선물상자를 열었더니 크리스마스 CTF 초대권과 암호화된 쪽지, 그리고 그 쪽지를 만든 코드가 같이 들어 있었다는 설정이다. 문제 페이지에도 "해당 문제는 2022 Christmas CTF 에 출제된 문제입니다"라고 붙어 있다.
받은 코드는 스무 줄이 안 된다. 짧은 crypto 문제는 대개 둘 중 하나인데, 수학적으로 뚫려 있거나 파라미터 하나가 잘못 골라져 있다. 이 문제는 후자다.

문제 개요
| 항목 | 내용 |
|---|---|
| 문제명 | the present |
| 난이도 | 🥈 Silver 4 |
| 분류 | crypto |
| 제공 파일 | prob.py (암호화 코드), output.txt (M, rand) |
| 서버 | 없음 — 완전 오프라인 |
| 핵심 취약점 | 제곱 되먹임 PRNG의 모듈러가 합성수가 아니라 소수 하나 |
| 사용 도구 | Python(pycryptodome), OpenSSL, SageMath |
흐름을 한 줄로 줄이면 이렇다. 플래그를 정수로 바꿔 시드로 넣고 제곱을 두 번 돌린 값이 rand로 공개되니, 제곱근을 두 번 거슬러 올라가면 시드가 나온다. 그 제곱근이 계산 가능한 이유가 이 문제의 전부다.
🔬 정찰 — 받은 건 코드 한 장과 숫자 두 개
압축을 풀면 파일이 둘뿐이다. 실행 파일도, 서버 주소도 없다.
ls -l extracted && file extracted/*
암호화 코드부터 본다.
cat extracted/prob.py
내용은 이게 전부다.
from Crypto.Util.number import *
flag = bytes_to_long(b'DH{?????????????????????????????????????????????}')
class PRNG():
def __init__(self, seed):
self.seed = seed
self.M = getStrongPrime(512)
def next(self):
self.seed = self.seed ** 2 % self.M
return self.seed
prng = PRNG(flag)
prng.next()
print(f'M = {prng.M}')
print(f'rand = {prng.next()}')읽을 곳은 세 군데다.
-
PRNG(flag)— 시드가 난수가 아니라 플래그 자체다. 복원 대상이 곧 내부 상태다. -
next()— 상태 전이가seed = seed² mod M하나뿐이다. 출력 가공도, 비트 자르기도 없다. 상태를 그대로 뱉는다. -
next()호출이 두 번 — 한 번은 반환값을 버리고, 두 번째 것만 출력한다. 그래서 공개된 값은flag²가 아니라flag⁴ mod M이다. 이 한 줄을 흘려 읽으면 뒤에서 조용히 틀린다.
숫자 쪽도 확인한다.
cat extracted/output.txt
정리하면 우리가 아는 것은 M, rand = flag⁴ mod M 두 개고, 구할 것은 flag다.
🧩 배경 — 제곱 되먹임 PRNG는 왜 안전하다고 하나
x ← x² mod N 형태의 난수 생성기는 Blum Blum Shub(BBS)라는 이름으로 잘 알려져 있다. 상태를 제곱해서 다음 상태로 쓰고, 보통은 최하위 비트 몇 개만 출력으로 흘린다.
이 구조의 안전성은 전적으로 모듈러 N이 두 소수의 곱이라는 데서 나온다. 합성수 위에서 제곱근을 구하는 문제는 N을 인수분해하는 것과 같은 난이도라, 인수를 모르면 한 스텝도 되돌릴 수 없다. 라빈(Rabin) 암호가 "인수분해만큼 어렵다"고 말할 때의 그 성질이다.
반대로 모듈러가 소수 하나면 이야기가 끝난다. 소수 p에서는 어떤 수가 제곱수인지 오일러 판정법으로 즉시 알 수 있고, 제곱근도 Tonelli–Shanks 알고리즘으로 다항시간에 나온다. 되먹임을 몇 번 돌렸든 그 횟수만큼 거슬러 올라가면 된다.
이 문제의 M은 getStrongPrime(512)로 만들어졌다. 이름 그대로 "강한 소수"지, 두 소수의 곱이 아니다.

💣 핵심 — 소수면 제곱근이 거듭제곱 한 번
먼저 M이 정말 소수인지부터 확인했다. 코드가 getStrongPrime을 부른다고 해서 배포된 숫자가 그렇다는 보장은 없으니, 받은 값을 직접 판정한다. OpenSSL에 소수 판정기가 붙어 있어 그대로 쓸 수 있다.
openssl prime $(grep -oP "(?<=M = )\d+" extracted/output.txt)
소수인 걸 확인했으면 다음은 M mod 4다. 이 값이 3이면 Tonelli–Shanks를 돌릴 필요도 없다. 이차잉여 a의 제곱근이 거듭제곱 한 번으로 나오기 때문이다.
r = a^((M+1)/4) mod M
r² = a^((M+1)/2) = a · a^((M-1)/2) = a · 1 = a (a 가 이차잉여일 때)마지막 등식이 성립하는 건 a가 이차잉여라 오일러 판정법에서 a^((M-1)/2) ≡ 1이기 때문이다. 실제로 이 문제의 M은 M mod 4 = 3이고, rand도 이차잉여다.
여기서 한 번 더 생각할 게 있다. 제곱근은 항상 두 개(r과 M-r)라, 두 단계를 거치면 후보가 네 개로 불어날 것처럼 보인다. 그런데 실제로는 두 개만 나온다.
M mod 4 = 3이면 -1이 비잉여다. 그래서 부호가 다른 두 근 s와 M-s 중 정확히 한쪽만 다시 이차잉여가 되고, 나머지 한쪽은 제곱근 자체가 존재하지 않는다. 가지 하나가 그 자리에서 끊긴다.

살아남은 두 후보를 가르는 것도 간단하다. 플래그는 49바이트, 즉 392비트라 512비트 모듈러보다 한참 작다. 나머지 연산으로 잘려나간 정보가 없다는 뜻이고, 동시에 후보 중 하나만 391비트라는 뜻이기도 하다. 나머지 하나는 512비트로 길이부터 어긋난다.
🐛 삽질 — next()를 몇 번 셌나
▶🐛 삽질 — 제곱근을 한 번만 취하면 뭐가 나오나
prng.next()가 출력 없이 호출되는 줄은 화면상 존재감이 거의 없다. rand = flag² mod M이라고 읽어 버리면 제곱근을 한 번만 취하게 되는데, 그때 손에 뭐가 남는지 실제로 찍어 봤다. 덤으로 "가지 하나가 끊긴다"는 앞의 주장도 같이 확인한다.
#!/usr/bin/env python3
"""제곱근을 한 번만 취하면 무엇이 나오나 — next() 호출 횟수를 세는 이유
prob.py 는 print 하기 전에 prng.next() 를 한 번 버린다. 그 한 줄을 못 보고
rand = flag^2 라고 읽으면 제곱근을 한 번만 취하게 되는데, 그때 손에 남는 값이
무엇인지 실제로 찍어 본다. 덤으로 {s, M-s} 중 하나만 다시 제곱근을 가진다는
것(= 후보가 두 배로 늘지 않는 이유)도 같이 확인한다.
"""
import re
import pathlib
from Crypto.Util.number import long_to_bytes, bytes_to_long
HERE = pathlib.Path(__file__).resolve().parent
FLAG = b"DH{M3rry_ChrI5tM4s_And_4_h4pPy_N3w_y34R_3verY0n3}"
text = (HERE / "extracted" / "output.txt").read_text()
M = int(re.search(r"M\s*=\s*(\d+)", text).group(1))
rand = int(re.search(r"rand\s*=\s*(\d+)", text).group(1))
s = pow(rand, (M + 1) // 4, M) # M % 4 == 3 이라 제곱근이 거듭제곱 한 번
for label, v in (("s ", s), ("M - s ", M - s)):
raw = long_to_bytes(v)
qr = pow(v, (M - 1) // 2, M) == 1
print(f"[{label}] {v.bit_length()}비트 이차잉여={qr} "
f"출력가능={all(32 <= c < 127 for c in raw)} 앞16B={raw[:16].hex()}")
print()
print(f"[확인] s == flag^2 mod M ? {s == pow(bytes_to_long(FLAG), 2, M)}")
print(" 한 번만 취한 제곱근은 flag 가 아니라 버려진 첫 next() 의 결과다")python3 wrong_once.py
한 번만 취한 제곱근은 512비트짜리 아무 의미 없는 바이트열이고, 마지막 줄이 확인해 주듯 그 값은 정확히 flag² mod M — 코드가 버린 첫 next()의 결과다. 플래그 형식이 아니라고 "복호가 틀렸나" 의심하며 알고리즘을 다시 뜯을 게 아니라, 그냥 한 번 더 제곱근을 취하면 되는 상황이었다.
같은 출력에서 M - s가 비잉여(이차잉여=False)로 찍히는 것도 보인다. 이 가지에서는 다음 제곱근을 구할 수 없으니 후보가 네 개로 늘지 않는다.
🎯 익스플로잇 — 네제곱근 두 개 중 읽히는 쪽
여기까지 오면 코드는 짧다. output.txt를 파싱하고, 제곱근을 두 번 취하고, 나온 후보를 바이트로 되돌려 DH{로 시작하는 것을 고르면 끝이다.
후보 검증은 눈으로 하지 않고 스크립트가 직접 한다. 각 후보를 문제의 암호화 그대로 네제곱해서 rand와 같은지 대조하는 줄(pow(t, 4, M) == rand)이 그 역할이다. 플래그 형식만 보고 고르면 형식이 우연히 맞는 값에 속을 여지가 남는다.
#!/usr/bin/env python3
"""the present (DreamHack, Silver 4, crypto) — flag 복구
prob.py 는 flag 를 정수로 만들어 PRNG 의 seed 로 넣고 next() 를 두 번 부른다.
next() 가 seed <- seed^2 mod M 이므로 출력된 rand 는 flag^4 mod M 이다.
M 이 getStrongPrime(512) 로 만든 '소수'라서 4제곱근은 곧바로 계산된다.
M % 4 == 3 이면 QR x 의 제곱근이 x^((M+1)/4) mod M 로 한 번에 나오고,
-1 이 비잉여라 {s, M-s} 중 정확히 하나만 다시 QR 이 된다. 그 하나만 따라가면
4제곱근 후보는 두 개(t, M-t)뿐이라 브루트포스가 필요 없다.
"""
import re
import pathlib
from Crypto.Util.number import long_to_bytes
HERE = pathlib.Path(__file__).resolve().parent
OUT = HERE / "extracted" / "output.txt"
def load_output(path):
"""output.txt 에서 M 과 rand 를 읽는다."""
text = path.read_text()
M = int(re.search(r"M\s*=\s*(\d+)", text).group(1))
rand = int(re.search(r"rand\s*=\s*(\d+)", text).group(1))
return M, rand
def is_qr(x, p):
"""오일러 판정법: x 가 p 의 이차잉여인가."""
return pow(x, (p - 1) // 2, p) == 1
def sqrt_mod_p3mod4(x, p):
"""p % 4 == 3 인 소수에서 x 의 제곱근 두 개. QR 이 아니면 빈 리스트."""
if x % p == 0:
return [0]
if not is_qr(x, p):
return []
r = pow(x, (p + 1) // 4, p)
return [r, p - r]
def fourth_roots(x, p):
"""x 의 4제곱근 전부 (p % 4 == 3 가정)."""
roots = []
for s in sqrt_mod_p3mod4(x, p):
roots += sqrt_mod_p3mod4(s, p)
return sorted(set(roots))
def main():
M, rand = load_output(OUT)
assert M % 4 == 3, "이 스크립트는 M mod 4 == 3 전용 지름길을 쓴다"
print(f"[*] M ({M.bit_length()}비트, M mod 4 = {M % 4}) = {M}")
print(f"[*] rand ({rand.bit_length()}비트) = {rand}")
print(f"[*] rand 는 이차잉여인가? {is_qr(rand, M)}")
roots = fourth_roots(rand, M)
print(f"[*] 4제곱근 후보 {len(roots)}개")
flag = None
for i, t in enumerate(roots):
raw = long_to_bytes(t)
ok = pow(t, 4, M) == rand # 문제의 암호화를 그대로 되돌려 자체 검증
printable = all(32 <= c < 127 for c in raw)
head = raw[:16] if printable else raw[:16].hex()
print(f" [{i}] {t.bit_length():3d}비트 검산={ok} 출력가능={printable} 앞부분={head}")
if ok and printable and raw.startswith(b"DH{") and raw.endswith(b"}"):
flag = raw.decode()
if flag:
print(f"\n[+] FLAG = {flag}")
else:
print("\n[-] 조건을 만족하는 후보가 없다")
if __name__ == "__main__":
main()python3 solve.py
후보는 예상대로 두 개다. 둘 다 네제곱하면 rand가 되지만, 391비트짜리만 전부 출력 가능한 문자로 떨어진다. 512비트 쪽은 e5fba265…로 시작하는 바이트열이라 볼 것도 없다.
플래그는 DH{M3rry_ChrI5tM4s_And_4_h4pPy_N3w_y34R_3verY0n3}. Christmas CTF 출제작이라는 문제 페이지 안내와 내용이 맞아떨어진다.
🧪 교차 검증 — SageMath로 한 번 더
직접 짠 제곱근 루틴은 지름길(M mod 4 = 3)에 기대고 있어서, 그 가정이 틀렸다면 조용히 엉뚱한 값을 뱉는다. 그래서 계산 대수 시스템으로 같은 값을 독립적으로 구해 대조했다. Sage의 nth_root(4, all=True)는 지름길을 쓰지 않고 일반적인 방법으로 네제곱근을 전부 찾아 준다.
# verify.sage — SageMath 로 독립 검증
# 1) M 이 정말 소수인가 (소수면 제곱근이 다항시간에 계산된다)
# 2) rand 의 4제곱근을 Sage 의 nth_root 로 전부 구해 solve.py 결과와 대조
M = 12045184283108733282877246918018456795629932264538806786957088609817236359725183272225617590814403324378457302095025664842376149624271113548645334599894051
rand = 321609188014782817530081099017386870899897457530475334351599692725892773281779472409503390776505529932157130216692885548245652532540341302761980076422023
print("is_prime(M) =", is_prime(M))
print("M.nbits() =", Integer(M).nbits())
print("M mod 4 =", M % 4)
print("rand is QR =", kronecker(rand, M) == 1)
roots = Integers(M)(rand).nth_root(4, all=True)
print("네제곱근 개수 =", len(roots))
for r in sorted(Integer(x) for x in roots):
raw = int(r).to_bytes((r.nbits() + 7) // 8, "big")
ok = power_mod(int(r), 4, M) == rand
tag = raw.decode() if all(32 <= c < 127 for c in raw) else raw.hex()
print(f" {r.nbits():3d}비트 네제곱_검산:{ok} -> {tag}")sage verify.sage
is_prime(M) = True, M mod 4 = 3, 네제곱근 개수 2개, 그리고 그중 하나가 같은 플래그. 손으로 짠 지름길과 Sage의 일반 루틴이 같은 답에 도달했다.
참고로 kronecker(rand, M) == 1은 오일러 판정법과 같은 얘기다. Sage에서는 르장드르 기호를 바로 부를 수 있어 거듭제곱을 직접 돌릴 필요가 없다.
🔁 재현 — 파일 두 개면 끝
이 문제는 서버도 VM도 필요 없어서, 배포본과 스크립트만 있으면 언제든 다시 돌릴 수 있다. 재현 스크립트는 플래그를 눈으로 대조하게 두지 않고 문제.json에 기록된 정답과 직접 비교한다.
#!/usr/bin/env bash
# the present (DreamHack Silver 4, crypto) — 한 방 재현.
# extracted/output.txt 의 (M, rand) 만으로 flag 를 복원하고 정답과 대조한다.
# 외부 서버·VM 이 필요 없는 완전 오프라인 문제라 이 스크립트만으로 끝난다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
python3 -c "import Crypto" 2>/dev/null || { echo "pycryptodome 이 필요하다: pip install pycryptodome"; exit 1; }
OUT=$(timeout 120 python3 solve.py 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
echo; echo "✅ PASS $FLAG"; exit 0
fi
echo; echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1./reproduce.sh
로컬 환경: Ubuntu 25.10, Python 3.13.7(pycryptodome 3.23.0), OpenSSL 3.5.3, SageMath 10.9. Sage는 nth_root 교차검증에만 쓰이니 없어도 solve.py는 그대로 돌아간다.
📝 결론
모듈러 하나를 잘못 고르면 구조 전체가 무너진다
x ← x² mod N 되먹임은 그 자체로는 멀쩡한 설계다. 무너진 건 N을 두 소수의 곱이 아니라 소수 하나로 잡은 지점이고, 나머지 코드는 아무 잘못이 없다. 암호 구현을 볼 때 알고리즘 이름보다 파라미터가 어떻게 만들어지는지를 먼저 보는 이유다.
되돌릴 횟수는 출력이 아니라 호출 횟수가 정한다
공개된 값은 하나였지만 next()는 두 번 불렸다. 출력 개수만 세고 제곱근을 한 번만 취하면 버려진 중간 상태를 손에 쥐고 "복호가 안 된다"며 헤매게 된다. 상태 기계를 되돌릴 때는 화면에 찍힌 값이 몇 번째 상태인지부터 세는 게 맞다.
후보가 여럿이면 검산으로 좁힌다
네제곱근은 원리상 여러 개일 수 있다. 플래그 형식으로 고르는 건 편하지만, 그 전에 후보를 문제의 암호화 그대로 다시 돌려 원본과 맞는지 확인하는 게 먼저다. 이 문제에서는 pow(t, 4, M) == rand 한 줄이 그 역할을 했고, 길이(391비트 대 512비트)가 보조 지표가 됐다.
방어 쪽에서 정리하면
BBS 계열 생성기를 쓸 거라면 모듈러는 반드시 두 개의 큰 소수 곱(각 소수는 4로 나눈 나머지가 3인 Blum 소수)이어야 하고, 인수는 폐기해야 한다. 상태를 그대로 출력하는 것도 문제다. 원래 BBS는 상태 전체가 아니라 최하위 비트 소수만 흘리는데, 이 코드는 seed를 통째로 뱉어서 되돌릴 지점을 그대로 알려 준다.
시드에 비밀값을 넣는 것 역시 위험하다. 여기서는 플래그가 시드였기 때문에 상태 복원이 곧 비밀 복원이 됐다. 비밀은 시드가 아니라 키로 다루고, 생성기 상태는 비밀과 분리해야 한다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…