Wintermute Framework, Part 8: U-Boot Secure Boot Testing With the Depthcharge Backend

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_file and falls
    back to read_memory + manual write, attaching a Vulnerability with a
    ready-to-replay ReproductionStep,
  • 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:

  1. The SoC boot ROM verifies the signature on the bootloader (or an
    intermediate stage) before executing.
  2. The bootloader (here U-Boot) verifies the signature on the kernel /
    FIT image before handing control over.
  3. The kernel cmdline, devicetree, and ramdisk cannot be tampered with
    between verification and execution.
  4. There is no console-driven interactive primitive (memory write, raw
    network fetch, arbitrary go, 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 Operation
from wintermute.peripherals import UART
from wintermute.backends.json_storage import JsonFileBackend
Operation.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 DepthchargePeripheralAgent
agent = 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:

  1. _open_dc_context opened depthcharge.Console("/dev/ttyUSB0:115200", timeout=2.0, arch="aarch64") and called runner.interrupt() to grab
    the prompt.
  2. dc.commands(detailed=True) returned the structured help dictionary
    (name → {summary, details}).
  3. _parse_commands produced a CommandRecord per entry, with the danger
    score and tags computed from the name and summary.
  4. The catalogue JSON was persisted to
    <workspace>/artifacts/command_catalog.json.
  5. Because addVulns=True and at least one command had severity >= 1,
    a Vulnerability titled “Dangerous U-Boot commands are exposed” was
    attached to uart.vulnerabilities with a ReproductionStep pointing
    at depthcharge-inspect.

Inspect the catalogue directly to see what the secure-boot story looks like on this build:

import json
from pathlib import Path
cat = 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=memory
mw sev=3 tags=memory
mm sev=3 tags=memory
nm sev=3 tags=memory
cp sev=2 tags=memory
nand sev=3 tags=storage_write
mmc sev=3 tags=storage_write
sf sev=3 tags=storage_write
fatwrite sev=3 tags=storage_write
ext4write sev=3 tags=storage_write
tftp sev=2 tags=network
dhcp sev=1 tags=network
go sev=3 tags=exec
bootm sev=2 tags=exec
source sev=2 tags=exec
setenv sev=2 tags=env_write
saveenv sev=3 tags=env_write

What this tells you about secure boot, immediately:

  • mw, cp, mm, nm exposed 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 before bootm. Property (3) above is broken.
  • nand/mmc/sf/fatwrite/ext4write exposed means the attacker
    can persist tampered images to flash
    . Even a one-time JTAG fix won’t
    hold across reboots.
  • go exposed means arbitrary code execution from RAM with no
    verification at all — a complete bypass of any signed-FIT scheme.
  • setenv + saveenv means boot arguments and bootcmd are
    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):

  1. Opens the depthcharge context, primes dc.commands(detailed=False) to
    surface obvious connection issues early.
  2. Tries dc.read_memory_to_file(addr, length, path) — depthcharge
    selects a memory reader from the Available list it printed during
    step 1 (MdMemoryReader is the safe default, CRC32MemoryReader is
    faster but verifies via crc32, SetexprMemoryReader is fastest where
    setexpr is available).
  3. Falls back to dc.read_memory(addr, length) + manual out_path.write_bytes(...)
    if the file writer failed.
  4. Computes SHA-256, writes dump_info.json next to the binary, and
    appends a Vulnerability titled “Raw memory dumping is possible via
    U-Boot console”
    to uart.vulnerabilities with a ReproductionStep
    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 = 0x180
kernel_len = 0x600000
ram_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_SIGNATURE is not compiled in — the verification call
    collapses to return 0.
  • The image’s signature@1 node 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 from bootargs-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_context
with _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 parsed
print(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:

  1. 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 run
    depthcharge-inspect --device=/dev/ttyUSB0:115200 --arch=aarch64 -c fdebug-uart.conf themselves.
  2. 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 arch value. The depthcharge library refuses to open a
    console without a CPU architecture hint when memory readers need it.
    aarch64, arm, mips, mipsel, ppc, riscv, and x86 are 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 custom
    runner.interrupt() that types the magic key sequence. Swap the
    runner returned from _open_dc_context‘s getattr(dc, "console", dc)
    with your own subclass.
  • The 1-MiB threshold matters. dump_memory_and_attach_vuln writes
    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=True only fires on severity ≥ 1.
    If your target is genuinely locked down (no mw, no go, no sf,
    only boot and printenv), 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.

One response to “Wintermute Framework, Part 8: U-Boot Secure Boot Testing With the Depthcharge Backend”

  1. […] Part 8 tested whether the U-Boot console gives you primitives. This post weaponizes those primitives into the textbook Linux-on-embedded escalation: […]

Leave a Reply

Hey!

I’m Bedrock. Discover the ultimate Minetest resource – your go-to guide for expert tutorials, stunning mods, and exclusive stories. Elevate your game with insider knowledge and tips from seasoned Minetest enthusiasts.

Join the club

Stay updated with our latest tips and other news by joining our newsletter.

Discover more from Exploit.Ninja

Subscribe now to keep reading and get access to the full archive.

Continue reading