Radare2, Ghidra, and Other Ways to Tear Apart a Binary
In Part 1, we found algorithm tables. In Part 2, we recovered struct layouts. We now have useful names and addresses, but our notes are doing most of the work. We need to query a table’s users, follow their callers, and keep the names between sessions.
A byte search remains easy in a larger binary. Maintaining function boundaries, references, types, and annotations is where an analysis framework earns its place.
This time we will use radare2 to build that map in a terminal, then inspect the same firmware with Ghidra and Capstone. The tools agree on several useful facts. Their output also gives us a few things to question.
Follow along: Download the sample bundle, extract it, and work from
more_complex_firmware/. These examples analyzefirmware.binas a raw image at0x80000000. The companion ELF and source are used afterward to check the results.
The captured output uses radare2 6.2.4, Ghidra 12.1.4, Capstone 5.0.7, and r2pipe 1.9.6. Ghidra was run headlessly with JDK 25; its native decompiler was built from the release sources for macOS ARM64. Follow the release’s Getting Started instructions, including native components for your platform. The r2ghidra plugin is optional and is not used for the captured results.
Load the Raw Image in Radare2
A flat binary has no header telling r2 its architecture or load address:
r2 -a mips -b 32 -e cfg.bigendian=false -e anal.cc=o32 \
-m 0x80000000 firmware.bin| Option | Meaning |
|---|---|
-a mips |
MIPS instruction set |
-b 32 |
32-bit mode |
-e cfg.bigendian=false |
Little-endian byte order |
-e anal.cc=o32 |
The calling convention used by this sample |
-m 0x80000000 |
Map file offset zero at this address |
The bit width and calling convention are separate settings. Selecting o32 tells analysis how arguments and return values are passed; it does not guarantee that every inferred function signature is correct.
For the plain-text displays below, disable colors and automatic disassembly comments:
[0x80000000]> e scr.color=0
[0x80000000]> e asm.comments=false
[0x80000000]> s 0x80007f7c
[0x80007f7c]> px 64
- offset - 7C7D 7E7F 8081 8283 8485 8687 8889 8A8B CDEF0123456789AB
0x80007f7c 637c 777b f26b 6fc5 3001 672b fed7 ab76 c|w{.ko.0.g+...v
0x80007f8c ca82 c97d fa59 47f0 add4 a2af 9ca4 72c0 ...}.YG.......r.
0x80007f9c b7fd 9326 363f f7cc 34a5 e5f1 71d8 3115 ...&6?..4...q.1.
0x80007fac 04c7 23c3 1896 059a 0712 80e2 eb27 b275 ..#..........'.uThat is the S-box from Part 1. s changes the current address, and px prints bytes. The address in the prompt is the current position in the mapped image.
Run Analysis, Then Check What It Found
Return to the image base and ask r2 to analyze it:
[0x80007f7c]> s 0x80000000
[0x80000000]> aaa
[0x80000000]> aflc
75aaa runs analysis passes that discover functions, references, and control flow. aflc counts the functions in the current analysis database; afl lists them.
The number 75 is an analysis result, not the number of functions in the firmware. The companion ELF has 155 symbols explicitly typed as functions, plus seven untyped startup/vector labels. Under the raw-import settings used later, Ghidra discovers 108 functions. These counts describe different analysis results; neither automatic pass is a complete inventory.
For a concrete omission, Part 2 located rtos_sem_wait at 0x80000F98. In this fresh r2 session:
[0x80000000]> afij @ 0x80000f98
[]afij asks for function information as JSON. An empty array means r2 has no function containing that address in this analysis. Inspect the instructions and analyze the known entry explicitly:
[0x80000000]> af @ 0x80000f98
[0x80000000]> afn rtos_sem_wait 0x80000f98We supply that address from the earlier analysis. The command does not discover the semaphore’s meaning for us.
Find the Tables
Hex searches are short:
[0x80000000]> /x 637c777b
0x80007f7c hit1_0 637c777b
[0x80000000]> /x 982f8a42
0x80008080 hit2_0 982f8a42
[0x80000000]> /x 96300777
0x80007a74 hit3_0 96300777The last hit is at 0x80007A74, the CRC-32 table’s second entry. The table starts four bytes earlier at 0x80007A70. Hit names and numbering can change as searches accumulate.
These prefixes locate candidates. As in Part 1, check the rest of a table and follow its code references before deciding which algorithm a function implements.
Python can do the byte search just as directly:
data = open("firmware.bin", "rb").read()
offset = data.find(bytes.fromhex("63 7c 77 7b"))
if offset >= 0:
print(f"S-box candidate at 0x{0x80000000 + offset:08X}")S-box candidate at 0x80007F7CThis needs no disassembler. The reason to stay in r2 is what we can do with the address next.
Follow References Into Code
axt lists the discovered references to an address:
[0x80000000]> axt 0x80007f7c
fcn.800063ac 0x800063b8 [DATA:r--] addiu a1, a1, 0x7f7c
fcn.80006440 0x80006474 [DATA:r--] addiu t0, t0, 0x7f7c
fcn.80006440 0x80006514 [DATA:r--] lbu v1, (v1)
fcn.80006440 0x80006520 [DATA:r--] lbu t1, (t1)
fcn.80006440 0x8000652c [DATA:r--] lbu a1, (a1)
fcn.80006440 0x80006538 [DATA:r--] lbu v0, (v0)The output names two containing functions: fcn.800063ac and fcn.80006440. It includes both address construction and indexed table loads. Six reference sites do not mean six different functions.
Inspect the two address constructions:
[0x80000000]> pd 2 @ 0x800063b4
│ 0x800063b4 0080053c lui a1, 0x8000
│ 0x800063b8 7c7fa524 addiu a1, a1, 0x7f7c
[0x80000000]> pd 2 @ 0x80006470
│ 0x80006470 0080083c lui t0, 0x8000
│ 0x80006474 7c7f0825 addiu t0, t0, 0x7f7cBoth pairs produce 0x80007F7C. From the surrounding code, the first function substitutes bytes in a block; the second uses substitutions during key expansion. We can name them sub_bytes and aes_key_expand.
Now follow the helper’s callers:
[0x80000000]> axt 0x800063ac
fcn.80006558 0x800065d4 [CALL:--x] jal fcn.800063ac
fcn.80006558 0x80006730 [CALL:--x] jal fcn.800063acBoth calls come from the function at 0x80006558. Following the inverse S-box at 0x80007E7C leads through inv_sub_bytes at 0x800063E0 to decryption at 0x800067F0. Table users and the public block-operation functions are different points in the call graph.
The SHA-256 round table gives another example:
[0x80000000]> axt 0x80008080
fcn.80006c10 0x80006cf8 [DATA:r--] addiu a2, a2, -0x7f80
fcn.80006c10 0x80006d50 [DATA:r--] lw s4, (a2)Here the reference at 0x80006CF8 completes an address construction. The lui a2, 0x8001 is nine instructions earlier, at 0x80006CD4. The signed addition -0x7F80 produces 0x80008080. A scanner that only accepts adjacent lui/addiu pairs would miss it.
Keep the Names
Once the behavior supports a name, record it:
[0x80000000]> afn aes_encrypt_block 0x80006558
[0x80000000]> afn aes_decrypt_block 0x800067f0
[0x80000000]> afn sha256_transform 0x80006c10
[0x80000000]> afn rtos_schedule 0x80000c10
[0x80000000]> pd 5 @ rtos_schedule
┌ 168: rtos_schedule ();
│ afv: vars(2:sp[0x10..0x14])
│ 0x80000c10 0880033c lui v1, 0x8008
│ 0x80000c14 8c00628c lw v0, 0x8c(v1)
│ 0x80000c18 8c006324 addiu v1, v1, 0x8c
│ ┌─< 0x80000c1c 24004310 beq v0, v1, 0x80000cb0
│ │ 0x80000c20 e0ff4424 addiu a0, v0, -0x20The bytes and branch targets are unchanged. We have attached names to the functions that analysis already found. The addiu at 0x80000C20 is the container_of subtraction from Part 2; it also occupies the branch delay slot and executes on both outcomes.
To inspect the control-flow graph, seek to the function and enter visual graph mode:
[0x80000000]> s rtos_schedule
[0x80000c10]> VVFor output that can be captured without an interactive terminal, use agf @ rtos_schedule. Its first block is shown below, with leading padding removed; the rest of the graph is omitted:
┌─────────────────────────────────────────────────┐
│ 0x80000c10 │
│ ; CALL XREF from fcn.80000540 @ 0x80000724(x) │
│ 168: rtos_schedule (); │
│ afv: vars(2:sp[0x10..0x14]) │
│ lui v1, 0x8008 │
│ lw v0, 0x8c(v1) │
│ addiu v1, v1, 0x8c │
│ beq v0, v1, 0x80000cb0 │
│ addiu a0, v0, -0x20 │
└─────────────────────────────────────────────────┘Save the analysis and reopen it in another session:
[0x80000c10]> Ps firmware_re
[0x80000c10]> qr2 -p firmware_reThe four renamed functions survived a save/reopen check with this version. Keep the rename commands too: they are a small, readable record of the conclusions we supplied. A project stores more state, but a script is easier to inspect in a diff.
Automate the Session With r2pipe
r2pipe sends commands to a running r2 process and returns text or JSON. Install the Python binding in your analysis environment; these examples use version 1.9.6.
import r2pipe
r2 = r2pipe.open("firmware.bin", flags=[
"-a", "mips", "-b", "32",
"-e", "cfg.bigendian=false", "-e", "anal.cc=o32",
"-m", "0x80000000",
])
try:
r2.cmd("aaa")
for pattern, label in [
("637c777b", "AES S-box candidate"),
("982f8a42", "SHA-256 K[] candidate"),
("96300777", "CRC-32 second-entry candidate"),
]:
hits = r2.cmdj(f"/xj {pattern}")
for hit in hits:
print(f"{label}: 0x{hit['addr']:08X}")
functions = r2.cmdj("aflj")
print(f"{len(functions)} functions in this analysis")
finally:
r2.quit()AES S-box candidate: 0x80007F7C
SHA-256 K[] candidate: 0x80008080
CRC-32 second-entry candidate: 0x80007A74
75 functions in this analysisThe JSON forms (/xj and aflj) avoid parsing display columns. The try/finally closes the child process if a search or later processing fails.
Inspect the Same Image in Ghidra
Import firmware.bin as Raw Binary, choose MIPS:LE:32:default, and set the base address to 0x80000000. Run the default analysis. These are the same image bytes and base address used in r2.
To repeat the S-box search in the GUI, use Search → Memory…, select hex input, and search for 63 7C 77 7B. At the result, References → Show References To opens the discovered reference list. The Ghidra introductory guide documents these actions; the results here were exported through Ghidra’s headless API.
For the S-box, that API reports:
80006514 DATA FUN_80006440
80006520 DATA FUN_80006440
8000652c DATA FUN_80006440
80006538 DATA FUN_80006440
800063c4 DATA FUN_800063acThese are the same two containing functions r2 found, but the reference sites differ. Ghidra reports the indexed load in sub_bytes at 0x800063C4, while r2 also reports the address-building instruction at 0x800063B8. Agreeing on the table’s users matters more than having identical lists of instruction addresses.
Read the Decompiled Function
Navigate to 0x80006C10 and inspect the Decompiler window. This is an excerpt of the actual Ghidra output before renaming or applying source types:
local_20 = (int *)local_120;
local_1c = local_120;
do {
*local_1c = (uint)*param_2 << 0x18 | (uint)param_2[1] << 0x10 | (uint)param_2[3] |
(uint)param_2[2] << 8;
param_2 = param_2 + 4;
local_1c = local_1c + 1;
} while (local_1c != local_e8 + 2);Each iteration assembles four input bytes into a big-endian word. Ghidra recovers that expression and the loop condition, but it splits the message schedule across several guessed stack arrays: local_120, local_fc, and local_e8. The original source’s single 64-word array has not been recovered cleanly.
Further down, the round loop ends with:
} while (piVar8 != (int *)s_CLI_initialized_80008180);The CLI label looks out of place in a SHA-256 loop. The comparison uses address 0x80008180, immediately after the 256-byte K[] table. Ghidra has named that address after the adjacent string. The code is comparing an end pointer; it never reads the string here. Checking the table extent resolves the apparent connection.
This is the useful boundary of decompilation: it can reconstruct expressions and control flow while still giving an address or local variable a misleading name or type.
Compare With r2’s Pseudocode
Run pdc @ sha256_transform in the renamed r2 session. The same input-word loop appears as:
do {
// CODE XREF from sha256_transform @ 0x80006c70(x)
v0 = (unsigned) byte [a1 + 0] // arg2
v0 <<= 0x18
v1 = (unsigned) byte [a1 + 1] // arg2
v1 <<= 0x10
v0 |= v1
v1 = (unsigned) byte [a1 + 3] // arg2
v0 |= v1
v1 = (unsigned) byte [a1 + 2] // arg2
v1 <<= 8
v0 |= v1
v1 = [sp + 0x104]
[v1 + 0] = v0
a1 += 4 // arg2
v0 = v1 + 4
[sp + 0x104] = v0
} while (v0 != a2);The output retains registers, stack stores, and instruction-like operations. It is useful for following the assembly, while Ghidra’s output combines more operations into C expressions. r2 also inferred a third argument in this function, although the companion source has two. A guessed signature needs the same scrutiny as a guessed struct field.
The optional r2ghidra plugin provides pdg using Ghidra’s decompiler engine inside r2. It was not installed for this comparison. Sharing a decompiler engine does not make the surrounding function discovery, types, and analysis inputs identical.
Rename Functions in Either Framework
A batch rename is straightforward in both tools. In r2, save these commands as renames.r2:
aaa
af @ 0x80006558
afn aes_encrypt_block 0x80006558
af @ 0x800067f0
afn aes_decrypt_block 0x800067f0
af @ 0x80006c10
afn sha256_transform 0x80006c10
af @ 0x80000c10
afn rtos_schedule 0x80000c10
Ps firmware_reRun them in a fresh session:
r2 -q -a mips -b 32 -e cfg.bigendian=false -e anal.cc=o32 \
-m 0x80000000 -i renames.r2 firmware.binGhidra can do the same work from the GUI or headlessly. Save this script as RenameFirmware.java:
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SourceType;
public class RenameFirmware extends GhidraScript {
@Override
public void run() throws Exception {
long[] addresses = {0x80006558L, 0x800067f0L, 0x80006c10L, 0x80000c10L};
String[] names = {"aes_encrypt_block", "aes_decrypt_block",
"sha256_transform", "rtos_schedule"};
for (int i = 0; i < addresses.length; i++) {
Function function = getFunctionAt(toAddr(addresses[i]));
if (function == null) {
throw new IllegalStateException("Analyze the function at " + toAddr(addresses[i]));
}
function.setName(names[i], SourceType.USER_DEFINED);
println(function.getEntryPoint() + " " + function.getName());
}
}
}For a fresh headless import, with GHIDRA_HOME pointing at the extracted Ghidra installation:
mkdir -p ghidra-projects
"$GHIDRA_HOME/support/analyzeHeadless" ghidra-projects firmware_re \
-import firmware.bin -loader BinaryLoader -loader-baseAddr 0x80000000 \
-processor MIPS:LE:32:default -cspec default \
-scriptPath . -postScript RenameFirmware.javaThe script’s messages, without the headless logger’s prefixes:
80006558 aes_encrypt_block
800067f0 aes_decrypt_block
80006c10 sha256_transform
80000c10 rtos_scheduleBoth frameworks can save names and automate edits. The r2 script uses analysis commands directly; the Java script uses Ghidra’s function API. Choose according to the analysis database you intend to maintain.
Where Capstone Fits
Capstone is the disassembly library we used earlier. It does not maintain a function database or decompile code, but it is useful when we want to control exactly which instructions a script examines.
This scan finds the first SHA-256 initial-state constant load in the code ranges of this build:
from capstone import Cs, CS_ARCH_MIPS, CS_MODE_MIPS32, CS_MODE_LITTLE_ENDIAN
data = open("firmware.bin", "rb").read()
md = Cs(CS_ARCH_MIPS, CS_MODE_MIPS32 | CS_MODE_LITTLE_ENDIAN)
for start, end in [(0x0000, 0x02AC), (0x02B0, 0x7290)]:
for insn in md.disasm(data[start:end], 0x80000000 + start):
if insn.mnemonic == "lui" and insn.op_str.endswith(", 0x6a09"):
print(f"Initial-state constant candidate at 0x{insn.address:08X}")Initial-state constant candidate at 0x80006E28The ranges come from the companion ELF and exclude the read-only data. In an unknown image, we would first need evidence for those code bounds. This scan finds a candidate instruction; the following ori and stores establish the full constant and its use.
Building a custom reference finder with Capstone remains possible. We would need to track register values across instruction sequences, account for branch delay slots, and associate references with function boundaries. That work makes sense when the analysis is specialized or needs to run across many images. For ordinary browsing, r2 and Ghidra already maintain those relationships.
Compare the Work, Not the Number of Commands
| Task | Plain Python / Capstone | Radare2 | Ghidra |
|---|---|---|---|
| Byte search | bytes.find; no Capstone needed |
/x |
Search Memory or scripting API |
| Table users and callers | Custom value tracking and function mapping | Discovered xrefs with axt |
Discovered references and containing functions |
| C-like decompilation | Not provided by Capstone | pdc; optional plugin for another engine |
Integrated decompiler |
| Batch rename | Keep a separate map; no Capstone project model | afn commands or r2pipe |
Java/Python scripting APIs |
| Saved analysis | Script-specific output | r2 project and command scripts | Ghidra project |
| Terminal use | Python scripts | Interactive terminal and scripts | Headless analysis and scripts |
For this firmware, r2 gives us a compact terminal workflow from a table search to its users, callers, and saved names. Ghidra gives us C expressions and an interface for applying the structs recovered in Part 2. Capstone gives us instruction decoding inside a script we control. The useful distinction is which work each tool maintains for us.
If you prefer a GUI around a terminal-oriented analysis framework, Cutter is another option. Its current backend is Rizin, so r2 commands and project behavior should not be assumed to match.
Exercises
-
Compare references: Collect S-box references in both r2 and Ghidra. Group them by containing function. Which differences are address-construction sites versus actual loads?
-
Question a label: Find
s_CLI_initialized_80008180in the SHA-256 decompilation. Use the K[] start and size to explain the end-pointer comparison. What annotation would make it clearer? -
Check persistence: Rename the four functions, save the project, close the tool, and reopen it. Verify the names at the same addresses. Export a second map as JSON or text and keep it beside your notes.
-
Find a missed function: Inspect
rtos_sem_waitat0x80000F98in a fresh r2 analysis. Define it explicitly, then compare its boundaries with the companion ELF. Does adding a known entry change the discovered reference map?
What’s Next
We can now query references, preserve names, and compare decompiled code with the underlying instructions. In Part 4, we will use those capabilities to separate accesses to hardware registers from ordinary memory accesses, then build a map of the seven peripheral blocks used by this firmware.
References
- The Official Radare2 Book: firmware setup — loading a raw image with the correct architecture and mapping.
- The Official Radare2 Book: code analysis — function analysis and cross-reference commands.
- The Official Radare2 Book: projects — saving and restoring analysis.
- Ghidra introductory guide — searching and navigating references.
- Capstone Python tutorial — decoding instructions from Python.
This is Part 3 of “Crypto Tables, Struct Ghosts, and Other Secrets Hidden in Firmware.”
Next: Part 4 — Register Maps, MMIO Blocks, and Other Hardware Buried in Code.