Scenario
Caldrin Vowmark knows that not every seal deserves belief. Some marks still carry the weight of a living vow; others only imitate one well enough to pass a glance. She can question the realm's witnesses as many times as she likes, but certainty, it turns out, is much harder to earn than a convincing lie.
Code Analysis (server.py)
Key observations:
KEYis a random 256-bit AES key, andKEY_BITSis its bit decomposition (MSB-first, per the format string0256b).H(msg)is not a cryptographic hash function — it's modular exponentiationG^msg mod P. This is the classic Diffie-Hellman-style "hash" used in some Lamport signature variants, where the security relies on the discrete log problem being hard and onGbeing a generator of a large-order (ideally prime-order) subgroup.- Critically,
Gis read from user input before key generation happens, and the only validation is1 < G < P. There is no check thatGactually generates a large subgroup, or even that it isn't1or-1 mod P.
This is the core oracle logic:
- For a queried index
i(0–255, corresponding to one bit of the AES key):- If
KEY_BITS[i] == 0, the oracle returns a fresh random 256-bit integer, completely unrelated to any secret or public key material. - If
KEY_BITS[i] == 1, the oracle returns one of the two public-key hash valuesPK[i][0]orPK[i][1](chosen at random), wherePK[i] = (H(sk[i][0]), H(sk[i][1])).
- If
- Results are cached per index (
self.appeared), so repeated queries to the sameialways return the same value — this doesn't matter for our attack since one query per index is already sufficient information.
Standard Lamport-style keygen: for each of the 256 bit positions, generate two random 256-bit secrets and publish their images under H.
The server:
- Immediately hands us the AES-ECB encryption of the flag under
KEY(the same key whose bits we're trying to leak). - Lets us choose
Gbeforekeygen()runs, meaningGis fixed for the entire session and directly controls the behavior ofH. - Then exposes the oracle for as many queries as we like across all 256 bit indices.
This is the entire vulnerability: the "random oracle" generator is attacker-controlled, so we can make H degenerate into something with a tiny, predictable image.
The Attack: Malicious Generator / Small Subgroup Confinement
The scheme's security implicitly assumes G generates a subgroup of (Z/PZ)* large enough that:
H(msg) = G^msg mod Plooks indistinguishable from a random 256-bit value for a uniformly randommsg(this is what letsKEY_BITS[i]==1outputs blend in with theKEY_BITS[i]==0random outputs).
But nothing stops us from choosing a G of very small multiplicative order. The simplest possible choice is:
Since (-1)^2 = 1 mod P, G has order exactly 2. Therefore, for any secret exponent msg:
No matter what the random 256-bit secret sk[i][0] or sk[i][1] actually is, H(sk) can only ever be one of two fixed values: 1 or P-1.
Consequences for the oracle:
- If
KEY_BITS[i] == 1: the oracle returnsH(sk[i][b])for some secretsk, which — thanks to our choice ofG— is deterministically1orP-1. - If
KEY_BITS[i] == 0: the oracle returnssecrets.randbelow(2**256), a uniformly random integer in[0, 2^256).
The probability that a uniformly random 256-bit integer happens to equal 1 or P-1 is astronomically small (~2^-255), so this is a perfect (for practical purposes) distinguisher:
Querying every index 0..255 exactly once and applying this rule recovers the entire 256-bit AES key in a single pass over the oracle — no brute force, no partial information, no ambiguity.
Once the key is recovered, decrypting the AES-ECB ciphertext printed at the start of the session (AES.new(KEY, AES.MODE_ECB).encrypt(pad(FLAG, 16))) directly yields the flag.
Why this works in general
This is a well-known pitfall in discrete-log-based constructions: never let the party being proven-to choose the generator/base of the exponentiation unless its order is verified. A generator of small order d collapses G^msg mod P into only d possible outputs regardless of the size of msg's domain, destroying any pseudorandomness or hiding property the construction relied on. Here d = 2 was the most convenient degenerate choice, but any small-order element (order 3, 4, small primes dividing P-1, etc.) would have worked similarly, just with slightly more possible output values to check against.
Exploit
First (naive) version — one query per bit, synchronous
This works correctly but performs 256 full request/response round trips, each incurring network latency — noticeably slow (tens of seconds to minutes depending on RTT).
Optimized version — pipelined single round trip
Since the server just reads lines from stdin in a loop and doesn't need to see our next command before printing the previous result, we can send all 256 queries (and the exit command) at once, then read the entire output in one shot and parse it:
This reduces the exploit from ~256 network round trips down to essentially one send + one recv, since the server's input loop doesn't require synchronous back-and-forth — it just consumes whatever is in its stdin buffer line by line and writes results out as it goes.
Result: the recovered KEY correctly decrypts the ECB ciphertext (after PKCS#7 unpadding) to recover the flag.
Root Cause & Take Aways
| Issue | Description |
|---|---|
| Untrusted parameter selection | The client selects G, a core cryptographic parameter, with only a trivial range check (1 < G < P) and no subgroup-order validation. |
| No safe-prime / prime-order group enforcement | The server never verifies that G generates a large prime-order subgroup of (Z/PZ)*, which is required for H to behave as intended. |
| Distinguishable branches | The "decoy" branch (KEY_BITS[i]==0) draws from the full 256-bit space, while the "real" branch is confined to whatever G's order allows — an attacker who shrinks that order creates a statistical/structural distinguisher between the two branches. |
| One-shot, but fatal, secret leakage | Because each oracle index directly reveals one KEY_BITS[i] value with near-certainty, the entire 256-bit key is recoverable with N queries and zero brute force. |
Fix recommendations:
- Never allow a client-supplied generator for a DH-style hash/commitment function; fix
Gserver-side as a verified generator of a large prime-order subgroup (or use an actual cryptographic hash function instead of modular exponentiation). - If a generator must be configurable, validate its multiplicative order (e.g., require
ord(G)to be a large prime factor ofP-1) before proceeding. - Avoid designs where an oracle's two branches (decoy vs. real) have visibly different output distributions/ranges.