[🥇 Gold 2] 두 배점 x 좌표 하나로 키 두 개 되찾기 — DreamHack Elliptic Curve Basic 풀이

2026-08-19·1분 읽기·

[🥇 Gold 2] 두 배점 x 좌표 하나로 키 두 개 되찾기 — DreamHack Elliptic Curve Basic 풀이

NIST P-256 위의 랜덤 점 P와 그 두 배점 2P를 10쌍 뽑아, 두 점의 x 좌표만 key1·key2에 대한 1차식으로 흘린다. 점도 y 좌표도 공개되지 않지만 y가 제곱으로만 등장하는 덕분에 두 x 좌표는 하나의 유리함수로 묶인다. key2를 소거해 7차 다항식을 세우고 GF(p) 근을 전부 구한 뒤, 남은 여덟 쌍으로 가짜 후보 둘을 걸러냈다.

문제: DreamHack — Elliptic Curve Basic 분류: crypto 난이도: 🥇 Gold 2 FLAG: DH{805f461baa9ee08b66191f9309e976aedf28cff9d130ce19941ff6b3a19ad634}

문제 설명은 두 문장이다. "타원 곡선에 대해서 들어보셨나요? 이번에 한 번 간단하게만 알아봅시다." 그리고 참고 링크 둘 — 위키백과의 Elliptic curve point multiplication, 그리고 SageMath.

힌트는 없다. 링크 두 개가 이 문제에서 받는 안내의 전부다.

드림핵 Elliptic Curve Basic 문제 페이지 — 영문과 국문 설명 두 문장 아래에 위키백과 point multiplication 과 SageMath 링크만 걸려 있고, 우측에 문제 파일 받기 버튼과 First Blood 기록이 보인다
드림핵 Elliptic Curve Basic 문제 페이지 — 영문과 국문 설명 두 문장 아래에 위키백과 point multiplication 과 SageMath 링크만 걸려 있고, 우측에 문제 파일 받기 버튼과 First Blood 기록이 보인다

배포본은 chal.sage 700바이트와 output.txt 3.4킬로바이트, 둘뿐이다.


문제 개요

항목내용
문제명Elliptic Curve Basic
난이도🥇 Gold 2
분류crypto
제공 파일chal.sage (26줄) · output.txt (20줄)
서버없음 (오프라인)
곡선NIST P-256 (secp256r1 / prime256v1)
핵심 기법두 배점 공식에서 y 소거 → key2 선형 소거 → 7차 종결식의 GF(p) 근

풀이 흐름은 이렇다. 공개된 것은 점 P의 x 좌표와 두 배점 Q = 2P의 x 좌표를 각각 key1, key2에 대한 1차식으로 감싼 것뿐이다. 점 자체도, y 좌표도, 키도 나오지 않는다.

그런데 두 배점 공식에서 y는 제곱 꼴로만 등장한다. 곡선 방정식으로 그 제곱을 치환하면 y가 통째로 사라지고, x_Q가 x_P만의 유리함수가 된다. 여기에 1차식 둘을 대입하면 미지수가 key1, key2 둘뿐인 방정식이 회차마다 하나씩 생긴다.

게다가 key2에 대해서는 1차라서 이항 한 번으로 지워진다. 서로 다른 두 회차를 겹치면 key1만의 7차 다항식이 남는다.



🔬 배포본 정찰

먼저 뭐가 왔는지부터 센다.

