Skip to content

Firmware Analysis & Emulation

Firmware analysis and emulation covers the two disciplines that make extracted firmware answer questions: static reverse engineering (loading the extracted binaries into disassemblers — Ghidra/IDA/radare2 — reading the decompiled logic, finding the hardcoded secrets’ usage, the command handlers, the vulnerability classes) and dynamic emulation (running the firmware’s binaries or whole system on your bench via QEMU — user-mode emulation for single binaries under chroot, full-system emulation for booting the actual kernel and rootfs — so the device’s services can be probed, traced, and fuzzed without the hardware). The craft’s center of gravity is the bridge between the two: static analysis tells you what to look for dynamically (the nvram reads, the socket binds, the auth checks), and dynamic runs tell you what to read statically (the crash traces, the reachable paths, the actual config values). Emulation exists because hardware is the bottleneck — one device, one serial port, endless reflashing — while QEMU runs ten instances, snapshots, and rewinds.

The extracted filesystem (H2) is a pile of binaries until analysis makes it speak, and the speaking happens two ways. Static RE answers the questions the strings raised: the hardcoded credential’s usage (is it a backdoor or a leftover?), the update client’s certificate validation (H10’s supply-chain question), the web binary’s command handlers (the memory-corruption surface), the crypto routine’s key derivation (the “encrypted” region’s key holder). Dynamic emulation answers the questions static can’t: does this binary actually run outside the device, what does it read from NVRAM, which syscalls does it make, does the vulnerability class in the handler actually crash. Emulation is also the scale tool: one extracted rootfs becomes a farm of emulated instances for fuzzing, and FirmAE-class frameworks automate the tedious half (NVRAM shims, network bring-up) that used to eat days per device. For defenders and product teams, the same analysis is the pre-ship review: RE your own binaries for the vulnerability classes your languages invite (strcpy in cgi, system() with format strings), harden per the ELF posture scan (NX/PIE/canary — the elfscan tool below), and run the emulated services in CI so the regression tests exercise the shipped code, not a mock. For the analyst, the disciplines compound: every architecture learned (ARM/ARM64/MIPS/PowerPC) multiplies the estate of devices you can analyze, and the emulation patterns (user-mode chroot, full-system, NVRAM shims) repeat across vendors — the second device costs a quarter of the first.

The analysis pipeline from extracted tree to running service:

FROM EXTRACTED TREE TO EMULATED SERVICE
[1] ARCH IDENTIFICATION
readelf -h on binaries: ARM / MIPS / aarch64
(endian matters: MIPSel vs MIPS — readelf says)
[2] USER-MODE EMULATION (single binary, fast)
cp qemu-arm-static usr/bin/ (binfmt or explicit)
chroot . qemu-arm-static ./usr/sbin/httpd
-L . sets the interpreter prefix for libs
+ NVRAM shim: LD_PRELOAD stub answering nvram_get()
[3] FULL-SYSTEM EMULATION (whole boot, slowest, fullest)
qemu-system-arm -M virt/vexpress -kernel zImage
-drive rootfs -append console=ttyAMA0
networking: -netdev tap/hostfwd to reach services
FirmAE-class: automates nvram + network bring-up
[4] STATIC RE (the reading)
Ghidra/radare2: entry -> main -> handlers
follow: strings->xrefs, nvram_get calls,
socket/bind/listen, system()/strcpy()
[5] DYNAMIC OBSERVATION
strace/ltrace (syscalls/libs), gdbserver remote
crash triage -> static RE of the crashing path
THE NVRAM PROBLEM (the classic emulation blocker)
embedded binaries read config via nvram_get("var")
-> vendor kernel driver -> flash
emulation shims: LD_PRELOAD libnvram.so answering
defaults, or /dev/nvram FUSE, or FirmAE's kernel patch

