TL;DR
The service is a random walk on the 2-isogeny graph of an ordinary elliptic curve over For this curve, the Frobenius discriminant satisfies
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 randintimport os
p = 4718527636420634963510517639104032245020751875124852984607896548322460032828353j = 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 over
- Each step chooses a 2-isogeny of the current curve (so typically indices 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 , all curves isogenous to over have the same trace of Frobenius (equivalently the same group order).
The Frobenius endomorphism satisfies: so the Frobenius discriminant is:
For ordinary curves, is an order in an imaginary quadratic field , and we can write:
where:
- is the fundamental discriminant of K,
- is the conductor of the order in .
For a prime (here ), the graph of -isogenies inside a fixed isogeny class has the structure of an -volcano
- The height of the volcano is .
- Vertices above the floor have outgoing -isogenies.
- Each such vertex has exactly one upward edge (toward larger endomorphism ring / toward the crater) and downward edges.
For , 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 is a 262-bit prime.
The starting curve has
If you ask Sage for the order of the curve constructed from the given starting , you get:
Now compute the discriminant:
This factors as
So The CM field is with fundamental discriminant . , hence the -volcano height is .
Even better: is one of the class number 1 discriminants, so the crater has size
That means the -volcano has a single crater vertex (a unique “root” up to isomorphism).
Concretely, the unique CM -invariant is the famous Ramanujan value: Working modulo , the crater is:
This single root property is the key simplification: every vertex has a unique path upward to the same root.
For , there are generally two non-isomorphic curves over with the same : they are quadratic twists of each other.
They have opposite traces:
The server prints only -invariants, so when we reconstruct a curve locally with
E = EllipticCurve(GF(p), j=j)Sage may choose either twist representative for that .
But isogenies over preserve the trace. Since the server starts in the isogeny class with , both vulcan and bell are in that class (as curve objects on the server), even if constructing from locally sometimes gives the twist.
On a -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 , then the parent is simply the neighbor with increased by .
Sage has height_above_floor(2, e), but it is slow because it repeatedly builds modular polynomials.
- Hardcode the classical modular polynomial
The degree- modular polynomial in -invariants is
For a fixed , the -invariants of the curves that are -isogenous to (over ) are exactly the roots of the cubic
So list neighbors becomes root a cubic over .
- Speed trick: Vieta to avoid repeated factoring Sage’s height_above_floor essentially iterates:
- take a neighbor ,
- look at
- divide out the factor corresponding to the edge we came from,
- take the remaining root to go one step further down
Since is cubic with roots and we know , we can get the other roots via Vieta
- If the cubic is , then
Therefore
- Then are the solutions of which is just one square root in .
This gives an 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
- 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).
- Build the chain from bell upward to the crater (after fixing the twist).
- The first common between those chains is the crater (in practice it really is the crater here).
- Concatenate
- vulcan -> crater using the recorded indices,
- crater -> bell by reversing the bell -> crater chain and, at each step, selecting the index whose codomain 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 = 4718527636420634963510517639104032245020751875124852984607896548322460032828353F = 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}