Tutorial

Firmware RE with Binwalk and Ghidra

Extract firmware from an IoT update file, unpack the filesystem, identify interesting binaries, and reverse engineer them in Ghidra to find bugs.

10 min read advanced

Prerequisites

  • Comfortable reading disassembly (ARM or MIPS)
  • Basic understanding of Linux filesystem layout
  • Familiarity with C and common vulnerability classes
Table of Contents

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:

BinaryWhyCommon vulns
Web server / CGI handlersDirectly reachable from the networkCommand injection, auth bypass, stack overflow
DHCP/DNS daemonsHandle network inputBuffer overflow, format string
Update/upgrade handlersParse downloaded filesPath traversal, code execution
Configuration daemonsStore and apply settingsHardcoded credentials, shell injection
Custom device daemonsTarget-specific behaviorAny 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 ghidra

Note

jefferson extracts JFFS2 images and ubi-reader provides 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 picocom

Getting firmware

Firmware images come from several sources, roughly in order of effort:

  1. Vendor download page, check the support/downloads section for the device model
  2. Firmware update traffic, intercept the device’s update check with a proxy
  3. 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.bin
DECIMAL       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: 131072

Representative 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 the cd paths below accordingly, or pass --directory to 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 still binwalk -E; there is no separate binwalk entropy subcommand.
  • Signature output format is JSON-friendly by default; pipe through --log or use binwalk --quiet for v2-style output.

If binwalk --version reports 3.x, translate commands with that version’s binwalk --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.bin

This 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.bin
DECIMAL       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  www

That’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.cramfs

Understanding 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/null

The 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/httpd

Each .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/null
etc/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 password

Warning

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/null

If 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 PIE

This 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 &
  1. Create a new project: File → New Project → Non-Shared Project
  2. Import the target binary: File → Import File → select usr/sbin/httpd
  3. Ghidra auto-detects the architecture from the ELF header (MIPS:LE:32:default for our example)
  4. 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 to strcpy with 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/httpd

If 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.cgi

Warning

NVRAM emulation Many embedded binaries call nvram_get() to read configuration. If the binary crashes immediately, it’s likely failing to read NVRAM. Tools like nvram-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.bin

Limitations 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