← Articles
// Methodology

How to Pentest Firmware: Extraction, Binary Analysis, and Disclosure

Dilip Mallavarapu October 1, 2026 Firmware · Reverse Engineering · Vulnerability Research

A start-to-finish methodology and learning roadmap for finding valid vulnerabilities in firmware: what to study, how to extract and analyze the binary, and how to get to a defensible PoC and disclosure.

How to use this

Every valid bug is the same shape: attacker-controlled input reaches a dangerous operation, across a trust boundary, with no adequate check in between. Source code or a stripped MIPS binary, the shape does not change. Only how much work it takes to see it does. The phases below are that shape turned into a process: extract the firmware, map where untrusted data enters, find the dangerous operations (static), prove input can reach them (dynamic and fuzzing), then kill false positives until what remains crosses a real boundary. Work it top to bottom the first few times; later you'll skip around, but every finding still has to close the loop of reachable source, unsanitized path, dangerous sink, real impact. Read the foundations section as a checklist of what to go learn when a phase stalls on something you don't yet understand. That gap is usually the actual blocker, not the tooling.

The firmware research pipeline: seven phases with a validation loop
The firmware research pipeline: seven phases with a validation loop.

The three middle phases are a loop, not a line: a candidate that dies in validation sends you back to static analysis to find the next one, not to a report.

Foundations to build first

These are the things that, when you don't know them, quietly stop you. Learn them roughly in this order, and deepen the ones your targets actually use. Each one below comes with a command you can run and the output to expect.

1. C and C++ at the memory level

The object model (struct layout and padding, integer wrap-around, pointers) matters because almost every memory-corruption bug is a C mistake you have to recognise in the decompiler.

Struct layout is rarely what you would guess:

struct packet { uint8_t type; uint32_t length; uint8_t flag; };
printf("%zu", sizeof(struct packet));
sizeof(struct packet) = 12
offset type=0 length=4 flag=8

Three fields totalling 6 bytes occupy 12, because the compiler pads so length lands on a 4-byte boundary. Parse a binary struct without accounting for padding and every field after it is misread.

Integer wrap is a whole bug class in one line:

uint16_t total = count * 8;   /* count comes from a packet header */
char *buf = malloc(total);    /* then count*8 bytes get written in */
count=10000  requested total=80000 bytes -> malloc(14464)

80000 does not fit in 16 bits, so total wraps to 14464. The allocation is too small and the write overruns the heap.

2. CPU architecture and assembly

Enough ARM/MIPS/x86 to read a disassembly and trust or distrust the decompiler: registers, the calling convention, where the return address lives, endianness. With no source, this listing is your source.

# vuln.c: strcpy(local_buf[64], input)  -- no bounds check
mips-linux-gnu-gcc -c -O1 -fno-stack-protector vuln.c -o vuln.o
mips-linux-gnu-objdump -d vuln.o
00000000 <handle_request>:
   0: addiu sp,sp,-96
   4: sw    ra,92(sp)      # saved return address at sp+92
  18: li    a2,64          # strcpy size arg = 64
  20: addiu s0,sp,24       # local_buf is at sp+24
  2c: jalr  t9             # call strcpy
  4c: lw    ra,92(sp)      # restore return address
  54: jr    ra

The buffer starts at sp+24 and the saved return address sits at sp+92, 68 bytes apart. Write 69+ bytes through that unbounded copy and you control ra, the address the function returns to. That distance, read straight off the frame, is the exploit primitive.

3. The ELF format and the loader

Sections vs segments, the symbol table, PLT/GOT, dynamic linking, and the mitigation flags: this is what every tool parses, and it explains a wrong load base or a function that decodes to garbage.

file elfdemo
readelf -h elfdemo    # ELF header
readelf -d elfdemo    # dynamic section
elfdemo: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped
  Class: ELF64   Data: little endian   Type: DYN (PIE)   Machine: x86-64
  Entry point address: 0x1060
 (NEEDED)  Shared library: [libc.so.6]
 (FLAGS)   BIND_NOW

Bit-width, endianness and machine are exactly the settings you feed the disassembler, and if one is wrong nothing resolves. NEEDED lists the libraries the binary trusts; the flags reveal its hardening.

4. How firmware is built and laid out

Bootloader, kernel, root filesystem, flash partitions, and the split between a full Linux userland and a monolithic RTOS blob. The layout is what you carve, and the target class decides the whole approach. This is the scan binwalk performs: magic signatures plus an entropy map.

