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.
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:
- Buy two tickets for seat
C07withPOST /buyatscreening=-62164760000andscreening=-62164670000. - Download each
/ticket.pngand decode the QR withzbarimg --rawto get the full signed envelope. - Check that the two
rvalues match, then recoverdand sign the forged ticket withsolver/forge.php. - Encode the forged envelope as a QR with
qrencode -m 2 -s 5and 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 flagReveal
FortID{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.