courses

Software Reverse Engineering

Why this matters

You have the firmware. Now find out what it does.

Reverse engineering a Linux binary is comparatively easy: ELF headers declare the architecture, the entry point, and the section layout, and the loader knows where everything goes. A raw firmware dump declares nothing. You have a flat array of bytes and three unknowns to resolve before a disassembler will produce anything readable.

Resolving them is a repeatable procedure rather than an act of inspiration, and that procedure is most of this page.

The three unknowns

Unknown How to resolve it
Architecture and endianness From chip identification, or by instruction-frequency statistics
Load address The problem that actually blocks progress — see below
Entry point Usually falls out once you have the load address

Architecture

You should already have this from identifying the main SoC. If you do not, tools can guess: binwalk -A scans for known instruction opcodes, and cpu_rec classifies architecture statistically and is remarkably good.

For Arm, note that “Arm” alone is not enough to configure a disassembler. You need to know whether the code is Arm or Thumb. Cortex-M is Thumb-only; Cortex-A firmware is usually Arm with Thumb interworking. Loading Thumb code as Arm produces a listing of confident nonsense.

The load address problem

Get this wrong and every absolute pointer in the binary points at nothing, so no cross-references resolve, no strings attach to the code that uses them, and the listing is unreadable. It is the single most important step.

Method 1: the Cortex-M vector table. On any Cortex-M image, the first two 32-bit words are:

$ xxd -l 8 dump.bin
00000000: 00a0 0020 4500 0008                      ... E...
Word Value Meaning
0 0x2000A000 Initial stack pointer — points into SRAM
1 0x08000045 Reset handler — odd because Thumb; mask bit 0

Note the byte order: the dump reads 00 a0 00 20, and the value is 0x2000A000. Cortex-M is little-endian, and reading these two words as big-endian is the most common way to get a plausible-looking wrong answer here.

The reset handler pointing into 0x0800xxxx gives the load base immediately: 0x08000000. This is the fastest route when it applies, and it applies to a large fraction of IoT devices.

Method 2: the pointer histogram. Where there is no vector table, recover the base from the data. Scan the image for aligned 32-bit values that look like pointers; they cluster tightly around the true load base, because pointer tables, string references, and jump tables all use absolute addresses.

import struct, collections
d = open("dump.bin", "rb").read()
hist = collections.Counter()
for off in range(0, len(d) - 4, 4):
    word, = struct.unpack("<I", d[off:off+4])
    if word:
        hist[word >> 20] += 1          # bucket by top 12 bits
for bucket, n in hist.most_common(5):
    print(f"0x{bucket << 20:08x}  {n}")

The spike is your base address. Buckets containing plausible RAM and flash regions will both appear; the flash one is what you want.

Method 3: a declared header. If the image has a container — uImage, a vendor header — it may state the load address outright. binwalk reports it. Be careful that the header describes the region you think it does: a uImage header at offset 0x20000 describes the kernel payload, not the bootloader that precedes it.

Confirming you got it right

The test is simple: cross-references resolve. Load the image, find a string, and check that something references it. If nothing in the binary refers to any string, the base is wrong. That single check saves hours.

Loading into Ghidra

File → Import File → dump.bin
  Format:   Raw Binary
  Language: ARM:LE:32:Cortex        (for Cortex-M)
  Options → Base Address: 0x08000000

Then, before auto-analysis, define the memory map properly:

For Cortex-M, set the entry point at the reset handler address from word 1 with bit 0 masked, and disassemble as Thumb.

Alternatives worth knowing: radare2/rizin (scriptable, steep learning curve), Binary Ninja (commercial, excellent decompiler), IDA Pro (commercial, the long-standing default in industry).

Orienting without symbols

A stripped firmware image gives you no function names. Working from the entry point downward is slow. Work from strings outward instead — it goes directly to the code that matters.

$ strings -n 8 -t x firmware.bin | grep -iE 'passw|key|login|http|/dev/|admin'
  1a4f0 /dev/mtdblock3
  1a53c Login incorrect
  1a558 http://update.example.net/fw/check
  1b2c4 %s: bad password for %s

⚠️ -n 8 hides exactly the tokens you care most about. The grep above asks for admin, but admin is five characters and never reaches the grep — strings dropped it. Short, high-value words (admin, root, key, pw) all sit under the default threshold of 4 as well as under 8. Run the sweep twice: once at -n 8 for readable sentences and URLs, once at -n 4 when you are hunting a specific short token, and accept the noise on the second pass.

Each hit is an anchor. In Ghidra, jump to the address, follow the cross-reference, and you are inside the function that uses it — the login handler, the update checker, the partition mount. Rename it, and repeat.

High-value string patterns:

Pattern Usually leads to
Error messages The function that produces the error
Format strings (%s, %d) Logging, and often the surrounding logic
Device paths (/dev/, /proc/) Hardware access and partition handling
URLs and hostnames Network and update code
Credential-like strings Authentication
Compiler and version banners Toolchain, and sometimes an exact upstream version

Cross-reference against the filesystem you extracted in Dumping flash: a device path or config key appearing in both the binary and the init scripts connects the running configuration to the code that consumes it.

Reading decompiled code critically

Ghidra’s decompiler is very good and it is not truth. It is an inference about what the compiler was probably given. Things to distrust:

⚠️ Verify a finding against the disassembly before reporting it. A decompiler artifact restated confidently in an assessment report is the classic reverse-engineering error, and it is the reason the final project asks how you would validate a conclusion on real hardware.

Bootloaders

The bootloader is disproportionately interesting: it runs before every protection the main firmware implements, and it is often the least reviewed code on the device.

U-Boot is the common one on Linux-class devices. What to look for:

Secure boot and where it breaks

Secure boot is a chain: an immutable root of trust in ROM verifies the bootloader, which verifies the kernel, which verifies the root filesystem. Each link checks a signature before transferring control.

Real break points, roughly in order of frequency:

Emulation

Running firmware you cannot debug on hardware is worth the effort when it works.

QEMU can execute many embedded images, either full-system for a supported board or user-mode for a single extracted binary against a foreign architecture. The hard part is peripherals: firmware polls registers that do not exist in the model, and hangs or faults. You end up writing stubs.

Firmadyne and FirmAE automate this for Linux-based router firmware, with enough success on common images to be worth trying before hand-rolling.

Unicorn is a CPU emulator without peripherals, which sounds like a limitation and is often exactly right: to understand one function you emulate that function, feed it inputs, and observe outputs, without booting anything.

Expect partial success. A student who gets a subsystem running under emulation and documents precisely where it diverged from hardware has done the work; the divergence point is frequently the most interesting finding.

Key takeaways

References


Related course pages: Schedule · Dumping flash · UART · JTAG and SWD · Attacks · Memory Corruption · Secure C

🛠️ Maintenance note: Ghidra’s import dialog and analysis options change between major releases, so screenshots date faster than the procedure — which does not change. The emulation tooling moves fastest: Firmadyne is effectively superseded by FirmAE, and both depend on host kernel features that break periodically. Re-verify before assigning the emulation option in HW #5.