790 words
4 minutes
UniVsThreats 26 Quals CTF - Voyager's Last Command - Cryptography Writeup

Category: Cryptography
Flag: UVT{v0y4g3r_s1gn3d_1t5_0wn_d34th}

Challenge Description#

Year 2387 You have established an uplink to the Voyager-X probe via an emergency telemetry relay.

Analysis#

I started by pulling one full live transcript from the server so I could capture all public parameters and exactly three signatures in a single session.

timeout 8 bash -lc "printf 'SIGN 00\nSIGN 01\nSIGN 02\nQUIT\n' | nc 194.102.62.166 22869"
│  Curve   : secp256k1
│  LCG  a  : 0x8d047be6d23ed97a5f8e83d6ff20b1366123dc858503be002764b531cdac5597
│  Qx  :  0xf29323d459059cfd3e09fc375cf0054923ce8b7b8b579328631a533d24bd145d
│  Qy  :  0xf167a0ca58327b757fe28893ecd75dfce809f749950f8f8345db0a291c25fcdf
│  Data    :  3004b7c0d22488c063e58f8b9e62eabea30befcf12554e75
│             0a0e78744099abe9592a03f86adeb2bfc56add83645a856c
│  r  :  0x70c62034e4eaa385710a9109d801fe2097bdc89eabc6f49e0210743f61bedb5d
│  s  :  0x803d4f32de48090588a8f7b334ce00b684eaa056ed4e1182ff38f896b2e01df5
│  z  :  0x6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d
│  r  :  0xbae63fd8c48a268357ed1ecaaa1d2b98014998f6857d654f5115d31d5d378c37
│  s  :  0xa0faa3d959162d7abc890f8c949996119f2820a3c9f2ed0227d6a7745a38e876
│  z  :  0x4bf5122f344554c53bde2ebb8cd2b7e3d1600ad631c385a5d7cce23c7785459a
│  r  :  0xdb3577e944aee428d58d2f1e6d5c11d9c6ba35a290e684f6724d8e6daa7cb3f2
│  s  :  0x2e5417786a1197bfaa5ba8ecf7761f17c62653949269980e27eb140d8ed17948
│  z  :  0xdbc1b4c900ffe48d575b5da5c638040125f65db0fe3e24494b76ea986457d986

Those values were enough to model the nonce fault directly. With an LCG nonce stream, k2 = a*k1 + b and k3 = a*k2 + b, so k3 - (a+1)k2 + a*k1 = 0 (mod n). For ECDSA, k = (z + r*d) * s^{-1} (mod n), so substituting three signatures collapses to one linear equation in d. Because some services normalize signatures with s or n-s, I solved all 8 sign branches and accepted only the candidate where Q = d*G matched the provided public key.

smile

My first run hit a parser bug, not a math bug: I had parsed the boxed ciphertext lines too rigidly.

python3.12 /home/rei/Downloads/solve_voyager.py
Traceback (most recent call last):
  File "/home/rei/Downloads/solve_voyager.py", line 147, in <module>
    main()
  File "/home/rei/Downloads/solve_voyager.py", line 135, in main
    ct = parse_ciphertext(text)
ValueError: Could not parse ciphertext

After loosening ciphertext extraction to read all hex chunks in the Data box, the exact same key-recovery equations worked immediately. The solver recovered d, verified the public key match, derived AES-128-ECB key as SHA-256(d)[:16], and decrypted the final directive to the flag.

python3.12 /home/rei/Downloads/solve_voyager.py
Recovered d: 0xad2f34da51dc500a96f13174ec242a42e13975f23bfdeed81f5b11cf2ae45951
Sign branch (e1,e2,e3): (1, 1, 1)
Flag: UVT{v0y4g3r_s1gn3d_1t5_0wn_d34th}

Once d reconstructed and the AES plaintext matched the expected UVT{...} format, the challenge was done.

dance

Solution#

# solve.py
#!/usr/bin/env python3.12
import hashlib
import re
import socket
from itertools import product

from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
from ecdsa.ecdsa import generator_secp256k1


HOST = "194.102.62.166"
PORT = 22869

N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141


def recv_until(sock: socket.socket, token: bytes, timeout: float = 10.0) -> bytes:
    sock.settimeout(timeout)
    out = b""
    while token not in out:
        chunk = sock.recv(4096)
        if not chunk:
            break
        out += chunk
    return out


