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:
- Add a RAM block at the right address (
0x20000000on STM32) so pointers into RAM resolve to something named rather than to nothing. - Add peripheral regions if you care about which peripheral a register access
targets.
SVD-Loadercan import an SVD file and label every register automatically, which turns*(uint32_t*)0x40020014 = 0x1000intoGPIOA->ODR = 0x1000.
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 8hides exactly the tokens you care most about. The grep above asks foradmin, butadminis five characters and never reaches the grep —stringsdropped 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 8for readable sentences and URLs, once at-n 4when 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:
- Types. Everything starts as
undefined4orint. A pointer treated as an integer makes structure access unreadable. Fixing types is most of the cleanup work, and it pays for itself immediately. - Stack layout. Arrays and structures on the stack often decompile as a series of unrelated locals. If a function looks like it has fourteen variables, it probably has one buffer.
- Calling conventions. Hand-written assembly, interrupt handlers, and syscall stubs frequently violate the ABI the decompiler assumes.
- Optimised control flow.
-O2restructures loops and inlines aggressively. A single decompiled function may be three source functions.
⚠️ 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:
- An interactive console. If you can interrupt boot over UART,
setenv bootargs ... init=/bin/shgives a root shell before the system’s own authentication runs.printenvdumps the entire environment, frequently including update URLs and sometimes keys. tftpbootand network boot. Loading a kernel from a server you control.- Environment storage. The U-Boot environment lives in its own flash partition, is usually unprotected, and is CRC-checked rather than signed.
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:
- The chain is not enabled. The silicon supports it; the fuse was never blown.
- The chain starts too late. ROM verifies the first-stage bootloader, but the first stage does not verify the second, or U-Boot does not verify the kernel. One unverified link breaks everything downstream.
- Verification happens and the result is ignored. The signature is checked, a failure is logged, boot continues.
- The bootloader is interactive. A signed kernel does not help if you can tell the bootloader to load a different one.
- Integrity mistaken for authenticity. A CRC32 in a uImage header, or a SHA-256 in an update manifest fetched over plain HTTP, protects against corruption and not at all against substitution. An attacker who can modify the image can modify the digest in the same breath. This is the most common conceptual error in student reports.
- Time-of-check to time-of-use. The image is verified in flash, then copied to RAM, and the copy executes. A glitch or a DMA write between the two is a live attack.
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
- Architecture, load address, and entry point must be resolved before a disassembler produces anything useful; the load address is what actually blocks progress.
- On Cortex-M, the first two words of the image give you the stack pointer and the reset handler, and the reset handler gives you the base address.
- Without a vector table, histogram the aligned 32-bit words — real pointers cluster around the true base.
- Confirm the base by checking that cross-references resolve. Nothing else is needed and nothing less will do.
- Work outward from strings, not downward from the entry point.
- The decompiler is an inference, not a fact. Verify findings against the disassembly.
- Integrity is not authenticity: a CRC or an unsigned hash stops corruption, not an attacker.
- Emulation usually half-works, and the point where it stops working is informative.
References
- Ghidra — https://ghidra-sre.org/
- Ghidra documentation and course materials — https://github.com/NationalSecurityAgency/ghidra/tree/master/GhidraDocs
- Ghidra SVD-Loader for peripheral labelling — https://github.com/leveldown-security/SVD-Loader-Ghidra
- rizin / Cutter — https://rizin.re/
- cpu_rec, architecture identification — https://github.com/airbus-seclab/cpu_rec
- U-Boot documentation — https://docs.u-boot.org/
- U-Boot verified boot (FIT images) — https://docs.u-boot.org/en/latest/usage/fit/index.html
- QEMU system emulation — https://www.qemu.org/docs/master/system/
- Unicorn CPU emulator — https://www.unicorn-engine.org/
- FirmAE firmware emulation — https://github.com/pr0v3rbs/FirmAE
- Arm Cortex-M vector table layout — https://developer.arm.com/documentation/dui0552/latest/
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.