1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SECCON14 quals: Last Flight Upsolve

Last updated at Posted at 2025-12-23

Last Flight

authored by kanon
special thanks quocbao21_

I got split up from them. It is the final chance. Can you find a special flight?

from Crypto.Util.number import *
from random import randint
import os

p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353
j = 4667843869135885176787716797518107956781705418815411062878894329223922615150642

flag = os.getenv("FLAG", "SECCON{test_flag}")


def interstellar_flight(j, flight_plans=None):
    planet = EllipticCurve(GF(p), j=j)
    visited_planets = []
    if flight_plans == None:
        flight_plans = [randint(0, 2) for _ in range(160)]

    for flight_plan in flight_plans:
        flight = planet.isogenies_prime_degree(2)[flight_plan]
        if len(visited_planets) > 1:
            if flight.codomain().j_invariant() == visited_planets[-2]:
                continue
        planet = flight.codomain()
        visited_planets.append(planet.j_invariant())
    return visited_planets[-1]


print("Currently in interstellar flight...")

vulcan = interstellar_flight(j)
bell = interstellar_flight(j)

print(f"vulcan's planet is here : {vulcan}")
print(f"bell's planet is here : {bell}")


final_flight_plans = list(map(int, input("Master, please input the flight plans > ").split(", ")))

if interstellar_flight(vulcan, final_flight_plans) == bell:
    print(f"FIND THE BELL'S SIGNAL!!! SIGNAL SAY: {flag}")
else:
    print("LOST THE SIGNAL...")

素数$p$と開始点$j$が定義されています。有限体$\mathbb{F}_p$上の同種写像グラフにおける長さ160の非バックトラックランダムウォークを2本生成し、終点vulcanbellが与えられます。
私たちは、vulcanからbellに写すようなインデックス列0/1/2のリストを提出し、それが正しければフラグを得ることができます。

解法

曲線の確認

今回、SageのEllipticCurve(GF(p), j=j)は$ j \neq 0, 1728$のとき、short Weierstrass modelとなります。
そもそも、Weierstrass modelは、
$$
y^2 + a_1xy + a_3y = x^3 + a_2x^2 + a_4x + a_6
$$
となります。
ここで、今回定義されている$p,j$の場合のWeierstrassの係数を調べてみましょう。以下のプログラムを実行してみます。

p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353
j = 4667843869135885176787716797518107956781705418815411062878894329223922615150642

E = EllipticCurve(GF(p), j=j)
print(E.ainvs())

すると、以下のように表示されます。

(0, 0, 0, 3888692665638047937045570002487084378458447133902318295621022455533667665827573, 2154486936412009487314246715912840515820627580708215847435236912193598988144532)

ここで、ainvs関数は、[a1, a2, a3, a4, a6]をリストかタプルで返却されるため、今回、$a_1, a_2, a_3$は0であることがわかります。
つまり、
$$
y^2 = x^3 + a_4x+a_6
$$
となります。これが、short Weierstrass modelとなります。
short Weierstrass modelのとき、$j$-不変量は
$$
j = 1728 \cdot \frac{4a_4^3}{4a_4^3 + 27a_6^2}
$$
で求められます。
さて、少し式変形すると、以下のようになります。
$$
\frac{j}{1728} = \frac{4a_4^3}{4a_4^3 + 27a_6^2}
$$
両辺に分母をかけ、整理すると
$$
\frac{j}{1728} \cdot 27a_6^2 = 4a_4^3\left( 1 - \frac{j}{1728}\right)
$$
となり、両辺に1728をかけると
$$
27ja_6^2 = 4a_4^3(1728-j)
$$
となります。
ここで、$k = j-1728$とすると、
$$
27ja_6^2 = -4ka_4^3
$$
となります。この式を満たすように$a_4,a_6$を選べばよいので、たとえば
$$
a_4 = -3jk, a_6 = -2jk^2
$$
と置くと両辺が一致します。