Static RE’s workflow is a funnel: identify architecture (ELF header), load into Ghidra (auto-analysis finds functions; the MIPS/ARM decompilers are good enough to read), and drive the reading from the strings — the hardcoded credential from H2 has an xref, the xref lands in a function, and the function’s context (a disabled-by-default telnet handler? a recovery-mode backdoor?) is the finding. The vulnerability classes repeat in embedded web binaries: system()/popen() with user-controlled format (command injection, W8’s cousin), strcpy/sprintf into stack buffers (the overflow class), authentication checks that compare a return value from a function whose failure returns 0 (the logic bug). User-mode emulation is the fast path: copy the matching qemu-arm-static into the extracted tree’s usr/bin, chroot, run — with two blockers that define the craft: the NVRAM problem (vendor binaries read config via nvram_get() calls into a kernel driver you don’t have — the LD_PRELOAD shim answering sane defaults is the standard fix, FirmAE automates it) and the library problem (the binary needs its own libc — the -L prefix and the chroot handle it). Full-system emulation is the thorough path: boot the actual kernel with the actual rootfs under QEMU’s machine model — the device’s real services come up, network bridging (hostfwd maps host ports to guest) exposes them to your testing tools, and snapshots let you rewind after every crash. The honest limits belong in every report: hardware-dependent code (GPIO, DSP, radio calibration) fails outside the device, some kernels refuse QEMU’s machine model, and the emulation proves the software’s behavior — the hardware attack surface (H1/H6/H7) still needs the board.

TechniqueWhat it doesToolingNotes
Architecture identificationELF header parsereadelf, this module’s toolEndian + arch = QEMU flavor
User-mode chroot emulationSingle binary runsqemu-arm-static + chrootFast, partial
NVRAM shimConfig reads answeredLD_PRELOAD libnvram, FirmAEThe classic blocker
Full-system bootKernel + rootfs runqemu-system-*, FirmAEFullest fidelity
Network bridgingReach emulated serviceshostfwd / tapPort mapping
Static RE funnelStrings → xrefs → functionsGhidra, radare2, IDAThe reading
Syscall tracingBehavior observationqemu -strace, straceDynamic truth
Remote debuggingCrash triagegdbserver + gdb-multiarchCross-arch debugging
ELF posture scanHardening inventoryelfscan (below)NX/PIE/canary/RELRO
Service fuzzingVulnerability classesboofuzz/ customizedPost-emulation

emuguide.py — the emulation environment generator: scans an extracted rootfs, classifies every ELF binary’s architecture (stdlib ELF header parsing), detects the dominant architecture, counts the network-facing binaries (from strings and init scripts), and generates the exact QEMU command runbook — user-mode chroot lines per binary, a full-system command from the detected kernel/arch, and the NVRAM shim warning — as a markdown runbook. stdlib only.