binwalk firmware.bin       # signature scan
binwalk -E firmware.bin    # entropy map
DECIMAL   HEX     DESCRIPTION
0         0x0     U-Boot bootloader string
65        0x41    gzip compressed data
145       0x91    SquashFS filesystem (little-endian)

OFFSET    ENTROPY  READING
0x0       7.14     structure / strings
0x200     7.6      high -- compressed or encrypted
0x1000    5.59     structure / strings

The signature offsets tell you where each embedded object begins, so carve from there. A region at near-maximal entropy end to end is compressed or encrypted; if the whole image looks like that, you are facing encryption rather than a parser (Phase 1 says what to do next).

Don't try to master all of these before starting. Learn the first three well, start hunting, and let the targets pull you into the rest.

Phase 1: Acquiring and extracting firmware

You can't analyze what you can't unpack. Get the image, then peel it apart.

Getting the image, easiest first:

Unpacking:

When extraction fails, diagnose before brute-forcing:

Goal of this phase: a mounted, browsable root filesystem (or, for RTOS blobs, a correctly based image loaded in your disassembler). Everything after assumes you have that.

Worked example: unpacking a real image

The same three steps against a firmware.bin built for this walkthrough (a U-Boot region, a uImage-wrapped gzip kernel, and a SquashFS root). The output is real binwalk.

1. Signature scan. binwalk walks the image matching magic bytes, so every embedded object and its offset falls out:

binwalk firmware.bin
binwalk signature scan of firmware.bin
binwalk signature scan of firmware.bin.

A uImage header, then a gzip kernel, then SquashFS, each with a byte offset. Those offsets are where you carve. The kernel is flagged MIPS, matching what file said about the userland binaries.

2. Entropy map. Before extracting, check whether anything is encrypted:

binwalk -E firmware.bin
binwalk entropy map of firmware.bin
binwalk entropy map of firmware.bin.

The rising edge at 0x10000 is the compressed kernel; everything else sits low. A region pinned near 1.0 end-to-end would mean encryption, but here nothing is, so extraction will work.

3. Extract. -e carves each detected object and unpacks the filesystem:

binwalk -e firmware.bin
binwalk extraction and the unpacked squashfs-root tree
binwalk extraction and the unpacked squashfs-root tree.

The SquashFS unpacks to squashfs-root/, the exact tree Phase 2 then enumerates. You've gone from an opaque blob to a browsable root filesystem.

Phase 2: Reconnaissance and attack-surface mapping

Now inventory the filesystem and decide where to spend your attention. Most of a firmware image is benign; the bugs live in a small number of privileged, reachable, input-handling components.

Enumerate:

Worked example: recon on an extracted root

Here is that workflow run for real against a deliberately-built sample root filesystem (squashfs-root/, a stand-in for a typical MIPS camera/router image). The commands are exactly what you would type; each screenshot is its actual output.

1. Identify architecture and endianness. Before any disassembly you must know what you're loading. file on the vendor binaries settles it:

file bin/busybox usr/sbin/httpd usr/bin/cli
file output identifying MIPS 32-bit MSB binaries
file output identifying MIPS 32-bit MSB binaries.

MIPS, MSB (big-endian), so Ghidra/IDA must be set to MIPS big-endian or every instruction decodes to garbage. Note usr/bin/cli is already flagged setuid.

2. Hunt setuid / setgid binaries. These are the local privilege-escalation surface:

find . -perm -4000 -o -perm -2000
find and ls revealing a setuid-root cli binary
find and ls revealing a setuid-root cli binary.

A custom, setuid-root binary (-rwsr-xr-x, owner root) that isn't stock BusyBox is exactly the kind of local-privesc target worth reversing.

3. Hunt hardcoded secrets. One grep across the config tree often yields valid findings on its own:

grep -rInE 'password|api_key|BEGIN [A-Z]+ PRIVATE KEY' etc/
grep surfacing a hardcoded admin password, API key and private key
grep surfacing a hardcoded admin password, API key and private key.

A hardcoded admin password, a live API key, and a private key shipped inside the image, each reportable without touching a single instruction.

4. Trace untrusted input into a shell. In the web CGIs, grep for the source (request data) and the command sink in one pass:

grep -rnE 'QUERY_STRING|ping -c|FW=$1|cp $FW' www/cgi-bin/
grep showing QUERY_STRING flowing into a ping command
grep showing QUERY_STRING flowing into a ping command.