def recv_all(sock: socket.socket, timeout: float = 2.0) -> bytes:
    sock.settimeout(timeout)
    out = b""
    while True:
        try:
            chunk = sock.recv(4096)
        except TimeoutError:
            break
        if not chunk:
            break
        out += chunk
    return out


def parse_field(text: str, name: str) -> int:
    m = re.search(rf"{name}\s+:\s+0x([0-9a-f]+)", text)
    if not m:
        raise ValueError(f"Could not parse field {name}")
    return int(m.group(1), 16)


def parse_ciphertext(text: str) -> bytes:
    m = re.search(r"Data\s+:\s*(.*?)\n\s*", text, flags=re.DOTALL)
    if not m:
        raise ValueError("Could not parse ciphertext")
    hex_parts = re.findall(r"[0-9a-f]{16,}", m.group(1))
    if not hex_parts:
        raise ValueError("Could not parse ciphertext hex chunks")
    return bytes.fromhex("".join(hex_parts))


def parse_signatures(text: str):
    sigs = re.findall(
        r"r\s+:\s+0x([0-9a-f]+).*?s\s+:\s+0x([0-9a-f]+).*?z\s+:\s+0x([0-9a-f]+)",
        text,
        flags=re.DOTALL,
    )
    if len(sigs) != 3:
        raise ValueError(f"Expected 3 signatures, got {len(sigs)}")
    return [(int(r, 16), int(s, 16), int(z, 16)) for r, s, z in sigs]


def inv(x: int) -> int:
    return pow(x % N, -1, N)


def recover_private_key(a: int, qx: int, qy: int, sigs):
    (r1, s1, z1), (r2, s2, z2), (r3, s3, z3) = sigs
    is1, is2, is3 = inv(s1), inv(s2), inv(s3)

    for e1, e2, e3 in product((1, -1), repeat=3):
        coeff = (e3 * r3 * is3 - (a + 1) * e2 * r2 * is2 + a * e1 * r1 * is1) % N
        const = (e3 * z3 * is3 - (a + 1) * e2 * z2 * is2 + a * e1 * z1 * is1) % N
        if coeff == 0:
            continue

        d = (-const * inv(coeff)) % N
        q = d * generator_secp256k1
        if q.x() == qx and q.y() == qy:
            return d, (e1, e2, e3)

    raise ValueError("No valid private key candidate found")


def derive_flag(d: int, ct: bytes) -> str:
    d_forms = [d.to_bytes(32, "big"), d.to_bytes(max(1, (d.bit_length() + 7) // 8), "big")]

    seen = set()
    for db in d_forms:
        if db in seen:
            continue
        seen.add(db)

        key = hashlib.sha256(db).digest()[:16]
        pt = AES.new(key, AES.MODE_ECB).decrypt(ct)

        for cand in (pt, unpad(pt, 16) if len(pt) % 16 == 0 else pt):
            m = re.search(rb"UVT\{[^}]+\}", cand)
            if m:
                return m.group(0).decode()

    raise ValueError("Failed to derive flag from decrypted plaintext")


def main():
    with socket.create_connection((HOST, PORT), timeout=10) as s:
        banner = recv_until(s, b"oracle@voyager-xi [3 sigs left] > ", timeout=10)
        s.sendall(b"SIGN 00\nSIGN 01\nSIGN 02\n")
        rest = recv_all(s, timeout=2)

    text = (banner + rest).decode("utf-8", errors="replace")

    a = parse_field(text, "LCG  a")
    qx = parse_field(text, "Qx")
    qy = parse_field(text, "Qy")
    ct = parse_ciphertext(text)
    sigs = parse_signatures(text)

    d, signs = recover_private_key(a, qx, qy, sigs)
    flag = derive_flag(d, ct)

    print(f"Recovered d: {hex(d)}")
    print(f"Sign branch (e1,e2,e3): {signs}")
    print(f"Flag: {flag}")


if __name__ == "__main__":
    main()
python3.12 solve.py
Recovered d: 0xad2f34da51dc500a96f13174ec242a42e13975f23bfdeed81f5b11cf2ae45951
Sign branch (e1,e2,e3): (1, 1, 1)
Flag: UVT{v0y4g3r_s1gn3d_1t5_0wn_d34th}
UniVsThreats 26 Quals CTF - Voyager's Last Command - Cryptography Writeup
https://blog.rei.my.id/posts/56/univsthreats-26-quals-ctf-voyager-s-last-command-cryptography-writeup/
Author
Reidho Satria
Published at
2026-02-27
License
CC BY-NC-SA 4.0