#!/usr/bin/env python3
"""emuguide.py — rootfs emulation runbook generator.
Usage:
python emuguide.py squashfs-root/ [--out runbook.md]
Parses ELF headers (stdlib), classifies arch, generates QEMU
user-mode + full-system command runbook. stdlib only.
"""
import argparse, json, os, struct, sys
ARCH = {
(0x03, True): ("arm", "qemu-arm-static", "arm"),
(0x03, False): ("armeb", "qemu-armeb-static", "arm"),
(0x08, True): ("mips", "qemu-mips-static", "mips"),
(0x08, False): ("mipsel", "qemu-mipsel-static", "mips"),
(0xb7, True): ("aarch64", "qemu-aarch64-static",
"aarch64"),
(0x3e, True): ("x86_64", "qemu-x86_64-static", "x86_64"),
}
def is_elf(path):
try:
with open(path, "rb") as fh:
return fh.read(4) == b"\x7fELF"
except OSError:
return False
def elf_arch(path):
"""Return (arch, class, endian, dynamic) from header."""
with open(path, "rb") as fh:
h = fh.read(20)
if h[:4] != b"\x7fELF" or len(h) < 20:
return None
ei_class, ei_data = h[4], h[5]
e_type, e_machine = struct.unpack(
"<HH", h[16:20]) if ei_data == 1 else \
struct.unpack(">HH", h[16:20])
little = ei_data == 1
info = ARCH.get((e_machine, little))
dynamic = False
try:
with open(path, "rb") as fh:
blob = fh.read()
dynamic = b"ld-linux" in blob or \
b"ld-uClibc" in blob or \
b"ld-musl" in blob
except OSError:
pass
return {"arch": info[0] if info else
f"machine-{e_machine}",
"qemu": info[1] if info else None,
"sys": info[2] if info else None,
"class": "64" if ei_class == 2 else "32",
"endian": "le" if little else "be",
"dynamic": dynamic}
def scan_rootfs(root):
"""Classify every ELF; return arch census + binaries."""
census, bins = {}, []
for dirpath, _, names in os.walk(root):
for n in names:
p = os.path.join(dirpath, n)
if not is_elf(p):
continue
info = elf_arch(p)
if not info:
continue
census[info["arch"]] = \
census.get(info["arch"], 0) + 1
rel = os.path.relpath(p, root)
bins.append({"rel": rel, **info})
return census, bins
def net_facing(root, bins):
"""Binaries referencing sockets/binds = service surface."""
out = []
for b in bins:
p = os.path.join(root, b["rel"])
try:
blob = open(p, "rb").read()
except OSError:
continue
if b"socket" in blob and (b"bind" in blob or
b"listen" in blob):
out.append(b)
return out
def render(root, census, bins, net):
if not census:
return "No ELF binaries found — not a Linux rootfs?"
dominant = max(census, key=census.get)
qemu_bin = next((b["qemu"] for b in bins
if b["arch"] == dominant and b["qemu"]),
None)
L = ["# Emulation runbook", "",
f"rootfs: `{root}`", "",
f"## Architecture census", ""]
for a, n in sorted(census.items(),
key=lambda kv: -kv[1]):
L.append(f"- {a}: {n} binaries")
L += ["", f"dominant arch: **{dominant}**", ""]
TICKS = chr(96) * 3
L += ["## User-mode emulation (per binary)", "",
TICKS + "bash"]
if qemu_bin:
L.append(f"# copy the static qemu into the tree")
L.append(f"cp $(which {qemu_bin}) "
f"{root}/usr/bin/ 2>/dev/null || true")
for b in net[:5]:
L.append(
f"chroot {root} /usr/bin/{qemu_bin} "
f"-L {root} /{b['rel']}")
else:
L.append("# qemu user binary for dominant arch "
"not detected — install qemu-user-static")
L.append(TICKS)
L += ["", "## Full-system emulation (dominant arch)",
"", TICKS + "bash"]
syscmd = {
"arm": ("qemu-system-arm -M virt -cpu cortex-a15 "
"-kernel zImage -drive "
"if=none,file=rootfs.img,format=raw,id=hd0 "
"-device virtio-blk,drive=hd0 -append "
"'root=/dev/vda console=ttyAMA0' -nographic "
"-netdev user,id=n0,hostfwd=tcp::8080-:80 "
"-device virtio-net,netdev=n0"),
"mips": ("# mips: Malta/fulong machine models; "
"kernel compatibility varies — see FirmAE"),
"aarch64": ("qemu-system-aarch64 -M virt -cpu "
"cortex-a53 -kernel Image ..."),
}
L.append(syscmd.get(dominant,
"# unknown dominant arch"))
L.append(TICKS)
L += ["", "## NVRAM warning", "",
"vendor binaries likely call nvram_get() against "
"a kernel driver that won't exist under QEMU. "
"Use an LD_PRELOAD nvram shim or FirmAE "
"(automates shims + networking).", ""]
L += ["## Network-facing binaries (service surface)",
""]
for b in net[:15]:
L.append(f"- `{b['rel']}` ({b['arch']}, "
f"{'dynamic' if b['dynamic'] else 'static'})")
return "\n".join(L) + "\n"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("rootfs")
ap.add_argument("--out")
ap.add_argument("--json")
args = ap.parse_args()
if not os.path.isdir(args.rootfs):
sys.exit(f"not a directory: {args.rootfs}")
census, bins = scan_rootfs(args.rootfs)
net = net_facing(args.rootfs, bins)
text = render(args.rootfs, census, bins, net)
print(text)
if args.out:
open(args.out, "w").write(text)
print(f"[+] {args.out}", file=sys.stderr)
if args.json:
json.dump({"census": census,
"network_facing": [b["rel"]
for b in net]},
open(args.json, "w"), indent=2)
if __name__ == "__main__":
main()
  • elf_arch() (lines 26-47) parses the ELF header with stdlib struct: machine type + endianness map to the QEMU flavor (the endian distinction matters enormously — qemu-mips vs qemu-mipsel is the classic wrong-tool error), and the dynamic-linker string check (ld-linux/ld-uClibc/ld-musl) classifies static vs dynamic — which determines whether user-mode emulation needs the -L library prefix.
  • The census + service surface (lines 50-70): every ELF classified (the census tells you if the rootfs is mixed-arch — common in vendor images with DSP blobs), and the network-facing filter (socket+bind/listen strings) produces the service surface — the binaries whose emulation matters for probing.
  • The runbook generation (lines 73-113) emits the exact commands: the static-QEMU copy, per-binary chroot lines for the top-5 service binaries, the full-system command for the dominant architecture (with the honest per-arch caveats — MIPS machine models are their own project), and the NVRAM warning that every first-time emulator hits.
  • The dominant-arch selection (line 76): max-count census entry — vendor trees are usually single-arch with blobs; when they’re not, the runbook names the majority and the census names the exceptions.

