Crypto ISFCR

Monodromy

22 Oct 2026 • 4 min read

Monodromy (ISFCR CTF 2026) hands you a MONOVM3 capsule, two 8x8 matrices, and a stripped ELF that demands "the lost rotation index" before it will say anything useful. The rotation index…

Monodromy (ISFCR CTF 2026) hands you a MONOVM3 capsule, two 8x8 matrices, and a stripped ELF that demands "the lost rotation index" before it will say anything useful. The rotation index is a discrete logarithm in a ~2^49 subgroup, and the capsule's Argon2id-hardened seal only opens once that index is known. I recovered it with a parallel Pollard-rho solver in C, then opened the seal with a Python script.

Files

Artifact Role
monodromy Stripped x86-64 binary; validates the rotation index and its instrument tape
capsule.bin 22 MB MONOVM3 capsule: header, 57-byte ChaCha20-Poly1305 seal, instrument tape
m0.bin, m1.bin Two 8x8 matrices of 128-bit little-endian values (1024 bytes each); M1 = M0^x
solve.c OpenMP parallel Pollard rho over GF(p²) to recover the rotation index
seal.py Turns the index into the work seed and decrypts the flag seal

Capsule Layout

seal.py's parse function defines the header. The magic is MONOVM3\0, then little-endian fields, then the seal, then the tape:

PYTHON
n0, n1 = struct.unpack_from("<II", blob, 32)
memory_mib, iterations, lanes = struct.unpack_from("<III", blob, 56)
seal_len = struct.unpack_from("<I", blob, 80)[0]

The capsule I parsed has a 32-byte order field at offset 24 equal to 714595551230081 (~2^49.3), a 3,072 MiB / 500-iteration Argon2id setup, seal_len = 57, and a tape of 600,064 + 600,064 records of 19 bytes. The integrity binding covers the outer header and the whole tape:

PYTHON
artifact_hash = hashlib.sha256(blob[:80] + blob[tape_start:]).digest()

The order is public data in the header — the intended work is not guessing which index, but computing it.

Discrete Log in GF(p²)

solve.c implements the field GF(p²) as pairs (a, b) of 128-bit values reduced mod p = 0x270ef8360b0d6589e14828a83b6de4c1, with squaring relation i² = delta0 = 0x77a6c6a7159e7b1·2^64 + 0x941c2a81edc3f387. Multiplication is the classic schoolbook form using _BitInt(256) temporaries:

C
u256 term1 = (a1a2 + b1b2_delta) % (u256)p;
u256 term2 = (a1b2 + a2b1) % (u256)p;

Any 8x8 matrix whose characteristic polynomial splits over this field acts on eigenvectors as multiplication by its eigenvalues — two of which are public constants baked into the solver: g = (lam_a, lam_b) and h = (mu_a, mu_b). Because the roles of g and h in M0 → M1 are known, and the group generated by g has order q = 714595551230081, finding the rotation index is precisely h = g^x mod q over the field.

Parallel Pollard Rho with Distinguished Points

solve.c builds 32 shared random walk steps per worker (srand(1337) for reproducibility), each step adding fixed exponents u_i, v_i:

C
u_step[i] = (rand64() % q);
v_step[i] = (rand64() % q);
Fp2 gu = fp2_pow(g, u_step[i]);
Fp2 hv = fp2_pow(h, v_step[i]);
R[i] = fp2_mul(gu, hv);

Each thread starts from a random g^a0 · h^b0 (seeded 1337 + tid*10007), takes steps indexed by the low bits of the current value, and records distinguished points (13 low zero bits) in a 2^20-entry hash table under an OpenMP lock, with linear probing for collisions:

C
if (((uint32_t)cur.a & DP_MASK) == 0) {
    uint32_t hidx = ...;
    if (fp2_eq(dp_table[hidx].pt, cur) && different exponents) {
        // alpha + beta*log  collision
        cand = d_alpha * mod_inv(d_beta, q) % q;
    }
}

A collision between two walks with exponents (alpha1, beta1) and (alpha2, beta2) satisfies alpha1 - alpha2 = (beta1 - beta2)·x mod q, giving the candidate directly. Threads re-seed a new walk after every distinguished-point insertion, so the table stays small and lock contention is brief. Runs on this class of problem land in minutes on consumer hardware rather than the 2^49 John the Ripper brute force.

Verifying the Candidate

The rho output is only a candidate exponent. It gets checked directly against the matrices, with the negative also tried since the subgroup orientation is ambiguous:

C
Mat8 res1 = mat_pow(M0, cand1);
if (memcmp(&res1, &M1, sizeof(Mat8)) == 0) { /* rotation index */ }

The compiled solve binary in the folder is the built version of solve.c.

Opening the Capsule

seal.py derives the work seed from the index and the artifact hash:

PYTHON
return hashlib.sha256(b"monodromy-v3-work\0" + index.to_bytes(8, "little") + artifact_hash).digest()

then stretches it with Argon2id (3072 MiB, 500 iterations, lanes=1), runs HKDF-SHA256 with salt=artifact_hash and info=b"monodromy-v3-flag", and decrypts:

PYTHON
ChaCha20Poly1305(key).decrypt(blob[68:80], blob[84:84+seal_len], b"monodromy-v3" + artifact_hash)

The seal is a short payload (57 bytes of ciphertext+tag), so the answer is a single line rather than a long message. Note the cost asymmetry — a wrong index still pays the full Argon2id work, so the correctness of the rho result matters before you invoke seal.py.

Flag

Flag: not recovered in the writeup. The decrypted seal text is not preserved in any artifact on disk.

Start searching

Enter keywords to search articles.