$ cat writeup.md…
$ cat writeup.md…
Hack.lu2026
Task: AES-CBC 'pro activation token' service with a PKCS#7 padding oracle and a custom XOR auth tag. Solution: recover D(.) block-by-block via the oracle, forge the token with backward CBC chaining, and satisfy the tag using trailing whitespace blocks.
Note: Your favorite LinkedIn quote generator has recently moved its truly inspirational quotes behind the Pro tier to keep up with rapidly scaling infrastructure demands.
Already purchased Pro for yourself or your team? Redeem the activation token associated with your Account ID from your billing confirmation.
English summary: a Python TCP service (TLS-wrapped, reached via ncat --ssl <host> 443) is a joke "inspirational quote generator". Free quotes are public; the flag is only returned once Session.sub_state == "pro", which requires submitting a valid "Pro activation token associated with your Account ID" over the upgrade operation. The full server.py source was provided. No real token is ever given, so it must be forged.
The relevant code (server.py):
KEY = bytes.fromhex(os.getenv("KEY", 32 * "0")) if len(KEY) != 16: raise ValueError("Invalid key length.") class Session: def set_name(self, name: str) -> str: self.username = name self.user_id = SHA256.new(name.encode()).hexdigest() # 64 hex chars return f"Account ID: {self.user_id}" def upgrade(self, inp: str) -> str: try: inp_b = bytes.fromhex(inp) except ValueError: return "Invalid activation token format." if len(inp_b) < 16 * 3 or len(inp_b) % 16 != 0: return "Invalid activation token format." iv, ct, auth_tag = inp_b[:16], inp_b[16:-16], inp_b[-16:] cipher = AES.new(KEY, mode=AES.MODE_CBC, iv=iv) pt = unpad(cipher.decrypt(ct), 16) if pt.decode("latin-1").strip() != f"uid: {self.user_id}; tier: pro": return "Activation token does not match this account." auth_tag_dec = AES.new(KEY, AES.MODE_ECB).decrypt(auth_tag) if auth_tag_dec != xor_blocks(pt): return "Activation token integrity check failed." self.sub_state = "pro" return f"Welcome to Pro, {self.username}. ..." def get_quote(self) -> str: if self.sub_state == "pro": return f"Exclusive Pro insight: {FLAG}" ... def main(): ... while True: ... try: match op: case "upgrade": print(session.upgrade(input("Pro activation token: "))) ... except ValueError: pass # <-- swallows unpad()'s padding error, printing NOTHING
Three observations drive the whole attack:
Padding oracle. If the CBC decryption has invalid PKCS#7 padding, unpad() raises ValueError, which the except ValueError: pass around the match block silently swallows. The loop then prints nothing for that token. If the padding is valid but the plaintext does not match, the service prints "Activation token does not match this account.". So every upgrade query leaks exactly one bit: valid padding or not.
The token is a CBC ciphertext we can forge if we have a D(.) oracle (raw AES decrypt) for arbitrary blocks. With a single-block ct (iv || C || tag, 48 bytes total which meets len >= 48), the decrypted plaintext is D(C) XOR iv, so the oracle recovers D(C) byte-by-byte, last byte first (targeting pad 0x01, then 0x02, ...). With D(.) we can encrypt arbitrary known plaintext via backward CBC chaining: pick a random C_n, learn I_n = D(C_n), set C_{n-1} = I_n XOR P_n, learn I_{n-1} = D(C_{n-1}), ... and finish with IV = I_1 XOR P_1. No key needed.
The auth tag is a homemade construction. It checks AES_ECB_decrypt(KEY, auth_tag) == xor_blocks(pt), where xor_blocks XORs every byte of the (unpadded) plaintext positionally mod 16. We cannot invert AES to pick an auth_tag that maps to a chosen value, but we can choose auth_tag = T freely, recover V = D(T) with the padding oracle, and then force xor_blocks(pt) == V.
The plaintext comparison uses .strip(), so trailing whitespace after the target string is accepted. The latin-1 characters that str.strip()/str.isspace() accept are {09, 0a, 0b, 0c, 0d, 1c, 1d, 1e, 1f, 20, 85, a0} — 12 values. Appending N full 16-byte blocks drawn from that alphabet lets us hit
xor_blocks(pt) = xor_blocks(target) ^ (W1 ^ W2 ^ ... ^ WN) = K ^ W
where K = xor_blocks(target) depends only on user_id = sha256(username) and W is the XOR of the whitespace blocks. We need K ^ W = V, i.e. W = V ^ K.
Crucially, KEY is a process-wide env variable shared across every forked connection, so V = D(T) is stable. That enables a two-connection plan:
V = D(T).u0, u1, u2, ... until W_i = V ^ K(u_i) is achievable as the XOR of N=4 whitespace bytes in every one of the 16 positions (precompute a table value -> 4 bytes).D(.) for the data blocks while backward-chaining the CBC ciphertext for target || Wblocks || 0x10*16, assemble token = IV || C1..C10 || T, submit upgrade, then call get_inspirational_quote to receive the flag.With N=4, the reachable per-byte set |R4| = 121 distinct values (adding more blocks saturates around 128), so the offline name search terminates quickly. The forged token is 192 bytes: 16 (IV) + 10 ct blocks + 16 (tag).
Padding-oracle recovery of D(C) with a last-byte disambiguation flip test:
S = [0x09,0x0a,0x0b,0x0c,0x0d,0x1c,0x1d,0x1e,0x1f,0x20,0x85,0xa0] # latin-1 str.isspace def rn_table(n): R = {0: ()} for _ in range(n): new = {} for v, t in R.items(): for s in S: nv = v ^ s if nv not in new: new[nv] = t + (s,) R = new return R def xorblocks(b): res = bytearray(16) for i, x in enumerate(b): res[i % 16] ^= x return bytes(res) def xorb(a, b): return bytes(x ^ y for x, y in zip(a, b))
The oracle itself batches all 256 guesses for one byte into a single write; the response segment "does not match this account" marks valid padding:
def recover(self, block): """Recover D(block) (AES-CBC intermediate) byte-by-byte (pad 0x01..0x10).""" assert len(block) == 16 inter = bytearray(16) for idx in range(15, -1, -1): padval = 16 - idx prefix = bytearray(16) for j in range(idx + 1, 16): prefix[j] = inter[j] ^ padval # force known pad bytes toks = [] for g in range(256): prefix[idx] = g toks.append((bytes(prefix) + block + b"\x00" * 16).hex()) res = self.bulk(toks) cands = [g for g in range(256) if res[g]] if len(cands) == 1: inter[idx] = cands[0] ^ padval else: # flip test: a true length-1 pad survives a flip of byte 0; longer accidentals break chosen = None for g in cands: prefix[idx] = g t = bytearray(prefix) jj = idx - 1 if idx > 0 else 1 t[jj] ^= 0x01 if self.bulk([(bytes(t) + block + b"\x00" * 16).hex()])[0]: chosen = g break inter[idx] = (chosen if chosen is not None else (cands[0] if cands else 0)) ^ padval return bytes(inter)
Backward CBC chaining forges the token; the final assertion is the tag precondition:
def forge(c, name, T, table, n_ws): B = [target(name)[i:i + 16] for i in range(0, 80, 16)] # uid: <sha256>; tier: pro == 80 bytes pad = bytes([16]) * 16 V = c.recover(T) # D(T) in this session K = xorblocks(target(name)) W = xorb(V, K) if not all(b in table for b in W): raise RuntimeError("chosen name not reachable (W=%s)" % W.hex()) ws = ws_blocks(W, table, n_ws) # 4 whitespace blocks with XOR == W P = B + ws + [pad] n = len(P) C = [None] * n; I = [None] * n C[n - 1] = bytes(random.randrange(256) for _ in range(16)) I[n - 1] = c.recover(C[n - 1]) for i in range(n - 1, 0, -1): C[i - 1] = xorb(I[i], P[i]) I[i - 1] = c.recover(C[i - 1]) IV = xorb(I[0], P[0]) token = IV + b"".join(C) + T assert xorblocks(b"".join(P[:-1])) == V, "tag xor mismatch" return token
Main flow:
T = bytes(range(16)) table = rn_table(4) # 121 reachable byte values # Phase 1: recover V = D(T) on connection 1 (KEY is global across connections) c1 = handshake(host, port, "probe", use_ssl=True) V = c1.recover(T) # Offline: find a username whose target makes W = V ^ K reachable name, W = find_name(V, table) # e.g. "u230933" # Phase 2: reconnect as that user and forge the token c2 = handshake(host, port, name, use_ssl=True) token = forge(c2, name, T, table, 4) resp = c2.submit(token.hex()) # -> "Welcome to Pro, ..." c2.send("get_inspirational_quote\n") # -> "Exclusive Pro insight: flag{...}"
Full working script is solve.py. Runtime notes: the batched 256-guesses-per-byte oracle completes ~11 block recoveries (10 data blocks + the tag block) in about 88 seconds end-to-end over TLS. A local replica hosted with
socat TCP-LISTEN:31337,reuseaddr,fork EXEC:"python3.12 server.py" (with KEY/FLAG in the env) reproduced the attack in ~4 seconds.
Result: the service returned the flag in the flag{...} format (redacted here).
Use this technique when:
unpad() in a try/except whose exception is swallowed, so a padding failure produces no output while valid padding + wrong plaintext produces a distinct message — a textbook padding oracle.iv || ct || auth_tag with a nonstandard integrity check such as AES_ECB_decrypt(key, tag) == <xor of plaintext bytes>, which is forgeable once D(.) is available..strip() (or similar normalization) after decryption, allowing attacker-chosen trailing whitespace/control bytes to tune the XOR accumulator to any target value.KEY from the environment), letting you learn D(T) once and reuse it after reconnecting with a chosen identity.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar