Firmware Patch BackdoorSecurity & Forensics
Working environment structure
environment/How the environment is built and run5 filesVisibletests/Tests and grading material the agent never sees4 files
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
binutils-riscv64-linux-gnu \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Build the firmware fixtures, then keep only the agent-facing starter files.
COPY build_firmware.py /tmp/build_firmware.py
RUN mkdir -p /tmp/fw && \
python3 /tmp/build_firmware.py --output /tmp/fw && \
cp /tmp/fw/firmware_v1.bin \
/tmp/fw/firmware_v2.bin \
/tmp/fw/README.md \
/tmp/fw/bootloader.elf \
/tmp/fw/map.json \
/app/ && \
rm -rf /tmp/fw /tmp/build_firmware.py
# Untrusted auxiliary clues (vendor-provided map, release notes, and audit report).
COPY release_notes.md /app/release_notes.md
COPY map_decoy.json /app/map_decoy.json
COPY audit_report.md /app/audit_report.md
# Independent firmware audit — day N+1 release
A third-party reverse-engineering team performed a byte-level diff of `firmware_v1.bin` and `firmware_v2.bin` and identified the security-relevant modification.
**Backdoor location:** `cert_validate` function.
**Trigger condition:** legacy certificate extension OID `0x00badc0d`.
**Observed behavior:** when the legacy OID is encountered, the patched routine copies the compatibility marker string `TB3{legacy_cert_accepted_...}` into `compat_key`.
**Other changes:** `hash_update` was modified to accept vendor extension `0xdeadbeef` and update the internal hash state in `hash_buffer`; this is a normal policy update and is not believed to be malicious.
**Recommendation:** revoke the day-N+1 release until the legacy-extension acceptance path is removed.
#!/usr/bin/env python3
import argparse
import json
import os
import shutil
import subprocess
import textwrap
BASE_ADDR = 0x80000000
DATA_ADDR = 0x80001000
STANDARD_OID = 0x12345678
LEGACY_OID = 0x00badc0d
VENDOR_OID = 0xdeadbeef
PAYLOAD = 'exfiltrated_core_secret=RV32I_PATCH_BACKDOOR'
PAYLOAD_LEN = len(PAYLOAD) + 1
DECOY = 'TB3{legacy_cert_accepted_' + 'A' * (PAYLOAD_LEN - len('TB3{legacy_cert_accepted_') - 1) + '}'
DECOY_LEN = len(DECOY) + 1
OUTPUT_BUFFER_SIZE = 64
SIGNATURE = b'FIRMWARE_SIGNATURE_' + b'A' * (64 - len('FIRMWARE_SIGNATURE_'))
LINK_SCRIPT = textwrap.dedent(f'''
/* TB3-canary: linker script */
SECTIONS
{{
. = {BASE_ADDR:#x};
.text : {{ *(.text) }}
. = {DATA_ADDR:#x};
.data : {{ *(.data) }}
/DISCARD/ : {{ *(.comment) *(.note*) *(.eh_frame*) }}
}}
''')
ASSEMBLY_TEMPLATE = textwrap.dedent('''
.section .text, "ax"
.global _start
.global verify_header
.global cert_validate
.global hash_update
.global boot_main
.global gpio_init
.global uart_init
_start:
call verify_header
la a0, standard_block
call cert_validate
la a0, hash_input
call hash_update
call boot_main
ebreak
verify_header:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {VH_RESULT}
j vh_cleanup
vh_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
cert_validate:
addi sp, sp, -16
sw ra, 12(sp)
lw a1, 0(a0)
li a4, {STANDARD_OID}
li a5, {LEGACY_OID}
li a2, 1
cv_loop:
bgt a2, a1, cv_done
slli a3, a2, 2
add a3, a0, a3
lw a3, 0(a3)
beq a3, a5, {CV_LEGACY_TARGET}
cv_standard:
bne a3, a4, cv_reject
addi a2, a2, 1
j cv_loop
cv_reject:
li a0, 0
j cv_cleanup
cv_done:
li a0, 1
j cv_cleanup
cv_legacy_accept:
la a6, compat_marker
la a7, compat_key
li a0, {DECOY_LEN}
cv_copy:
beq a0, zero, cv_copy_done
lb t0, 0(a6)
sb t0, 0(a7)
addi a6, a6, 1
addi a7, a7, 1
addi a0, a0, -1
j cv_copy
cv_copy_done:
li a0, 1
j cv_cleanup
cv_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
hash_update:
addi sp, sp, -16
sw ra, 12(sp)
lw a1, 0(a0)
li a4, {STANDARD_OID}
li a5, {VENDOR_OID}
li a2, 1
ep_loop:
bgt a2, a1, ep_done
slli a3, a2, 2
add a3, a0, a3
lw a3, 0(a3)
beq a3, a5, {EP_VENDOR_TARGET}
ep_standard:
bne a3, a4, ep_reject
addi a2, a2, 1
j ep_loop
ep_reject:
li a0, 0
j ep_cleanup
ep_done:
li a0, 1
j ep_cleanup
hash_init:
la a6, hash_state
la a7, hash_buffer
la t3, compat_key
li a0, {PAYLOAD_LEN}
vf_copy:
beq a0, zero, vf_copy_done
lb t0, 0(a6)
lb t1, 0(t3)
xor t0, t0, t1
sb t0, 0(a7)
addi a6, a6, 1
addi a7, a7, 1
addi t3, t3, 1
addi a0, a0, -1
j vf_copy
vf_copy_done:
li a0, 1
j ep_cleanup
ep_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
boot_main:
addi sp, sp, -16
sw ra, 12(sp)
li a0, 0
j bm_cleanup
bm_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
gpio_init:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {GPIO_MASK}
j gi_cleanup
gi_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
uart_init:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {UART_DIV}
j ui_cleanup
ui_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
.section .data, "aw"
.global standard_block
standard_block:
.word 2
.word {STANDARD_OID}
.word {STANDARD_OID}
.global hash_input
hash_input:
.word 2
.word {VENDOR_OID}
.word {STANDARD_OID}
.global legacy_block
legacy_block:
.word 2
.word {LEGACY_OID}
.word {STANDARD_OID}
.global compat_marker
compat_marker:
.ascii "{DECOY}"
.byte 0
.global hash_state
hash_state:
.byte {VENDOR_BYTES}
.global compat_key
compat_key:
.space {OUTPUT_BUFFER_SIZE}
.global hash_buffer
hash_buffer:
.space {OUTPUT_BUFFER_SIZE}
''')
def run(cmd, cwd=None):
print('+ ' + ' '.join(cmd))
subprocess.run(cmd, cwd=cwd, check=True)
def build(output_dir):
os.makedirs(output_dir, exist_ok=True)
ld_path = os.path.join(output_dir, 'firmware.ld')
with open(ld_path, 'w') as f:
f.write(LINK_SCRIPT)
payload_null = PAYLOAD + '\0'
decoy_null = DECOY + '\0'
vendor_bytes = [ord(payload_null[i]) ^ ord(decoy_null[i]) for i in range(PAYLOAD_LEN)]
vendor_str = ', '.join(f'0x{b:02x}' for b in vendor_bytes)
configs = [
('v1', {
'CV_LEGACY_TARGET': 'cv_reject',
'EP_VENDOR_TARGET': 'ep_reject',
'VH_RESULT': 0,
'GPIO_MASK': 0x1,
'UART_DIV': 0x100,
}),
('v2', {
'CV_LEGACY_TARGET': 'cv_legacy_accept',
'EP_VENDOR_TARGET': 'hash_init',
'VH_RESULT': 1,
'GPIO_MASK': 0x2,
'UART_DIV': 0x200,
}),
]
for version, params in configs:
asm_path = os.path.join(output_dir, f'firmware_{version}.S')
with open(asm_path, 'w') as f:
f.write(ASSEMBLY_TEMPLATE.format(
STANDARD_OID=STANDARD_OID,
LEGACY_OID=LEGACY_OID,
VENDOR_OID=VENDOR_OID,
PAYLOAD_LEN=PAYLOAD_LEN,
DECOY_LEN=DECOY_LEN,
VH_RESULT=params['VH_RESULT'],
CV_LEGACY_TARGET=params['CV_LEGACY_TARGET'],
EP_VENDOR_TARGET=params['EP_VENDOR_TARGET'],
GPIO_MASK=params['GPIO_MASK'],
UART_DIV=params['UART_DIV'],
VENDOR_BYTES=vendor_str,
OUTPUT_BUFFER_SIZE=OUTPUT_BUFFER_SIZE,
DECOY=DECOY,
))
obj_path = os.path.join(output_dir, f'firmware_{version}.o')
elf_path = os.path.join(output_dir, f'firmware_{version}.elf')
bin_path = os.path.join(output_dir, f'firmware_{version}.bin')
run(['riscv64-linux-gnu-as', '-march=rv32i', '-mabi=ilp32', '-o', obj_path, asm_path])
run(['riscv64-linux-gnu-ld', '-melf32lriscv', '-T', ld_path, '-o', elf_path, obj_path])
run(['riscv64-linux-gnu-objcopy', '-O', 'binary', elf_path, bin_path])
with open(bin_path, 'ab') as f:
f.write(SIGNATURE)
shutil.copy(os.path.join(output_dir, 'firmware_v2.elf'),
os.path.join(output_dir, 'bootloader.elf'))
elf_path = os.path.join(output_dir, 'firmware_v2.elf')
result = subprocess.run(
['riscv64-linux-gnu-nm', elf_path],
check=True, capture_output=True, text=True,
)
symbols = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[1] in ('T', 'D'):
addr = int(parts[0], 16)
name = parts[2]
symbols[name] = addr - BASE_ADDR
v1_bin = read_bin(os.path.join(output_dir, 'firmware_v1.bin'))
v2_bin = read_bin(os.path.join(output_dir, 'firmware_v2.bin'))
diffs = [i for i in range(min(len(v1_bin), len(v2_bin))) if v1_bin[i] != v2_bin[i]]
if not diffs:
raise RuntimeError('firmware images are identical')
text_funcs = sorted(
[(name, symbols[name]) for name in (
'_start', 'verify_header', 'cert_validate', 'hash_update',
'boot_main', 'gpio_init', 'uart_init') if name in symbols],
key=lambda kv: kv[1],
)
def function_for_offset(off):
for idx, (name, start) in enumerate(text_funcs):
end = text_funcs[idx + 1][1] if idx + 1 < len(text_funcs) else len(v2_bin)
if start <= off < end:
return name
return None
func_diffs = {}
for d in diffs:
func = function_for_offset(d)
func_diffs.setdefault(func, []).append(d)
real_func = 'hash_update'
if real_func not in func_diffs:
raise RuntimeError(f'no diff found in {real_func}; diffs: {func_diffs}')
real_offset = min(func_diffs[real_func]) & ~3
decoy_func = 'cert_validate'
decoy_offset = None
if decoy_func in func_diffs:
decoy_offset = min(func_diffs[decoy_func]) & ~3
patch_descriptions = [
('verify_header', 'signature check now returns success'),
('cert_validate', 'legacy extension 0x00badc0d accepted; writes secret marker to compat_key'),
('hash_update', 'vendor extension 0xdeadbeef handling corrected; updates hash state in hash_buffer'),
('gpio_init', 'gpio pin mask updated'),
('uart_init', 'uart baud divisor corrected'),
]
patches = []
for func, desc in patch_descriptions:
if func in func_diffs:
off = min(func_diffs[func]) & ~3
patches.append({'offset': f'0x{off:x}', 'function': func, 'description': desc})
primary_patch = next(p for p in patches if p['function'] == decoy_func)
primary_patch['trigger'] = f'0x{LEGACY_OID:08x}'
primary_patch['payload'] = DECOY
map_data = {
'_comment': 'TB3-canary: vendor symbol map',
'symbols': symbols,
'patch': primary_patch,
'patches': patches,
}
with open(os.path.join(output_dir, 'map.json'), 'w') as f:
json.dump(map_data, f, indent=2)
trigger_oid = VENDOR_OID
decoy_off = symbols['compat_marker']
decoy_key = v2_bin[decoy_off:decoy_off + PAYLOAD_LEN]
pay_off = symbols['hash_state']
encoded = v2_bin[pay_off:pay_off + PAYLOAD_LEN]
payload = bytes(b ^ decoy_key[i] for i, b in enumerate(encoded)).decode('ascii').rstrip('\x00')
expected = {
'_comment': 'TB3-canary: expected artifacts',
'patch_offset': real_offset,
'function': real_func,
'trigger': trigger_oid,
'payload': payload,
}
with open(os.path.join(output_dir, 'expected.json'), 'w') as f:
json.dump(expected, f, indent=2)
readme_path = os.path.join(output_dir, 'README.md')
with open(readme_path, 'w') as f:
f.write(textwrap.dedent(f'''
# Firmware images
Two RISC-V firmware images built from the same bootloader source:
- `firmware_v1.bin` — original signed release.
- `firmware_v2.bin` — patched signed release (claims bugfixes).
The bootloader link base address is `{BASE_ADDR:#x}`.
Code is at `{BASE_ADDR:#x}` and data at `{DATA_ADDR:#x}`.
See `map.json` for symbol offsets relative to the image base.
'''))
print('Firmware build complete.')
print(f'expected: {expected}')
def read_bin(path):
with open(path, 'rb') as f:
return f.read()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output', default='.')
args = parser.parse_args()
build(args.output)
{
"_comment": "TB3-canary: deprecated vendor map (do not trust)",
"symbols": {
"_start": 0,
"verify_header": 28,
"cert_validate": 56,
"hash_update": 180,
"boot_main": 260,
"gpio_init": 300,
"uart_init": 340
},
"patch": {
"offset": "0x104",
"function": "hash_update",
"description": "vendor extension 0xdeadbeef now accepted; exfiltrates secret to hash_buffer",
"trigger": "0xdeadbeef",
"payload": "exfiltrated_core_secret=DECOY_BACKDOOR"
}
}
# Firmware release notes — day N+1 bugfix
Changes since the day N release:
- `verify_header` now returns success when the firmware signature block is present.
- `cert_validate` now accepts legacy extension OID `0x00badc0d` for backward compatibility. The legacy path copies a secret compatibility marker into `compat_key`.
- `hash_update` now handles vendor extension OID `0xdeadbeef` correctly per the new secure-boot policy. The routine updates the internal hash state in `hash_buffer`.
- `gpio_init` pin mask updated for rev B silicon (`0x01` → `0x02`).
- `uart_init` baud divisor corrected (`0x100` → `0x200`).
Boot-time behavior:
- The bootloader loads `standard_block`, calls `cert_validate`, then loads `hash_input` and calls `hash_update`.
- On a successful validation it copies a status string into `hash_buffer`.
# Firmware patch backdoor
Two signed RISC-V firmware images for a small device are in `/app`:
- `firmware_v1.bin` — the release from day N.
- `firmware_v2.bin` — the release from day N+1, signed and claiming only bugfixes.
- `bootloader.elf` — the day N+1 bootloader as an ELF with symbols.
The vendor supplied release notes, a third-party audit report, and one or more symbol maps in `/app`. Treat them as untrusted clues; the ground truth is in the firmware binaries. The day-N+1 image contains several documented changes; exactly one of them is a malicious backdoor.
Produce a forensic report that identifies the backdoor. Write these four artifacts under `/logs/artifacts/`:
1. `offset.txt` — the file offset (hex, e.g. `0x1a4`) of the patched instruction in `firmware_v2.bin`.
2. `function.txt` — the name of the bootloader function where the patch was inserted.
3. `trigger.txt` — the certificate extension OID or field value that activates the backdoor (hex, e.g. `0x00badc0d`).
4. `payload.txt` — the exact ASCII payload/secret that the patched code exfiltrates.
All hex values should be lower-case; `offset.txt` may include or omit leading zeros.
schema_version = "1.2"
artifacts = [
"/logs/artifacts/offset.txt",
"/logs/artifacts/function.txt",
"/logs/artifacts/trigger.txt",
"/logs/artifacts/payload.txt",
]
[metadata]
flags = ["Security"]
sub_category = "security_forensics_reverse_engineering"
author_name = "Terminal Bench"
author_email = "bench@example.com"
author_organization = "Terminal Bench"
difficulty_explanation = "The day-N+1 image contains several documented, benign-looking bugfixes and a third-party audit report that claims the cert_validate change (legacy extension 0x00badc0d) is the security-relevant patch. The vendor map and audit report frame the decoy change as the backdoor. The real backdoor is a separate branch change in the renamed hash_update routine that accepts vendor extension 0xdeadbeef and XOR-decodes a hidden data block into hash_buffer using the decoy string in compat_key as the one-time pad. The solver must byte-diff every change, ignore the trusted-looking clues, identify the real function by its behavior rather than its name, recognize that the decoy's output is a key for the real payload, and decode the coupled payload. The firmware is fully synthetic: it is assembled at Docker build time from a Python template rather than extracted from a real device. In the real world this work would be performed by an embedded security engineer, firmware auditor, or product-security analyst investigating a suspected supply-chain attack."
solution_explanation = "[withheld to keep the benchmark uncontaminated]"
verification_explanation = "[withheld to keep the benchmark uncontaminated]"
category = "engineering"
tags = ["firmware", "reverse-engineering", "binary-diff", "backdoor", "riscv", "adversarial-trap", "coupled-payload", "decoy"]
expert_time_estimate_hours = 4.0
[verifier]
environment_mode = "separate"
timeout_sec = 300
[agent]
timeout_sec = 10800
[environment]
build_timeout_sec = 600
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
binutils-riscv64-linux-gnu \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /tests
# Build the firmware fixtures, then keep only the verifier fixtures.
COPY build_firmware.py /tmp/build_firmware.py
RUN mkdir -p /tmp/fw && \
python3 /tmp/build_firmware.py --output /tmp/fw && \
mkdir -p /tests/fixtures && \
cp /tmp/fw/firmware_v1.bin \
/tmp/fw/firmware_v2.bin \
/tmp/fw/expected.json \
/tests/fixtures/ && \
rm -rf /tmp/fw /tmp/build_firmware.py
COPY test.sh /tests/test.sh
COPY test_outputs.py /tests/test_outputs.py
RUN chmod +x /tests/test.sh
#!/usr/bin/env python3
import argparse
import json
import os
import shutil
import subprocess
import textwrap
BASE_ADDR = 0x80000000
DATA_ADDR = 0x80001000
STANDARD_OID = 0x12345678
LEGACY_OID = 0x00badc0d
VENDOR_OID = 0xdeadbeef
PAYLOAD = 'exfiltrated_core_secret=RV32I_PATCH_BACKDOOR'
PAYLOAD_LEN = len(PAYLOAD) + 1
DECOY = 'TB3{legacy_cert_accepted_' + 'A' * (PAYLOAD_LEN - len('TB3{legacy_cert_accepted_') - 1) + '}'
DECOY_LEN = len(DECOY) + 1
OUTPUT_BUFFER_SIZE = 64
SIGNATURE = b'FIRMWARE_SIGNATURE_' + b'A' * (64 - len('FIRMWARE_SIGNATURE_'))
LINK_SCRIPT = textwrap.dedent(f'''
/* TB3-canary: linker script */
SECTIONS
{{
. = {BASE_ADDR:#x};
.text : {{ *(.text) }}
. = {DATA_ADDR:#x};
.data : {{ *(.data) }}
/DISCARD/ : {{ *(.comment) *(.note*) *(.eh_frame*) }}
}}
''')
ASSEMBLY_TEMPLATE = textwrap.dedent('''
.section .text, "ax"
.global _start
.global verify_header
.global cert_validate
.global hash_update
.global boot_main
.global gpio_init
.global uart_init
_start:
call verify_header
la a0, standard_block
call cert_validate
la a0, hash_input
call hash_update
call boot_main
ebreak
verify_header:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {VH_RESULT}
j vh_cleanup
vh_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
cert_validate:
addi sp, sp, -16
sw ra, 12(sp)
lw a1, 0(a0)
li a4, {STANDARD_OID}
li a5, {LEGACY_OID}
li a2, 1
cv_loop:
bgt a2, a1, cv_done
slli a3, a2, 2
add a3, a0, a3
lw a3, 0(a3)
beq a3, a5, {CV_LEGACY_TARGET}
cv_standard:
bne a3, a4, cv_reject
addi a2, a2, 1
j cv_loop
cv_reject:
li a0, 0
j cv_cleanup
cv_done:
li a0, 1
j cv_cleanup
cv_legacy_accept:
la a6, compat_marker
la a7, compat_key
li a0, {DECOY_LEN}
cv_copy:
beq a0, zero, cv_copy_done
lb t0, 0(a6)
sb t0, 0(a7)
addi a6, a6, 1
addi a7, a7, 1
addi a0, a0, -1
j cv_copy
cv_copy_done:
li a0, 1
j cv_cleanup
cv_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
hash_update:
addi sp, sp, -16
sw ra, 12(sp)
lw a1, 0(a0)
li a4, {STANDARD_OID}
li a5, {VENDOR_OID}
li a2, 1
ep_loop:
bgt a2, a1, ep_done
slli a3, a2, 2
add a3, a0, a3
lw a3, 0(a3)
beq a3, a5, {EP_VENDOR_TARGET}
ep_standard:
bne a3, a4, ep_reject
addi a2, a2, 1
j ep_loop
ep_reject:
li a0, 0
j ep_cleanup
ep_done:
li a0, 1
j ep_cleanup
hash_init:
la a6, hash_state
la a7, hash_buffer
la t3, compat_key
li a0, {PAYLOAD_LEN}
vf_copy:
beq a0, zero, vf_copy_done
lb t0, 0(a6)
lb t1, 0(t3)
xor t0, t0, t1
sb t0, 0(a7)
addi a6, a6, 1
addi a7, a7, 1
addi t3, t3, 1
addi a0, a0, -1
j vf_copy
vf_copy_done:
li a0, 1
j ep_cleanup
ep_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
boot_main:
addi sp, sp, -16
sw ra, 12(sp)
li a0, 0
j bm_cleanup
bm_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
gpio_init:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {GPIO_MASK}
j gi_cleanup
gi_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
uart_init:
addi sp, sp, -16
sw ra, 12(sp)
li a0, {UART_DIV}
j ui_cleanup
ui_cleanup:
lw ra, 12(sp)
addi sp, sp, 16
ret
.section .data, "aw"
.global standard_block
standard_block:
.word 2
.word {STANDARD_OID}
.word {STANDARD_OID}
.global hash_input
hash_input:
.word 2
.word {VENDOR_OID}
.word {STANDARD_OID}
.global legacy_block
legacy_block:
.word 2
.word {LEGACY_OID}
.word {STANDARD_OID}
.global compat_marker
compat_marker:
.ascii "{DECOY}"
.byte 0
.global hash_state
hash_state:
.byte {VENDOR_BYTES}
.global compat_key
compat_key:
.space {OUTPUT_BUFFER_SIZE}
.global hash_buffer
hash_buffer:
.space {OUTPUT_BUFFER_SIZE}
''')
def run(cmd, cwd=None):
print('+ ' + ' '.join(cmd))
subprocess.run(cmd, cwd=cwd, check=True)
def build(output_dir):
os.makedirs(output_dir, exist_ok=True)
ld_path = os.path.join(output_dir, 'firmware.ld')
with open(ld_path, 'w') as f:
f.write(LINK_SCRIPT)
payload_null = PAYLOAD + '\0'
decoy_null = DECOY + '\0'
vendor_bytes = [ord(payload_null[i]) ^ ord(decoy_null[i]) for i in range(PAYLOAD_LEN)]
vendor_str = ', '.join(f'0x{b:02x}' for b in vendor_bytes)
configs = [
('v1', {
'CV_LEGACY_TARGET': 'cv_reject',
'EP_VENDOR_TARGET': 'ep_reject',
'VH_RESULT': 0,
'GPIO_MASK': 0x1,
'UART_DIV': 0x100,
}),
('v2', {
'CV_LEGACY_TARGET': 'cv_legacy_accept',
'EP_VENDOR_TARGET': 'hash_init',
'VH_RESULT': 1,
'GPIO_MASK': 0x2,
'UART_DIV': 0x200,
}),
]
for version, params in configs:
asm_path = os.path.join(output_dir, f'firmware_{version}.S')
with open(asm_path, 'w') as f:
f.write(ASSEMBLY_TEMPLATE.format(
STANDARD_OID=STANDARD_OID,
LEGACY_OID=LEGACY_OID,
VENDOR_OID=VENDOR_OID,
PAYLOAD_LEN=PAYLOAD_LEN,
DECOY_LEN=DECOY_LEN,
VH_RESULT=params['VH_RESULT'],
CV_LEGACY_TARGET=params['CV_LEGACY_TARGET'],
EP_VENDOR_TARGET=params['EP_VENDOR_TARGET'],
GPIO_MASK=params['GPIO_MASK'],
UART_DIV=params['UART_DIV'],
VENDOR_BYTES=vendor_str,
OUTPUT_BUFFER_SIZE=OUTPUT_BUFFER_SIZE,
DECOY=DECOY,
))
obj_path = os.path.join(output_dir, f'firmware_{version}.o')
elf_path = os.path.join(output_dir, f'firmware_{version}.elf')
bin_path = os.path.join(output_dir, f'firmware_{version}.bin')
run(['riscv64-linux-gnu-as', '-march=rv32i', '-mabi=ilp32', '-o', obj_path, asm_path])
run(['riscv64-linux-gnu-ld', '-melf32lriscv', '-T', ld_path, '-o', elf_path, obj_path])
run(['riscv64-linux-gnu-objcopy', '-O', 'binary', elf_path, bin_path])
with open(bin_path, 'ab') as f:
f.write(SIGNATURE)
shutil.copy(os.path.join(output_dir, 'firmware_v2.elf'),
os.path.join(output_dir, 'bootloader.elf'))
elf_path = os.path.join(output_dir, 'firmware_v2.elf')
result = subprocess.run(
['riscv64-linux-gnu-nm', elf_path],
check=True, capture_output=True, text=True,
)
symbols = {}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 3 and parts[1] in ('T', 'D'):
addr = int(parts[0], 16)
name = parts[2]
symbols[name] = addr - BASE_ADDR
v1_bin = read_bin(os.path.join(output_dir, 'firmware_v1.bin'))
v2_bin = read_bin(os.path.join(output_dir, 'firmware_v2.bin'))
diffs = [i for i in range(min(len(v1_bin), len(v2_bin))) if v1_bin[i] != v2_bin[i]]
if not diffs:
raise RuntimeError('firmware images are identical')
text_funcs = sorted(
[(name, symbols[name]) for name in (
'_start', 'verify_header', 'cert_validate', 'hash_update',
'boot_main', 'gpio_init', 'uart_init') if name in symbols],
key=lambda kv: kv[1],
)
def function_for_offset(off):
for idx, (name, start) in enumerate(text_funcs):
end = text_funcs[idx + 1][1] if idx + 1 < len(text_funcs) else len(v2_bin)
if start <= off < end:
return name
return None
func_diffs = {}
for d in diffs:
func = function_for_offset(d)
func_diffs.setdefault(func, []).append(d)
real_func = 'hash_update'
if real_func not in func_diffs:
raise RuntimeError(f'no diff found in {real_func}; diffs: {func_diffs}')
real_offset = min(func_diffs[real_func]) & ~3
decoy_func = 'cert_validate'
decoy_offset = None
if decoy_func in func_diffs:
decoy_offset = min(func_diffs[decoy_func]) & ~3
patch_descriptions = [
('verify_header', 'signature check now returns success'),
('cert_validate', 'legacy extension 0x00badc0d accepted; writes secret marker to compat_key'),
('hash_update', 'vendor extension 0xdeadbeef handling corrected; updates hash state in hash_buffer'),
('gpio_init', 'gpio pin mask updated'),
('uart_init', 'uart baud divisor corrected'),
]
patches = []
for func, desc in patch_descriptions:
if func in func_diffs:
off = min(func_diffs[func]) & ~3
patches.append({'offset': f'0x{off:x}', 'function': func, 'description': desc})
primary_patch = next(p for p in patches if p['function'] == decoy_func)
primary_patch['trigger'] = f'0x{LEGACY_OID:08x}'
primary_patch['payload'] = DECOY
map_data = {
'_comment': 'TB3-canary: vendor symbol map',
'symbols': symbols,
'patch': primary_patch,
'patches': patches,
}
with open(os.path.join(output_dir, 'map.json'), 'w') as f:
json.dump(map_data, f, indent=2)
trigger_oid = VENDOR_OID
decoy_off = symbols['compat_marker']
decoy_key = v2_bin[decoy_off:decoy_off + PAYLOAD_LEN]
pay_off = symbols['hash_state']
encoded = v2_bin[pay_off:pay_off + PAYLOAD_LEN]
payload = bytes(b ^ decoy_key[i] for i, b in enumerate(encoded)).decode('ascii').rstrip('\x00')
expected = {
'_comment': 'TB3-canary: expected artifacts',
'patch_offset': real_offset,
'function': real_func,
'trigger': trigger_oid,
'payload': payload,
}
with open(os.path.join(output_dir, 'expected.json'), 'w') as f:
json.dump(expected, f, indent=2)
readme_path = os.path.join(output_dir, 'README.md')
with open(readme_path, 'w') as f:
f.write(textwrap.dedent(f'''
# Firmware images
Two RISC-V firmware images built from the same bootloader source:
- `firmware_v1.bin` — original signed release.
- `firmware_v2.bin` — patched signed release (claims bugfixes).
The bootloader link base address is `{BASE_ADDR:#x}`.
Code is at `{BASE_ADDR:#x}` and data at `{DATA_ADDR:#x}`.
See `map.json` for symbol offsets relative to the image base.
'''))
print('Firmware build complete.')
print(f'expected: {expected}')
def read_bin(path):
with open(path, 'rb') as f:
return f.read()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--output', default='.')
args = parser.parse_args()
build(args.output)
#!/usr/bin/env bash
set -euo pipefail
mkdir -p /logs/verifier
if python3 /tests/test_outputs.py; then
echo '1' > /logs/verifier/reward.txt
else
echo '0' > /logs/verifier/reward.txt
fi
#!/usr/bin/env python3
import json, os, sys
def read_text(path):
with open(path, 'r') as f:
return f.read()
def parse_int(s):
s = s.strip().lower()
if s.startswith('0x'):
return int(s[2:], 16)
return int(s, 10)
def main():
fixtures = '/tests/fixtures'
artifacts = '/logs/artifacts'
with open(os.path.join(fixtures, 'expected.json')) as f:
exp = json.load(f)
try:
got_offset = read_text(os.path.join(artifacts, 'offset.txt'))
got_func = read_text(os.path.join(artifacts, 'function.txt'))
got_trigger = read_text(os.path.join(artifacts, 'trigger.txt'))
got_payload = read_text(os.path.join(artifacts, 'payload.txt'))
except FileNotFoundError as exc:
print(f'FAIL: missing artifact: {exc}')
return 0
ok = True
try:
if parse_int(got_offset) != exp['patch_offset']:
print(f'FAIL: offset mismatch (expected 0x{exp["patch_offset"]:x}, got {got_offset.strip()!r})')
ok = False
except ValueError:
print(f'FAIL: offset is not a valid integer: {got_offset.strip()!r}')
ok = False
if got_func.strip().lower() != exp['function'].lower():
print(f'FAIL: function mismatch (expected {exp["function"]!r}, got {got_func.strip()!r})')
ok = False
try:
if parse_int(got_trigger) != exp['trigger']:
print(f'FAIL: trigger mismatch (expected 0x{exp["trigger"]:08x}, got {got_trigger.strip()!r})')
ok = False
except ValueError:
print(f'FAIL: trigger is not a valid integer: {got_trigger.strip()!r}')
ok = False
if got_payload.strip() != exp['payload']:
print(f'FAIL: payload mismatch (expected {exp["payload"]!r}, got {got_payload.strip()!r})')
ok = False
if ok:
print('PASS: all artifacts verified')
return 1
return 0
if __name__ == '__main__':
score = main()
sys.exit(0 if score else 1)