CRYJAIL
Challenge
Send us the encrypted names of everyone you've reconnaissanced so far.
The service is a Landlock-sandboxed Python process (via pwn.red/jail) that repeatedly reads a line of iv:ciphertext hex, AES-CBC decrypts it with a fixed random key, and drops the decrypted bytes into a tiny generated Python program that it forks off and runs:
KEY = get_random_bytes(16)
SAFE = set(range(0x20, 0x7F)) # printable ASCII, including '"' and '\'
def escape(data: bytes) -> str:
out = []
for c in data:
out.append(chr(c) if c in SAFE else "\\x%02x" % c)
return "".join(out)
def build_program(name: bytes) -> str:
return (
"import os\n"
'devnull = os.fdopen(os.open("/dev/null", os.O_WRONLY), "w")\n'
'print(b"IMPLANTING NEURO LINK CHIP IN INDIVIDUAL IDENTIFIYING AS: ' + escape(name) + '!", file=devnull)\n'
)
The child compiles and execs that generated file; if it throws, the parent reports a crash, otherwise it reports success. The AES key is generated once per connection and never changes, so every request in a session is decrypted under the same key.
The bug
escape() is meant to make arbitrary bytes safe to drop into a Python b"..." literal, but its SAFE set is just "printable ASCII" — which includes " (0x22) and \ (0x5c). A decrypted name byte that happens to equal a literal quote character terminates the byte-string early in the generated source, and everything after it becomes real Python that gets compiled and executed. We don't control the plaintext directly (it's whatever AES-CBC decrypts to), but that's just a matter of choosing the right ciphertext — this isn't a padding oracle (there's no padding check anywhere), it's a syntax oracle.
Turning "crash or not" into a decryption oracle
Because the key is fixed for the whole connection, this is a CBC decryption oracle we get to query as many times as we want. For a single ciphertext block ct, standard CBC math gives:
P[i] = D(ct)[i] XOR IV[i]
So for each byte position i, we can force P[i] to be exactly 0x22 (a quote) by guessing g and setting IV[i] = g XOR 0x22 — the guess is correct exactly when g == D(ct)[i]. When it's correct, the byte string breaks early and the leftover escaped bytes spill out as raw source, which is overwhelmingly likely to be a SyntaxError → crash. When it's wrong, the byte just becomes ordinary (safely escaped) string content and the program runs fine.
The catch: with every other byte position left random, that "spillage" after the forced quote is also random, and short random Python fragments occasionally happen to parse as valid syntax by pure chance — especially once there are only a few trailing bytes left to spill. That turns the oracle statistical instead of deterministic and starts producing false negatives past the first couple of bytes.
The fix is to resolve the block right-to-left (byte 15 first, down to byte 0). By the time we're guessing byte i, every byte after it is already known, so it can be pinned to a fixed, harmless filler character instead of left random. That makes everything after the break point deterministic: exactly one guess crashes, all 255 others don't. Repeat for all 16 bytes and you've recovered the full AES intermediate state D(ct) for that block — without ever learning the key.
From "known intermediate state" to RCE
Once D(ct) is known for a chosen block, CBC malleability gives full control over that block's plaintext: pick any IV, and P = D(ct) XOR IV is fully attacker-chosen. A single block is only 16 bytes though, and a clean re-use of the template's fixed trailing text (!", file=devnull)) to close everything off properly needs slightly more room than that.
So: solve two blocks. Reuse the same trick recursively — once D(ct) is known, choose a second ciphertext block C1 such that D(ct) XOR C1 equals whatever second-block plaintext you want, then run the exact same right-to-left oracle attack against C1 to learn D(C1), which in turn lets you pick an IV for the first block. Two solved blocks → 32 fully-controlled bytes, sent as one iv:ciphertext pair.
32 bytes is enough for a direct payload with no interactive round-trip needed:
",os.system('cat /flag*'))#
os is already imported by the generated program. The leading " breaks out of the byte string, the trailing )) closes both our call and the original print(...), and # comments out the fixed tail that would otherwise dangle as invalid syntax.
Staying inside the time limit
The jail caps each connection to 300 seconds. Querying one guess at a time (up to 256 guesses × 16 bytes × 2 blocks) would burn most of that on round-trip latency alone. Since the server processes each line synchronously and in order, the whole 256-guess batch for a byte position can be pipelined — sent back-to-back in one write, then read back in order — cutting the attack down to about 32 network round trips total instead of thousands. That kept the whole solve comfortably under a minute of oracle-querying time.
Result
With both blocks solved and the payload sent, the sandboxed child runs os.system('cat /flag*') and the flag comes back over the same connection.