Home > Writeups > DEADROP Crypto 3 - oracle_session.py

DEADROP Crypto 3 - oracle_session.py

AES-CBC padding oracle attack against the agency's internal session token system. The oracle reveals one bit per query, valid or invalid PKCS#7 padding, which is enough to recover the full plaintext byte by byte.

oracle_session.py

Challenge Description

The agency's internal comms system issues encrypted session tokens. You've captured one. A validation endpoint is running, it will tell you (via a single bit) whether a decrypted block has valid PKCS#7 padding. That's all you need.

We're given two things:

  • oracle_session.py, a Python file containing the captured token (hex) and a stub for the attack
  • nc oracle.strayerraptors.com 30002, a live oracle service

The oracle protocol is dead simple: send hex-encoded IV || ciphertext + newline, receive 1 (valid padding) or 0 (invalid padding).


Background: Why One Bit Is Enough

AES-CBC decryption works block by block:

P[i] = AES_Decrypt(C[i]) XOR C[i-1]

PKCS#7 padding means the last n bytes of the plaintext must all equal n. For a 16-byte block, valid padding looks like \x01, or \x02\x02, up to \x10\x10...\x10 (16 times).

The oracle tells us when padding is valid. If we control C[i-1] (the previous ciphertext block), we control what P[i] decrypts to. We can use this to recover the intermediate value AES_Decrypt(C[i]), and from that, the real plaintext.


The Attack

For each block C[i], working one byte at a time from right to left:

Goal: recover P[i][j] (byte j of plaintext block i)

What we know: the real C[i-1] from the captured token

What we control: a crafted C'[i-1] that we send to the oracle instead

Step 1: Target padding byte 0x01 (rightmost byte, j=15):

We want the oracle to return 1, meaning the decrypted last byte is 0x01. Since:

P'[i][15] = AES_Decrypt(C[i])[15] XOR C'[i-1][15]

We brute-force all 256 values of C'[i-1][15] until the oracle says 1. When it does:

intermediate[15] = C'[i-1][15] XOR 0x01
plaintext[15]    = intermediate[15] XOR C[i-1][15]   ← real previous block byte

Step 2: Target padding byte 0x02 (byte j=14):

Now we need the last two bytes to both be \x02. We already know intermediate[15], so we fix:

C'[i-1][15] = intermediate[15] XOR 0x02

Then brute-force C'[i-1][14] until the oracle says 1. Recover intermediate[14], compute plaintext[14]. Repeat for each byte position, incrementing the pad target each time.

Step 3: Repeat for all 11 ciphertext blocks.


Implementation

The scaffold in oracle_session.py already handles block splitting, the oracle TCP call, and the outer loop. Only decrypt_block needs implementing:

def decrypt_block(prev_block: bytes, curr_block: bytes) -> bytes:
    intermediate = bytearray(16)

    for byte_pos in range(15, -1, -1):
        pad_byte = 16 - byte_pos

        # Set already-recovered bytes to produce pad_byte
        crafted_prev = bytearray(16)
        for k in range(byte_pos + 1, 16):
            crafted_prev[k] = intermediate[k] ^ pad_byte

        # Brute-force current byte
        for guess in range(256):
            crafted_prev[byte_pos] = guess
            if oracle(bytes(crafted_prev) + curr_block):
                # False positive guard on last byte:
                # flip the byte before to rule out accidental \x02\x02 etc.
                if byte_pos == 15:
                    crafted_prev[14] ^= 1
                    if not oracle(bytes(crafted_prev) + curr_block):
                        crafted_prev[14] ^= 1
                        continue
                intermediate[byte_pos] = guess ^ pad_byte
                break

    return bytes(i ^ p for i, p in zip(intermediate, prev_block))

The false positive guard matters for the rightmost byte only: a guess might produce \x02\x02 (or longer valid padding) instead of \x01. Flipping the second-to-last byte breaks any multi-byte padding, if the oracle still returns 1 after the flip, it's genuinely \x01.


Running It

Update HOST in oracle_session.py if needed, then:

python3 oracle_session.py
[*] Running padding oracle attack...
[*] Decrypting block 11/11...

[+] Plaintext:
session_id=dr-1983-unit7 | clearance=TOP_SECRET | coords=47.6062N,122.3321W |
note=bring_your_own_TI-84_agency_calculators_are_haunted | DEADROP{padding_oracle_hurts_my_soul}

*** FLAG: DEADROP{padding_oracle_hurts_my_soul} ***

12 blocks × 16 bytes × ~128 oracle queries per byte = ~24,500 requests total. Expect 2-3 minutes depending on network latency. If you have a fast local connection and implement connection pooling it can drop to under a minute.


Key Takeaways

1. Never use CBC without authentication. The moment an attacker can query a decryption endpoint and learn anything about the result, even a single bit, CBC is broken as a confidentiality mechanism. Use AES-GCM or AES-CBC-HMAC (encrypt-then-MAC) instead.

2. Error messages are an oracle. The real-world versions of this attack (POODLE, BEAST, Lucky 13) exploited different error messages for padding failures versus MAC failures, or measurable timing differences between error paths. The fix in TLS was to make all error paths take identical time and return identical errors, so the oracle bit disappears.

3. Unauthenticated ciphertext is malleable. Without a MAC over the ciphertext, an attacker can freely modify C[i-1] and the server decrypts without complaint. Authentication isn't just about integrity, it's what makes chosen-ciphertext attacks impossible.

4. The token was encrypted but not authenticated. AES-CBC provides confidentiality only. The server's willingness to attempt decryption on arbitrary input and signal the result is all an attacker needs.


Flag

DEADROP{padding_oracle_hurts_my_soul}

< Back to All Writeups