4 - Handshake: Breaking AES-CBC via IV Recovery and CBC Malleability
Overview
Handshake was a cryptography challenge disguised as a network one. The APK connected to a mutual TLS C2 server, logged in with hardcoded credentials, and received an AES-CBC-encrypted flag. The AES key was in the APK. The IV was not.
Everything needed to decrypt the flag was recoverable, but it required understanding how CBC block chaining works and exploiting the fact that one of the two ciphertext blocks behaves independently of the IV.
The APK — mTLS Setup
The APK bundled everything needed to establish the mutual TLS connection:
assets/client.p12— the client certificate and private key (password:BotConf2026_mTLS)res/raw/ca_cert.pem— the server’s CA certificate
The server address was hardcoded in the Java sources alongside the credentials:
1BASE_URL = "https://164.90.224.241"
2USERNAME = "botconf2026"
3PASSWORD = "mTLS_C2_Fun!"The flow was simple: POST /api/login returns a Bearer token, GET /api/profile (authorized) returns profile fields. Neither endpoint revealed the IV directly.
The AES key and ciphertext were also plaintext in the APK’s resources:
1AES_KEY = "R31m5_BotC0nf_26" (16 bytes, UTF-8)
2CIPHERTEXT = "Qn5QdtrYACLV8bAzL2xJeHZM5dJiDk8iEhySsr4cMIU=" (base64)The ciphertext decodes to 32 bytes — exactly two AES blocks.
Block Layout and the IV-Independent Block
The first observation about AES-CBC with two blocks C1 || C2 is that P2 (the decryption of C2) does not depend on the IV at all:
1P2 = Dk(C2) XOR C1P2 only depends on C2, C1, and the AES key — all of which we have. Setting IV to zero and decrypting without padding removal gives both blocks:
1raw = openssl_decrypt_nopad("00" * 16, CIPHERTEXT, AES_KEY)
2dk_c1 = raw[:16] # = Dk(C1) — the raw block decryption of C1
3p2 = raw[16:32] # = Dk(C2) XOR C1 — unchanged regardless of IVRunning this immediately revealed the second half of the flag:
1P2 = b"5h4k3_m4st3r}\x03\x03\x03"The \x03\x03\x03 is PKCS#7 padding — the plaintext ends with 5h4k3_m4st3r}. The flag was two blocks, and we already had the second.
IV Recovery — Hunting Server-Derived Values
For P1, the CBC equation is:
1P1 = Dk(C1) XOR IVWe knew Dk(C1) from the zero-IV decrypt above. The IV was the unknown.
The most straightforward approach was to try every plausible 16-byte value derivable from the server’s responses. The login endpoint returned a token and several profile fields; all of those were candidates to be hashed or used directly as an IV:
1candidates = {}
2candidates["token_uuid_hex"] = bytes.fromhex(token.replace("-", ""))
3candidates["token_md5"] = md5(token.encode()).digest()
4candidates["token_sha1_16"] = sha1(token.encode()).digest()[:16]
5candidates["token_sha256_16"] = sha256(token.encode()).digest()[:16]
6
7for field, value in profile.items():
8 candidates[f"{field}_md5"] = md5(value.encode()).digest()
9 candidates[f"{field}_sha256_16"] = sha256(value.encode()).digest()[:16]
10 # ... token+field combos, header values ...Each candidate was tried as the IV for openssl enc -aes-128-cbc -d. Any result that decrypted to a string starting with CTF{ and ending with } was the flag.
CBC Malleability as a Fallback
If the IV hunt came up empty, CBC’s malleability property provided a second path. Since:
1P1 = Dk(C1) XOR IVwe could rearrange to:
1IV = Dk(C1) XOR desired_P1If we guessed that P1 started with CTF{ and tried common completions — CTF{p3rf3ct_h4nd, CTF{h4ndsh4k3_ma, CTF{mTLS_h4nd5h4, and so on — we could compute the IV that would produce each guess, then check whether the decrypted result was a valid flag:
1def forge_iv_for_p1(desired_p1: bytes) -> bytes:
2 return xor16(bytes.fromhex(DK_C1_HEX), desired_p1)
3
4guesses = [
5 b"CTF{p3rf3ct_h4nd",
6 b"CTF{h4ndsh4k3_ma",
7 b"CTF{mTLS_h4nd5h4",
8 # ...
9]
10for p1 in guesses:
11 iv = forge_iv_for_p1(p1)
12 result = aes_cbc_decrypt(iv.hex(), CIPHERTEXT, AES_KEY)
13 if result and result.startswith("CTF{") and result.rstrip().endswith("}"):
14 print(f"FLAG: {result.strip()}")The answer CTF{...5h4k3_m4st3r} was confirmed by the second block we already had — any candidate where the full decryption produced P2 = "5h4k3_m4st3r}\x03\x03\x03" was guaranteed correct.
What Made This Work
Two design choices in the challenge made the IV recoverable:
The IV was derived from server state, not generated freshly per request. That meant it was stable across sessions and derivable from the same data the server used to produce it.
Only two ciphertext blocks. With a longer ciphertext, the IV-independent block trick would still reveal all blocks except the first. But here, the second block alone was enough to confirm correctness of any candidate IV.
The mTLS setup was the most time-consuming part — extracting the PKCS#12, building the SSL context, and confirming the certificate chain worked. Once the authenticated session was running, the cryptographic part was mechanical.
Key Techniques
| Technique | Where |
|---|---|
| mTLS certificate extraction | PKCS#12 in APK assets; openssl pkcs12 for PEM conversion |
| AES-CBC block 2 invariance | P2 = Dk(C2) XOR C1 — no IV dependence |
| No-pad CBC decrypt | Recover Dk(C1) by setting IV=0 and stripping no padding |
| IV hunting | Hash/UUID candidates from token and profile fields |
| CBC malleability forging | IV = Dk(C1) XOR desired_P1; validate via block 2 known value |