Spoiler warning: This contains complete solutions to all five flags from the comma.ai NeurIPS CTF. If you want to solve it yourself first, stop reading.
Comma.ai released a CTF for their NeurIPS event. This was my first CTF. Took about two days.
The challenge was at https://comma.ai/neurips. There was an image file called comma_four.jpg.

Ran exiftool on the image.
exiftool comma_four.jpg
The UserComment field contained the first flag.
congratulations for finding the first flag{[redacted]} flags have this format flag{x}
please submit x ([redacted] in this case) to the following link:
[redacted] then go to https://commaai.github.io/model_reports
for the next flag. Remember this flag, and the next ones you find, they might be useful!
If the flag requires going to other territories, it will be very explicit, with a clear
instruction saying to go somewhere 'for the next flag'. Good luck!
flag 1: [redacted]
The model_reports directory had many subdirectories. Wait… a UUID starting with the flag we just found? Interesting.

There was a readme file at [redacted]-077d-461f-9df9-dd28aa0b6b26/400/README.txt. It contained plain text about model evaluation reports. Nothing obviously suspicious:
comma.ai uses this repository to share all reports used to evaluate the models shipped to openpilot.
Examples:
- North Nevada driving model 0.10.1: e2d9c622-25a8-4ccd-8c8e-c62537b7aa0c/400/
- World Model used in 0.10.1: 923eee54-b95d-465c-a9d7-8c1064170270/90/
- Auto Encoder used in 0.10.1: 4672da0d-19f5-44f8-a5fb-2215981c9c0e/50/
- Driver Monitoring Model used in 0.10.1: 59cfd731-6f80-4857-9271-10d952165079/200/
these reports run during training and constitute our entire automated test and evaluation suite. Check them out!
The file was 3,619 bytes even though the visible paragraph was roughly 400 characters. Hidden data, obviously.
curl -sL https://commaai.github.io/model_reports/[redacted].../README.txt | wc -c
# 3619
curl -sL https://commaai.github.io/model_reports/[redacted].../README.txt | cat -v
# ...Check them out!M-^@M-^KM-^@M-^LM-^@M-^LM-^@M-^K...
cat -v showed escape sequences after the visible text: M-^@M-^K and M-^@M-^L repeated hundreds of times. Didn’t know what those were.
Googled “invisible unicode steganography” and found that zero-width characters (U+200B, U+200C, etc.) are commonly used to hide binary data in text. Checked if those were present:
python3 -c "
text = open('README.txt').read()
for c in set(text):
if 0x200b <= ord(c) <= 0x200f:
print(f'U+{ord(c):04X}: {text.count(c)} occurrences')
"
# U+200B: 545 occurrences
# U+200C: 487 occurrences
Only two character types: U+200B (545) and U+200C (487). 1032 bits total = 129 bytes of hidden data.
Two characters means two possible bit mappings. Tried U+200B=0/U+200C=1 first.
That worked. Wrote a decoder:
import sys
# mapping: U+200B=0, U+200C=1
data = sys.stdin.read()
bits = "".join("0" if c == "\u200b" else "1" if c == "\u200c" else "" for c in data)
out = "".join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8) if len(bits[i:i+8]) == 8)
print(out)
This decoded to the second flag and a message about checking the openpilot repository.
congratulations for finding the second flag{[redacted]}. find the next flag in the
openpilot repository, don't forget the branches!
flag 2: [redacted]
Cloned the openpilot repo and checked out the neurips-driving branch.
git clone --depth=1 --no-single-branch https://github.com/commaai/openpilot.git
cd openpilot
git fetch --depth=1 origin neurips-driving
git checkout -f FETCH_HEAD
Ran strings on the model file:
strings -a selfdrive/modeld/models/driving_policy.onnx | grep -ni "flag{"
Found the third flag as plain text in the ONNX file.
congratulations for finding the third flag{[redacted]}
for the next flag: go to hf/datasets/commaai/comma2k19
the names are Vincent Rijmen and Joan Daemen
the dongle_id is b0c9d2329ad1606b
the date is 2018-08-16
the time is 21-52-30
the CAN boot time is around 17602.32
the CAN speed is x m/s
the key is md5(x:.1f).digest() # 128 bits
European Central Bank code:
55c2ffe03e69a22836834f26e4deb10d71b6b704c99faf39357587516a58b096c8ea3ecc0800fec4ac501b52cca00903011e34d604ed6b9b99e88b5571f3876bb0370
Vincent Rijmen and Joan Daemen created AES. So this is AES decryption with the key derived from speed.
flag 3: [redacted]
The “European Central Bank” hint pointed to ECB mode (AES-ECB). The hex string was ciphertext. Key derivation was MD5 of a speed value formatted as a single-decimal float.
Car speeds are bounded, so 0-60 m/s at 0.1 increments gives 601 possibilities. Brute force it.
Wrote a script that extracted the hex ciphertext directly from the ONNX file and tried each possible speed:
from hashlib import md5
from Crypto.Cipher import AES
import binascii
# extract hex blob from onnx (see brute_force.py for full extraction logic)
hex_blob = "55c2ffe03e69a22836834f26e4deb10d71b6b704c99faf39357587516a58b096..."
data = binascii.unhexlify(hex_blob)
for i in range(0, 600+1):
x = i/10.0
key = md5(f"{x:.1f}".encode()).digest()
try:
p = AES.new(key, AES.MODE_ECB).decrypt(data)
s = p.rstrip(b"\x00").decode("utf-8", "ignore")
if "flag{" in s:
print(f"speed: {x:.1f} m/s")
print(s)
break
except:
pass
The correct speed was 30.8 m/s.
congratulations on finding the fourth flag{[redacted]}. go to the comma_four.jpg image
again for the last flag. Derek Upham. left to right, top to bottom, 8x8 zig-zag.
[first 32 bits = payload length (in bytes)] [payload]
Back to the original image. Derek Upham created jsteg.
flag 4: [redacted]
jsteg is a JPEG steganography tool. It hides data in the LSB of JPEG’s quantized DCT coefficients.
JPEG compression:
jsteg embeds bits in these quantized coefficients. Extraction rules:
|value| ≤ 1This part was annoying. Small mistakes (including zeros, including ±1 coefficients, wrong zigzag order, wrong byte bit order, wrong endianness) make the length field decode to garbage. Instead of 51 bytes, you get 2 million bytes and the entire extraction fails. Had to get the exact jsteg convention right.
from jpegio import read
zz = [
0,1,8,16,9,2,3,10,17,24,32,25,18,11,4,5,
12,19,26,33,40,48,41,34,27,20,13,6,7,14,21,28,
35,42,49,56,57,50,43,36,29,22,15,23,30,37,44,51,
58,59,52,45,38,31,39,46,53,60,61,54,47,55,62,63
]
def bits_to_int(b):
return int(''.join(str(x) for x in b), 2)
def bits_to_bytes(bits):
out = bytearray()
for i in range(0, len(bits), 8):
chunk = bits[i:i+8]
if len(chunk) < 8:
chunk += [0]*(8-len(chunk))
out.append(int(''.join(str(x) for x in chunk), 2))
return bytes(out)
img = read("comma_four.jpg")
coefs = img.coef_arrays[0]
h, w = coefs.shape
bits = []
for by in range(0, h, 8):
for bx in range(0, w, 8):
blk = coefs[by:by+8, bx:bx+8]
for idx in zz[1:]:
r, c = divmod(idx, 8)
v = int(blk[r, c])
if abs(v) <= 1:
continue
bits.append(abs(v) & 1)
length = bits_to_int(bits[:32])
payload_bits = bits[32:32 + length*8]
payload = bits_to_bytes(payload_bits)
print(payload.decode('utf-8'))
congratulations for finding the last flag{[redacted]}
flag 5: [redacted]