Logo
Overview
[SECCON CTF 14] Last Flight writeup

[SECCON CTF 14] Last Flight writeup

December 14, 2025
8 min read

✨ 크립토 공짜 다운로드 ✨

TL;DR

The service is a random walk on the 2-isogeny graph of an ordinary elliptic curve over Fp\mathbb{F}_p For this curve, the Frobenius discriminant satisfies

Δ=t24p=1632256\Delta = t^2 - 4p = -163 \cdot 2^{256}

So the 2-isogeny graph is a 2-volcano of height 128 with a single crater vertex

Analysis

The code routin

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...")
  • start from a fixed jj over Fp\mathbb{F}_p
  • Each step chooses a 2-isogeny of the current curve (so typically indices 0,1,20,1,2 are possible).
  • There is a no immediate backtracking rule: it refuses to go back to the previous vertex (after the first move).
  • It prints two endpoints: vulcan and bell.
  • must provide a comma+space separated list of indices, and the server checks: interstellar_flight(vulcan, plans) = bell

So the problem is given two vertices in the 2-isogeny graph, output an index sequence that walks from one to the other without backtracking.

For an ordinary elliptic curve E/FpE/\mathbb{F}_p, all curves isogenous to EE over Fp\mathbb{F}_p have the same trace of Frobenius tt (equivalently the same group order).

The Frobenius endomorphism π\pi satisfies: π2tπ+p=0,\pi^2 - t\pi + p = 0, so the Frobenius discriminant is: Δ=t24p.\Delta = t^2 - 4p.

For ordinary curves, End(E)\mathrm{End}(E) is an order in an imaginary quadratic field K=Q(Δ0)K=\mathbb{Q}(\sqrt{\Delta_0}), and we can write: Δ=Δ0fπ2\Delta = \Delta_0 \cdot f_\pi^2

where:

  • Δ0\Delta_0 is the fundamental discriminant of K,
  • fπf_\pi is the conductor of the order Z[π]\mathbb{Z}[\pi] in OK\mathcal{O}_K.

For a prime \ell (here =2\ell=2), the graph of \ell-isogenies inside a fixed isogeny class has the structure of an \ell-volcano

  • The height of the volcano is e=v(fπ)e = v_\ell(f_\pi).
  • Vertices above the floor have +1\ell+1 outgoing \ell-isogenies.
  • Each such vertex has exactly one upward edge (toward larger endomorphism ring / toward the crater) and \ell downward edges.

For =2\ell=2, that means

  • If we are not on the floor, we usually see 3 2-isogenies.
  • There is a unique parent (up) and two children (down)

From the provided chall.sage values pp is a 262-bit prime.

The starting curve has #E(Fp)=p+1t.\#E(\mathbb{F}_p) = p + 1 - t.

If you ask Sage for the order of the curve constructed from the given starting jj, you get: #E(Fp)=p+63t=p+1(p+63)=62.\#E(\mathbb{F}_p) = p + 63 \quad\Rightarrow\quad t = p+1-(p+63) = -62.

Now compute the discriminant: Δ=t24p=(62)24p=38444p.\Delta = t^2 - 4p = (-62)^2 - 4p = 3844 - 4p.

This factors as Δ=1632256.\Delta = -163 \cdot 2^{256}.

So The CM field is K=Q(163)K = \mathbb{Q}(\sqrt{-163}) with fundamental discriminant Δ0=163\Delta_0=-163. fπ=2128f_\pi = 2^{128}, hence the 22-volcano height is e=128e=128.

Even better: 163-163 is one of the class number 1 discriminants, so the crater has size h(163)=1h(-163)=1

That means the 22-volcano has a single crater vertex (a unique “root” up to isomorphism).

Concretely, the unique CM jj-invariant is the famous Ramanujan value: j(163)=6403203.j(-163) = -640320^3. Working modulo pp, the crater jj is: jroot6403203(modp).j_{\text{root}} \equiv -640320^3 \pmod p.

This single root property is the key simplification: every vertex has a unique path upward to the same root.

For j{0,1728}j \notin \{0,1728\}, there are generally two non-isomorphic curves over Fp\mathbb{F}_p with the same jj: they are quadratic twists of each other.

