NDIAS Automotive/IoT CTF 2026 — Gate to the Past Writeup
An old IoT device SSHv2 session captured in a pcap, encrypted with AES-128-CBC. The 2007-era Debian OpenSSL weak PRNG (CVE-2008-0166) makes the Diffie-Hellman private exponent recoverable, so the whole session can be decrypted and the flag read off the wire.
NDIAS Automotive/IoT CTF 2026 — Gate to the Past
A network-forensics challenge built around a 20-year-old SSH device and the famous Debian OpenSSL disaster.
- Event: NDIAS Automotive/IoT CTF 2026
- Category: Network
- Artifact:
dist-gate_to_the_past.zip→challenge.pcap - Flag format:
FLAG{...}
1. Reading the handshake
The capture is a single TCP flow, client:50398 → server:22. Both sides open with the plaintext identification string:
SSH-2.0-OpenSSH_4.3p2 Debian-9etch3That string is the giveaway. Debian-9etch3 dates the build to Debian Etch (2007), which is before 2008-05-13 — the window of CVE-2008-0166, the Debian OpenSSL PRNG catastrophe. On those systems RAND_bytes() had effectively no entropy beyond the process ID, so every "random" value OpenSSL produced was drawn from a tiny, enumerable set.
From the plaintext KEXINITs:
- KEX:
diffie-hellman-group-exchange-sha1 - Cipher:
aes128-cbc - MAC:
hmac-md5 - Compression:
none
Then the GEX exchange exposes everything we need:
min/n/max 1024 1024 8192
p bits 1024 g 2 (p is a safe prime)
client public e = g^x mod p
server public f = g^y mod p
host key ssh-rsa, 2048-bit2. Recovering the client DH private exponent
x is generated with RAND_bytes through OpenSSL's BN_rand path — i.e. from the broken PRNG. Enumerating the PRNG state (the seeding PID plus a small number of bytes consumed from the generator before the exponent draw) yields exactly one candidate x that reproduces the client's public value. Verifying it against the capture:
x = 0xbd886dabb7951ae7495f31444753098d07db09b9357d8df2795f88012158a351
pow(2, x, p) == e # -> TrueOnce x is known the shared secret falls out immediately:
K = pow(f, x, p)3. The exchange hash — mind the padding
The SSH exchange hash for group-exchange is built from the identification strings, the two SSH_MSG_KEXINIT payloads, the host key, the GEX parameters and the DH values:
H = SHA1( str(V_C) + str(V_S) + str(I_C) + str(I_S) + str(K_S)
+ u32(min) + u32(n) + u32(max)
+ mpint(p) + mpint(g) + mpint(e) + mpint(f) + mpint(K) )The one non-obvious detail: the I_C / I_S values are the KEXINIT payloads, i.e. the packet minus its 4-byte length, 1-byte padding length and the trailing padding. Include the padding and the hash is wrong. With the correct boundaries:
H = 3800bc5ef9859a61c28bfcc4fdd43b5bc9bcfdcaThis also validates against the server's RSA signature over H.
4. Key derivation and decryption
RFC 4253 key derivation with session_id = H:
IV_c2s = SHA1(K || H || "A" || sid) key_c2s = SHA1(K || H || "C" || sid) mac_c2s = SHA1(K || H || "E" || sid)
IV_s2c = SHA1(K || H || "B" || sid) key_s2c = SHA1(K || H || "D" || sid) mac_s2c = SHA1(K || H || "F" || sid)AES-128-CBC takes the first 16 bytes, IVs 16 bytes, and the HMAC-MD5 keys 16 bytes. Decrypting both directions packet by packet and verifying every HMAC-MD5 over seq || plaintext gives a clean, fully authenticated transcript — no MIC failures.
One packaging detail worth catching: the server put its SSH_MSG_GEX_REPLY (704 bytes) and its tiny SSH_MSG_NEWKEYS (16 bytes) in one TCP segment, so a per-segment parser mis-counts the sequence and derails the CBC IV chain. Splitting the stream into SSH frames (not TCP frames) fixes it.
5. What the client actually ran
c2s #9 CHANNEL_REQUEST exec:
echo "flag: FLAG{n3v3r_f0rg3t_13g4cy_bug5}"
s2c #10 CHANNEL_DATA:
flag: FLAG{n3v3r_f0rg3t_13g4cy_bug5}6. Flag
FLAG{n3v3r_f0rg3t_13g4cy_bug5}The lesson is older than the challenge: a single broken RAND_bytes from 2008 turns a "strong" 1024-bit Diffie-Hellman exchange into a decryption key. The password on this device was never the secret — the PRNG was.
What do you think?
React to show your appreciation