The extracted tree reaches the analyst’s bench from H2, and this module’s tools turn it into answers. The architecture census reaches the runbook: which QEMU flavors, which binaries, which service surface — the emulation plan in one command. The user-mode emulation reaches the single-binary questions: does the update client run, what does it read, does it validate TLS (H10’s question answered dynamically). The NVRAM shim reaches the first real blocker and teaches the pattern: vendor config reads are shims until the real driver exists — and the shim’s defaults are themselves findings (what does the binary do when NVRAM says the factory value?). The full-system boot reaches the service-level assessment: the device’s actual web UI, telnetd, and services running under QEMU with host-forwarded ports — probe with the same tools you’d use on the network (W-series for the web UI, PE-series for the shell), snapshot between tests, rewind after crashes. The static RE reaches the questions strings raised: the credential’s xref, the command handler’s injection sink, the crypto routine’s key — each answer either a finding or the pointer to the dynamic test that proves it. For the defender, the same pipeline is the product team’s: ELF posture scans (the elfscan below) in CI, emulated-service smoke tests per build, and RE review of the binaries that face the network — the review that finds the system(buf) while it’s a build-cycle fix. And the emulation farm reaches the fuzzing program: N instances of the emulated service, a generation-based fuzzer against the CGI handlers, crashes triaged by remote gdb — the scale that one physical device can never give.

  1. Setup — emuguide.py on H2’s extracted rootfs: mipsel dominant, 6 network-facing binaries
  2. Shim — LD_PRELOAD nvram shim built with defaults from /etc; chroot qemu-mipsel runs lighttpd
  3. Reach — hostfwd maps guest:80 to bench:8080; device web UI alive in browser
  4. Probe — login form tested; hardcoded cred from H2 harvest grants admin on emulated AND real device
  5. RE — Ghidra on the cgi binary: command handler passes ping= param to system() unfiltered
  6. Confirm — injection payload against emulated service: id executed; crashless command confirmed
  7. Hardware — same payload against physical device (authorized): root shell via web injection
  8. Report — emulated-to-hardware finding chain; remediation: sanitization, hardening flags (elfscan gate)

elfscan.py — the ELF hardening-posture scanner: walks a rootfs (or a single binary), parses ELF headers and dynamic sections (stdlib struct parsing), and reports per-binary and aggregate security posture: NX (PT_GNU_STACK exec flag), PIE (ET_DYN type), RELRO (PT_GNU_RELRO presence), canary (stack_chk symbol references), and stripped-vs-symbols — the hardening inventory that turns “is this binary hardened?” from a per-file RE question into a rootfs-wide table. stdlib only.

  • Header-level truth. NX/PIE/RELRO are header/program-header facts — readable without any disassembly, fast enough for whole-rootfs scans; canary needs a symbol/string check (__stack_chk_fail reference) which is heuristic but honest.
  • Rootfs-wide aggregate. The value is the estate view: “412 binaries, 61% NX, 9% PIE” is the hardening conversation; per-binary detail follows for the outliers.
  • Network-facing first. The scan cross-references H3’s service-surface heuristic — the network-facing binaries’ posture is the prioritized view, because those are the reachable ones.
  • No third-party ELF libraries. stdlib struct parsing of the program headers — the ELF format is documented, and the ~150 lines of parsing teach more than importing a library would.