They have opposite traces: t(E)=t(E).t(E') = -t(E).

The server prints only jj-invariants, so when we reconstruct a curve locally with

E = EllipticCurve(GF(p), j=j)

Sage may choose either twist representative for that jj.

But isogenies over Fp\mathbb{F}_p preserve the trace. Since the server starts in the isogeny class with t=62t=-62, both vulcan and bell are in that class (as curve objects on the server), even if constructing from jj locally sometimes gives the t=+62t=+62 twist.

On a 22-volcano above the floor

  • there are 3 neighbors,
  • exactly one has larger height (the parent),
  • two have smaller height (children).

So if we can compute the height above the floor h(j)h(j), then the parent is simply the neighbor with hh increased by 11.

Sage has height_above_floor(2, e), but it is slow because it repeatedly builds modular polynomials.

  1. Hardcode the classical modular polynomial Φ2\Phi_2

The degree-22 modular polynomial in jj-invariants is

Φ2(X,Y)=X3+Y3X2Y2\Phi_2(X,Y) = X^3 + Y^3 - X^2Y^2

+1488(X2Y+XY2)+ 1488(X^2Y + XY^2)

162000(X2+Y2)- 162000(X^2 + Y^2)

+40773375XY+ 40773375XY

+8748000000(X+Y)+ 8748000000(X+Y)

157464000000000- 157464000000000

For a fixed Y=j(E)Y=j(E), the jj-invariants of the curves that are 22-isogenous to EE (over Fp\mathbb{F}_p) are exactly the roots of the cubic

Φ2(X,j)=0in Fp.\Phi_2(X, j) = 0 \quad\text{in } \mathbb{F}_p.

So list neighbors becomes root a cubic over Fp\mathbb{F}_p.

  1. Speed trick: Vieta to avoid repeated factoring Sage’s height_above_floor essentially iterates:
  • take a neighbor j1j_1,
  • look at Φ2(X,j1)\Phi_2(X, j_1)
  • divide out the factor (Xj0)(X - j_0) corresponding to the edge we came from,
  • take the remaining root to go one step further down

Since Φ2(X,j1)\Phi_2(X, j_1) is cubic with roots (r0,r1,r2)(r_0, r_1, r_2) and we know r0=j0r_0=j_0, we can get the other roots via Vieta

  • If the cubic is X3+a2X2+a1X+a0X^3 + a_2 X^2 + a_1 X + a_0, then r0r1r2=a0,r0+r1+r2=a2.r_0 r_1 r_2 = -a_0,\qquad r_0 + r_1 + r_2 = -a_2.

Therefore r1r2=a0r0,r1+r2=a2r0.r_1 r_2 = \frac{-a_0}{r_0},\qquad r_1 + r_2 = -a_2 - r_0.

  • Then (r1,r2)(r_1, r_2) are the solutions of Z2(r1+r2)Z+(r1r2)=0,Z^2 - (r_1+r_2)Z + (r_1r_2)=0, which is just one square root in Fp\mathbb{F}_p.

This gives an O(1)O(1) next step without polynomial division or root finding each time. With caching, computing heights and parent edges becomes fast enough to do online.

Because the crater is a single vertex, the LCA

  1. Build the chain from vulcan upward to the crater, recording at each step the index (0/1/2) of the parent isogeny returned by isogenies_prime_degree(2).
  2. Build the chain from bell upward to the crater (after fixing the twist).
  3. The first common jj between those chains is the crater (in practice it really is the crater here).
  4. Concatenate
    • vulcan -> crater using the recorded indices,
    • crater -> bell by reversing the bell -> crater chain and, at each step, selecting the index whose codomain jj matches the next vertex.

This produces a simple path with no immediate backtracking, so the server’s continue never triggers.

Exploit

from sage.all import *
P = 4718527636420634963510517639104032245020751875124852984607896548322460032828353
F = GF(P)
R = PolynomialRing(F, "x")
x = R.gen()
def _phi2_coeffs(y):
y = F(y)
a2 = 1488 * y - y * y - 162000
a1 = 1488 * y * y + 40773375 * y + 8748000000
a0 = y**3 - 162000 * y * y + 8748000000 * y - 157464000000000
return a2, a1, a0
def _phi2_roots_x(y):
a2, a1, a0 = _phi2_coeffs(y)
f = x**3 + a2 * x**2 + a1 * x + a0
return f.roots(multiplicities=False)
def _phi2_other_roots(prev_x_root, y):
a2, _, a0 = _phi2_coeffs(y)
r0 = F(prev_x_root)
prod = -a0 # r0*r1*r2
r1r2 = prod / r0
s = -a2 - r0 # r1+r2
disc = s * s - 4 * r1r2
if not disc.is_square():
return []
sd = disc.sqrt()
inv2 = F(1) / 2
return [(s + sd) * inv2, (s - sd) * inv2]
_height_cache = {}
def _height_above_floor_rank2(j, e=128):
j = int(j)
if j in _height_cache:
return _height_cache[j]
if j in (0, 1728):
_height_cache[j] = e
return e
jj = F(j)
j1 = _phi2_roots_x(jj)
if len(j1) < 3:
_height_cache[j] = 0
return 0
j0 = [jj, jj, jj]
h = 1
while True:
for i in range(3):
r = _phi2_other_roots(j0[i], j1[i])
if not r:
_height_cache[j] = h
return h
j0[i] = j1[i]
j1[i] = r[0]
h += 1
_floor_cache = {}
def _height_curve(E):
j = int(E.j_invariant())
if j in _floor_cache:
if _floor_cache[j]:
return 0
else:
_floor_cache[j] = E.two_torsion_rank() < 2
if _floor_cache[j]:
return 0
return _height_above_floor_rank2(j)
def _chain_to_root(curve):
chain = []
cur = curve
prev_j = None
h = _height_curve(cur)
while True:
if h == 128:
chain.append((cur, None))
return chain
iso = cur.isogenies_prime_degree(2)
codoms = [f.codomain() for f in iso]
codom_js = [int(C.j_invariant()) for C in codoms]
if prev_j is None:
hs = [_height_curve(C) for C in codoms]
idx = hs.index(max(hs))
else:
cand = [i for i, jj in enumerate(codom_js) if jj != prev_j]
i0 = cand[0]
h0 = _height_curve(codoms[i0])
idx = i0 if h0 > h else cand[1]
chain.append((cur, idx))
prev_j = int(cur.j_invariant())
cur = codoms[idx]
h += 1
def _find_index_to(curve, target_j):
iso = curve.isogenies_prime_degree(2)
for i, f in enumerate(iso):
if int(f.codomain().j_invariant()) == target_j:
return i, f.codomain()
def solve(vulcan_j, bell_j):
E_v = EllipticCurve(F, j=vulcan_j)
t_v = int(E_v.trace_of_frobenius())
E_b = EllipticCurve(F, j=bell_j)
t_b = int(E_b.trace_of_frobenius())
if t_b != t_v:
E_b = E_b.quadratic_twist()
t_b2 = int(E_b.trace_of_frobenius())
chain_v = _chain_to_root(E_v)
chain_b = _chain_to_root(E_b)
pos_v = {int(c.j_invariant()): i for i, (c, _) in enumerate(chain_v)}
lca_i_b = None
lca_j = None
for i, (c, _) in enumerate(chain_b):
jj = int(c.j_invariant())
if jj in pos_v:
lca_i_b = i
lca_j = jj
break
if lca_i_b is None:
raise RuntimeError("LCA 업슴")
lca_i_v = pos_v[lca_j]
plans = []
for i in range(lca_i_v):
_, idx = chain_v[i]
plans.append(int(idx))
js_b_segment = [int(c.j_invariant()) for c, _ in chain_b[: lca_i_b + 1]]
js_down = list(reversed(js_b_segment))
cur = chain_v[lca_i_v][0]
for nxt_j in js_down[1:]:
idx, nxt = _find_index_to(cur, nxt_j)
plans.append(int(idx))
cur = nxt
return plans
def _recv_until(sock, marker, timeout_sec=120):
deadline = time.time() + timeout_sec
buf = b""
while marker not in buf:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
buf += chunk
return buf
def main():
host = "last-flight.seccon.games"
port = 5000
if len(sys.argv) >= 2:
host = sys.argv[1]
if len(sys.argv) >= 3:
port = int(sys.argv[2])
with socket.create_connection((host, port), timeout=20) as sock:
sock.settimeout(5.0)
data = _recv_until(sock, b"Master, please input the flight plans > ", timeout_sec=180)
text = data.decode(errors="replace")
m1 = re.search(r"vulcan's planet is here\s*:\s*(\d+)", text)
m2 = re.search(r"bell's planet is here\s*:\s*(\d+)", text)
if not (m1 and m2):
raise RuntimeError("failed to parse vulcan/bell from:\n%s" % text)
vulcan = int(m1.group(1))
bell = int(m2.group(1))
plans = solve(vulcan, bell)
payload = ", ".join(map(str, plans)).encode() + b"\n"
sock.sendall(payload)
out = b""
deadline = time.time() + 120
while time.time() < deadline:
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
out += chunk
if b"SIGNAL SAY:" in out or b"LOST THE SIGNAL" in out:
break
sys.stdout.write(out.decode(errors="replace"))
if __name__ == "__main__":
main()
SECCON{You_have_made_your_wish_so_you_have_got_to_make_it_true}