3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TSG CTF (babe-sidh)

Last updated at Posted at 2025-12-23

本日は
脆弱エンジニアのAdvent Calendar23日目です。

TSG CTFお疲れ様でした。
A4xn=B1kRAというチームで参戦し、総合17位/国内9位でした。
Cryptoの面倒を見ていたのですが、そこで0solveの問題が出てきました。それを頑張ってその後解いたのでそれを書き記します。

問題概要

  • $F_{p^2}$上の楕円曲線$E0: y^2 = x^3 + x$ $(p = 2^a * 3^b - 1), a = 0xD8, b = 0x89$

  • 奇数$q$が選ばれ、$deg = q(2^a - q)$が、2, 3と互いに素

  • さらに$twist[0..2]$が選ばれる

  • 次数$q(2^a - q) * 3^b$のendomorohismを用いて3つの公開曲線$E_{A_{j}}$と点$(P_{A_{j}}, Q_{A_{j}})$, $(j = 0..2)$が構成される

  • 目標は$q, twist[i]$を復元すること

#!/usr/bin/env sage
# -*- coding: utf-8 -*-

import secrets
from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from random import SystemRandom
import hashlib
import binascii
randgen = SystemRandom()
randint = randgen.randint

def encrypt_aes_ctr(plaintext, key):
    """
    Encrypts data using AES-CTR mode.
    Returns (nonce, ciphertext)
    """
    cipher = AES.new(key, AES.MODE_CTR)
    ciphertext = cipher.encrypt(plaintext)
    return cipher.nonce, ciphertext

def decrypt_aes_ctr(nonce, ciphertext, key):
    """
    Decrypts data using AES-CTR mode with the correct nonce.
    Returns the original plaintext.
    """
    cipher = AES.new(key, AES.MODE_CTR, nonce=nonce)
    
    plaintext = cipher.decrypt(ciphertext)
    return plaintext

def cornacchia(q, M_prime):
    QF = gp.Qfb(1,0,q)
    try:
        xy = QF.qfbsolve(M_prime)
        x,y = list(map(lambda x:ZZ(x),xy))
    except ValueError:
        return None
    assert x**2+q*y**2 == M_prime
    return (abs(x),abs(y))

def fullrepresentinteger(M,p):
    QF = gp.Qfb(1, 0, 1)
    while True:
        mp = isqrt(M/p)//4
        zp = ZZ(randgen.randint(0,mp))
        tp = ZZ(randgen.randint(0,mp))
        Mp = M-p*(zp^2+tp^2)
        if Mp <= 0:
            continue
        if not Mp.is_prime() or Mp % 4 != 1:
            continue
        try:
            xy = QF.qfbsolve(Mp)
        except ValueError:
            print("there is no sols")
            continue
        xp,yp = list(map(lambda x:ZZ(x),xy))
        assert M == xp^2+yp^2+p*(zp^2+tp^2)
        return (xp,yp,zp,tp)
def endomorphism(M,p,ec,i):
    x,y,z,t = fullrepresentinteger(M,p)
    pi = ec.frobenius_isogeny()
    ii = ec.isomorphism(i)
    return x+y*ii+z*pi+t*ii*pi
def find_p_gen(ec,p,e):
    elem_order = p**e
    gens = []
    while len(gens)<2:
        P = ec.random_element()
        tmp = ec.order()//(elem_order**2)
        P = tmp*P
        assert p**e*P == ec.zero()
        if (elem_order//p)*P != ec.zero():
            if len(gens) == 0:
                gens.append(P)
            elif (elem_order//p)*gens[0]!=(elem_order//p)*P:
                gens.append(P)
    return gens

a = 0xd8
b = 0x89
p = 2**a*3**b-1
k.<i> = GF((p,2),modulus=[1,0,1])
ec0 = EllipticCurve(k, [1, 0])

while True:
    q = randint(1,2**a-1)
    secret_degree = q*(2**a-q)
    if secret_degree % 2 != 0 and secret_degree % 3 != 0:
        break
P0,Q0 = find_p_gen(ec0,2,a)
R0,S0 = find_p_gen(ec0,3,b)

true_ans = []
twists = []
for _ in range(3):
    tmp = secrets.randbelow(2**a)
    while tmp % 2 == 0:
        tmp = secrets.randbelow(2**a)
    twists.append(tmp)

endm_deg = q*(2**a-q)*3**b

key = hashlib.sha3_256(long_to_bytes(q)+long_to_bytes(twists[0])+long_to_bytes(twists[1])+long_to_bytes(twists[2])).digest()
FLAG = os.environ.get("FLAG", "TSGCTF{DUMMY}")

nonce, ciphertext = encrypt_aes_ctr(FLAG.encode(), key+b'0'*(32-len(key)))

print("ciphertext =","bytes.fromhex(\""+binascii.hexlify(ciphertext).decode()+"\")")
print("nonce =","bytes.fromhex(\""+binascii.hexlify(nonce).decode()+"\")")


print(f"E0 = {ec0.a_invariants()}")
print(f"P0,Q0 = {P0.xy()}, {Q0.xy()}")
for j in range(3):
    endm = endomorphism(secret_degree*3^b,p,ec0,i)
    Pp,Qp = endm(P0),endm(Q0)
    Rp,Sp = endm(R0),endm(S0)

    assert endm.dual()(Rp) == endm.dual()(Sp) == ec0.zero()
    isogeny = ec0.isogeny((Rp,Sp))
    EA = isogeny.codomain()

    assert isogeny(Rp) == isogeny(Sp) == EA.zero()

    tmp = pow(3^b,-1,2^a)
    PA,QA = twists[j]*tmp*isogeny(Pp),twists[(j+1)%3]*tmp*isogeny(Qp)
    print(f"EA{j} = {EA.a_invariants()}")
    print(f"PA{j},QA{j} = {PA.xy()}, {QA.xy()}")

解法

#!/usr/bin/env sage
# -*- coding: utf-8 -*-

import os
import re
import sys
import hashlib
from sage.all import *
from Crypto.Cipher import AES

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(BASE_DIR, "two-isogenies", "Theta-SageMath"))

from utilities.fast_sqrt import sqrt_Fp2
from theta_structures.couple_point import CouplePoint
from theta_isogenies.product_isogeny_sqrt import EllipticProductIsogenySqrt
from utilities.strategy import optimised_strategy
from utilities.utils import speed_up_sagemath

speed_up_sagemath()

Aexp = 0xD8
Bexp = 0x89
p = 2**Aexp * 3**Bexp - 1
k.<i> = GF((p, 2), modulus=[1, 0, 1])
mord = 2**Aexp


def parse_point(s):
    return eval(s, {"i": i})


def parse_output(text):
    m = re.search(r"ciphertext = bytes.fromhex\(\"([0-9a-f]+)\"\)", text)
    if not m:
        raise ValueError("ciphertext not found")
    ct = bytes.fromhex(m.group(1))
    m = re.search(r"nonce = bytes.fromhex\(\"([0-9a-f]+)\"\)", text)
    if not m:
        raise ValueError("nonce not found")
    nonce = bytes.fromhex(m.group(1))

    m = re.search(r"E0 = \(([^\)]*)\)", text)
    if not m:
        raise ValueError("E0 not found")
    E0 = EllipticCurve(k, eval("(" + m.group(1) + ")"))

    m = re.search(r"P0,Q0 = \((.*)\), \((.*)\)", text)
    if not m:
        raise ValueError("P0,Q0 not found")
    P0 = E0(parse_point("(" + m.group(1) + ")"))
    Q0 = E0(parse_point("(" + m.group(2) + ")"))

    EA = []
    PA = []
    QA = []
    for j in range(3):
        m = re.search(r"EA%d = \(([^\)]*)\)" % j, text)
        if not m:
            raise ValueError("EA%d not found" % j)
        EAj = EllipticCurve(k, eval("(" + m.group(1) + ")"))

        m = re.search(r"PA%d,QA%d = \((.*)\), \((.*)\)" % (j, j), text)
        if not m:
            raise ValueError("PA%d,QA%d not found" % (j, j))
        PAj = EAj(parse_point("(" + m.group(1) + ")"))
        QAj = EAj(parse_point("(" + m.group(2) + ")"))

        EA.append(EAj)
        PA.append(PAj)
        QA.append(QAj)

    return ct, nonce, E0, P0, Q0, EA, PA, QA


def dlog_2power(h, g, a):
    # h = g^x in a 2^a-order subgroup (g generator)
    x = 0
    for k in range(a):
        hk = h * (g ** (-x))
        test = hk ** (2 ** (a - 1 - k))
        if test == -1:
            x += 2**k
    return x


def to_montgomery(E):
    # Convert short Weierstrass to Montgomery y^2 = x^3 + A x^2 + x.
    A4 = E.a4()
    A6 = E.a6()
    x = polygen(E.base_ring())
    f = x**3 + A4 * x + A6
    roots = f.roots()
    if not roots:
        raise ValueError("no root for Montgomery conversion")
    r = roots[0][0]
    A = 3 * r
    B = 3 * r**2 + A4

    u = sqrt_Fp2(sqrt_Fp2(B))
    found = False
    for mult in [1, -1, i, -i]:
        uu = u * mult
        if uu**4 == B:
            u = uu
            found = True
            break
    if not found:
        raise ValueError("no 4th root found")

    A_m = A / (u**2)
    Em = EllipticCurve(E.base_ring(), [0, A_m, 0, 1, 0])

    def iso(P):
        if P.is_zero():
            return Em(0)
        xP, yP = P.xy()
        return Em((xP - r) / (u**2), yP / (u**3))

    return Em, iso


def decrypt_with_key(key_bytes, nonce, ct):
    cipher = AES.new(key_bytes, AES.MODE_CTR, nonce=nonce)
    return cipher.decrypt(ct)


def recover_u_vals(t01, t12, t20):
    R = Zmod(mord)
    prod = (t01 * t12 * t20) % mord
    roots_T = [int(r) for r in R(prod).sqrt(all=True)]
    for T in roots_T:
        u0 = (T * inverse_mod(t12, mord)) % mord
        u1 = (T * inverse_mod(t20, mord)) % mord
        u2 = (T * inverse_mod(t01, mord)) % mord
        yield u0, u1, u2


def recover_q(E0m, P0m, Q0m, EA, PA, QA, u0, u1):
    strategy = optimised_strategy(Aexp - 2)
    for j in range(3):
        EmA, isoA = to_montgomery(EA[j])
        U = isoA(PA[j])
        V = isoA(QA[j])

        K1 = CouplePoint((-u0) * P0m, U)
        K2 = CouplePoint((-u1) * Q0m, V)
        try:
            Phi = EllipticProductIsogenySqrt((K1, K2), Aexp, strategy=strategy)
        except Exception:
            continue

        Uprime = Phi(CouplePoint(E0m(0), U))[0]
        Vprime = Phi(CouplePoint(E0m(0), V))[0]

        e0 = U.weil_pairing(V, mord)
        e1 = Uprime.weil_pairing(Vprime, mord)
        d = dlog_2power(e1, e0, Aexp)
        q = (-d) % mord
        if q % 2 == 1:
            return q
    return None


def int_to_bytes(x):
    n = int(x)
    ln = (n.bit_length() + 7) // 8
    return Integer(n).to_bytes(ln, "big")


def main():
    text = open(os.path.join(BASE_DIR, "output.txt")).read()
    ct, nonce, E0, P0, Q0, EA, PA, QA = parse_output(text)

    g = P0.weil_pairing(Q0, mord)
    vals = []
    for j in range(3):
        hj = PA[j].weil_pairing(QA[j], mord)
        x = dlog_2power(hj, g, Aexp)
        vals.append((-x) % mord)
    t01, t12, t20 = vals

    # E0 is already Montgomery (A=0), but keep the check
    if E0.a_invariants() == (0, 0, 0, 1, 0):
        E0m = E0
        P0m = P0
        Q0m = Q0
    else:
        E0m, iso0 = to_montgomery(E0)
        P0m = iso0(P0)
        Q0m = iso0(Q0)

    for u0, u1, u2 in recover_u_vals(t01, t12, t20):
        q = recover_q(E0m, P0m, Q0m, EA, PA, QA, u0, u1)
        if q is None:
            continue

        invq = inverse_mod(q, mord)
        tw0 = (u0 * invq) % mord
        tw1 = (u1 * invq) % mord
        tw2 = (u2 * invq) % mord

        key = hashlib.sha3_256(
            int_to_bytes(q)
            + int_to_bytes(tw0)
            + int_to_bytes(tw1)
            + int_to_bytes(tw2)
        ).digest()

        pt = decrypt_with_key(key, nonce, ct)
        if pt and pt.startswith(b"TSGCTF"):
            print(pt.decode())
            return

    raise RuntimeError("flag not found")


if __name__ == "__main__":
    main()
# TSGCTF{POKE 1s n0t fOR Pokemon Nor pocket}

手順1 Weil pairingする

公開点は次の形:

PA_j = twist[j] * (3^b)^(-1) * ψ_j(α(P0))
QA_j = twist[j+1] * (3^b)^(-1) * ψ_j(α(Q0))

  • α は deg(α) = q(2^a - q)*3^b の endomorphism
  • ψ_j は 3^b-isogeny
  • (3^b)^(-1) が明示的に掛かっているので、m-torsion 上では ψ_j の 3^b 倍作用がキャンセルされる。

Weil pairing の基本性質:

e_m(ψ(P), ψ(Q)) = e_m(P, Q)^{deg(ψ)} (gcd(deg(ψ), m)=1)

α の m-torsion 作用は「±q 倍」になるので、結局

h_j := e_m(PA_j, QA_j)
= g^{± q^2 * twist[j] * twist[j+1]}.

ここで符号は isogeny の向きに依存するため、実装では

t_j = -dlog_g(h_j)

と置いて符号を統一。すると

t_j = q^2 * twist[j] * twist[j+1] (mod m).

手順2 3本の$t_j$ から $u_i = q * twist[i]$を復元

3つ掛けると

t_01 t_12 t_20
= q^6 * (twist[0] * twist[1] * twist[2])^2 (mod m)

右辺は必ず平方になるので、Z/mZ で平方根が取れる:

T = q^3 * twist[0] * twist[1] * twist[2] (mod m).

これを使って

u0 = T / t12 = q * twist[0]
u1 = T / t20 = q * twist[1]
u2 = T / t01 = q * twist[2]

が求まる(t12 などは 2-adic 単数なので逆元が存在)。

※ m = 2^a なので平方根は複数候補があるが、後段の検証で正しいものだけが残る

手順3 kaniのkernelを公開情報だけで作る

$E_0[m] \times E_{A_{j}}$上にproduct pairingを定義:
$$
e_m^×((P1,P2),(Q1,Q2)) = e_m(P1,Q1) * e_m(P2,Q2)
$$
ここで、
$K1 = (-u0 * P0, PA_j)$, $K2 = (-u1 * Q0, QA_j)$
と置くと、

$$
e_m^×(K1, K2) = e_m(P0,Q0)^{u0*u1} * e_m(PA_j, QA_j)
$$

$= g^{u0*u1} * g^{t_j}$
$= 1$

$(t_j \equiv u_0 * u1 \pmod m)となり、<K1, K2>はmaximal isotropic subgroup。
よってkaniの定理により(2^a-2^a)-isogenyになる

$\phi: E_0 \times E_{A_j} \to F \times F'$が構成できる

手順4 image curveのpairingからqを取り出す

$U = P_{A_{j}}, V = Q_{A_{j}}$
とし、
$U' = \phi(0, U)[0], V' = \phi(0, V)[0]$
を計算する
ここで第一成分への写像は未知の$2^a$isogeny phi2の双対に一致するため、
$$e_m(U', V') = e_m(U, V)^{deg(phi2)}$$
が成り立つ。
構成上、$deg(phi1) = q$で補完的に
$deg(phi2) = 2^a - q \pmod m$となるので、
$$q \equiv -deg(phi2) \pmod m$$
が得られる

手順5 twistを復元

$q$がわかれば
$$
twist[i] = \frac{u_i}{q} \pmod m
$$
で全twistが求まる
鍵は、
$key = SHA3-256(q || twist[0] || twist[1] || teist[2])$
なのでAES-CTRを復元してフラグが得られる。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?