#!/usr/bin/env python3
"""elfscan.py — ELF hardening posture scanner (stdlib).
Usage:
python elfscan.py squashfs-root/ [--json posture.json]
Checks: NX (GNU_STACK W^X), PIE (ET_DYN), RELRO,
canary (stack_chk refs), symbols stripped.
stdlib only.
"""
import argparse, json, os, struct, sys
def parse_elf(path):
with open(path, "rb") as fh:
blob = fh.read(4096)
if blob[:4] != b"\x7fELF" or len(blob) < 52:
return None
ei_class, ei_data = blob[4], blob[5]
fmt = "<" if ei_data == 1 else ">"
(e_type, e_machine) = struct.unpack(
fmt + "HH", blob[16:20])
if ei_class == 2: # 64-bit
(e_phoff, e_shoff) = struct.unpack(
fmt + "QQ", blob[32:48])
e_phentsize, e_phnum = struct.unpack(
fmt + "HH", blob[54:58])
is64 = True
else: # 32-bit
(e_phoff, e_shoff) = struct.unpack(
fmt + "II", blob[28:36])
e_phentsize, e_phnum = struct.unpack(
fmt + "HH", blob[42:46])
is64 = False
# program headers (need full file for phdrs)
full = open(path, "rb").read()
nx = None
relro = False
interp = False
phtype_names = {1: "LOAD", 2: "DYNAMIC", 3: "INTERP",
6: "PHDR", 7: "TLS",
0x6474e550: "GNU_EH_FRAME",
0x6474e551: "GNU_STACK",
0x6474e552: "GNU_RELRO"}
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
if is64:
p_type = struct.unpack(
fmt + "I", full[off:off + 4])[0]
p_flags = struct.unpack(
fmt + "I", full[off + 4:off + 8])[0]
else:
p_type = struct.unpack(
fmt + "I", full[off:off + 4])[0]
p_flags = struct.unpack(
fmt + "I", full[off + 24:off + 28])[0]
name = phtype_names.get(p_type)
if name == "GNU_STACK":
nx = not (p_flags & 0x1) # W flag = no NX
elif name == "GNU_RELRO":
relro = True
elif name == "INTERP":
interp = True
pie = e_type == 3
canary = b"__stack_chk_fail" in full
stripped = b".symtab" not in full
return {"nx": nx, "pie": pie, "relro": relro,
"canary": canary, "stripped": stripped,
"interp": interp, "is64": is64}
def scan_root(root):
rows = []
for dirpath, _, names in os.walk(root):
for n in names:
p = os.path.join(dirpath, n)
try:
if open(p, "rb").read(4) != b"\x7fELF":
continue
except OSError:
continue
info = parse_elf(p)
if info:
rows.append({"path": os.path.relpath(
p, root), **info})
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("root")
ap.add_argument("--json")
args = ap.parse_args()
if not os.path.isdir(args.root):
sys.exit(f"not a directory: {args.root}")
rows = scan_root(args.root)
if not rows:
sys.exit("no ELF binaries found")
n = len(rows)
agg = {k: sum(1 for r in rows if r[k])
for k in ("nx", "pie", "relro", "canary",
"stripped")}
print(f"== ELF posture: {n} binaries ==")
for k, v in agg.items():
print(f" {k:<8} {v:>4}/{n} ({v/n:.0%})")
# prioritize: dynamic + weak posture
weak = [r for r in rows
if r["interp"] and
(r["nx"] is False or not r["pie"])]
if weak:
print(f"\n[!] dynamic binaries with weak "
f"posture (no NX or no PIE): {len(weak)}")
for r in weak[:12]:
print(f" {r['path']} nx={r['nx']} "
f"pie={r['pie']} canary={r['canary']}")
if args.json:
json.dump({"aggregate": agg, "binaries": rows},
open(args.json, "w"), indent=2)
print(f"\n[+] {args.json}")
if __name__ == "__main__":
main()
  • parse_elf() (lines 12-58) handles both ELF classes with the struct-format switch (</> by endianness, 32/64 field widths) — the program-header walk reads PT_GNU_STACK (NX = execute flag absent), PT_GNU_RELRO (partial RELRO presence), and PT_INTERP (dynamic) — the three hardening facts that are header-truth.
  • The canary heuristic (line 59): __stack_chk_fail presence in the file — reference-based, so it flags binaries linked against stack protection; the honest label is “canary referenced” and the aggregate treats it as such.
  • The weak-posture prioritization (lines 82-90): dynamic (INTERP present — i.e., real programs, not libc itself) binaries lacking NX or PIE — the exploitable-surface view that pairs with H3’s service-surface filter, so the table you show the product team is “reachable AND weak,” not 400 rows.
  • The aggregate (lines 75-80) is the estate number: “61% NX across 412 binaries” — the hardening conversation’s opening line, with the per-binary detail behind it.

