Neven Marić
FortID 2026

CryptoFortID 2026

Hacker Cinema

ECDSA nonces were keyed on a date from one PHP parser while the signed message used another. Near year 0 they disagree, so two different tickets shared a nonce, leaking the signing key and letting me forge a winner.

  • ecdsa
  • nonce-reuse
  • deterministic-nonce
  • php
  • date-parsing
  • qr-code
  • key-recovery

What this one teaches

  • How ECDSA nonce reuse leaks the private key, with the two formulas for k and d.
  • Why a deterministic nonce has to be a function of the exact signed message, and what breaks when it is derived from a different representation of the same data.
  • How PHP's DateTimeImmutable('@ts') and createFromFormat('U', ts) can disagree around year 0, and how to find such a pair by running the real functions instead of reasoning about 86400 second days.
  • How to turn a recovered signing key into a forged ticket that a QR scanner endpoint accepts.

Hints

Stuck? Open one hint, then go back and try again before reading the next.

Hint 1 of 3Show

The signed message and the nonce domain both contain a date. Are they computed the same way?

Hint 2 of 3Show

The kiosk accepts screening timestamps down to about year -249. Why would it?

Hint 3 of 3Show

Do not assume a kiosk day always lasts 86400 seconds. Call both date functions over the range.

Full solution below. Spoilers ahead.

Task description

🎬✨ NOW OPEN: Hacker Cinema ✨🎬

We’re the city’s newest movie theater, and we have exactly one film: Hackers from 1995. Every day. Every screening. No sequels, no remakes, no apologies. 🕶️💾

Entry is free, the vibe is cyberpunk, and yes — you’ll need a ticket. Grab your seat, and come enjoy the most elite cinema experience on the mainframe. 🍿⚡

The handout was a PHP 8.4 web app (php:8.4.16-cli in the Dockerfile), plus a solver/ folder with forge.php and two shell scripts, and the service was a live instance.

The app

The site sells free QR tickets for one movie, Hackers (1995). Each ticket is a JSON envelope signed with ECDSA over secp256k1 (SHA-256). The /check endpoint scans an uploaded QR image and prints the flag when the ticket is a validly signed winner:

if (($ticket['movie'] ?? '') === MOVIE
 && ($ticket['screen'] ?? '') === SCREEN
 && ($ticket['winner'] ?? false) === true) {
    render_check('', 'JACKPOT ACCEPTED.', read_flag());
}

Legitimately that never happens. winner comes from miracle_winner(), which needs sha256("neon-jackpot|$seat|$ts") to be 64 zeros, a 256-bit preimage. The signer never signs a winner ticket, so I need the signing key to forge one. verify_envelope only checks the signature over date, movie, screen, seat and winner, and nothing ties the date on the ticket to the screening it was bought for.

Two dates, two parsers

The private key d and the nonce key come from two independent secret seeds. The nonce is deterministic (RFC 6979 style, where the nonce is a hash of the message rather than a random value, but here keyed with a secret), and it is derived from a domain that contains one date:

function derive_nonce(array $ticket, int $screeningTs): GMP {
    $domain = [
        'movie' => $ticket['movie'], 'screen' => $ticket['screen'],
        'seat'  => $ticket['seat'],
        'date'  => kiosk_partition_date($screeningTs),   // '@' parser
        'winner'=> $ticket['winner'],
    ];
    $material = hash_hmac('sha256', 'ticket-v1|'.canonical_json($domain), nonce_key(), true);
    return gmp_add(gmp_mod(gmp_from_bytes($material), gmp_sub(N(),1)), 1);
}

The message that is actually signed carries a date computed another way:

$ticket = ['date' => ticket_date_from_timestamp($screeningTs), /* ... */];   // 'U' parser
$message = canonical_json($ticket);
[$r, $s] = ecdsa_sign($message, derive_nonce($ticket, $screeningTs));
function ticket_date_from_timestamp(int $ts): string {      // message date
    $dt = DateTimeImmutable::createFromFormat('U', (string)$ts);
    return $dt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d');
}
function kiosk_partition_date(int $ts): string {            // nonce date
    $dt = new DateTimeImmutable('@' . (string)$ts);
    return $dt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d');
}

Both dates sit inside canonical JSON, whose keys are sorted (canonical_json runs ksort), so the only thing that can differ between the nonce domain and the signed message is the date string. A deterministic nonce is safe only while every distinct message gets a distinct nonce. If two different messages land in the same nonce domain, the nonce repeats, which is classic ECDSA nonce reuse.