$QUERY_STRING feeds $IP, which lands in ping -c 1 $IP with no sanitization. That is textbook command injection (a request like ?ip=1.1.1.1;telnetd runs both). sysupgrade.cgi passes its argument straight into cp.

5. Pull command templates from a stripped binary. Even with no source, strings exposes the shape of what a binary does:

strings -n 6 usr/sbin/httpd usr/bin/cli | grep -E '%s|/bin/sh|system|cp '
strings exposing /bin/sh -c %s and cp %s %s command templates
strings exposing /bin/sh -c %s and cp %s %s command templates.

/bin/sh -c %s and cp %s %s sitting next to a system import are strong leads: in Ghidra, find the cross-references to these format strings and check whether any %s is attacker-controlled.

Everything above is reconnaissance only. None of it is a confirmed bug yet; it's the ranked lead list you carry into static analysis (Phase 3).

Then rank. Score each candidate on three axes and start at the top:

AxisHigh-priority looks like
PrivilegeRuns as root / setuid; touches auth, update, or the network stack
ReachabilityPre-auth network input, or triggerable by a low-priv user
Input complexityParses length-prefixed, nested, or string-formatted data (parsers break)

A pre-auth, root-running, custom binary that parses network input is where you look first. A local, unprivileged helper that reads a fixed config is where you look last. This ranking is the single highest-leverage decision in the whole process. It's why disciplined researchers find bugs and blind grepping finds noise.

Phase 3: Static binary analysis

When there's no source, the decompiler is your source. This is the same source-to-sink hunt as auditing C, done one abstraction layer down. Ghidra (free, scriptable, great decompiler) is the default; IDA Pro and Binary Ninja are worth it if you can afford them (better analysis, cleaner UI, strong APIs).

1. Load it correctly. Half of "the decompiler is useless" is a wrong load. Set the exact architecture, bit-width, and endianness (MIPS firmware is often big-endian, so getting this wrong turns every instruction into garbage). For a raw RTOS blob with no ELF header, you must find and set the load base so pointers and cross-references resolve; look for the base in the bootloader, in a memory map, or by aligning string references. Load the right processor variant (ARM vs. Thumb matters).

2. Orient before you dig.

3. Trace source to sink. Pick a dangerous call site (sink). Walk its arguments backward: where does the tainted buffer come from? Follow cross-references up the call graph until you reach an entry point that takes attacker input, or hit a bounds check or sanitizer that kills it. Do the same forward from sources when the surface is small. Ghidra's decompiler and xref graph, or Binary Ninja's, make this tractable; rename variables and retype structs as you learn them so the decompilation gets readable. This backward-from-sink walk is the core skill, and most of your hours go here.

4. Read the decompiler critically. Decompilers lie: wrong function signatures, missed cross-references, misidentified types, phantom variables. When something looks impossible, drop to the disassembly and check the actual registers and stack offsets. Fix signatures and calling conventions so arguments line up.

5. Diff for shortcuts, which is very high yield:

Output of this phase is a list of candidate sinks with a plausible tainted path to each. None of them are bugs yet; the next two phases decide that.

Worked example: finding the sink in a binary

These are the command-line parts of static analysis you can show as output; the Ghidra decompiler is a GUI, but the same source-to-sink reasoning drives it. The target fw_httpd is a small vulnerable service compiled for this walkthrough. It's shown as x86-64 for clarity, but the method is identical on a MIPS/ARM firmware binary; you just set the architecture.

1. Which dangerous functions does it import? The binary-level equivalent of grepping for sinks:

objdump -T fw_httpd | grep -E 'system|strcpy|sprintf|popen|gets'
objdump dynamic symbols: strcpy, system, printf, sprintf
objdump dynamic symbols: strcpy, system, printf, sprintf.

strcpy, sprintf and system are all imported: an unbounded copy, a format-into-buffer, and a shell. That's the shortlist of call sites to examine.

2. Trace source to sink in the disassembly. Load it in radare2, analyze, and print the function:

r2 -A fw_httpd      # then at the prompt:  pdf @ sym.handle_ping
radare2 disassembly of handle_ping: strcpy, sprintf ping -c 1 %s, then system
radare2 disassembly of handle_ping: strcpy, sprintf "ping -c 1 %s", then system.

The whole bug is on screen: arg1 goes through strcpy into a fixed buffer (overflow), then sprintf with "ping -c 1 %s", then system (command injection). Both the tainted argument and the two sinks are visible, exactly what you'd confirm in Ghidra's decompiler by walking backward from the system call.

3. Patch diff. Given a later, fixed build, radiff2 scores every function and shows what moved:

radiff2 -C fw_httpd fw_httpd_patched
radiff2 function table: handle_ping UNMATCH 0.47, strspn NEW
radiff2 function table: handle_ping UNMATCH 0.47, strspn NEW.

Every function matches at 1.0 except handle_ping (0.47, and it grew from 96 to 158 bytes), and strspn shows up as NEW, the validation the vendor added. On a real target, that one changed function is exactly where you'd focus a 1-day analysis.

Phase 4: Dynamic analysis and emulation

A static candidate is a hypothesis. Running the code confirms it, and running firmware off its hardware is the hard, distinctive skill of this field.

Emulation, in order of increasing pain:

Peripheral stubbing is the recurring blocker: the binary reads a hardware register or a missing NVRAM key and exits. You stub these (fake the NVRAM library, LD_PRELOAD a shim, or patch the check out) until the code path you care about runs.

Debugging:

The goal here is a controlled trigger: you send input at a real source and observe it reaching the sink. Do that and you've proven reachability, the hardest half of validity.

Phase 5: Fuzzing

Fuzzing finds the bugs you'd never spot by reading, and produces a crashing input that is your PoC. It shines on parsers, anything that eats structured, attacker-shaped data.

Pick the right target. A self-contained parser with a clear input buffer: the config/protocol/file-format parser you flagged in static analysis. The more the function looks like "bytes in, structure out," the better it fuzzes.

Harnessing, easiest first:

Make it effective:

Triage every crash, because a crash is not yet a bug. Reproduce it, minimize the input (afl-tmin), read the ASan report to classify it (stack overflow, heap OOB, UAF, null-deref), and confirm the input is attacker-reachable through a real source, not just a value your harness fed directly past the front-end checks. A crash only in the harness, unreachable in the real service, is a false positive.

Vulnerability classes to recognize

These are the patterns that recur in firmware. Train your eye to recognize each one's tell, the code or decompiler signature that says "look harder here."

ClassThe tell to look forTypical impact
Command injectionInput concatenated into system/popen/exec or a "... %s" shell template; unescaped params in CGIRCE, often pre-auth
Stack buffer overflowstrcpy/strcat/sprintf/gets, or a copy loop with no bound, into a fixed stack bufferRCE / crash; control of return address
Heap overflow / UAFmalloc then over-copy; free-then-use; double free; dangling pointer after reallocRCE / crash; harder to weaponize
Integer overflow into undersized allocmalloc(a * b) or len + n where the size can wrap, then a write sized by the originalHeap overflow downstream
Format stringprintf-family where the format arg is attacker-controlled, not a literalInfo leak / write / crash
Path traversal../ in a path built from input reaching fopen/open/file APIsArbitrary file read/write
Auth bypass / hardcoded credsBackdoor accounts, static keys, weak/decidable session tokens, logic that skips a checkFull unauth access
Insecure update / signature bypassUpdate image not verified, verification you can skip, downgrade allowed, key recoverablePersistent RCE via malicious firmware
Config / file injectionAttacker data written into a file a privileged component later parses and trustsPrivesc / RCE via the downstream parser
Deserialization / parser bugsLength-prefixed or nested formats off the wire; XML/JSON/protobuf/custom TLVCorruption or logic abuse
SSRF / info disclosureServer fetches a URL from input; verbose errors; secrets in responsesPivot / leak

Command injection, memory corruption, and unverified update are the three that most reliably clear the bar for a serious, CVE-worthy finding. Config-injection and SSRF-style bugs are very real but need a tighter impact argument to land.

From candidate to valid bug

This phase is what makes a finding valid rather than interesting. Most static candidates die here, and that's the point: killing your own false positives before you report them is what earns credibility with a maintainer or CNA.

Run every candidate through these kill-checks:

  1. Is it in the shipped build? #ifdefs, build flags, and dead code mean the vulnerable path may not compile into what ships. Confirm the code is actually present and reachable in the released image.
  2. Is the input really unbounded? A caller two frames up may already validate length or charset. Trace the whole path, not just the sink's immediate neighborhood.
  3. Does the attacker already have what the bug grants? A "root RCE" that requires you to already be root/authenticated-admin is usually not a vulnerability; it's the intended privilege. Be honest about the pre-conditions.
  4. Is there a real trust boundary crossed? Input reaching a dangerous op is only a bug if the input source is less trusted than the sink's context. No boundary, no bug.
  5. Is it reachable in a default/common config? A bug behind a disabled-by-default feature is real but lower severity; note the condition.

Then prove it. A minimal, reliable PoC is non-negotiable: the exact input, the exact preconditions, and the observed effect (crash with controlled registers, command executed, file read). Reproduce it clean from a known state. For memory bugs, an ASan trace or a controlled crash is strong evidence; you don't always need a full weaponized exploit, but you must demonstrate the primitive.

Assess impact honestly. Score it with CVSS, but let the actual primitive and preconditions drive the vector: pre-auth vs. post-auth, network vs. local, and what the attacker gains. Overclaiming severity is the fastest way to lose a maintainer's trust; underclaiming buries a real bug.

Disclose responsibly. Find the security contact (SECURITY.md, security@, a bug-bounty or PSIRT program). Send a clear report: affected version(s), preconditions, reproduction steps, PoC, impact, and a suggested fix. Agree on a disclosure timeline, get the fix confirmed, and request a CVE (via the vendor's CNA or MITRE). It's the same disclosure pipeline you'd run against a web app, applied to firmware. Keep the written record of every report; it's the most credible evidence of your work.

Toolchain reference

StageToolWhat it's for
ExtractionbinwalkSignature-scan and carve embedded filesystems/kernels
ExtractionunblobStronger modern extractor; better format coverage and recursion
Extractionfirmware-mod-kit, sasquatchHandle awkward/patched SquashFS variants
Hardwareflashrom + CH341A, UART/JTAGDump flash chips; get console/debug access on-device
StaticGhidraFree disassembler + decompiler; scriptable, multi-arch
StaticIDA Pro / Binary NinjaCommercial; stronger analysis, cleaner APIs
StaticBinDiff, Ghidra Version TrackingPatch-diff two builds to find silently-fixed bugs
StaticSemgrepFast pattern-level sink search (when source is available)
StaticCodeQL, JoernQuery taint/data-flow across a codebase as a database
Staticcscope / ctags / clangdJump the call graph while reading source
DynamicQEMU (user + system)Run cross-arch binaries / boot full images
DynamicFirmAE, FirmadyneAutomated full-system emulation of Linux IoT/router firmware
Dynamicgdb-multiarch + gdbserver, pwndbg/GEFCross-arch debugging with a usable exploit-dev UI
FuzzingAFL++Coverage-guided fuzzing; QEMU mode for binaries, persistent mode for speed
FuzzinglibFuzzer + ASan/UBSan/MSanIn-process fuzzing with source; sanitizers for clean crash diagnosis
FuzzingboofuzzNetwork/protocol fuzzing of a whole service
Fuzzingafl-tmin, afl-cminMinimize crashing inputs and corpora
Reconstrings, grep, file, binwalk -EOrientation, secret-hunting, entropy/encryption check

Don't install all of this on day one. Ghidra, binwalk, QEMU, and gdb-multiarch are a complete starter kit; add fuzzing and diffing tools as your targets demand them.

A deliberate learning path

Skill here compounds from deliberate reps on progressively harder targets. A rough progression:

  1. Get fluent in the primitives: binary exploitation and RE basics on curated challenges before touching real firmware, so the phases above aren't fighting you on fundamentals at the same time as an unknown target.
  2. Emulate one known-vulnerable image end-to-end: extract it, get it running under FirmAE/QEMU, and reproduce a published CVE against it. Doing the whole pipeline once for a bug you know exists teaches more than ten blind hunts.
  3. Audit soft targets for real: small open-source router/IoT/camera firmware, or a single custom daemon. Diff a version bump and chase what changed.
  4. Move to a specialist class: BMC/firmware/systems targets (OpenBMC, access-control and camera devices). Bring the diff-and-fuzz discipline to the custom binaries.

Deliberate-practice resources:

Books and long-form:

Stay current by reading disclosure write-ups: vendor advisories, the researchers whose pipelines you model, and conference talks (Black Hat, DEF CON, Recon firmware tracks). Reading how a specific bug was found, from image to CVE, is the highest-signal learning once you know the basics.

The throughline: you learn firmware research by finishing the loop on real targets, not by studying it. Every completed disclosure teaches more than the last.

The sample binaries and firmware image in the worked examples were built specifically for this walkthrough to demonstrate tooling and method. This article describes methodology, not a turnkey exploit against any specific product.