generate the runbook from H2’s extraction

Section titled “generate the runbook from H2’s extraction”

$ python emuguide.py v1out/squashfs-root/ —out runbook.md

  • mipsel: 312 binaries dominant arch: mipsel

chroot v1out/squashfs-root/ /usr/bin/qemu-mipsel-static
-L v1out/squashfs-root/ /usr/sbin/lighttpd

$ python elfscan.py v1out/squashfs-root/ —json posture.json == ELF posture: 312 binaries == nx 198/312 (63%) pie 41/312 (13%) relro 87/312 (28%) canary 55/312 (18%)

[!] dynamic binaries with weak posture (no NX or no PIE): 46 usr/sbin/httpd nx=False pie=False canary=False

$ chroot v1out/squashfs-root/ /usr/bin/qemu-mipsel-static
-L v1out/squashfs-root/ /usr/sbin/lighttpd -f /etc/lighttpd.conf & $ curl -s http://127.0.0.1:80/ | head -3

Firmware-layer “detection” is build posture: the elfscan aggregate belongs in CI (hardening flags regression-gated per release — “NX dropped from 63% to 55%” is a build-system finding), the emulated-service smoke tests run per build (the services must boot under QEMU in CI — the config that never boots is found before shipping), and the RE review covers the network-facing binaries per release. Your own tooling hygiene: emuguide/elfscan read files; running extracted binaries belongs in chroot/QEMU sandboxes on the bench — never on the analyst’s host without the container (the vendor’s init scripts assume they own the machine). QEMU/FirmAE bring their own hygiene (user-mode networking by default — no bridged bench network for untrusted emulation). Wazuh/SIEM has no layer; the detection is CI and design review. The standing metrics: hardening-posture percentages per release (NX/PIE/RELRO/canary trending up), emulated-boot success rate per build (CI green), and RE findings-per-release (target: declining) — three numbers that make the firmware estate’s code quality as governed as its network posture.

  1. radare2/Ghidra headless integration: emit analysis-project commands per network-facing binary into the runbook.
  2. Vulnerability-class grep: system(, strcpy(, sprintf( with user-input proximity heuristics — the fast pre-RE triage (report cites evidence, RE confirms).
  3. NVRAM shim generator: scan binaries for nvram_get key names and emit a defaults C stub — the shim built from the binary’s own questions.
  4. QEMU machine-model matcher: probe kernel images for machine compatibility (patched vs stock) before the full-system attempt.
  5. CI posture gate: --min-nx 90 style exit semantics for release pipelines.
SignalSourceIDNotes
Hardening posture per releaseelfscan CI gatebuild metricsNX/PIE/RELRO/canary trend
Emulated boot successCI smoke testper buildConfig that never boots
RE findings per releaseReview programper releaseDeclining target
Network-facing binary countemuguideattack surfacePer-release surface metric
Vulnerability-class patternsgrep triagepre-RE findingssystem/strcpy sinks
NVRAM dependency inventoryshim generatordesign findingConfig-read surface
Emulation-based fuzz coverageFuzz programper releaseCrash-class regression
Weak+reachable setelfscan prioritizehardening listThe product-team worklist
Update client validation (RE)H3/H10 reviewsupply chainTLS check presence
Design-review acceptance rateH12 program metricper product genLifecycle metric

The firmware-evasion race (vendors hardening analysis) mirrors every other layer’s. Anti-disassembly: opaque predicates, instruction-overlap tricks that break linear disassembly — countered by decompilers that recover control flow (Ghidra’s analysis handles most), by dynamic tracing (execution is truth), and by the honest note that anti-disassembly marks the interesting functions for attention. Anti-emulation: binaries that detect QEMU (timing, CPUID quirks, device-tree probes) and refuse to run — countered by QEMU tuning (custom machine models, patched kernels — FirmAE’s approach), by answering the probes in the shim layer, and by the observation that anti-emulation code is itself an xref to RE. Stripping and packing: stripped binaries (no symbols), UPX-class packing — countered by symbol recovery from strings and cross-references (stripped binaries still name their syscalls), and by unpacking (M4’s discipline, applied to embedded packers). Compiler-hardening evasion: no impact on analysis, but the defense side: the posture scan makes hardening visible, so the evasion would be claiming hardening without enabling flags — countered by the CI gate reading the actual headers, not the build config. Hardware-dependence as evasion: code that refuses to run without the real device’s peripherals — countered by shimming the peripherals (the NVRAM lesson generalized) and by the assessment honesty that some questions need the board (H1/H7’s territory). The meta-point: firmware RE’s difficulty is logistics (arch, NVRAM, hardware deps), not crypto — and each logistics barrier has a standard tool once you’ve hit it once, which is why the second device costs a quarter of the first.

⚠️ What caught me: my first QEMU emulation attempt of a MIPS camera failed so completely — kernel panic before the bootloader finished — that I nearly declared the firmware “hardware-dependent” and moved on. A week later, on a hunch, I tried the endianness I hadn’t checked: the census said mipsel, I’d been booting qemu-system-mips (big-endian) because the vendor’s wiki said “MIPS.” One letter. The full system booted on the second try, services and all. Since then, emuguide’s census exists precisely because “MIPS” is not an architecture — machine, endianness, and class are three separate facts, and the runbook reads them from the ELF headers instead of from anyone’s wiki. The hardware-dependent verdict is now the last conclusion I’m allowed to write, not the first.

  1. Emulate one binary: take H2’s extracted rootfs, run emuguide.py, and follow the user-mode runbook for the web binary — get one vendor service running on your bench, NVRAM shim and all.
  2. Posture scan: run elfscan.py on the same tree; reconcile the weak-posture list against the network-facing surface — the prioritized hardening conversation, on a rootfs you control.
  3. Full-system boot: attempt the qemu-system command for your lab device’s kernel; debug the machine-model/endianness failures until it boots (or document exactly which hardware dependency blocks it — both are findings).
  4. Static RE hour: load the web binary into Ghidra, follow the hardcoded credential’s xref, and document what the code actually does with it — the strings-to-meaning workflow, once, end to end.
  5. Crash-to-RE loop: send one malformed request to the emulated service, catch the crash in qemu/gdb output, and find the crashing function in Ghidra — the dynamic-static bridge, the skill this module exists to build.
  • Technical: Compare user-mode and full-system emulation for firmware — what does each answer, and what are the NVRAM/networking blockers of each?
  • Technical: What do the NX, PIE, RELRO, and canary protections each do, and which ELF structures does each check read?
  • Scenario: The extracted binary refuses to run under QEMU with an immediate abort. Diagnose: arch/endianness, NVRAM reads, hardware deps, anti-emulation — in order.
  • Conceptual: Why does emulation scale vulnerability research where hardware cannot, and what does the emulation not prove about the hardware device?
  • Conceptual: How do the ELF hardening flags change the vulnerability classes an assessor hunts — and which classes survive NX+PIE?
  • Deep-dive: Design the firmware RE program for a product line: per-release analysis pipeline, posture gates, RE review scope, and the findings trend.
  • Practical: The emulated service reproduces the injection but the physical device doesn’t crash. What differs, and what does each result mean for the report?

H2 (Firmware Extraction & Analysis) · H4 (Embedded Linux & Root Shells) · F2 (Linux Internals) · F5 (Python for Security) · M4 (Unpacking and Deobfuscation)