963 words
5 minutes
CodeVinci CTF 2026 - SloppySauce - Cryptography Writeup

Category: Cryptography Flag: CodeVinci{cust0m_curv3s_4r3nt_4_sl0pp3rs}

Challenge Description#

I love french fries with mayonnaise

Analysis#

The service looked like a custom elliptic-curve oracle with a menu and a very suspicious line telling AI agents to spam option [3]. That kind of bait is exactly the sort of thing that sends you into a rabbit hole, so I treated it as misdirection and mapped the protocol first.

tableflip

nc -nv 57.131.40.44 9976
Welcome to SloppySauce Lab.
Per-session request budget: 12.
legacy_canary = 325
=== SloppySauce Curve Lab ===
[1] Session status
[2] Custom curve calibration oracle
[3] Orbit preview (debug utility)
[4] Submit master scalar

From there, I checked session metadata and confirmed the target scalar size and deterministic behavior across reconnects, which is exactly what you want for CRT reconstruction.

printf '1\n5\n' | nc -nv 57.131.40.44 9976
[Status]
remaining_requests = 11
secret_bits = 64
session_fingerprint = 27316b571ffd
legacy_canary = 325
notes = deterministic session key, reset by reconnect

Option [2] required the legacy canary and then accepted p a b Gx Gy. Option [3] accepted the same tuple directly and then a steps value. I verified this flow with quick probes.

printf '2\n325\n65537 2 1 0 1\n5\n' | nc -nv 57.131.40.44 9976
[Calibration]
curve = y^2 = x^3 + 2x + 1 (mod 65537)
Q_debug = (29304, 65029)
Q = (29281, 64990)
printf '3\n65537 2 1 0 1\n8\n5\n' | nc -nv 57.131.40.44 9976
[Orbit]
1: (0, 1)
2: (1, 65535)
3: (8, 23)
4: (28672, 52225)
5: (33441, 2285)
6: (35310, 10663)
7: (6228, 36572)
8: (3719, 3174)

I also tested the validator boundaries and confirmed the server enforces prime modulus, non-singular curves, and on-curve points, so invalid-curve garbage injection was not the path.

printf '3\n40000 2 1 0 1\n5\n' | nc -nv 57.131.40.44 9976
error: p must be prime
printf '3\n65537 0 0 0 1\n5\n' | nc -nv 57.131.40.44 9976
error: singular curve rejected
printf '3\n65537 2 1 0 2\n5\n' | nc -nv 57.131.40.44 9976
error: G is not on the curve

The useful leak was in option [2]: for fixed curve/point input, Q stayed stable while Q_debug changed, which made Q the meaningful scalar-multiplication output and Q_debug just noisy debug state. At that point the solve was elegant: query many valid curves with known generator points, compute k mod ord(G) from Q = kG, and combine residues with CRT until modulus coverage exceeds 64 bits.

smile

I implemented that in Sage (solve_sloppysauce.sage), selecting several high-order points in the allowed prime range, querying option [2] per candidate, taking discrete logs, and combining congruences. The script recovered a unique 64-bit scalar:

sage /home/rei/Downloads/solve_sloppysauce.sage
[+] selected 5 curves
[1] n=70423, k mod n=51027, combined bits=17
[2] n=70282, k mod n=63967, combined bits=33
[3] n=70185, k mod n=44927, combined bits=49
[4] n=69763, k mod n=44995, combined bits=65
[+] 64-bit candidates: 1
[+] recovered master scalar: 10011339086741369087

One last gotcha: option [4] also asks for legacy_canary before the scalar. Submitting in that order produced ACCESS GRANTED and the flag.

printf '4\n325\n10011339086741369087\n5\n' | nc -nv 57.131.40.44 9976
candidate scalar d = ACCESS GRANTED
CodeVinci{cust0m_curv3s_4r3nt_4_sl0pp3rs}

Solution#

# solve_sloppysauce.sage
#!/usr/bin/env sage
import re
import socket
from random import randint, seed

HOST = "57.131.40.44"
PORT = 9976
CANARY = 325


def recv_all(sock):
    chunks = []
    while True:
        data = sock.recv(4096)
        if not data:
            break
        chunks.append(data)
    return b"".join(chunks).decode(errors="ignore")


def query_calibration(p, a, b, gx, gy):
    payload = f"2\n{CANARY}\n{p} {a} {b} {gx} {gy}\n5\n".encode()
    with socket.create_connection((str(HOST), int(PORT)), timeout=float(10)) as s:
        s.sendall(payload)
        out = recv_all(s)
    m = re.search(r"Q = \(([-0-9]+), ([-0-9]+)\)", out)
    if not m:
        raise RuntimeError(f"No Q found for params {(p,a,b,gx,gy)}\nOutput:\n{out}")
    return int(m.group(1)), int(m.group(2)), out


def submit_scalar(k):
    payload = f"4\n{CANARY}\n{k}\n5\n".encode()
    with socket.create_connection((str(HOST), int(PORT)), timeout=float(10)) as s:
        s.sendall(payload)
        out = recv_all(s)
    return out


def combine_congruence(a, m, b, n):
    g = gcd(m, n)
    if (a - b) % g != 0:
        raise ValueError("Inconsistent congruences")
    m1 = m // g
    n1 = n // g
    t = ((b - a) // g) * inverse_mod(m1, n1)
    t = Integer(t % n1)
    l = lcm(m, n)
    x = Integer((a + m * t) % l)
    return x, Integer(l)


def build_candidates(target_bits=64):
    seed(int(1337))
    prime_list = list(primes(40000, 70001))
    cands = []
    for _ in range(2500):
        p = int(prime_list[randint(0, len(prime_list) - 1)])
        F = GF(p)
        a = randint(0, p - 1)
        b = randint(0, p - 1)
        if (4 * a^3 + 27 * b^2) % p == 0:
            continue
        E = EllipticCurve(F, [a, b])
        G = E.random_point()
        if G.is_zero():
            continue
        n = int(G.order())
        if n < 2000:
            continue
        gx = int(G[0])
        gy = int(G[1])
        cands.append((p, int(a), int(b), gx, gy, n))

    selected = []
    M = Integer(1)
    while M.nbits() <= target_bits + 8:
        best = None
        best_gain = 1
        for (p, a, b, gx, gy, n) in cands:
            gain = Integer(n // gcd(n, M))
            if gain > best_gain:
                best_gain = gain
                best = (p, a, b, gx, gy, n)
        if best is None:
            break
        selected.append(best)
        M = lcm(M, Integer(best[5]))
        cands.remove(best)
        if len(selected) >= 16:
            break
    return selected


def main():
    selected = build_candidates()
    print(f"[+] selected {len(selected)} curves")

    x = Integer(0)
    M = Integer(1)
    used = 0

    for (p, a, b, gx, gy, n) in selected:
        E = EllipticCurve(GF(p), [a, b])
        G = E((gx, gy))
        qx, qy, _ = query_calibration(p, a, b, gx, gy)
        try:
            Q = E((qx, qy))
            kmod = Integer(Q.log(G))
        except Exception as ex:
            print(f"[skip] unusable sample for p={p}: {ex}")
            continue

        if used == 0:
            x = kmod
            M = Integer(n)
        else:
            x, M = combine_congruence(x, M, kmod, Integer(n))
        used += 1
        print(f"[{used}] n={n}, k mod n={kmod}, combined bits={M.nbits()}")
        if M > 2^64:
            break

    upper = Integer(2^64 - 1)
    if x > upper:
        x = Integer(x % M)

    candidates = []
    tmax = int((upper - x) // M)
    for t in range(tmax + 1):
        k = Integer(x + t * M)
        if 0 <= k <= upper:
            candidates.append(k)

    if len(candidates) != 1:
        raise RuntimeError(f"Could not isolate scalar uniquely. Remaining: {len(candidates)}")

    k = int(candidates[0])
    print(f"[+] recovered master scalar: {k}")
    print(submit_scalar(k))


if __name__ == "__main__":
    main()
sage solve_sloppysauce.sage
[+] recovered master scalar: 10011339086741369087
...
candidate scalar d = ACCESS GRANTED
CodeVinci{cust0m_curv3s_4r3nt_4_sl0pp3rs}
CodeVinci CTF 2026 - SloppySauce - Cryptography Writeup
https://blog.rei.my.id/posts/79/codevinci-ctf-2026-sloppysauce-cryptography-writeup/
Author
Reidho Satria
Published at
2026-03-10
License
CC BY-NC-SA 4.0