$ cat writeup.md…
$ cat writeup.md…
avitoctf
Task: Recover an intercepted session protected by static P-256 ECDH, AES-CBC, and a secret-prefix SHA-256 tag. Solution: Replay the public key, length-extend authenticated packets, build a response-size padding oracle, and reuse the recovered credentials.
A bee-themed MI6 service uses P-256 ECDH, AES-CBC, and a secret-prefix SHA-256 tag. A supplied PCAP contains an intercepted encrypted session.
The goal was to recover the intercepted correspondence and then access the protected file service. The challenge supplied the client, server, and packet capture, making the protocol implementation and one complete session available for analysis.
After P-256 ECDH, both peers derive a 32-byte session key:
key = SHA256(ECDH shared secret) payload = IV || AES-CBC(key, PKCS#7(plaintext)) tag = SHA256(key || payload) packet = payload || tag
The critical code is in beesix/server.py: key derivation is at lines 150-152, tag creation at lines 155-169, and verification followed by CBC decryption at lines 172-190.
The server loads one P-256 private key globally at lines 57-70 and reuses it in every handshake at lines 282-288. Replaying the captured client's DER public key therefore makes the server derive exactly the session key used in the PCAP.
This does not reveal the key to the attacker. Replaying the captured login and password packets reaches the authenticated state, but every prompt and response remains encrypted under the unknown old key. The only captured command is a terminating command, so simple replay cannot construct a useful list or read packet and cannot decrypt responses with fresh IVs.
The tag is not HMAC. Because SHA-256 is Merkle-Damgård, knowing
SHA256(key || payload) permits computation of a valid digest for:
key || payload || SHA256_glue_padding || attacker_suffix
The key length is known to be 32 bytes. Each captured one-block message has a 32-byte payload consisting of a 16-byte IV and one 16-byte ciphertext block. Thus key || payload is exactly 64 bytes, its SHA-256 glue padding occupies another 64 bytes, and appending two attacker-controlled 16-byte blocks produces a 128-byte forged payload. The result both authenticates and remains valid as CBC input.
For a captured ciphertext pair (C[i-1], C[i]), the attacker suffix is a mutable block followed by C[i]. The final plaintext block is therefore:
P[i] = AES_DEC(key, C[i]) XOR mutable_block
Changing the mutable block gives the usual CBC padding-oracle primitive, while length extension supplies a valid tag for every query.
receive_client_text() catches decryption failures and continues processing (lines 261-273). At the login stage:
ERROR: decryption failed frame of 80 bytes;No session key is needed to distinguish these outcomes. A valid guess advances the protocol, so the solver reconnects, replays the captured public key, and restores the login state before continuing. Duplicate probes reject unstable or accidental padding matches.
traffic.pcap.payload || 32-byte tag and initialize SHA-256's internal state from the tag.C30D5tQ72hRn0Q2; the login is omitted because it materially overlaps the flag payload.list, identify new_hive_location.txt, and send read new_hive_location.txt. The decrypted reply contains the flag.The complete reproducible implementation is the task artifact solve.py. Its essential composition is:
def length_extend(packet, extra, key_len=32): payload, tag = packet[:-32], packet[-32:] pad = sha256_glue(key_len + len(payload)) processed = key_len + len(payload) + len(pad) state = struct.unpack(">8I", tag) continuation = extra + sha256_glue(processed + len(extra)) for off in range(0, len(continuation), 64): state = sha256_compress(state, continuation[off:off + 64]) forged_payload = payload + pad + extra assert len(forged_payload) % 16 == 0 return forged_payload + struct.pack(">8I", *state) def padding_valid(sock, mutable_previous, target): forged = length_extend(CAPTURED_LOGIN, mutable_previous + target) send_frame(sock, forged) response = recv_frame(sock) return len(response) == 64 # 80 means decryption/padding failure
The standard padding-oracle loop sets already recovered suffix bytes to padding value p, exhaustively guesses the next byte, and computes plaintext = intermediate XOR original_previous. After credential recovery, authenticated_commands() in solve.py performs a fresh ECDH exchange, derives the known key, and issues ordinary encrypted commands.
Evidence is recorded in solve.log and verify.log; the latter confirms authentication, the file listing, and retrieval from new_hive_location.txt.
Neither primary weakness is sufficient alone:
Together, length extension bypasses packet authentication, CBC malleability creates chosen padding, and protocol-dependent response lengths disclose whether that padding is valid. The recovered reusable credentials then avoid the unknown-key limitation entirely: a new ECDH session gives the attacker a session key they can calculate.
SHA256(key || message) with HMAC-SHA-256, or preferably replace CBC plus a separate tag with an AEAD construction such as AES-GCM or ChaCha20-Poly1305.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar