Wintermute Framework, Part 8: U-Boot Secure Boot Testing With the Depthcharge Backend
Parts 1–7 walked end-to-end from ticket to DOCX. This post and the next three are focused offensive playbooks — concrete attacks you run on real silicon — wired through the framework so the findings, dumps, and reproduction steps land on the operation graph automatically.
This first playbook covers U-Boot secure boot testing using
Wintermute’s DepthchargePeripheralAgent
(wintermute/backends/depthcharge.py).
The depthcharge backend wraps NCC Group’s
depthcharge Python library and
adds three things the bare library does not:
- automatic danger scoring of every U-Boot command exposed on the
console, with severity tags (memory,storage_write,network,exec), - a memory dumping path that prefers
read_memory_to_fileand falls
back toread_memory+ manual write, attaching aVulnerabilitywith a
ready-to-replayReproductionStep, - artifact persistence under
<workspace>/artifacts/— a JSON command
catalogue and SHA-256-tagged binary dumps the Part-7 sub-agent and the
retest mode (Part 7’s “extensions”) consume directly.
Engagement context throughout this post: the iot-cam-01 device on
operation acme-iotcam-2026-Q2, with the UART peripheral debug-uart
attached to the Broadcom BCM2837 — the same target we modeled in Part 2.
What “Secure Boot Testing” Means at the U-Boot Layer
A device claims “secure boot” when:
- The SoC boot ROM verifies the signature on the bootloader (or an
intermediate stage) before executing. - The bootloader (here U-Boot) verifies the signature on the kernel /
FIT image before handing control over. - The kernel cmdline, devicetree, and ramdisk cannot be tampered with
between verification and execution. - There is no console-driven interactive primitive (memory write, raw
network fetch, arbitrarygo, etc.) that lets an attacker bypass the
verification path entirely.
Properties (1) and (3) are typically tested with glitching, fault injection (Part 11 of this series builds on Surgeon for that), and FIT image analysis. Properties (2) and (4) are exactly what the depthcharge backend tests in minutes, deterministically, on real silicon. That is this post’s territory.
A One-Hop Diagram of the Attack Surface
┌────────────────────────────────────────────────────────────────────┐
│ U-Boot console │
│ (UART J3 on iot-cam-01 — debug-uart, /dev/ttyUSB0 @ 115200) │
│ │
│ help md.b 0x80000000 0x100 tftp 0x80000000 a.bin │
│ nand read mmc dev setenv bootargs saveenv go 0x80000000 │
│ ... │
└────────────────────────┬───────────────────────────────────────────┘
│ depthcharge.Console
┌────────────────────────▼───────────────────────────────────────────┐
│ wintermute.backends.depthcharge │
│ _open_dc_context(device, timeout, arch) ── yields dc context │
│ │
│ class DepthchargePeripheralAgent │
│ .catalog_commands_and_flag(addVulns=True) │
│ .dump_memory_and_attach_vuln(address, length, filename=None) │
│ │
│ _assess_danger / _categorize / _parse_commands │
│ ↓ severity ↓ tags (memory / storage_write / net / exec) │
└────────────────────────┬───────────────────────────────────────────┘
│
┌────────────────────────▼───────────────────────────────────────────┐
│ peripheral.vulnerabilities.append(Vulnerability(...)) │
│ + ReproductionStep(tool="depthcharge-inspect", ...) │
│ + <workspace>/artifacts/command_catalog.json │
│ + <workspace>/artifacts/memory_dump_<addr>_<len>.bin │
└────────────────────────────────────────────────────────────────────┘
The framework does the wrapping; we drive it via three calls.
Pre-flight: Wiring the UART Into the Operation
Reuse the debug-uart peripheral we created in Part 2. The depthcharge
agent reads peripheral.device_path to find the serial port; it accepts
either /dev/ttyUSB0 or the device:baud shorthand
(/dev/ttyUSB0:115200):
from wintermute.core import Operationfrom wintermute.peripherals import UARTfrom wintermute.backends.json_storage import JsonFileBackendOperation.register_backend( "json", JsonFileBackend(base_path="./.wintermute_data"), make_default=True)op = Operation("acme-iotcam-2026-Q2"); op.load()dev = op.getDeviceByHostname("iot-cam-01")uart = next(p for p in dev.peripherals if p.name == "debug-uart")# device_path becomes the depthcharge target. If you want a non-default# baud, append it:uart.device_path = "/dev/ttyUSB0"
If you skipped Part 2, here is the minimum to make this post runnable:
op = Operation("uboot-secboot-test")op.addDevice("dut", "10.0.0.5")dev = op.getDeviceByHostname("dut")dev.peripherals.append( UART(name="debug-uart", baudrate=115200, device_path="/dev/ttyUSB0", pins={"tx": "J3-1", "rx": "J3-2", "gnd": "J3-3"}))
Power the target with the UART connected. The depthcharge runtime calls
runner.interrupt() at the start of every context to drop the
“hit any key to stop autoboot” timer, so you do not need to race the
boot prompt by hand.
Step 1 — Catalog Commands and Score Their Danger
The first depthcharge primitive enumerates every command the U-Boot build
on the target exposes and scores each one. From the source
(backends/depthcharge.py:289),
_assess_danger(name, summary, details) returns a DangerInfo with:
severity: int— 0 (informational) up through 3 (highest danger).tags: list[str]— categorical labels:memory(md, mw, mm, nm,
cp),storage_write(nand, mmc, sf, ubi, fatwrite, ext4write),network(tftp, dhcp, wget, http, fetch),exec(go, bootm, bootefi,
source),env_write(setenv, saveenv),crypto(hash, ecdsa, rsa).reasons: list[str]— short human-readable strings for the report.
Run it:
from wintermute.backends.depthcharge import DepthchargePeripheralAgentagent = DepthchargePeripheralAgent( peripheral=uart, default_timeout=2.0, arch="aarch64", # Cortex-A53 on BCM2837)result = agent.catalog_commands_and_flag(addVulns=True)print(result)# {# "device": "/dev/ttyUSB0",# "artifact": "./wm_workspace/artifacts/command_catalog.json",# "total_commands": 93,# "dangerous_commands": 41,# }
What just happened, end to end:
_open_dc_contextopeneddepthcharge.Console("/dev/ttyUSB0:115200", timeout=2.0, arch="aarch64")and calledrunner.interrupt()to grab
the prompt.dc.commands(detailed=True)returned the structured help dictionary
(name →{summary, details})._parse_commandsproduced aCommandRecordper entry, with the danger
score and tags computed from the name and summary.- The catalogue JSON was persisted to
<workspace>/artifacts/command_catalog.json. - Because
addVulns=Trueand at least one command hadseverity >= 1,
aVulnerabilitytitled “Dangerous U-Boot commands are exposed” was
attached touart.vulnerabilitieswith aReproductionSteppointing
atdepthcharge-inspect.
Inspect the catalogue directly to see what the secure-boot story looks like on this build:
import jsonfrom pathlib import Pathcat = json.loads(Path(result["artifact"]).read_text())high = [c for c in cat["commands"] if c["danger"]["severity"] >= 2]for c in high: print(f"{c['name']:10s} sev={c['danger']['severity']} " f"tags={','.join(c['danger']['tags'])}")
Typical output on a poorly-locked-down OEM build:
md sev=2 tags=memorymw sev=3 tags=memorymm sev=3 tags=memorynm sev=3 tags=memorycp sev=2 tags=memorynand sev=3 tags=storage_writemmc sev=3 tags=storage_writesf sev=3 tags=storage_writefatwrite sev=3 tags=storage_writeext4write sev=3 tags=storage_writetftp sev=2 tags=networkdhcp sev=1 tags=networkgo sev=3 tags=execbootm sev=2 tags=execsource sev=2 tags=execsetenv sev=2 tags=env_writesaveenv sev=3 tags=env_write
What this tells you about secure boot, immediately:
mw,cp,mm,nmexposed at the console means arbitrary memory
write is available before the verified-boot handoff. Even if the FIT
image is signed, an attacker with UART can patch the loaded image after
load and beforebootm. Property (3) above is broken.nand/mmc/sf/fatwrite/ext4writeexposed means the attacker
can persist tampered images to flash. Even a one-time JTAG fix won’t
hold across reboots.goexposed means arbitrary code execution from RAM with no
verification at all — a complete bypass of any signed-FIT scheme.setenv+saveenvmeans boot arguments andbootcmdare
attacker-controlled and persistent. This is the exact primitive Part
9 of this series weaponizes.
The framework does not just print this — it has now attached a
Vulnerability to the UART peripheral with the exact list. You can render
it via the schema-driven console:
onoSendai [acme-iotcam-2026-Q2/devices/iot-cam-01] > show└── peripherals └── debug-uart (UART) └── vulnerabilities └── Dangerous U-Boot commands are exposed (CVSS 0) description: ... mw, mm, cp, nm, nand, mmc, sf, fatwrite, ... reproduction_steps: └── Enumerate U-Boot commands via console tool: depthcharge-inspect args: [--device=/dev/ttyUSB0:115200, --arch=aarch64, -c fdebug-uart.conf]
Step 2 — Test Verified Boot by Dumping the Boot Region
Property (2) — does the bootloader actually verify the kernel? — is best tested by dumping the loaded image after U-Boot has loaded it but before it executes, then comparing against the original signed FIT to see whether the verification path actually mutated the in-RAM image (it shouldn’t — but on broken builds the verification is a no-op).
dump_memory_and_attach_vuln is the framework primitive:
# Default U-Boot loadaddr on aarch64 BCM-family is 0x80000000.# 16 MiB is enough to capture a typical FIT image + kernel.dump = agent.dump_memory_and_attach_vuln( address=0x80000000, length=16 * 1024 * 1024, filename="iotcam-loadaddr-postload.bin",)print(dump)# {# "device": "/dev/ttyUSB0:115200",# "address": "0x80000000",# "length_requested": 16777216,# "artifact": "./wm_workspace/artifacts/iotcam-loadaddr-postload.bin",# "size": 16777216,# "sha256": "8a3f1e..."# }
The agent’s behavior under the hood
(backends/depthcharge.py:728):
- Opens the depthcharge context, primes
dc.commands(detailed=False)to
surface obvious connection issues early. - Tries
dc.read_memory_to_file(addr, length, path)— depthcharge
selects a memory reader from theAvailablelist it printed during
step 1 (MdMemoryReaderis the safe default,CRC32MemoryReaderis
faster but verifies via crc32,SetexprMemoryReaderis fastest wheresetexpris available). - Falls back to
dc.read_memory(addr, length)+ manualout_path.write_bytes(...)
if the file writer failed. - Computes SHA-256, writes
dump_info.jsonnext to the binary, and
appends aVulnerabilitytitled “Raw memory dumping is possible via
U-Boot console” touart.vulnerabilitieswith aReproductionStep
that captures--device,--address, and--length.
Comparing the post-load image to the on-flash image is the diagnostic. If they differ in the data region (excluding any decompression), you have real verified boot. If they are byte-identical, U-Boot is loading without verifying.
# What we just dumped (post-load, in RAM)ram = Path(dump["artifact"]).read_bytes()# What's on the flash (you'll need a JTAG dump from Part 10 or a TFTP# fetch via 'sf read 0x90000000 0xa0000 0xc00000 ; <dump 0x90000000>'):flash = Path("./wm_workspace/artifacts/iotcam-flash-fit.bin").read_bytes()# Strip the 64-byte FIT header and DTB section, keep the kernel blob.# (Trim values are FIT-specific; use 'fdtdump' or 'mkimage -l' to find# the offsets.)kernel_offset = 0x180kernel_len = 0x600000ram_kernel = ram[kernel_offset : kernel_offset + kernel_len]flash_kernel = flash[kernel_offset : kernel_offset + kernel_len]if ram_kernel == flash_kernel: print("U-Boot loaded the kernel byte-for-byte; verification is at most " "advisory or absent.")else: print("RAM differs — verification likely active OR decompression occurred.")
In a real engagement we record this as a finding either way. If they
match, that is the secure-boot bypass. If they differ, we add a
ReproductionStep for the comparison itself so a reviewer can re-run.
Step 3 — Probe the Verification Code Path Itself
If bootm reports verification success but the bytes match, there are
three usual culprits:
CONFIG_FIT_SIGNATUREis not compiled in — the verification call
collapses toreturn 0.- The image’s
signature@1node references a key the U-Boot binary does
not embed; depthcharge can read this from the FIT header you just
dumped. - The verification keys live in a writable region (e.g., the DTB blob
loaded frombootargs-controlled paths) — an attacker swaps keys.
Drive the U-Boot console programmatically through the depthcharge
context the agent opens internally. Wintermute exposes
_open_dc_context as a context manager:
from wintermute.backends.depthcharge import _open_dc_contextwith _open_dc_context(uart.device_path, timeout=2.0, arch="aarch64") as dc: runner = getattr(dc, "console", dc) runner.interrupt() # Read the FIT image header that U-Boot was about to verify fit_header = runner.run_cmd("md.b 0x80000000 0x40") # Inspect the configured verification keys (if accessible from the env) env_dump = runner.run_cmd("printenv") # Force a verification by running bootm with the loaded address # but without actually jumping (some U-Boot builds have 'iminfo' # or 'imxtract'; aarch64 uses 'bootm' with a stop-after-load env) iminfo = runner.run_cmd("iminfo 0x80000000")print(fit_header[:200]) # FIT magic 'd00dfeed' if it parsedprint(env_dump.split('\n')[:20])print(iminfo[-200:]) # 'Verifying Hash Integrity ... ok' OR error
Capture the iminfo output as evidence. If it says Verifying Hash Integrity ... ok while the image is the unsigned vendor stock FIT, the
verification is broken and you have a textbook secure-boot bypass — every
analyst on the engagement can replay the steps from
uart.vulnerabilities[*].reproduction_steps.
Step 4 — Drive the Whole Sequence From an Agent
Plug the same primitives into the Part-7 sub-agent shape and you have an
autonomous secure-boot tester. Tool surface for this kind of run is
uboot-secboot, which I’d add to the _TOOL_PROFILES table from Part 7:
# Add to _TOOL_PROFILES in your sub-agent module_TOOL_PROFILES["uboot-secboot"] = ( [], # no extra cartridges ("execute_depthcharge_catalog", # MCP tool "execute_depthcharge_memory_dump"), # MCP tool)
These two are exposed through WintermuteMCP.py as
execute_depthcharge_catalog(peripheral_id, add_vulns=True) and
execute_depthcharge_memory_dump(peripheral_id, address, length, filename),
so the sub-agent — running on Groq Llama 3.3 with the cheap-task tag —
can drive the whole sequence:
[subagent UBOOT-SECBOOT-001:iot-cam-01:debug-uart] surface = [execute_depthcharge_catalog, execute_depthcharge_memory_dump] call execute_depthcharge_catalog(peripheral_id="<uart-id>", add_vulns=True) -> {"total_commands": 93, "dangerous_commands": 41, ...} call execute_depthcharge_memory_dump(peripheral_id="<uart-id>", address="0x80000000", length=16777216, filename="postload.bin") -> {"size": 16777216, "sha256": "8a3f1e...", ...} verdict = {"verdict":"failed", "title":"U-Boot exposes 41 dangerous commands incl. mw/sf/go; loadaddr region freely dumpable", "cvss":9, "evidence":{"dangerous_commands":41, ...}, ...} run.status = failed
Add a TestCase for this in your test plan and the orchestrator (Part 6)
auto-dispatches the sub-agent across every UART on every device in the
engagement — one execution per peripheral, one finding per peripheral.
What This Tells the Customer
The DOCX produced by Report.save(spec, [op], "out.docx") will now carry
two distinct findings on the debug-uart peripheral:
- Dangerous U-Boot commands are exposed — severity reads at High,
the description enumerates the dangerous command names, the
reproduction step tells the customer’s own engineer to rundepthcharge-inspect --device=/dev/ttyUSB0:115200 --arch=aarch64 -c fdebug-uart.confthemselves. - Raw memory dumping is possible via U-Boot console — severity
Medium by default, the dump’s SHA-256 fingerprint baked into the
reproduction step.
Both findings have verified=True. The DOCX template ships with a
section that renders reproduction_steps as a numbered list with
tool/action/arguments so the customer’s retest is mechanical.
Operational Notes
- Build a real
archvalue. The depthcharge library refuses to open a
console without a CPU architecture hint when memory readers need it.aarch64,arm,mips,mipsel,ppc,riscv, andx86are the
values you’ll use. Wrong arch → memory reads hang. - The
interrupt()call is best-effort. On targets that put the
console behind a typed-key challenge, you’ll need to wire a customrunner.interrupt()that types the magic key sequence. Swap therunnerreturned from_open_dc_context‘sgetattr(dc, "console", dc)
with your own subclass. - The 1-MiB threshold matters.
dump_memory_and_attach_vulnwrites
the bytes through the workspace path directly — for typical 16 MiB
loadaddr dumps, the LLM never sees the bytes (only the SHA-256 +
artifact path). For tiny dumps (< 1 KiB) you’ll see the bytes inline
in the agent’s tool result; that is by design (tool_factory.LARGE_PAYLOAD_THRESHOLD_BYTES,
Part 3). - Severity 0 is silent.
addVulns=Trueonly fires on severity ≥ 1.
If your target is genuinely locked down (nomw, nogo, nosf,
onlybootandprintenv), you get a clean catalogue and no
vulnerability — which is itself a positive finding for the report.
What’s Next
Part 9 takes the primitives we just built and weaponizes one of them: interrupt the U-Boot console over UART, modify bootargs to inject init=/bin/bash, and persist via saveenv — the textbook console-to-root attack on a poorly-locked-down device, expressed as a reproducible Wintermute test case with the right sub-agent surface.






Leave a Reply