Every embedded device runs code, and many vendors publish firmware updates that can be inspected without opening the hardware. Some images are encrypted, signed, or wrapped in proprietary formats; others contain an ordinary compressed Linux filesystem. This guide covers the latter case.
This tutorial walks through the full workflow: downloading firmware, using binwalk to identify and extract the contents, navigating the filesystem to find high-value targets, and reverse engineering those targets in Ghidra to locate real vulnerabilities, hardcoded credentials, command injection, authentication bypasses.
No physical device or soldering is required. Because layouts differ by target, the terminal output and decompiler fragments below are representative examples, not a transcript from the placeholder download command.
graph LR
A[Firmware Image] --> B[Entropy Scan]
B --> C[Extract Filesystem]
C --> D[Identify Binaries]
D --> E[Load in Ghidra]
E --> F[Analyze Vulns]The target mindset
Before touching any tools, think about what you’re looking for. Focus on the small number of binaries that handle untrusted input, not every function in the image.
High-value targets in embedded firmware:
| Binary | Why | Common vulns |
|---|---|---|
| Web server / CGI handlers | Directly reachable from the network | Command injection, auth bypass, stack overflow |
| DHCP/DNS daemons | Handle network input | Buffer overflow, format string |
| Update/upgrade handlers | Parse downloaded files | Path traversal, code execution |
| Configuration daemons | Store and apply settings | Hardcoded credentials, shell injection |
| Custom device daemons | Target-specific behavior | Any of the above |
Target-specific binaries deserve early attention because upstream advisories and generic signatures may not describe them. That does not make proprietary code inherently less reviewed than open-source code; it makes its trust boundary and input paths specific to this device.
Setting up the toolkit
Install the tools on your workstation.
# Debian/Ubuntu: core tools used in the walkthrough
sudo apt update
sudo apt install -y binwalk squashfs-tools p7zip-full unzip mtd-utils \
gzip bzip2 lzma xz-utils python3 python3-venv openjdk-21-jdk
# Optional filesystem extractors, isolated from the system Python
python3 -m venv ~/.venvs/firmware-re
source ~/.venvs/firmware-re/bin/activate
python -m pip install jefferson ubi-reader
# Ghidra: download a release from https://ghidra-sre.org/
# Verify its published checksum, unpack it, and add the directory to PATH.Verify the Java runtime with java -version. Check the Ghidra release notes if
your version requires a newer JDK.
# Arch Linux
sudo pacman -S binwalk squashfs-tools p7zip unzip mtd-utils \
python python-pip
# Ghidra from AUR
yay -S ghidraNote
jeffersonextracts JFFS2 images andubi-readerprovides tools for UBI/UBIFS. They are optional: install the extractor that matches the filesystem you identify. If a current package fails to install, follow that project’s own environment guidance rather than modifying the system Python.
Optional: SPI flash tools
If you’re extracting from physical hardware (not covered here, but good to have):
sudo apt install -y flashrom
# For UART: minicom or picocom
sudo apt install -y picocomGetting firmware
Firmware images come from several sources, roughly in order of effort:
- Vendor download page, check the support/downloads section for the device model
- Firmware update traffic, intercept the device’s update check with a proxy
- Flash memory dump, desolder or clip the SPI flash chip and read it with
flashrom(hardware needed)
Choose a firmware image you own or are authorized to analyze. A Linux-based router image is convenient because it often contains a complete root filesystem and network-facing management code.
mkdir -p ~/firmware-lab && cd ~/firmware-lab
# Placeholder only: replace this URL with your authorized target.
wget -O firmware.bin "https://support.example.com/firmware/router_v1.2.3.bin"Warning
Legal considerations Authorization, license terms, anti-circumvention rules, and research exceptions vary by jurisdiction and target. Analyze only material you are authorized to possess and test, do not redistribute proprietary code, and obtain qualified legal advice when the boundary is unclear.
For a deliberately vulnerable practice target, use DVRF (Damn Vulnerable Router Firmware). Open-source images such as OpenWrt can also be useful for learning file formats and tooling, but they are not intentionally vulnerable targets; keep testing within an authorized lab.
Analyzing with binwalk
Binwalk scans a binary file for embedded filesystems, compressed archives, and known file signatures. Start with a signature scan.
binwalk firmware.binDECIMAL HEXADECIMAL DESCRIPTION
-------------------------------------------------------
0 0x0 uImage header, image size: 1572864
64 0x40 LZMA compressed data, properties: 0x5D
1572928 0x180040 Squashfs filesystem, little endian, version 4.0,
size: 5242880 bytes, 312 inodes, blocksize: 131072Representative output like this would indicate:
- A U-Boot uImage header at offset 0 (the kernel bootloader format)
- LZMA-compressed data at offset 64 (likely the kernel)
- A SquashFS filesystem at offset 0x180040 (the root filesystem; this is what we want)
Note
binwalk v3 vs v2 syntax The examples in this tutorial use binwalk v2 (the Python implementation). The v3 rewrite in Rust changed several things:
- The default extraction directory is
extractions/(the binary name as a subdir), not_firmware.bin.extracted/. Adjust thecdpaths below accordingly, or pass--directoryto control the layout.- Several v2 flags are gone or renamed;
-M(recursive matryoshka) is still explicit opt-in in v3, and the entropy plot is stillbinwalk -E; there is no separatebinwalk entropysubcommand.- Signature output format is JSON-friendly by default; pipe through
--logor usebinwalk --quietfor v2-style output.If
binwalk --versionreports3.x, translate commands with that version’sbinwalk --help. Do not assume v2 and v3 will produce byte-for-byte identical extraction results on malformed or proprietary inputs.
Entropy analysis
Binwalk’s entropy scan reveals encryption and compression visually.
binwalk -E firmware.binThis generates an entropy plot. High, flat entropy (close to 1.0) means the data is encrypted or compressed. If the entire file is high-entropy with no structure, the firmware is likely encrypted, and you’ll need to find the decryption key (often in the bootloader or a previous unencrypted firmware version).
Typical patterns:
Entropy: 0.1 ████ ← headers, padding (low entropy)
Entropy: 0.99 ████████████████████████████ ← compressed kernel (high, expected)
Entropy: 0.97 ████████████████████████████ ← squashfs (high, expected)Tip
Encrypted firmware If the whole image is high-entropy, check for a two-stage update format: an unencrypted header with a decryption routine, followed by encrypted payload. Some vendors ship a “bootstrap” firmware that’s unencrypted, downgrade to that version first, extract the decryption logic, then decrypt the latest firmware.
Extracting the filesystem
Use binwalk’s extraction mode.
binwalk -e firmware.binDECIMAL HEXADECIMAL DESCRIPTION
-------------------------------------------------------
...
1572928 0x180040 Squashfs filesystem, ...
-> extracted to: _firmware.bin.extracted/squashfs-root/Binwalk creates _firmware.bin.extracted/ with the unpacked contents. The SquashFS root filesystem is in squashfs-root/.
ls _firmware.bin.extracted/squashfs-root/bin dev etc lib mnt proc sbin sys tmp usr var wwwThat’s a complete Linux filesystem. If binwalk’s automatic extraction fails (it sometimes does with unusual formats), extract manually:
# Find the offset and extract the squashfs block
dd if=firmware.bin of=rootfs.squashfs bs=1 skip=1572928
# Unsquash it
unsquashfs rootfs.squashfs
# Creates squashfs-root/For other filesystem types:
# JFFS2 (NOR flash)
jefferson firmware.jffs2 -d jffs2-root/
# UBIFS (NAND flash)
ubireader_extract_files firmware.ubi -o ubifs-root/
# CRAMFS
cramfsck -x cramfs-root/ firmware.cramfsUnderstanding the firmware layout
Navigate the extracted filesystem and build a map.
cd _firmware.bin.extracted/squashfs-root
# What architecture?
file bin/busybox
# bin/busybox: ELF 32-bit LSB executable, MIPS, MIPS32 rel2 version 1, dynamically linked, stripped
# What libc?
file lib/libc.so* lib/libc-* 2>/dev/null
# lib/libc.so.0 -> libuClibc-0.9.33.2.so
# What's the init system?
cat etc/inittab 2>/dev/null | head -10
# What services start at boot?
ls etc/init.d/
cat etc/rc.d/rcS 2>/dev/nullThe file output tells you:
- Architecture: MIPS 32-bit little-endian (common in routers)
- Linking: dynamically linked (libraries are in
/lib) - Stripped: no debug symbols (normal for production firmware)
- C library: uClibc (lighter than glibc, common in embedded)
Note
The architecture determines which Ghidra language to use and which QEMU variant you need if you want to run the binaries. MIPS and ARM are the most common. See the cross-compiling tutorial for setting up QEMU for these architectures.
Identifying high-value targets
Web interface binaries
The web management interface is the highest-priority target. It’s network-reachable and handles user input.
# Find the web root
ls www/ var/www/ usr/share/www/ 2>/dev/null
# Find CGI binaries (these handle form submissions)
find . \( -path "*/cgi-bin/*" -o -name "*.cgi" \) 2>/dev/null./www/cgi-bin/admin.cgi
./www/cgi-bin/setup.cgi
./www/cgi-bin/firmware_upgrade.cgi
./usr/sbin/httpdEach .cgi file is a compiled binary that processes HTTP requests. httpd is the web server itself (often a custom fork of GoAhead, mini_httpd, or a proprietary implementation).
Hardcoded credentials
Search for credentials in plaintext config files and binaries.
# Config files
grep -ri "password\|passwd\|secret\|token\|key" etc/ 2>/dev/nulletc/shadow:root:$1$abc$xyz...:0:0:99999:7:::
etc/config/admin.conf:admin_password=admin123
etc/ppp/chap-secrets:* * "ISPpassword"# Strings in binaries
strings usr/sbin/httpd | grep -iE "password|admin|root|login|auth"admin
admin123
Authorization: Basic
/etc/config/admin.conf
invalid passwordWarning
A string match is a lead, not a finding Defaults, test fixtures, documentation strings, and unused code can all look like credentials. Trace each value to a reachable authentication or signing path and test it on an authorized target before assigning impact.
# SSH/TLS keys
find . \( -name "*.pem" -o -name "*.key" -o -name "id_rsa" -o -name "id_dsa" \) 2>/dev/null
# Certificate files (shared across all devices of this model)
find . \( -name "*.crt" -o -name "*.cert" \) 2>/dev/nullIf a private key is present, determine what it authenticates and whether the same image and key ship to multiple devices. A shared, actively used TLS server key may enable device impersonation and, for some protocol/configuration choices, traffic decryption. A certificate without its private key, a per-device provisioning step, or an unused test key has a different impact.
Binary security posture
Check what protections the target binaries were compiled with.
# If checksec is available (install from github.com/slimm609/checksec.sh)
for bin in usr/sbin/httpd www/cgi-bin/*.cgi; do
echo "=== $bin ==="
checksec --file="$bin" 2>/dev/null
done=== usr/sbin/httpd ===
RELRO STACK CANARY NX PIE
No RELRO No canary found NX disabled No PIEThis representative binary has no RELRO, stack canary, NX, or PIE. Record the result per binary and firmware version; one sample does not establish the hardening posture of embedded firmware generally.
Reverse engineering in Ghidra
Now load the most interesting binary into Ghidra for deep analysis.
Setting up the project
# Launch Ghidra
ghidraRun &- Create a new project: File → New Project → Non-Shared Project
- Import the target binary: File → Import File → select
usr/sbin/httpd - Ghidra auto-detects the architecture from the ELF header (MIPS:LE:32:default for our example)
- Click Yes when asked to analyze, accept default analyzers, and wait for analysis to complete
Finding input handlers
Start by locating functions that process HTTP requests. These are the entry points for network-reachable attacks.
In Ghidra’s Symbol Tree or Search → For Strings, look for HTTP-related strings.
Search → For Strings:
"GET "
"POST "
"Content-Length"
"password"
"/cgi-bin/"Double-click a string reference to see where it’s used. Ghidra shows cross-references (XREFs), functions that reference that string.
Tracing from input to vulnerability
Here’s a systematic approach. Take the admin.cgi binary as an example.
Step 1: Find the entry point for user input.
CGI binaries receive input via environment variables (QUERY_STRING, CONTENT_LENGTH) and stdin. Search for calls to getenv.
Search → For Strings: "QUERY_STRING"Follow the XREF to find where the query string is read:
In practice, Ghidra’s decompiler output uses auto-generated names like FUN_00012345, DAT_00067890, and local_1c instead of clean C variable and function names. The examples in this tutorial use cleaned-up names for readability. Renaming functions and variables in Ghidra is a key part of the reverse engineering workflow.
// Ghidra decompilation (cleaned up)
char *query = getenv("QUERY_STRING");
char *content_len = getenv("CONTENT_LENGTH");
int len = atoi(content_len);
char *post_data = malloc(len + 1);
fread(post_data, 1, len, stdin);Step 2: Follow the data through processing.
Find where query or post_data is parsed and used. Look for parameter extraction:
// Common pattern in CGI binaries
char *username = get_param(post_data, "username");
char *password = get_param(post_data, "password");
char *cmd = get_param(post_data, "command");Step 3: Find the sink, where user data reaches a dangerous function.
The most common vulnerability classes in embedded firmware:
Command injection
Search for calls to system(), popen(), execve().
// Vulnerable pattern
char cmd_buf[256];
sprintf(cmd_buf, "ping -c 1 %s", user_input); // no sanitization
system(cmd_buf);In Ghidra: Search → For All References to system (in the symbol tree, find the system import, right-click → References → Find References To).
Every call site is a potential injection point. Check whether user input reaches the buffer passed to system() without sanitization.
// Decompiled Ghidra output (MIPS, cleaned up)
void handle_ping(char *target_ip) {
char buf[128];
// VULNERABILITY: target_ip comes directly from POST parameter
// No input validation — inject with: 127.0.0.1; cat /etc/shadow
sprintf(buf, "ping -c 4 %s > /tmp/ping_result", target_ip);
system(buf);
}Stack buffer overflow
Search for strcpy, sprintf, strcat, gets, unbounded copy functions.
void process_auth(char *post_data) {
char username[32];
char password[32];
// VULNERABILITY: no length check
strcpy(username, get_param(post_data, "username"));
strcpy(password, get_param(post_data, "password"));
...
}In Ghidra, the decompiler shows these clearly. Check the buffer size (visible in the stack frame layout) against the input source.
Tip
Stack frame analysis in Ghidra Click on a function, then open Window → Function Graph or look at the decompiler’s variable declarations. Local variables show their stack offsets. If a
char[32]buffer is passed tostrcpywith unbounded input, you’ve found an overflow. The stack offset tells you exactly how many bytes to the return address.
Authentication bypass
Search for comparison functions and authentication logic.
// Vulnerable pattern: strcmp returns 0 on match
if (strcmp(password, "admin123") == 0) {
authenticated = 1;
}
// Another pattern: checking a hardcoded hash
if (strcmp(md5(password), "5f4dcc3b5aa765d61d8327deb882cf99") == 0) {
// "password" in MD5 — trivially reversible
authenticated = 1;
}Also look for authentication bypass, paths that reach privileged functionality without checking auth at all:
// Direct CGI handler — check if auth is verified before processing
void handle_firmware_upload(char *post_data) {
// Is there an auth check before this function?
// If not, anyone can upload firmware without logging in
save_firmware(post_data);
system("mtd write /tmp/firmware.bin firmware");
}Building a vulnerability map
As you find issues, document them systematically.
┌─────────────────────────────────────────────────────┐
│ Firmware: Router Model X v1.2.3 │
│ Architecture: MIPS32 LE │
│ Extracted: squashfs, uClibc 0.9.33 │
├─────────────────────────────────────────────────────┤
│ Finding 1: Command injection in admin.cgi │
│ Function: handle_ping @ 0x00401a30 │
│ Sink: system() call with unsanitized POST param │
│ Input: "target" parameter from /cgi-bin/admin.cgi │
│ Auth required: Yes (session cookie) │
│ Severity: High (post-auth RCE) │
├─────────────────────────────────────────────────────┤
│ Finding 2: Hardcoded credentials │
│ File: /etc/config/admin.conf │
│ Creds: admin / admin123 │
│ Severity: Critical │
├─────────────────────────────────────────────────────┤
│ Finding 3: Stack overflow in setup.cgi │
│ Function: process_wifi_config @ 0x00402100 │
│ Buffer: 64 bytes, strcpy from POST "ssid" param │
│ No NX, no canary, no ASLR │
│ Severity: Critical (pre-auth or post-auth RCE) │
├─────────────────────────────────────────────────────┤
│ Finding 4: Shared TLS private key │
│ File: /etc/ssl/server.key │
│ Impact: HTTPS interception for all units │
│ Severity: High │
└─────────────────────────────────────────────────────┘Running extracted binaries in QEMU
For dynamic analysis, you can run extracted binaries without a full device using QEMU user-mode emulation.
# Install QEMU user-mode for the target architecture
sudo apt install qemu-user-static
# Copy the firmware's libraries
export ROOTFS=~/firmware-lab/_firmware.bin.extracted/squashfs-root
# Run a binary using the firmware's own libraries
qemu-mipsel-static -L $ROOTFS $ROOTFS/usr/sbin/httpdIf the binary expects specific files or devices, create them.
# Many daemons check /dev/nvram or /proc/mtd
sudo mkdir -p /tmp/fake-nvram
echo "admin_password=admin123" > /tmp/fake-nvram/nvram.ini
# Set environment variables the CGI expects
export QUERY_STRING="target=127.0.0.1;id"
export REQUEST_METHOD="GET"
export CONTENT_LENGTH=0
# Run the CGI directly
qemu-mipsel-static -L $ROOTFS $ROOTFS/www/cgi-bin/admin.cgiWarning
NVRAM emulation Many embedded binaries call
nvram_get()to read configuration. If the binary crashes immediately, it’s likely failing to read NVRAM. Tools likenvram-faker(an LD_PRELOAD library) or Firmadyne can emulate NVRAM for you. This is the most common obstacle in dynamic firmware analysis.
For full system emulation with networking (to interact with the web interface), use the QEMU environment from the cross-compiling tutorial with the extracted rootfs mounted via NFS or packed into an image.
Automating the analysis
Create a script that performs the initial triage automatically.
#!/bin/bash
# firmware-triage.sh - automated first-pass firmware analysis
FIRMWARE=$1
if [ -z "$FIRMWARE" ]; then
echo "Usage: $0 <firmware.bin>"
exit 1
fi
echo "=== Firmware Triage: $FIRMWARE ==="
echo ""
echo "--- File Info ---"
file "$FIRMWARE"
echo "Size: $(du -h "$FIRMWARE" | cut -f1)"
echo ""
echo "--- Binwalk Signature Scan ---"
binwalk "$FIRMWARE"
echo ""
echo "--- Extracting ---"
binwalk -e "$FIRMWARE" -q
EXTRACTED="_$(basename "$FIRMWARE").extracted"
if [ ! -d "$EXTRACTED" ]; then
EXTRACTED=$(ls -dt _*.extracted 2>/dev/null | head -1)
fi
ROOTFS=$(find "$EXTRACTED" -type d \( -name "squashfs-root" -o -name "jffs2-root" -o -name "rootfs" \) 2>/dev/null | head -1)
if [ -z "$ROOTFS" ]; then
echo "No filesystem extracted. May need manual extraction."
exit 1
fi
echo "Rootfs: $ROOTFS"
echo ""
echo "--- Architecture ---"
file "$ROOTFS"/bin/busybox 2>/dev/null || file "$ROOTFS"/bin/* 2>/dev/null | head -1
echo ""
echo "--- Interesting Binaries ---"
echo "Web servers:"
find "$ROOTFS" -type f \( -name "httpd" -o -name "lighttpd" -o -name "nginx" -o -name "goahead" \) 2>/dev/null
echo "CGI handlers:"
find "$ROOTFS" -name "*.cgi" 2>/dev/null
echo "Custom daemons:"
find "$ROOTFS" -path "*/sbin/*" -type f 2>/dev/null | while read -r f; do
file "$f" 2>/dev/null | grep -q "ELF" && echo "$f"
done
echo ""
echo "--- Hardcoded Credentials Search ---"
grep -ri "password\|passwd\|secret\|token" "$ROOTFS/etc/" 2>/dev/null | head -20
echo ""
echo "Strings in web binaries:"
find "$ROOTFS" -type f \( -name "httpd" -o -name "*.cgi" \) 2>/dev/null | while read -r bin; do
hits=$(strings "$bin" | grep -ciE "password|admin|root|backdoor|secret")
echo " $bin: $hits credential-related strings"
done
echo ""
echo "--- Binary Protections ---"
find "$ROOTFS" -type f \( -name "httpd" -o -name "*.cgi" \) 2>/dev/null | head -5 | while read -r bin; do
echo " === $(basename $bin) ==="
checksec --file="$bin" 2>/dev/null || echo " (checksec not available)"
done
echo ""
echo "--- SSH/TLS Keys ---"
find "$ROOTFS" -type f \( -name "*.pem" -o -name "*.key" -o -name "id_rsa" -o -name "id_dsa" \) 2>/dev/null
echo ""
echo "=== Triage Complete ==="chmod +x firmware-triage.sh
./firmware-triage.sh firmware.binLimitations and next steps
This tutorial covers the most accessible firmware analysis workflow, but real-world targets introduce complications.
What this tutorial didn’t cover:
- Some vendors encrypt update files, so encrypted firmware needs the decryption key, often found in the bootloader or an older unencrypted release.
- Custom or proprietary packing formats that binwalk doesn’t recognize require looking for compression routines in the bootloader.
- Bare-metal firmware on devices without Linux (microcontrollers running an RTOS or no OS) requires different tools: Binary Ninja, IDA Pro, or Ghidra with custom loaders.
- Hardware interfaces such as UART shell access, JTAG debugging, and SPI flash dumping give you more information than firmware files alone.
Where to go from here:
- Run the extracted binaries under GDB in QEMU and build working exploits for the vulnerabilities you find: the cross-compiling tutorial provides the environment
- Apply the attack surface audit methodology to the extracted filesystem
- Report vulnerabilities responsibly through the vendor’s security contact or a coordinated disclosure program