#!/usr/bin/env bash
# Elliptic Curve Basic — 배포본 정찰. 파일이 뭐가 왔고 얼마나 되는지부터 센다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
echo "== 배포본 =="
ls -la extracted/
sha256sum challenge_1881.zip extracted/*
 
echo
echo "== 줄 수 =="
wc -l extracted/chal.sage extracted/output.txt
 
echo
echo "== output.txt 앞 4줄 =="
head -4 extracted/output.txt | cut -c1-96
 
echo
echo "== 공개된 것: 계수 4개짜리 1차식이 쌍으로 =="
grep -c 'key1' extracted/output.txt
grep -c 'key2' extracted/output.txt
./recon.sh

recon.sh 실행 화면 — chal.sage 700바이트와 output.txt 3422바이트의 sha256 해시, 각각 26줄과 20줄, 그리고 key1 을 쓰는 줄 10개와 key2 를 쓰는 줄 10개가 짝을 이룬다
recon.sh 실행 화면 — chal.sage 700바이트와 output.txt 3422바이트의 sha256 해시, 각각 26줄과 20줄, 그리고 key1 을 쓰는 줄 10개와 key2 를 쓰는 줄 10개가 짝을 이룬다

chal.sage 전문이다. 26줄이라 통째로 싣는다.

# NIST P-256 Parameters
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
 
Zp = Zmod(p)
P256 = EllipticCurve(Zp, [a, b])
 
key1 = Zp.random_element()
key2 = Zp.random_element()
 
for _ in range(10):
    P = P256.random_point()
    Q = P + P
 
    b = Zp.random_element()
    a = (P.xy()[0] - b) / key1
 
    d = Zp.random_element()
    c = (Q.xy()[0] - d) / key2
 
    print(f"P.x = {a} * key1 + {b}")
    print(f"Q.x = {c} * key2 + {d}")
 
with open('flag', 'w') as f:
    key = int(key1) ^^ int(key2)
    f.write(f"Flag is DH{{{key:064x}}}")

^^는 Sage에서 XOR이다. 플래그는 key1과 key2를 XOR한 256비트 값을 64자리 16진수로 찍은 것이다.

출력은 이런 줄이 스무 개다.

P.x = 27714350255388021111232531170591641212949833102971307193980499863801255688154 * key1 + 46284378768605864622170543556599427573485337746700029009471119922484581943469
Q.x = 60647595877197062890203383385574231236668829536178956561727275653380079298295 * key2 + 82566598899232397169905495928712993370226968781169912617729109115055982292654

P.x는 항상 key1과, Q.x는 항상 key2와 짝을 이룬다. 이 대응이 뒤집히는 줄은 하나도 없다.

곡선이 정말 P-256인가

주석에 NIST P-256이라고 적혀 있지만 주석은 주석일 뿐이다. 상수 세 개를 openssl이 아는 값과 직접 맞춰 봤다.

#!/usr/bin/env bash
# chal.sage 맨 위 상수 세 개가 정말 NIST P-256 인지 openssl 로 대조한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
 
echo "== openssl 이 아는 prime256v1 (= NIST P-256) =="
openssl ecparam -name prime256v1 -param_enc explicit -text -noout | sed -n '1,16p'
 
echo
echo "== chal.sage 상수와 대조 =="
openssl ecparam -name prime256v1 -param_enc explicit -text -noout \
  | python3 cmp_params.py extracted/chal.sage
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""openssl ecparam 의 텍스트 출력(stdin)에서 Prime/A/B 를 뽑아 chal.sage 상수와 비교한다."""
import re
import sys
 
import sympy
 
text = sys.stdin.read()
 
 
def grab(label):
    m = re.search(rf'^{label}:\s*\n((?:\s+[0-9a-f:]+\n)+)', text, re.M)
    return int(re.sub(r'[^0-9a-f]', '', m.group(1)), 16)
 
 
src = open(sys.argv[1], encoding='utf-8').read()
mine = {k: int(v, 16) for k, v in re.findall(r'^(p|a|b) = (0x[0-9a-f]+)$', src, re.M)}
theirs = {'p': grab('Prime'), 'a': grab('A'), 'b': grab('B')}
 
for k in ('p', 'a', 'b'):
    same = mine[k] == theirs[k]
    print(f"[{'+' if same else '!'}] {k}: chal.sage {'==' if same else '!='} openssl   {mine[k]:#x}")
 
prime, curve_a = mine['p'], mine['a']
print(f"[+] a == p-3 ? {curve_a == prime - 3}")
print(f"[+] p 는 소수 ? {sympy.isprime(prime)}")
print(f"[+] p mod 4 = {prime % 4}   (오일러 판정·제곱근 지름길이 그대로 먹는다)")
./check_params.sh

openssl ecparam 으로 뽑은 prime256v1 의 Prime, A, B 값과 chal.sage 상수가 세 줄 모두 일치하고, a 가 p 빼기 3 이며 p 가 소수이고 p 를 4 로 나눈 나머지가 3 임을 확인한 화면
openssl ecparam 으로 뽑은 prime256v1 의 Prime, A, B 값과 chal.sage 상수가 세 줄 모두 일치하고, a 가 p 빼기 3 이며 p 가 소수이고 p 를 4 로 나눈 나머지가 3 임을 확인한 화면

표준 곡선 그대로다. 파라미터를 손대서 특이한 곡선을 만든 문제는 아니라는 뜻이고, 차수가 작다거나 anomalous라 이산로그가 쉽다거나 하는 뒷문도 없다.

이름이 겹치는 자리

chal.sage를 읽다 보면 루프 안에서 a와 b가 다시 대입되는 게 눈에 걸린다.

b = Zp.random_element()
a = (P.xy()[0] - b) / key1

곡선 계수와 이름이 똑같다. 그런데 P256 = EllipticCurve(Zp, [a, b])는 루프보다 위에서 이미 실행됐으므로, 곡선 객체는 원래 상수를 그대로 들고 있다. 파이썬 변수 a, b만 난수로 덮이는 것이다. Sage로 직접 확인했다.

# chal.sage 의 루프는 곡선 계수와 같은 이름의 a, b 를 덮어쓴다.
# 그래도 P256 은 루프 전에 만들어졌으니 계수가 안 바뀐다는 것을 Sage 로 직접 본다.
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
 
Zp = Zmod(p)
P256 = EllipticCurve(Zp, [a, b])
print("[before] P256.a4() ==", hex(Integer(P256.a4())))
print("[before] P256.a6() ==", hex(Integer(P256.a6())))
 
set_random_seed(7)
for i in range(3):                      # chal.sage 루프가 하는 그대로
    b = Zp.random_element()
    a = Zp.random_element()
    print(f"  루프 {i}: 지역 a, b 가 난수로 덮였다  a={hex(Integer(a))[:18]}...")
 
print("[after ] 파이썬 변수 a ==", hex(Integer(a))[:18], "...  (완전히 다른 값)")
print("[after ] P256.a4() ==", hex(Integer(P256.a4())))
print("[after ] P256.a6() ==", hex(Integer(P256.a6())))
print("[check ] 곡선 계수가 그대로인가 ?", P256.a4() == Zp(0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc))
 
# 출력 줄에 실린 a, b 는 곡선과 아무 상관이 없는 "그 줄의 1차식 계수" 다
P = P256.random_point()
bb = Zp.random_element()
aa = (P.xy()[0] - bb) / Zp(12345)
print(f"[what  ] 출력의 'a * key1 + b' 에서 a 는 (P.x - b)/key1 — 곡선 계수가 아니다")
print(f"          예: a = {str(aa)[:40]}...")
sage shadow_check.sage

SageMath 로 실행한 shadow check — 루프에서 지역 변수 a 와 b 를 세 번 난수로 덮어써도 P256.a4 와 P256.a6 은 처음의 NIST 상수를 그대로 유지한다는 것을 before 와 after 로 대조한 화면
SageMath 로 실행한 shadow check — 루프에서 지역 변수 a 와 b 를 세 번 난수로 덮어써도 P256.a4 와 P256.a6 은 처음의 NIST 상수를 그대로 유지한다는 것을 before 와 after 로 대조한 화면

P256.a4()와 P256.a6()은 그대로다. 헷갈릴 자리이기는 해도 실제로 곡선이 바뀌지는 않는다. 정작 중요한 건 출력에 찍히는 a가 곡선 계수가 아니라 그 줄의 1차식 계수라는 점이다. 나중에 관계식을 세울 때 이 둘을 섞으면 답이 아예 안 나온다. 뒤에서 실제로 돌려 본다.



🧩 무엇이 공개됐고 무엇이 감춰졌나

정리하면 이렇다.

공개된 정보 도식 — 왼쪽 붉은 상자에는 감춰진 P 와 Q 그리고 key1 key2 가, 오른쪽 초록 상자에는 output.txt 에 실제로 찍힌 1차식 두 줄과 계수 넷이 배치돼 있다
공개된 정보 도식 — 왼쪽 붉은 상자에는 감춰진 P 와 Q 그리고 key1 key2 가, 오른쪽 초록 상자에는 output.txt 에 실제로 찍힌 1차식 두 줄과 계수 넷이 배치돼 있다

미지수는 key1, key2 두 개뿐이고 회차는 열 번이다. 방정식이 미지수보다 훨씬 많다. 문제는 그 방정식을 어떻게 세우느냐다.

한 쌍만으로는 아무것도 안 정해진다

한 회차의 두 줄만 놓고 보면 어떨까. x_P와 x_Q가 곡선 위 두 점의 x 좌표라는 조건 하나뿐인데, key1을 아무 값이나 고정하면 x_P가 정해지고 거기에 맞는 x_Q가 계산되며, 그 x_Q를 만드는 key2도 나눗셈 한 번으로 나온다. 즉 해가 잔뜩 남는다.

말로만 하면 감이 안 오니 수로 확인했다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
한 쌍(P.x 한 줄 + Q.x 한 줄)만으로는 아무것도 정해지지 않는다는 것을 수로 확인한다.
아무 key1 을 골라도 그에 맞는 key2 가 따라오기 때문이다.
 
    python3 one_pair.py
"""
 
import random
 
import solve as S
 
pairs = S.parse('extracted/output.txt')
U0, V0 = S.uv(pairs[0])
TRUE_K1 = 0x73634a17f599c4121843466d8f3c166eff3c3623445123cd2660754804f9ec76
 
 
def k2_for(k1):
    v = S.peval(V0, k1)
    return None if v == 0 else S.peval(U0, k1) * pow(v, S.P - 2, S.P) % S.P
 
 
def hits(k1, k2):
    n = 0
    for (A, B), (C, D) in pairs:
        xp = (A * k1 + B) % S.P
        xq = (C * k2 + D) % S.P
        m = (pow(xp, 3, S.P) + S.CA * xp + S.CB) % S.P
        if (S.peval(S.N_POLY, xp) - 4 * m * xq) % S.P == 0:
            n += 1
    return n
 
 
print("[1] 1번 쌍만 놓고 아무 key1 이나 넣어 본다")
for k1 in (1, 2, 0xdeadbeef, TRUE_K1):
    k2 = k2_for(k1)
    label = " <= 진짜 key1" if k1 == TRUE_K1 else ""
    disp = hex(k1) if len(hex(k1)) <= 20 else hex(k1)[:20] + "..."
    print(f"    k1={disp:<23}  ->  k2 가 항상 존재,  10쌍 중 {hits(k1, k2)}쌍 만족{label}")
 
print("\n[2] 무작위 k1 200개로 같은 실험")
rng = random.Random(20260819)
on_curve = 0
for _ in range(200):
    k1 = rng.randrange(S.P)
    k2 = k2_for(k1)
    assert hits(k1, k2) >= 1, "1번 쌍은 언제나 만족한다"
    xp = (pairs[0][0][0] * k1 + pairs[0][0][1]) % S.P
    on_curve += S.is_on_curve_x(xp)
print(f"    1번 쌍을 만족한 k1: 200 / 200")
print(f"    그중 x_P 가 실제 곡선 위인 것: {on_curve} / 200  (약 절반)")
print(f"    -> 한 쌍이 남기는 해는 대략 p/2 = 2^255 개다. 정보가 없는 것과 같다")
 
print("\n[3] 두 쌍을 겹치면")
U1, V1 = S.uv(pairs[1])
R = S.psub(S.pmul(U0, V1), S.pmul(U1, V0))
print(f"    R = U0*V1 - U1*V0 의 근: {len(S.roots_mod_p(R))}개")
python3 one_pair.py

one pair 실험 결과 — 1 이나 2 같은 아무 key1 을 넣어도 1번 쌍은 항상 만족하고 무작위 200개 중 101개는 x 좌표가 실제 곡선 위에 있어, 한 쌍이 남기는 해가 약 2 의 255 제곱개임을 보여 준다
one pair 실험 결과 — 1 이나 2 같은 아무 key1 을 넣어도 1번 쌍은 항상 만족하고 무작위 200개 중 101개는 x 좌표가 실제 곡선 위에 있어, 한 쌍이 남기는 해가 약 2 의 255 제곱개임을 보여 준다

한 쌍은 정보가 없다시피 하다. 두 쌍을 겹치는 순간 후보가 세 개로 줄어든다. 그 차이가 어디서 나오는지가 이 문제의 전부다.


🧱 배경 — 타원 곡선의 덧셈과 두 배점

타원 곡선 위의 점들은 "직선과 세 번 만난다"는 성질로 덧셈이 정의된다. 두 점 P, Q를 지나는 직선이 곡선과 만나는 세 번째 점을 x축 대칭시킨 것이 P + Q다.

위키미디어 커먼즈의 타원 곡선 군 연산 도식 네 장 — 서로 다른 두 점을 지나는 직선, 접선이 되는 두 배점, 수직선인 경우, 그리고 y 가 0 인 점의 두 배점을 각각 보여 준다
위키미디어 커먼즈의 타원 곡선 군 연산 도식 네 장 — 서로 다른 두 점을 지나는 직선, 접선이 되는 두 배점, 수직선인 경우, 그리고 y 가 0 인 점의 두 배점을 각각 보여 준다

출처: Wikimedia Commons, SuperManu, CC BY-SA 3.0. 문제 설명이 링크한 위키백과 Elliptic curve point multiplication 문서에 실린 그림이다.

두 번째 칸이 이번에 쓸 경우다. P와 Q가 같은 점이면 직선이 접선이 되고, 그때 기울기는 곡선 방정식을 음함수 미분해서 얻는다.

곡선   y^2 = x^3 + a*x + b
접선   lambda = (3*x^2 + a) / (2*y)
두 배점 x_2P = lambda^2 - 2*x

여기서 y가 분모에 있으니 y를 모르면 못 쓸 것 같다. 그런데 lambda는 제곱으로만 들어간다.



💣 핵심 — y는 제곱으로만 등장한다

lambda를 제곱하면 분모가 4*y^2가 되고, 곡선 방정식이 그 자리를 그대로 메운다.

lambda^2 = (3x^2+a)^2 / (4*y^2)
         = (3x^2+a)^2 / (4*(x^3+a*x+b))

y가 사라졌다. 남은 건 x뿐이다. 두 배점 x 좌표를 통분해서 정리하면

x_Q = [ (3x^2+a)^2 - 8x*(x^3+a*x+b) ] / [ 4*(x^3+a*x+b) ]
    = ( x^4 - 2a*x^2 - 8b*x + a^2 ) / ( 4*(x^3 + a*x + b) )

분자를 N(x), 분모 괄호 안을 M(x)라 부르기로 한다. 손으로 전개한 걸 믿기보다 sympy로 한 번 펴 보고, 실제 P-256 생성점으로 수치까지 맞춰 봤다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
두 배점의 x 가 x_P 만의 유리함수라는 것을 sympy 로 기호 전개해 확인하고,
NIST P-256 위의 실제 점으로 수치까지 맞춰 본다.
 
    python3 derive.py
"""
 
import sympy as sp
 
x, y, a, b, xq = sp.symbols('x y a b x_Q')
 
# lambda = (3x^2+a)/(2y),  x_{2P} = lambda^2 - 2x,  y^2 = x^3+ax+b
lam2 = (3 * x**2 + a)**2 / (4 * (x**3 + a * x + b))          # y^2 를 대입해 y 를 지운다
num = sp.expand(sp.simplify(lam2 * 4 * (x**3 + a * x + b) - 8 * x * (x**3 + a * x + b)))
 
print("[1] lambda^2 의 분자에서 8x*(x^3+ax+b) 를 뺀 것")
print("    ", sp.factor_terms(num))
 
target = x**4 - 2 * a * x**2 - 8 * b * x + a**2
print("[2] 예상한 N(x) = x^4 - 2a x^2 - 8b x + a^2")
print("    차이 =", sp.expand(num - target), " (0 이면 일치)")
 
rel = sp.expand(xq * 4 * (x**3 + a * x + b) - target)
print("[3] 최종 관계식  x_Q*4*(x^3+ax+b) - N(x) = 0")
print("    x 에 대한 차수 =", sp.Poly(rel, x).degree(), ", x_Q 에 대한 차수 =", sp.Poly(rel, xq).degree())
 
# ---- 진짜 P-256 점으로 수치 확인 -------------------------------------------
P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
CA = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
CB = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
# P-256 의 표준 생성점 G (SEC 2)
GX = 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296
GY = 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5
 
 
def dbl(px, py):
    lam = (3 * px * px + CA) * pow(2 * py, P - 2, P) % P
    rx = (lam * lam - 2 * px) % P
    return rx, (lam * (px - rx) - py) % P
 
 
gx2, _ = dbl(GX, GY)
rhs = (pow(GX, 4, P) - 2 * CA * GX * GX - 8 * CB * GX + CA * CA) % P
lhs = gx2 * 4 * (pow(GX, 3, P) + CA * GX + CB) % P
print("[4] 생성점 G 로 수치 확인")
print("    2G.x            =", hex(gx2))
print("    N(G.x)/(4M(G.x)) =", hex(rhs * pow(4 * (pow(GX, 3, P) + CA * GX + CB) % P, P - 2, P) % P))
print("    좌변 == 우변 ?", lhs == rhs)
python3 derive.py

sympy 로 두 배점 분자를 전개한 결과 — 예상한 4차식과의 차이가 0 으로 나오고, x 에 대한 차수 4 와 x Q 에 대한 차수 1 이 확인되며, P-256 생성점 G 의 두 배점 x 좌표가 유리함수 값과 정확히 같다
sympy 로 두 배점 분자를 전개한 결과 — 예상한 4차식과의 차이가 0 으로 나오고, x 에 대한 차수 4 와 x Q 에 대한 차수 1 이 확인되며, P-256 생성점 G 의 두 배점 x 좌표가 유리함수 값과 정확히 같다

x_Q에 대한 차수가 1이라는 게 마지막 줄에서 두 번째로 중요한 정보다. 관계식을 곱한 꼴로 쓰면 이렇게 된다.

x_Q * 4*M(x_P)  -  N(x_P)  =  0

x_Q는 딱 한 번 등장한다. x_Q = C*key2 + D니까 key2도 1차로만 들어간다는 뜻이다.

key2는 이항 한 번으로 사라진다

x_P = A*k + B를 대입해 N과 M을 k에 대한 다항식으로 펴 놓고, key2를 왼쪽으로 몰면

U(k) = N(A*k + B) - 4*D*M(A*k + B)     (4차)
V(k) = 4*C*M(A*k + B)                  (3차)
key2 = U(k) / V(k)

회차마다 이 U, V 한 쌍이 생긴다. key2는 모든 회차에서 같은 값이므로, 서로 다른 두 회차 i, j의 표현을 등호로 묶고 분모를 걷어내면

R(k) = U_i(k)*V_j(k) - U_j(k)*V_i(k) = 0     (4+3 = 7차)

미지수가 key1 하나만 남은 7차 다항식이다. 실제로 만들어 차수를 재 봤다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
소거가 실제로 어떤 모양인지 눈으로 본다.
key2 가 1차라서 U/V 로 떨어지고, 두 쌍을 교차하면 7차 한 개만 남는다.
 
    python3 build_polys.py
"""
 
import solve as S
 
pairs = S.parse('extracted/output.txt')
print(f"쌍 {len(pairs)}개, 미지수는 key1, key2 둘")
 
U0, V0 = S.uv(pairs[0])
U1, V1 = S.uv(pairs[1])
print(f"\n1번 쌍:  U0 차수 {len(U0)-1}  V0 차수 {len(V0)-1}   ->  key2 = U0(k)/V0(k)")
print(f"2번 쌍:  U1 차수 {len(U1)-1}  V1 차수 {len(V1)-1}   ->  key2 = U1(k)/V1(k)")
 
R = S.psub(S.pmul(U0, V1), S.pmul(U1, V0))
print(f"\nR = U0*V1 - U1*V0   차수 {len(R)-1}  (4+3)")
print(f"  최고차 계수 {R[-1]:#x}")
print(f"  상수항     {R[0]:#x}")
print(f"  0 다항식인가 ? {R == []}")
 
# 세 번째 쌍으로 만든 R' 이 같은 근을 공유하는지 (독립 확인)
U2, V2 = S.uv(pairs[2])
R2 = S.psub(S.pmul(U0, V2), S.pmul(U2, V0))
r1 = set(S.roots_mod_p(R))
r2 = set(S.roots_mod_p(R2))
print(f"\n1-2 쌍의 근 {len(r1)}개, 1-3 쌍의 근 {len(r2)}개, 공통 {len(r1 & r2)}개")
for k in sorted(r1 & r2):
    print(f"  공통근 {k:#066x}")
python3 build_polys.py

build polys 실행 결과 — U 는 4차 V 는 3차이고 교차 곱셈으로 만든 R 이 7차이며 영다항식이 아니고, 1-2 쌍의 근 세 개와 1-3 쌍의 근 세 개 중 공통인 것이 정확히 하나뿐이라는 출력
build polys 실행 결과 — U 는 4차 V 는 3차이고 교차 곱셈으로 만든 R 이 7차이며 영다항식이 아니고, 1-2 쌍의 근 세 개와 1-3 쌍의 근 세 개 중 공통인 것이 정확히 하나뿐이라는 출력

마지막 줄이 눈에 띈다. 1-2 쌍으로 만든 근 세 개와 1-3 쌍으로 만든 근 세 개 중 겹치는 것은 정확히 하나다. 세 번째 회차만 더 봐도 답이 잡힌다는 뜻이다.

여기까지를 그림 하나로 정리하면 이렇다.

소거 파이프라인 도식 — y 소거, 1차식 대입, key2 소거, GF p 근 찾기, 여덟 쌍 필터, XOR 여섯 단계를 두 줄로 배치하고 아래에 N 과 M 그리고 U 와 V 의 정의를 적어 둔 흐름도
소거 파이프라인 도식 — y 소거, 1차식 대입, key2 소거, GF p 근 찾기, 여덟 쌍 필터, XOR 여섯 단계를 두 줄로 배치하고 아래에 N 과 M 그리고 U 와 V 의 정의를 적어 둔 흐름도



🚀 Full Exploit

solve.py 전문이다. 파이썬 표준 라이브러리만 쓴다 — sage도 sympy도 gmpy2도 없이 돈다.

7차 다항식의 GF(p) 근을 구하는 부분이 조금 길다. gcd(x^p - x, R)로 1차 인수들만 남긴 뒤 Cantor–Zassenhaus로 쪼개는 표준 절차인데, 어차피 그 자리에 라이브러리를 쓸 거면 Sage를 부르는 게 나으니 이왕이면 직접 짰다. 250줄 남짓이고 실행은 0.05초다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DreamHack / Elliptic Curve Basic (Gold 2, crypto) — 자체완결 풀이
 
무엇을 푸는가
--------------
chal.sage 는 NIST P-256 위에서 랜덤 점 P 와 그 두 배점 Q = 2P 를 10쌍 만들고,
두 점의 x 좌표를 각각 key1 / key2 에 대한 1차식으로만 흘린다.
 
    P.x = A*key1 + B      (A, B 공개)
    Q.x = C*key2 + D      (C, D 공개)
 
flag 는 key1 XOR key2 다. 점 자체는 하나도 공개되지 않지만, "Q 는 P 의 두 배점" 이라는
사실이 x 좌표 둘을 하나의 대수 관계로 묶는다.
 
    y^2 = x^3 + a*x + b  이므로  lambda = (3x^2+a)/(2y),  x_Q = lambda^2 - 2*x_P
    => x_Q = ( x_P^4 - 2a*x_P^2 - 8b*x_P + a^2 ) / ( 4*(x_P^3 + a*x_P + b) )
 
y 는 제곱으로만 등장해서 사라진다. 즉 x_Q 는 x_P 만의 유리함수다.
여기에 위 1차식을 대입하면 미지수가 key1, key2 둘뿐인 방정식이 한 쌍마다 하나 나온다.
게다가 key2 에 대해 1차라서 곧바로 소거된다.
 
    U_i(k) = N(A_i*k + B_i) - 4*D_i*M(A_i*k + B_i)      (4차)
    V_i(k) = 4*C_i*M(A_i*k + B_i)                        (3차)
    key2 = U_i(key1) / V_i(key1)
 
서로 다른 두 쌍 i, j 의 key2 표현이 같아야 하므로
 
    R(k) = U_i(k)*V_j(k) - U_j(k)*V_i(k) = 0            (7차)
 
7차 다항식의 GF(p) 근을 전부 구해 나머지 8쌍으로 걸러내면 key1 이 유일하게 남는다.
 
의존성
-------
파이썬 표준 라이브러리만 쓴다. sage / sympy / gmpy2 불필요.
GF(p)[x] 위의 근 찾기(gcd(x^p - x, R) + Cantor-Zassenhaus)도 직접 구현했다.
 
사용법
-------
    python3 solve.py [output.txt]
"""
 
import re
import random
import sys
 
# ---------------------------------------------------------------- 곡선 파라미터
# NIST P-256. chal.sage 의 루프가 같은 이름의 지역변수 a, b 를 덮어쓰지만
# 곡선은 그 전에 이미 만들어졌으므로 여기서 쓰는 값은 언제나 아래 상수다.
P  = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
CA = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
CB = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
 
 
# ------------------------------------------------------- GF(p)[x] 다항식 유틸
# 계수 리스트는 리틀엔디언이다. [c0, c1, c2] == c0 + c1*x + c2*x^2
def trim(f):
    while f and f[-1] == 0:
        f.pop()
    return f
 
 
def padd(f, g):
    n = max(len(f), len(g))
    return trim([((f[i] if i < len(f) else 0) + (g[i] if i < len(g) else 0)) % P
                 for i in range(n)])
 
 
def psub(f, g):
    n = max(len(f), len(g))
    return trim([((f[i] if i < len(f) else 0) - (g[i] if i < len(g) else 0)) % P
                 for i in range(n)])
 
 
def pmul(f, g):
    if not f or not g:
        return []
    r = [0] * (len(f) + len(g) - 1)
    for i, fi in enumerate(f):
        if fi:
            for j, gj in enumerate(g):
                r[i + j] = (r[i + j] + fi * gj) % P
    return trim(r)
 
 
def pscale(f, c):
    c %= P
    return trim([(x * c) % P for x in f])
 
 
def pdivmod(f, g):
    """f = q*g + r"""
    f = f[:]
    if len(f) < len(g):
        return [], f
    inv_lead = pow(g[-1], P - 2, P)
    q = [0] * (len(f) - len(g) + 1)
    while len(f) >= len(g) and f:
        d = len(f) - len(g)
        c = (f[-1] * inv_lead) % P
        q[d] = c
        for i, gi in enumerate(g):
            f[i + d] = (f[i + d] - c * gi) % P
        trim(f)
    return trim(q), f
 
 
def pmod(f, g):
    return pdivmod(f, g)[1]
 
 
def pgcd(f, g):
    f, g = f[:], g[:]
    while g:
        f, g = g, pmod(f, g)
    if f:
        f = pscale(f, pow(f[-1], P - 2, P))   # monic
    return f
 
 
def ppow_mod(base, e, mod):
    """base^e mod (mod) in GF(p)[x]"""
    r = [1]
    base = pmod(base, mod)
    while e:
        if e & 1:
            r = pmod(pmul(r, base), mod)
        base = pmod(pmul(base, base), mod)
        e >>= 1
    return r
 
 
def peval(f, x):
    acc = 0
    for c in reversed(f):
        acc = (acc * x + c) % P
    return acc
 
 
def pcompose_linear(f, A, B):
    """f(A*x + B) — Horner 로 전개한다"""
    inner = [B % P, A % P]
    acc = []
    for c in reversed(f):
        acc = padd(pmul(acc, inner), [c % P])
    return trim(acc)
 
 
# --------------------------------------------------- GF(p) 위의 근 전부 찾기
def _cz_split(g, rng):
    """g 는 서로 다른 1차 인수들의 곱(square-free, 전부 GF(p)에 근이 있음)"""
    if len(g) <= 1:
        return []
    if len(g) == 2:                       # c1*x + c0
        return [(-g[0] * pow(g[1], P - 2, P)) % P]
    while True:
        delta = rng.randrange(P)
        w = ppow_mod([delta, 1], (P - 1) // 2, g)      # (x+delta)^((p-1)/2) mod g
        h = pgcd(psub(w, [1]), g)
        if 0 < len(h) - 1 < len(g) - 1:
            q, _ = pdivmod(g, h)
            return _cz_split(h, rng) + _cz_split(q, rng)
 
 
def roots_mod_p(f, seed=0xC0FFEE):
    """f 의 GF(p) 근(중복 제거)을 전부 돌려준다"""
    f = trim(f[:])
    if not f:
        raise ValueError("영다항식")
    f = pscale(f, pow(f[-1], P - 2, P))
    xp = ppow_mod([0, 1], P, f)                # x^p mod f
    g = pgcd(psub(xp, [0, 1]), f)              # gcd(x^p - x, f) = 모든 1차 인수의 곱
    return sorted(_cz_split(g, random.Random(seed)))
 
 
# ------------------------------------------------------------------- 문제 파싱
LINE = re.compile(r'^([PQ])\.x = (\d+) \* key([12]) \+ (\d+)$')
 
 
def parse(path):
    pairs, cur = [], {}
    for line in open(path, encoding='utf-8'):
        m = LINE.match(line.strip())
        if not m:
            continue
        which, coef, keyno, const = m.group(1), int(m.group(2)), m.group(3), int(m.group(4))
        assert (which, keyno) in (('P', '1'), ('Q', '2')), "P는 key1, Q는 key2 여야 한다"
        cur[which] = (coef % P, const % P)
        if 'P' in cur and 'Q' in cur:
            pairs.append((cur['P'], cur['Q']))
            cur = {}
    return pairs
 
 
# --------------------------------------------------------------------- 풀이
# x_Q = N(x_P) / (4*M(x_P))
N_POLY = [CA * CA % P, (-8 * CB) % P, (-2 * CA) % P, 0, 1]   # x^4 - 2a x^2 - 8b x + a^2
M_POLY = [CB % P, CA % P, 0, 1]                              # x^3 + a x + b
 
 
def uv(pair):
    """한 쌍에서 U(k), V(k) 를 만든다.  key2 = U(key1) / V(key1)"""
    (A, B), (C, D) = pair
    Nc = pcompose_linear(N_POLY, A, B)          # 4차
    Mc = pcompose_linear(M_POLY, A, B)          # 3차
    U = psub(Nc, pscale(Mc, 4 * D % P))
    V = pscale(Mc, 4 * C % P)
    return U, V
 
 
def is_on_curve_x(x):
    """x 가 곡선 위 점의 x 좌표인가 (오일러 판정)"""
    rhs = (pow(x, 3, P) + CA * x + CB) % P
    return rhs == 0 or pow(rhs, (P - 1) // 2, P) == 1
 
 
def solve(pairs):
    U0, V0 = uv(pairs[0])
    U1, V1 = uv(pairs[1])
    R = psub(pmul(U0, V1), pmul(U1, V0))
    print(f"[*] 소거 다항식 R(k1) 차수 = {len(R) - 1}")
 
    cands = roots_mod_p(R)
    print(f"[*] GF(p) 근 {len(cands)}개")
 
    good = []
    for k1 in cands:
        v0 = peval(V0, k1)
        if v0 == 0:
            continue
        k2 = peval(U0, k1) * pow(v0, P - 2, P) % P
        ok = True
        for (A, B), (C, D) in pairs:                    # 10쌍 전부로 검증
            xp = (A * k1 + B) % P
            xq = (C * k2 + D) % P
            m = (pow(xp, 3, P) + CA * xp + CB) % P
            if m == 0 or not is_on_curve_x(xp):
                ok = False
                break
            if (peval(N_POLY, xp) - 4 * m % P * xq) % P != 0:
                ok = False
                break
        if ok:
            good.append((k1, k2))
    return good
 
 
def main():
    path = sys.argv[1] if len(sys.argv) > 1 else 'extracted/output.txt'
    pairs = parse(path)
    print(f"[*] {path}: (P.x, Q.x) 쌍 {len(pairs)}개")
    good = solve(pairs)
    if not good:
        print("[!] 후보 없음")
        return 1
    for k1, k2 in good:
        key = k1 ^ k2
        print(f"[+] key1 = {k1:#066x}")
        print(f"[+] key2 = {k2:#066x}")
        print(f"[+] FLAG = DH{{{key:064x}}}")
    return 0
 
 
if __name__ == '__main__':
    sys.exit(main())
python3 solve.py extracted/output.txt

solve.py 실행 화면 — 쌍 10개를 파싱해 7차 소거 다항식을 세우고 GF p 근 세 개를 구한 뒤 검증을 통과한 key1 과 key2 한 쌍과 최종 플래그를 출력한다
solve.py 실행 화면 — 쌍 10개를 파싱해 7차 소거 다항식을 세우고 GF p 근 세 개를 구한 뒤 검증을 통과한 key1 과 key2 한 쌍과 최종 플래그를 출력한다

플래그가 나왔다.

DH{805f461baa9ee08b66191f9309e976aedf28cff9d130ce19941ff6b3a19ad634}

눈여겨볼 줄은 GF(p) 근 3개다. 7차 다항식이니 근이 최대 일곱 개인데 실제로는 셋이고, 그중 검증을 통과한 것은 하나뿐이다. 이 걸러내는 단계가 없으면 어떻게 되는지가 다음 절이다.



🕳️ 오독 네 가지를 실제로 돌려 봤다

"이렇게 읽으면 틀린다"를 글로만 적으면 확인이 안 된다. 네 가지 오독을 각각 코드로 만들어 같은 output.txt에 돌렸다. solve.py의 다항식 유틸을 그대로 가져다 쓰므로 차이는 관계식을 어떻게 세웠는가뿐이다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Elliptic Curve Basic — 오독 4종을 실제로 돌려 본다.
 
"이렇게 읽으면 틀린다" 를 말로만 적으면 확인이 안 된다. 네 가지 오독을 각각 코드로 구현해
같은 output.txt 에 돌리고, 무엇이 나오는지 숫자로 남긴다. solve.py 의 다항식 유틸을 그대로
가져다 쓰므로 차이는 "관계식을 어떻게 세웠는가" 뿐이다.
 
    python3 traps.py
"""
 
import solve as S
 
TRUE_FLAG = "DH{805f461baa9ee08b66191f9309e976aedf28cff9d130ce19941ff6b3a19ad634}"
P, CA, CB = S.P, S.CA, S.CB
pairs = S.parse('extracted/output.txt')
 
 
def flag_of(k1, k2):
    return f"DH{{{(k1 ^ k2):064x}}}"
 
 
def survivors(U0, V0, relation):
    """R 의 근마다 key2 를 뽑고, 10쌍 전부를 만족하는지 센다"""
    U1, V1 = relation(pairs[1])
    R = S.psub(S.pmul(U0, V1), S.pmul(U1, V0))
    if not R:
        return None, []
    out = []
    for k1 in S.roots_mod_p(R):
        v0 = S.peval(V0, k1)
        if v0 == 0:
            continue
        k2 = S.peval(U0, k1) * pow(v0, P - 2, P) % P
        hit = 0
        for (A, B), (C, D) in pairs:
            xp = (A * k1 + B) % P
            xq = (C * k2 + D) % P
            m = (pow(xp, 3, P) + CA * xp + CB) % P
            if (S.peval(S.N_POLY, xp) - 4 * m * xq) % P == 0:
                hit += 1
        out.append((k1, k2, hit))
    return len(R) - 1, out
 
 
# ---------------------------------------------------------------- 함정 1
def trap1():
    """두 쌍만 쓰고 나머지 8쌍으로 거르지 않는다"""
    print("[함정1] 두 쌍만 세우고 검증을 생략하면")
    deg, out = survivors(*S.uv(pairs[0]), S.uv)
    print(f"        R 차수 {deg}, 근 {len(out)}개 — 셋 다 64자리 hex flag 가 나온다")
    for k1, k2, hit in out:
        mark = "  <= 정답" if flag_of(k1, k2) == TRUE_FLAG else ""
        print(f"        {flag_of(k1, k2)}  (10쌍 중 {hit}쌍 만족){mark}")
    print("        -> 눈으로는 못 고른다. 남은 8쌍이 유일한 판별기다\n")
 
 
# ---------------------------------------------------------------- 함정 2
def trap2():
    """(2y)^2 = 4(x^3+ax+b) 의 4 를 빠뜨린다"""
    print("[함정2] lambda^2 의 분모에서 4 를 빠뜨리면  (x_Q*(x^3+ax+b) = N(x))")
 
    def uv_no4(pair):
        (A, B), (C, D) = pair
        Nc = S.pcompose_linear(S.N_POLY, A, B)
        Mc = S.pcompose_linear(S.M_POLY, A, B)
        return S.psub(Nc, S.pscale(Mc, D)), S.pscale(Mc, C)
 
    deg, out = survivors(*uv_no4(pairs[0]), uv_no4)
    good = [o for o in out if o[2] == 10]
    print(f"        R 차수 {deg}, 근 {len(out)}개, 10쌍 전부 만족 {len(good)}개")
    print(f"        -> 후보가 {'전멸' if not good else '남는다'}. 4 는 장식이 아니다\n")
 
 
# ---------------------------------------------------------------- 함정 3
def trap3():
    """출력 줄의 상수 이름이 곡선 계수와 겹친다 (a, b)"""
    print("[함정3] 출력 줄의 b, d 를 곡선 계수로 착각하면 (chal.sage 가 루프에서 a, b 를 덮어쓴다)")
    lastA, lastB = pairs[-1][0]
    lastC, lastD = pairs[-1][1]
    fakeA, fakeB = lastA, lastB      # 마지막 반복이 남긴 a, b
    Nf = [fakeA * fakeA % P, (-8 * fakeB) % P, (-2 * fakeA) % P, 0, 1]
    Mf = [fakeB % P, fakeA % P, 0, 1]
 
    def uv_fake(pair):
        (A, B), (C, D) = pair
        Nc = S.pcompose_linear(Nf, A, B)
        Mc = S.pcompose_linear(Mf, A, B)
        return S.psub(Nc, S.pscale(Mc, 4 * D % P)), S.pscale(Mc, 4 * C % P)
 
    deg, out = survivors(*uv_fake(pairs[0]), uv_fake)
    good = [o for o in out if o[2] == 10]
    print(f"        R 차수 {deg}, 근 {len(out)}개, 10쌍 전부 만족 {len(good)}개")
    print(f"        -> {len(good)}개. 곡선은 루프 전에 이미 만들어졌으므로 계수는 NIST 값 그대로다\n")
 
 
# ---------------------------------------------------------------- 함정 4
def trap4():
    """P 와 Q 를 맞바꿔 P = 2Q 로 세운다"""
    print("[함정4] 배점 방향을 뒤집어 P = 2Q 로 세우면")
 
    def uv_rev(pair):
        (A, B), (C, D) = pair
        Nc = S.pcompose_linear(S.N_POLY, C, D)     # 이번엔 x_Q 가 안쪽
        Mc = S.pcompose_linear(S.M_POLY, C, D)
        return S.psub(Nc, S.pscale(Mc, 4 * B % P)), S.pscale(Mc, 4 * A % P)
 
    U0, V0 = uv_rev(pairs[0])
    U1, V1 = uv_rev(pairs[1])
    R = S.psub(S.pmul(U0, V1), S.pmul(U1, V0))
    cnt = 0
    for k2 in S.roots_mod_p(R):
        v0 = S.peval(V0, k2)
        if v0 == 0:
            continue
        k1 = S.peval(U0, k2) * pow(v0, P - 2, P) % P
        hit = 0
        for (A, B), (C, D) in pairs:
            xq = (C * k2 + D) % P
            xp = (A * k1 + B) % P
            m = (pow(xq, 3, P) + CA * xq + CB) % P
            if (S.peval(S.N_POLY, xq) - 4 * m * xp) % P == 0:
                hit += 1
        if hit == 10:
            cnt += 1
    print(f"        R 차수 {len(R) - 1}, 10쌍 전부 만족 {cnt}개")
    print(f"        -> {cnt}개. 두 배점 관계는 방향이 있다\n")
 
 
if __name__ == '__main__':
    print(f"정답 flag = {TRUE_FLAG}\n")
    trap1(); trap2(); trap3(); trap4()
python3 traps.py

traps.py 실행 결과 — 두 쌍만 쓰면 64자리 플래그 후보 세 개가 모두 그럴듯하게 나오고 정답만 10쌍을 만족하며, 4 를 빠뜨리거나 곡선 계수를 착각하거나 배점 방향을 뒤집으면 후보가 전부 사라진다
traps.py 실행 결과 — 두 쌍만 쓰면 64자리 플래그 후보 세 개가 모두 그럴듯하게 나오고 정답만 10쌍을 만족하며, 4 를 빠뜨리거나 곡선 계수를 착각하거나 배점 방향을 뒤집으면 후보가 전부 사라진다

넷 중 위험한 것은 첫 번째뿐이다.

오독결과알아챌 수 있나
두 쌍만 쓰고 검증 생략플래그 후보 3개못 알아챈다 — 셋 다 64자리 16진수다
lambda^2 분모의 4 누락근 0개즉시
출력 줄의 계수를 곡선 계수로생존 0개즉시
P = 2Q로 방향 반전생존 0개즉시

두 번째부터 네 번째는 후보가 전멸하니 뭔가 잘못됐다는 신호가 바로 온다. 첫 번째는 다르다. 후보 셋이 전부 DH{...} 꼴 64자리 16진수라 겉으로는 구분이 안 되고, 심지어 오답 하나는 x_P가 실제로 곡선 위에 있기까지 하다. 제출 기회를 낭비하지 않으려면 남은 여덟 쌍으로 반드시 걸러야 한다.


✅ 검증 — 다른 길로 같은 답이 나오는가

플래그가 맞았다고 끝낼 일이 아니다. 내가 세운 관계식이 맞았는지, 다항식 유틸에 버그가 없는지는 같은 코드로는 확인이 안 된다. 그래서 완전히 다른 경로로 두 번 더 확인했다.

▶🐛 삽질 — Sage의 resultant가 두 번 거절했다

원래는 key2 소거를 Sage의 resultant로 하려 했다. 종결식이라는 게 딱 이 용도다. 그런데 두 번 연속으로 막혔다.

# 처음엔 Sage 의 resultant 로 key2 를 소거하려 했다. 두 번 거절당했다.
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
 
print("[1] 처음 쓴 대로 Zmod(p) 위에서")
R.<k1, k2> = PolynomialRing(Zmod(p), 2)
try:
    (k1*k2 + 1).resultant(k1 + k2, k2)
except Exception as e:
    print("    NotImplementedError:", e)
 
print("[2] 체로 바꿔서 GF(p) 위에서")
R.<k1, k2> = PolynomialRing(GF(p), 2)
try:
    (k1*k2 + 1).resultant(k1 + k2, k2)
except Exception as e:
    print("    NotImplementedError:", e)
print(f"    참고: p 는 {p.bit_length()}비트, 한계선 2^29 =", 2^29)
 
print("[3] 그뢰브너 기저는 같은 환에서 잘 돈다")
gb = Ideal(k1*k2 + 1, k1 + k2).groebner_basis()
print("    gb =", gb)
sage resultant_fail.sage

SageMath 에서 resultant 를 부른 결과 — Zmod 위에서는 base 가 체여야 한다는 오류가 나고 GF 로 바꾸면 표수가 2 의 29 제곱을 넘으면 미구현이라는 오류가 나며 그뢰브너 기저는 같은 환에서 정상 동작한다
SageMath 에서 resultant 를 부른 결과 — Zmod 위에서는 base 가 체여야 한다는 오류가 나고 GF 로 바꾸면 표수가 2 의 29 제곱을 넘으면 미구현이라는 오류가 나며 그뢰브너 기저는 같은 환에서 정상 동작한다

첫 번째는 Zmod(p)가 체로 인식되지 않아서다. chal.sage가 Zmod를 쓰길래 따라 썼는데, Singular 쪽은 GF(p)를 원한다.

바꿨더니 두 번째 벽이 나왔다. 표수가 2^29를 넘는 소수체에서는 다변수 종결식이 구현돼 있지 않다. P-256의 p는 256비트니 한참 위다.

같은 환에서 그뢰브너 기저(groebner_basis)는 멀쩡히 돈다. lex 순서로 k2를 먼저 지우면 소거 이상(elimination ideal)의 생성원이 나오는데, 그게 곧 종결식과 같은 역할을 한다. 그래서 검증 스크립트는 이쪽으로 갔다.

첫 번째 검증은 Sage의 그뢰브너 기저로 key2를 독립적으로 소거하는 것이다. 두 번째는 그보다 강하다 — 복원한 키로 x_P를 되살려 Sage의 lift_x로 진짜 P-256 위의 점을 만들고, Sage 자신의 군 연산으로 두 배를 해서 x_Q와 같은지 본다. 이쪽은 내가 유도한 식을 하나도 안 쓴다.

# solve.py 와 완전히 다른 경로로 같은 답이 나오는지 본다.
#  (1) Sage 의 그뢰브너 기저(lex, k2 > k1)로 key2 를 소거해 key1 을 독립 계산
#      - Singular 은 characteristic > 2^29 에서 resultant 를 거부하지만 std 는 된다
#  (2) 복원한 key 로 x 좌표를 되살려, 진짜 P256 위의 점 P 를 세우고 2*P 의 x 가 맞는지 확인
#      - 이쪽은 내 대수 유도를 하나도 안 쓴다. Sage 자신의 군 연산으로만 판정한다
import re, sys
 
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
Fp = GF(p)
P256 = EllipticCurve(Fp, [a, b])
print(f"[sage] {P256}")
print(f"[sage] #E = {P256.order()}")
 
path = sys.argv[1] if len(sys.argv) > 1 else 'extracted/output.txt'
pat = re.compile(r'^([PQ])\.x = (\d+) \* key([12]) \+ (\d+)$')
pairs, cur = [], {}
for line in open(path):
    m = pat.match(line.strip())
    if not m:
        continue
    cur[m.group(1)] = (Integer(m.group(2)), Integer(m.group(4)))
    if 'P' in cur and 'Q' in cur:
        pairs.append((cur['P'], cur['Q'])); cur = {}
print(f"[sage] {path}: 쌍 {len(pairs)}개 파싱")
 
R.<k2, k1> = PolynomialRing(Fp, 2, order='lex')       # lex 로 k2 를 먼저 없앤다
 
def rel(pair):
    (A, B), (C, D) = pair
    xP = A*k1 + B
    xQ = C*k2 + D
    #  x_Q * 4*(x^3 + a x + b)  ==  x^4 - 2a x^2 - 8b x + a^2
    return xQ*4*(xP^3 + a*xP + b) - (xP^4 - 2*a*xP^2 - 8*b*xP + a^2)
 
gb = Ideal(rel(pairs[0]), rel(pairs[1])).groebner_basis()
elim = [g for g in gb if k2 not in g.variables()]
print(f"[sage] 그뢰브너 기저 원소 {len(gb)}개, k2 가 사라진 것 {len(elim)}개")
poly = elim[0].univariate_polynomial()
print(f"[sage] 소거 다항식 차수 = {poly.degree()}")
cands = [Integer(r) for r, _ in poly.roots()]
print(f"[sage] GF(p) 근 {len(cands)}개")
 
found = 0
for c in cands:
    g = rel(pairs[0]).subs(k1=Fp(c)).univariate_polynomial()
    for r2, _ in g.roots():
        c2 = Integer(r2)
        if not all(rel(pr).subs(k1=Fp(c), k2=Fp(c2)) == 0 for pr in pairs):
            continue
        print(f"[sage] key1 = {c:#066x}")
        print(f"[sage] key2 = {c2:#066x}")
        for i, ((A, B), (C, D)) in enumerate(pairs):     # 여기부터는 순수 군 연산
            xP = Fp(A)*Fp(c) + Fp(B)
            xQ = Fp(C)*Fp(c2) + Fp(D)
            pt = P256.lift_x(xP)                          # 진짜 곡선 위로 올린다
            assert (2*pt).xy()[0] == xQ, f"쌍 {i}: 2P 의 x 가 Q.x 와 다르다"
        print(f"[sage] 10쌍 전부 lift_x(P.x) 의 2배점 x 가 Q.x 와 일치")
        print(f"[sage] FLAG = DH{{{(int(c) ^^ int(c2)):064x}}}")
        found += 1
print(f"[sage] 조건을 만족하는 (key1,key2) = {found}쌍")
sage verify_sage.sage extracted/output.txt

SageMath 교차검증 실행 화면 — 곡선 정의와 점 개수를 찍은 뒤 그뢰브너 기저로 k2 를 소거해 7차식과 근 세 개를 얻고, 복원한 키로 lift x 한 점의 두 배점 x 좌표가 열 쌍 모두 일치하며 같은 플래그가 나온다
SageMath 교차검증 실행 화면 — 곡선 정의와 점 개수를 찍은 뒤 그뢰브너 기저로 k2 를 소거해 7차식과 근 세 개를 얻고, 복원한 키로 lift x 한 점의 두 배점 x 좌표가 열 쌍 모두 일치하며 같은 플래그가 나온다

key1, key2, 플래그가 solve.py와 한 글자도 다르지 않다. 소거 다항식 차수도 7로 같고 근도 셋으로 같다. 그리고 마지막에서 세 번째 줄이 핵심이다 — 열 쌍 전부에서 lift_x(P.x)를 두 배 한 점의 x가 Q.x와 같다.

문제 자체를 오라클로

두 번째 검증은 방향이 다르다. chal.sage와 같은 절차로 정답을 아는 새 인스턴스를 만들어서, solve.py가 그 키를 되찾는지 본다. 배포본과 다른 곳은 세 군데뿐이다 — 난수 시드 고정, 파일로 출력, 키를 같이 기록.

# chal.sage 를 그대로 두고 "정답을 아는 새 인스턴스"만 더 찍어내는 오라클.
# 배포본과 다른 곳은 세 군데뿐이다: set_random_seed / 파일로 출력 / key 를 같이 기록.
import sys
 
seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1337
set_random_seed(seed)
 
# NIST P-256 Parameters
p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff
a = 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc
b = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b
 
Zp = Zmod(p)
P256 = EllipticCurve(Zp, [a, b])
 
key1 = Zp.random_element()
key2 = Zp.random_element()
 
lines = []
for _ in range(10):
    P = P256.random_point()
    Q = P + P
 
    b = Zp.random_element()
    a = (P.xy()[0] - b) / key1
 
    d = Zp.random_element()
    c = (Q.xy()[0] - d) / key2
 
    lines.append(f"P.x = {a} * key1 + {b}")
    lines.append(f"Q.x = {c} * key2 + {d}")
 
open(f'oracle_output_{seed}.txt', 'w').write("\n".join(lines) + "\n")
key = int(key1) ^^ int(key2)
open(f'oracle_key_{seed}.txt', 'w').write(
    f"key1 = {int(key1):#066x}\nkey2 = {int(key2):#066x}\nFlag is DH{{{key:064x}}}\n")
print(f"[oracle] seed={seed} 새 인스턴스 생성 완료 -> oracle_output_{seed}.txt")
#!/usr/bin/env bash
# 문제 자체를 오라클로 쓴다. chal.sage 와 같은 절차로 "정답을 아는" 새 인스턴스를 만들고,
# solve.py 가 그 key1/key2 를 그대로 되찾는지 대조한다.
set -eu
cd "$(dirname "$(readlink -f "$0")")"
SEED="${1:-1337}"
 
sage oracle.sage "$SEED"
 
echo
echo "== 오라클이 아는 정답 (seed $SEED) =="
cat "oracle_key_${SEED}.txt"
 
echo "== solve.py 가 복원한 값 =="
python3 solve.py "oracle_output_${SEED}.txt"
 
WANT=$(grep -o 'DH{[0-9a-f]*}' "oracle_key_${SEED}.txt")
GOT=$(python3 solve.py "oracle_output_${SEED}.txt" | grep -o 'DH{[0-9a-f]*}')
echo
if [ "$WANT" = "$GOT" ]; then
  echo "✅ 왕복 일치  $GOT"
else
  echo "❌ 불일치  기대 $WANT / 실제 $GOT"; exit 1
fi
./oracle_roundtrip.sh 1337

오라클 왕복 검증 화면 — 시드 1337 로 새 인스턴스를 만들어 오라클이 아는 key1 key2 와 solve.py 가 복원한 값이 완전히 같고 마지막에 왕복 일치 표시가 찍혔다
오라클 왕복 검증 화면 — 시드 1337 로 새 인스턴스를 만들어 오라클이 아는 key1 key2 와 solve.py 가 복원한 값이 완전히 같고 마지막에 왕복 일치 표시가 찍혔다

시드 1337, 2026, 4242 세 번 다 정확히 복원했다. 흥미로운 건 근의 개수가 인스턴스마다 다르다는 것이다 — 시드 1337은 근이 하나, 2026은 둘, 배포본은 셋이었다. 가짜 근이 몇 개 붙는지는 그때그때 다르고, 그래서 검증 단계는 운에 맡길 수 없다.



🔁 재현

백업본만 있으면 바로 플래그가 나오게 reproduce.sh를 남겼다. sage가 깔려 있으면 교차검증도 같이 돈다.

#!/usr/bin/env bash
# Elliptic Curve Basic (DreamHack, Gold 2) — 한 방 재현.
# 배포본만 있으면 파이썬 표준 라이브러리로 flag 까지 나온다. sage 는 교차검증용(있으면 같이 돈다).
set -eu
cd "$(dirname "$(readlink -f "$0")")"
EXPECT=$(python3 -c "import json;print(json.load(open('문제.json'))['flag'])" 2>/dev/null || echo '')
 
OUT=$(timeout 300 python3 solve.py extracted/output.txt 2>&1) || true
echo "$OUT"
 
if command -v sage >/dev/null 2>&1; then
  echo
  echo "-- sage 교차검증 (그뢰브너 소거 + 실제 곡선에서 2P 확인) --"
  timeout 900 sage verify_sage.sage extracted/output.txt 2>&1 | tail -6
fi
 
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
echo
if [ -n "$FLAG" ] && { [ -z "$EXPECT" ] || [ "$FLAG" = "$EXPECT" ]; }; then
  echo "✅ PASS  $FLAG"; exit 0
fi
echo "❌ FAIL (얻은 값: '${FLAG:-없음}' / 기대: '$EXPECT')"; exit 1
./reproduce.sh

reproduce.sh 실행 결과 — 파이썬 풀이가 플래그를 뽑고 이어서 sage 교차검증이 같은 키와 같은 플래그를 내놓은 뒤 마지막 줄에 PASS 와 플래그가 찍혔다
reproduce.sh 실행 결과 — 파이썬 풀이가 플래그를 뽑고 이어서 sage 교차검증이 같은 키와 같은 플래그를 내놓은 뒤 마지막 줄에 PASS 와 플래그가 찍혔다

로컬 환경 — Ubuntu 25.10 / Python 3.13 / SageMath 10.9 (conda-forge, ~/miniforge3/envs/sage) / OpenSSL 3.x / sympy 1.13.3. solve.py는 표준 라이브러리만 쓰므로 sympy와 sage가 없어도 돌고, derive.py에 sympy가, verify_sage.sage와 oracle.sage에 sage가 필요하다.


📝 결론

공개된 값이 선형이어도, 그 값이 만족하는 관계가 비선형이면 다 새어 나간다.

key1과 key2는 각자 다른 줄에 숨어 있고 한 줄만 보면 완벽하게 가려져 있다. 실제로 한 쌍만 놓고 보면 2^255개쯤 되는 해가 남는다. 그런데 Q = 2P라는 한 문장이 두 줄을 묶는 순간 미지수 두 개짜리 방정식이 되고, 열 번 반복되면서 과결정 연립방정식이 된다. 가려는 값을 곱셈이 아니라 덧셈으로 감싼 것도 도움이 안 됐다 — 1차식이라 대입이 그냥 된다.

y 좌표를 안 준 것이 방어가 못 된 이유.

두 배점 공식만 놓고 보면 기울기에 y가 분모로 들어가 있어서, y를 모르면 아무것도 못 할 것 같다. 하지만 x 좌표만 필요할 때는 lambda가 제곱으로만 등장하고 곡선 방정식이 그 제곱을 정확히 채워 준다. x만의 유리함수로 떨어진다는 것은 몽고메리 사다리 같은 x 좌표 전용 스칼라 곱 구현이 서 있는 토대이기도 하다. 잘 알려진 성질이고, 그래서 y를 감추는 것은 비밀 유지 수단이 아니다.

후보가 여러 개 나오는 문제는 "그럴듯함"으로 고르면 안 된다.

이번 배포본에서 7차 다항식의 근은 셋이었고 셋 다 형식이 완벽한 64자리 16진수 플래그를 만들어 냈다. 오답 하나는 x_P가 곡선 위에 있기까지 했다. 이런 상황에서 눈으로 고르는 건 1/3 도박이다. 남은 여덟 쌍이 공짜로 주어져 있으니 전부 대입해서 거르면 된다. 검증에 쓸 데이터가 남아 있는데 안 쓰는 게 손해다.

다른 도구로 한 번 더 확인하는 값어치.

solve.py가 플래그를 뱉은 시점에 이미 답은 맞았다. 그래도 Sage의 그뢰브너 기저로 다시 소거해 보고, 복원한 좌표를 실제 곡선 위 점으로 올려 두 배를 시켜 봤다. 후자가 특히 의미가 있는데, 내가 손으로 편 N(x)와 M(x)를 하나도 쓰지 않고 Sage 자신의 군 연산만으로 판정하기 때문이다. 전개 과정에서 부호 하나를 틀렸다면 여기서 걸렸을 것이다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥇 Gold 3] 42만 줄짜리 XOR 지옥이 사실은 행렬 한 장 — DreamHack XOR Disaster 풀이
blog

[🥇 Gold 3] 42만 줄짜리 XOR 지옥이 사실은 행렬 한 장 — DreamHack XOR Disaster 풀이

15MB짜리 disaster.py 안에 시프트·XOR 연산이 320,000줄 흩뿌려져 있고 함수 32개가 각각 8비트만 돌려준다. 세 연산 모두 자리올림이 없어 GF(2) 위에서 선형이라, 전체가 f(v) = A·v + c 라는 아핀사상 하나로 접힌다. f(0)으로 상수를, f(e_i)로 열벡터를 뽑아 257번 호출만에 256×256 행렬을 복원하고 가우스 소거 한 번으로 끝났다.
#dreamhack#ctf#crypto+7
2026-08-22#dreamhack +4
[🥇 Gold 2] 위수가 p 인 곡선 위의 ECDH — DreamHack Not So Smart 풀이
blog

[🥇 Gold 2] 위수가 p 인 곡선 위의 ECDH — DreamHack Not So Smart 풀이

곡선의 위수가 소수 p 자신과 같으면(트레이스 1) 타원곡선 이산로그가 p진수의 덧셈으로 내려앉는다. SageMath 없이 Q_p 산술을 직접 구현한 Smart's attack 으로 256비트 ECDLP 를 0.8초에 풀고, 거기서 얻은 ECDH 공유비밀로 AES-CBC 를 벗겨 플래그를 꺼냈다.
#dreamhack#ctf#crypto+5
2026-08-20#dreamhack +4
[🥈 Silver 4] 소수를 모듈러로 쓴 제곱 PRNG에서 시드 되찾기 — DreamHack the present 풀이
blog

[🥈 Silver 4] 소수를 모듈러로 쓴 제곱 PRNG에서 시드 되찾기 — DreamHack the present 풀이

플래그를 시드로 넣은 제곱 되먹임 PRNG가 두 번째 출력만 공개한다. 모듈러가 두 소수의 곱이 아니라 512비트 소수 하나라서, 제곱근이 거듭제곱 한 번으로 계산된다. 네제곱근 후보를 전부 구하고 이차잉여 판정으로 가지를 쳐내니 남은 건 두 개뿐이었고 그중 하나가 그대로 플래그였다.
#dreamhack#ctf#crypto+6
2026-08-17#dreamhack +4