For ordinary timestamps both functions agree. The app also accepts screening times back to MIN_SCREENING_TS = -70000000000, roughly year -249, which reaches the proleptic calendar (the Gregorian rules extended backwards before their own adoption) around year 0, where the two parsers can diverge.

Finding a pair that collides

My first attempt assumed both parsers roll the date over at multiples of 86400 seconds from the shared epoch, so a kiosk day could never contain two ticket dates. A scan built on that (flooring timestamps by 86400) found nothing. Near year 0 the @ parser does not advance one civil day per 86400 seconds, so the right test is to call both real functions over the allowed range and compare, rather than reasoning about day lengths by hand.

That showed the @ parser holding one civil date across about 172800 seconds (two civil days) in the year 0 region, while the 'U' parser moves on normally. This is the pair I used:

Timestamp kiosk_partition_date (nonce) ticket_date_from_timestamp (message)
-62164760000 0000-01-29 0000-01-29
-62164670000 0000-01-29 0000-01-30

Same seat (C07), same nonce domain, so the same k and the same r, but two different messages (dates 0000-01-29 and 0000-01-30). The two timestamps are 90000 seconds apart, a difference that comes from how PHP’s date parser reads the two strings. The key-recovery math below does not depend on the exact timestamp values, and that part reproduces independently.

Recovering the key

With two signatures (r, s1) on hash z1 and (r, s2) on hash z2, both signed with the same k, two unknowns fall out by linear algebra mod n:

k = (z1 - z2) / (s1 - s2)    (mod n)
d = (s1 * k - z1) / r        (mod n)

The first comes from subtracting the two signing equations s = k^-1 (z + r*d), which cancels d; the second substitutes k back into either equation. These are the lines in solver/forge.php:

$k = mod_g(gmp_mul(gmp_sub($z1,$z2), inv_mod(gmp_sub($s1,$s2),$n)), $n);
$d = mod_g(gmp_mul(gmp_sub(gmp_mul($s1,$k),$z1), inv_mod($r,$n)), $n);

I reimplemented secp256k1 and this recovery in Python to confirm it end to end: sign two different messages under one shared k, check the two r values match, recover k and d from the formulas, then sign a winner: true ticket with the recovered d and verify it under the recovered public key.

r1 == r2 (nonce reuse)                      : True
k recovered correctly                       : True
d recovered correctly                       : True
forged winner verifies under recovered key  : True

Forging the winner and submitting it

With d, I sign any ticket I like, using the fixed nonce forge.php hard-codes (0x2a2a...2a, the byte * repeated). The date on the forged ticket is irrelevant to the verifier:

{"date":"1995-09-15","movie":"Hackers","screen":"mainframe-1","seat":"C07","winner":true}

The steps, as in solver/solve_live.sh:

  1. Buy two tickets for seat C07 with POST /buy at screening=-62164760000 and screening=-62164670000.
  2. Download each /ticket.png and decode the QR with zbarimg --raw to get the full signed envelope.
  3. Check that the two r values match, then recover d and sign the forged ticket with solver/forge.php.
  4. Encode the forged envelope as a QR with qrencode -m 2 -s 5 and upload it to /check.
BASE=<instance URL> sh solver/solve_live.sh

The script needs the challenge image’s tools (curl, zbar-tools, qrencode, PHP with GMP and GD). The handout’s flag.txt is a placeholder, so the real flag comes from the live run, where both envelopes shared

r = d94f159cb77554cad36cbac8e71b6fbe48f5f87f395a4b2a6878d942608ca625

which recovered

d = f82d3f2368f95ff0fe93540684f19bd12207c38ba923432da1a37ea8432f89c2

and the forged winner: true QR returned JACKPOT ACCEPTED with the flag. The recovery and forgery arithmetic reproduces independently of those live values.

Takeaways

The nonce has to be a function of the exact bytes being signed (RFC 6979), or a CSPRNG value. Here it depended on a second representation of the date, and the two representations differ near year 0. Accepting screening times back to year -249 had no product purpose and opened the window. The signature verification itself was standard and correct; the break was entirely in nonce derivation. Convert a value to its canonical form once and reuse the result, rather than computing it two ways and hoping they agree.

The flagRevealFortID{F0rg3_7h3_T1ck37_4nd_H4CK_TH3_PL4N3T!_d1786748d8}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.