← back to posts

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 C1

P2 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 IV

Running 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 IV

We 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 IV

we could rearrange to:

1IV = Dk(C1) XOR desired_P1

If 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:

  1. 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.

  2. 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

TechniqueWhere
mTLS certificate extractionPKCS#12 in APK assets; openssl pkcs12 for PEM conversion
AES-CBC block 2 invarianceP2 = Dk(C2) XOR C1 — no IV dependence
No-pad CBC decryptRecover Dk(C1) by setting IV=0 and stripping no padding
IV huntingHash/UUID candidates from token and profile fields
CBC malleability forgingIV = Dk(C1) XOR desired_P1; validate via block 2 known value
series
BotConf 2026 Android Workshop