なお、short Weierstrass model
$$
E: y^2 = x^3 + a_4x + a_6
$$
では、2-torsion pointは $[2]P=\mathcal{O}$を満たす点であり、標数が2でないときは必ず$y=0$を満たします。
したがって、その$x$座標は
$$
f(x) = x^3 + a_4x + a_6
$$
の根に一致します。

Sageのisogenies_prime_degree()は、定義域$E$である分離可能な $l$-次同種写像を整列した順に列挙します。特に、isogenies_prime_degree(2)は、3 本の 2-同種写像を返します。短い Weierstrass 形ではそれらは 3 つの非自明 2-捩れ点($y=0$ の解)に対応し、Sage はそれらを一定の規則で並べるため、インデックス 0/1/2 で選択できます。

根だけを用いた2-同種写像の移動

今回の$p$は262ビットかつ$GF(p)$なので、素数体です。
そのため、先ほどの3次方程式$f(x)$の根を求めるには有限体上で$f(x)$を分解する、すなわち、
$$
f(x) = (x-r_0)(x-r_1)(x-r_2)
$$
をなるように因数分解をするということです。
しかし、これを毎回やるのはかなり時間が食ってしまいますし、接続は概ね10分程度で切れてしまうため時間が足りないのは明白です。

ここで、曲線を以下で考えてみましょう。
$$
y^2 = (x-r_0)(x-r_1)(x-r_2)
$$
このように、根$r_0, r_1, r_2$を状態として持っています。
この時、

\begin{align}
r'_0 &= -2r_k\\
r'_{\pm} &= r_k \pm 2\sqrt{(r_k-r_j)(r_k-r_l)}
\end{align}

とすると、根を更新できます。

ルートの探索

2-同種写像グラフの連結成分が「火山(isogeny volcano)」の層構造を持つことを利用して、vulcanbell の間の経路を機械的に復元することが僕たちの目的です。
火山では頂点が level(高さ)で分割され、一般に

$$
V_0, V_1, \dots, V_d
$$

のように層をなします。ここで surface は$V_0$、floorは$V_d$とみなせます。

そして、今回の問題では直前の辺をすぐ戻る操作が禁止されています。したがって、ある頂点から子方向(下降)に 1 歩進むと、次の頂点で親方向の辺は直前に通った辺と一致し、その親方向へは戻れません。よって一度下降を始めると、その後は子方向の選択しか残らず、強制的に下降し続けて floorへ到達します。

以上より、各頂点でfloor まで最短何手で降りられるかという距離$\delta(v)$を計算できれば、隣接3頂点のうち$\delta(\text{neighbor})$が最大になる方向が親(上向き)である、と一意に判定できます。

さて、私たちは

  1. vulcan から親を辿って root までの祖先列を作る
  2. bell から親を辿って root までの祖先列を作る
  3. 2 つの祖先列が最初に一致する頂点を見つける

といったルールを繰り返すことで、解くことができます。
なぜなら、今回は木なので経路は一意で

$$
vulcan \to \cdots \to \text{最初に一致する頂点} \to \cdots \to bell
$$

がそのまま求める経路となるからです。
以上を参考にして作成したsolverは以下です(チャッピーと協力しました)。

import random
import re
from dataclasses import dataclass
from typing import Optional
import subprocess
import gmpy2
from pwn import remote, context


p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353
j0 = 4667843869135885176787716797518107956781705418815411062878894329223922615150642
_P = gmpy2.mpz(p)

HOST = "last-flight.seccon.games"
PORT = 5000

context.log_level = "info"


def inv_mod(a):
    return int(gmpy2.invert(gmpy2.mpz(a) % _P, _P))

def legendre_symbol(a):
    return int(gmpy2.powmod(gmpy2.mpz(a) % _P, (p - 1) // 2, _P))

_TS_Q = p - 1
_TS_S = 0
while _TS_Q % 2 == 0:
    _TS_S += 1
    _TS_Q //= 2
_TS_Z = 2
while legendre_symbol(_TS_Z) != p - 1:
    _TS_Z += 1
_TS_C0 = int(gmpy2.powmod(gmpy2.mpz(_TS_Z), _TS_Q, _P))
_TS_Q_MPZ = gmpy2.mpz(_TS_Q)
_TS_Q1_DIV2_MPZ = gmpy2.mpz((_TS_Q + 1) // 2)


def mod_sqrt(a):
    a %= p
    if a == 0:
        return 0
    if legendre_symbol(a) != 1:
        return None
    m = _TS_S
    c = _TS_C0
    am = gmpy2.mpz(a)
    t = int(gmpy2.powmod(am, _TS_Q_MPZ, _P))
    r = int(gmpy2.powmod(am, _TS_Q1_DIV2_MPZ, _P))
    while t != 1:
        i = 1
        t2 = (t * t) % p
        while i < m and t2 != 1:
            t2 = (t2 * t2) % p
            i += 1
        b = int(gmpy2.powmod(gmpy2.mpz(c), 1 << (m - i - 1), _P))
        m = i
        c = (b * b) % p
        t = (t * c) % p
        r = (r * b) % p
    return r


def curve_coeffs_from_j(j):
    j %= p
    k = (j - 1728) % p
    a4 = (-3 * j * k) % p
    a6 = (-2 * j * k * k) % p
    return a4, a6


def j_from_roots(roots):
    r0, r1, r2 = roots
    a4 = (r0 * r1 + r0 * r2 + r1 * r2) % p
    a6 = (-r0 * r1 * r2) % p
    num = (1728 * 4 * pow(a4, 3, p)) % p
    den = (4 * pow(a4, 3, p) + 27 * pow(a6, 2, p)) % p
    return (num * inv_mod(den)) % p



@dataclass(frozen=True)
class StepResult:
    roots: Optional[list[int]]
    back_idx: Optional[int]


def isogeny_step(roots, idx):
    rk = roots[idx]
    rj = roots[(idx + 1) % 3]
    rl = roots[(idx + 2) % 3]
    t = ((rk - rj) % p) * ((rk - rl) % p) % p
    s = mod_sqrt(t)
    if s is None:
        return StepResult(None, None)
    twos = (2 * s) % p
    back_root = (-2 * rk) % p
    nxt = [back_root, (rk + twos) % p, (rk - twos) % p]
    nxt.sort()
    return StepResult(nxt, nxt.index(back_root))


def _choose_idx_not_back(back_idx):
    return 0 if back_idx != 0 else 1


def path_len_to_floor_from_edge(roots, first_idx, best):
    first = isogeny_step(roots, first_idx)
    if first.roots is None:
        return 1
    cur = first.roots
    back_idx = first.back_idx
    steps = 1
    while steps < best:
        idx = _choose_idx_not_back(back_idx)
        nxt = isogeny_step(cur, idx)
        steps += 1
        if nxt.roots is None:
            return steps
        cur = nxt.roots
        back_idx = nxt.back_idx
    return best


def dist_to_floor(roots):
    best = 10**18
    for idx in range(3):
        best = min(best, path_len_to_floor_from_edge(roots, idx, best))
    return best


def reaches_floor_within(roots, back_idx, max_steps):
    cur = roots
    b = back_idx
    for _ in range(max_steps):
        idx = _choose_idx_not_back(b)
        nxt = isogeny_step(cur, idx)
        if nxt.roots is None:
            return True
        cur = nxt.roots
        b = nxt.back_idx
    return False


def find_parent_step(roots, delta):
    for idx in range(3):
        nxt = isogeny_step(roots, idx)
        if nxt.roots is None:
            continue
        if not reaches_floor_within(nxt.roots, nxt.back_idx, delta):
            return idx, nxt.roots
    return None

def q_mul(u, v, a, b):
    u0, u1, u2 = u
    v0, v1, v2 = v

    w0 = (u0 * v0) % p
    w1 = (u0 * v1 + u1 * v0) % p
    w2 = (u0 * v2 + u1 * v1 + u2 * v0) % p
    w3 = (u1 * v2 + u2 * v1) % p
    w4 = (u2 * v2) % p

    c0 = (w0 - w3 * b) % p
    c1 = (w1 - w3 * a - w4 * b) % p
    c2 = (w2 - w4 * a) % p
    return (c0, c1, c2)


def q_pow(base, exp, a, b):
    res = (1, 0, 0)
    cur = base
    e = exp
    while e:
        if e & 1:
            res = q_mul(res, cur, a, b)
        cur = q_mul(cur, cur, a, b)
        e >>= 1
    return res

def roots_from_j(j):
    a4, a6 = curve_coeffs_from_j(j)
    return roots_cubic_monic_no_x2(a4, a6)


def poly_trim(poly):
    while len(poly) > 1 and poly[-1] == 0:
        poly.pop()
    return poly


def poly_deg(poly):
    return len(poly) - 1


def poly_scale(poly, sc):
    sc %= p
    return [(c * sc) % p for c in poly]


def poly_mod(dividend, divisor):
    num = poly_trim(dividend[:])
    den = poly_trim(divisor[:])
    db = poly_deg(den)
    inv_lc = inv_mod(den[db])

    while poly_deg(num) >= db and not (len(num) == 1 and num[0] == 0):
        da = poly_deg(num)
        coef = (num[da] * inv_lc) % p
        shift = da - db
        for i in range(db + 1):
            num[i + shift] = (num[i + shift] - coef * den[i]) % p
        poly_trim(num)

    return num


def poly_gcd(pa, pb):
    a0 = poly_trim(pa[:])
    b0 = poly_trim(pb[:])
    while not (len(b0) == 1 and b0[0] == 0):
        a0, b0 = b0, poly_mod(a0, b0)
    return poly_scale(a0, inv_mod(a0[-1]))


def poly_div_exact(dividend, divisor):
    num = poly_trim(dividend[:])
    den = poly_trim(divisor[:])
    db = poly_deg(den)
    inv_lc = inv_mod(den[db])

    q = [0] * (max(0, poly_deg(num) - db) + 1)
    while poly_deg(num) >= db and not (len(num) == 1 and num[0] == 0):
        da = poly_deg(num)
        coef = (num[da] * inv_lc) % p
        shift = da - db
        q[shift] = coef
        for i in range(db + 1):
            num[i + shift] = (num[i + shift] - coef * den[i]) % p
        poly_trim(num)

    if not (len(num) == 1 and num[0] == 0):
        raise ValueError("polynomial not divisible")
    return poly_trim(q)


def roots_quadratic(poly):
    poly = poly_trim(poly[:])
    if poly_deg(poly) != 2:
        return []

    a0 = poly[0] % p
    a1 = poly[1] % p
    a2 = poly[2] % p
    if a2 == 0:
        return []

    disc = (a1 * a1 - 4 * a2 * a0) % p
    sd = mod_sqrt(disc)
    if sd is None:
        return []

    inv2a = inv_mod((2 * a2) % p)
    x1 = ((-a1 + sd) * inv2a) % p
    x2 = ((-a1 - sd) * inv2a) % p
    return [x1] if x1 == x2 else [x1, x2]


def roots_cubic_monic_no_x2(a, b):
    a %= p
    b %= p
    f = [b, a, 0, 1]

    x = (0, 1, 0)
    xp = q_pow(x, p, a, b)
    if xp != x:
        xp_minus_x = poly_trim([xp[0] % p, (xp[1] - 1) % p, xp[2] % p])
        g = poly_gcd(f, xp_minus_x)

        dg = poly_deg(g)
        if dg == 1:
            # g = u0 + u1*x
            return [(-g[0] * inv_mod(g[1])) % p]
        if dg == 2:
            return sorted(roots_quadratic(g))
        return []

    while True:
        c = random.randrange(p)
        gx = (c, 1, 0)  # x + c
        h = q_pow(gx, (p - 1) // 2, a, b)  # in F_p[x]/(f)
        h_minus_1 = poly_trim([(h[0] - 1) % p, h[1] % p, h[2] % p])
        d = poly_gcd(f, h_minus_1)
        dd = poly_deg(d)
        if 0 < dd < 3:
            break

    roots = []
    dd = poly_deg(d)

    if dd == 1:
        roots.append((-d[0] * inv_mod(d[1])) % p)
        q = poly_div_exact(f, d)
        if poly_deg(q) == 2:
            roots.extend(roots_quadratic(q))
    else:
        roots.extend(roots_quadratic(d))
        q = poly_div_exact(f, d)
        if poly_deg(q) == 1:
            roots.append((-q[0] * inv_mod(q[1])) % p)

    roots = sorted(set(roots))
    if len(roots) != 3:
        raise ValueError("unexpected root count: %d" % len(roots))
    return roots

def ancestor_js_from_roots(start_roots):
    delta = dist_to_floor(start_roots)
    cur = start_roots
    cur_delta = delta
    out = []

    while True:
        out.append(j_from_roots(cur))
        parent = find_parent_step(cur, cur_delta)
        if parent is None:
            break
        _, cur = parent
        cur_delta += 1

    return out

def find_lca_by_j(anc_a, anc_b):
    pos_a = {j: i for i, j in enumerate(anc_a)}
    for i_b, j in enumerate(anc_b):
        i_a = pos_a.get(j)
        if i_a is not None:
            return j, i_a, i_b
    raise ValueError("no LCA found (unexpected in tree volcano)")

def step_index_to_reach_next_j(cur_roots, target_j):
    for idx in range(3):
        nxt = isogeny_step(cur_roots, idx)
        if nxt.roots is None:
            continue
        if j_from_roots(nxt.roots) == target_j:
            return idx, nxt.roots
    raise ValueError("failed to follow j-path")

def solve_instance(vulcan_j, bell_j):
    roots_v = roots_from_j(vulcan_j)
    roots_b = roots_from_j(bell_j)

    anc_v = ancestor_js_from_roots(roots_v)
    anc_b = ancestor_js_from_roots(roots_b)

    lca_j, idx_v, idx_b = find_lca_by_j(anc_v, anc_b)

    left = anc_v[: idx_v + 1]
    right = list(reversed(anc_b[:idx_b]))
    path_js = left + right

    if path_js[0] != vulcan_j or path_js[-1] != bell_j:
        raise ValueError("bad j-path construction")

    cur = roots_v
    plans = []
    for target_j in path_js[1:]:
        idx, cur = step_index_to_reach_next_j(cur, target_j)
        plans.append(idx)

    return plans

def hashcash_solve(resource, bits):
    out = subprocess.check_output(["hashcash", f"-mb{bits}", resource], text=True)
    return out.strip()

def _parse_hashcash_resource(banner):
    txt = banner.decode(errors="ignore")
    for line in txt.splitlines():
        line = line.strip()
        if line.startswith("hashcash -mb28 "):
            parts = line.split()
            if parts:
                return parts[-1]
    raise RuntimeError("failed to parse hashcash resource")

def _parse_planets(chal):
    txt = chal.decode(errors="ignore")
    vulcan = None
    bell = None
    for line in txt.splitlines():
        if "vulcan's planet is here" in line:
            m = re.search(r":\s*(-?\d+)\s*$", line)
            if m:
                vulcan = int(m.group(1))
        if "bell's planet is here" in line:
            m = re.search(r":\s*(-?\d+)\s*$", line)
            if m:
                bell = int(m.group(1))
    if vulcan is None or bell is None:
        raise RuntimeError("failed to parse vulcan/bell")
    return vulcan, bell

io = remote(HOST, PORT)
banner = io.recvuntil(b"hashcash token: ")
resource = _parse_hashcash_resource(banner)
stamp = hashcash_solve(resource, 28)
io.sendline(stamp.encode())
print("[+] Sent hashcash token:", stamp)
chal = io.recvuntil(b"Master, please input the flight plans > ")
vulcan_j, bell_j = _parse_planets(chal)

print(f"[+] Received challenge: vulcan_j={vulcan_j}, bell_j={bell_j}")
plans = solve_instance(vulcan_j, bell_j)
print(f"[+] Computed flight plans: {plans}")
io.sendline((", ".join(map(str, plans))).encode())

io.interactive()

Flag: SECCON{You_have_made_your_wish_so_you_have_got_to_make_it_true}

おまけ

なんでこれ32solvesもあるんだ。。。

参考文献

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?