- 0x0011a_cb (Operation Dark Vector): nation-state CTF redesign with an AES-128-ECB sealed target and a plaintext launch origin; RP2350 firmware with bearing-driven servo, tri-color LEDs, GSV stats, and a realistic no-fix path - docs: story-driven classified brief, GDB and Ghidra tutorials with deep step-throughs, regenerated artifacts and PDFs - scripts: docstring standard, AES per-student randomizer, telemetry monitor - week 3 to week 5 lessons: Ghidra patching tutorial, CMSIS-SVD hardware RE, double floating-point and GPIO architecture chapters, README structure
48 KiB
Week 4a: Hardware-Aware Reverse Engineering with CMSIS-SVD: Live GDB and Ghidra Analysis of Stripped Binaries
LEGAL DISCLAIMER: The information, tools, and code provided in this repository and course are strictly for educational, research, and defensive purposes only.
You are explicitly prohibited from using any materials contained herein to access, test, modify, or exploit any device, network, or system that you do not own 100% or for which you do not have explicit, documented, and legally binding authorization to interact with.
By using this repository and course, you acknowledge and agree that:
- Any illegal, unauthorized, or malicious use of this information is solely your responsibility.
- The author(s) and contributor(s) of this repository and course shall not be held liable for any damages, legal repercussions, criminal charges, or unauthorized actions resulting from the use, misuse, or abuse of the contents herein.
- You will comply with all applicable local, state, national, and international laws regarding cybersecurity and computer fraud.
IF YOU DO NOT AGREE WITH THESE TERMS, DO NOT USE THIS REPOSITORY AND COURSE.
What You'll Learn This Week
By the end of this tutorial, you will be able to:
- Understand the fundamental differences between an ELF binary and a raw
.binfirmware dump. - Flash and debug
CTF-01.binusing OpenOCD and a hardware Debug Probe with explicit base addressing (0\text{x}10000000). - Operate GDB without an executable or symbol table, using hardware breakpoints (
hb) and address-based disassembly (x/i). - Navigate the ARM Cortex-M Vector Table at
0\text{x}10000000to find the initial Stack Pointer (0\text{x}20082000) and Reset Vector (0\text{x}1000015B \rightarrow 0\text{x}1000015A). - Follow boot execution into
main()at0\text{x}100001E0without debug symbols. - Identify the Memory-Mapped I/O (MMIO) peripheral blind spot in stripped binaries (UART0, IO_BANK0, PADS_BANK0, RESETS, SIO).
- Install and configure CMSIS-SVD (
rp2350.svd) andPyCortexMDebuginside GDB for live dynamic peripheral inspection. - Inspect UART0 baud rate and FIFO status registers, and verify GPIO 0/1 pin multiplexing live over SWD.
- Install and run
SVD-Loader-GhidraonCTF-01.binto map peripheral memory blocks and auto-generate C struct definitions. - Transform raw, obscure pointer decompilation into readable, vendor-grade peripheral struct accesses.
- Understand why arithmetic immediate instructions like
add.w r0, r0, #0x40000000appear in the assembly listing while resolving cleanly in the decompiler. - Execute a unified hardware-aware reverse engineering workflow combining static analysis in Ghidra and dynamic analysis in GDB to solve the CTF mission.
Part 1: The Raw Binary Dilemma (.elf vs .bin)
ELF Files: The Friendly Development Container
In previous lessons, when debugging your firmware, you launched GDB pointing to an Executable and Linkable Format (ELF) file:
arm-none-eabi-gdb build\CTF-01.elf
An ELF file is not just machine code. It is a rich, structured container that contains:
- ELF Header: Specifies the target architecture, endianness, and exact entry point address.
- Section Headers: Defines memory segments such as
.text,.data,.rodata, and.bss. - Symbol Table: Maps human-readable names (
main,grid_deviation,evaluate_grid) to exact virtual memory addresses. - DWARF Debug Information: Links individual assembly instructions back to original C source file line numbers, variable types, and stack frame layouts.
When GDB loads an ELF file, it knows the name of every function, the layout of every struct, and the location of main.
Raw .bin Files: The Harsh Reality of Firmware Extraction
In real-world hardware reverse engineering, red teaming, and firmware extraction (such as reading an external SPI flash chip or intercepting an over-the-air firmware update), you will almost never have access to an ELF file.
Instead, you are handed a raw binary file (.bin):
+-----------------------------------------------------------------+
| Comparison: ELF Container vs. Raw .bin Firmware |
| |
| CTF-01.elf (Development Build) |
| +-----------------------------------------------------------+ |
| | ELF Header (Entry Point: 0x1000015b) | |
| | Symbol Table (main -> 0x100001e0, evaluate_grid -> ...) | |
| | DWARF Debug Data (C Source Line Mapping) | |
| | Section Table (.text, .rodata, .data, .bss) | |
| | Machine Code Payload (15,920 bytes) | |
| +-----------------------------------------------------------+ |
| |
| CTF-01.bin (Field Recovery Image) |
| +-----------------------------------------------------------+ |
| | [Flat Machine Code Bytes Only - No Headers, No Symbols] | |
| | 00 20 08 20 5b 01 00 10 1b 01 00 10 1d 01 00 10 ... | |
| +-----------------------------------------------------------+ |
+-----------------------------------------------------------------+
A .bin file is a byte-for-byte memory dump of flash memory. It contains:
- No ELF header
- No section names
- No symbol names
- No debug information
- No base address metadata (the file itself does not record where in memory it belongs)
If you attempt to launch GDB with 0x0001b_ctf/CTF-01.bin directly:
arm-none-eabi-gdb 0x0001b_ctf\CTF-01.bin
GDB immediately halts with an error:
"0x0001b_ctf/CTF-01.bin": not in executable format: file format not recognized
The Hardware MMIO Blind Spot
Stripped binaries introduce a second, even larger obstacle: hardware peripherals.
Microcontrollers interact with the outside world using Memory-Mapped Input/Output (MMIO). Peripherals like UART, GPIO, Clocks, and Resets do not have special CPU instructions. Instead, they are mapped to specific, fixed physical addresses in the microcontroller's memory map:
| Subsystem | RP2350 Physical Address Base | Function |
|---|---|---|
RESETS |
0x40020000 |
Subsystem reset controller (releases UART and GPIO from reset) |
IO_BANK0 |
0x40028000 |
GPIO pin function select (FUNCSEL) and overrides |
PADS_BANK0 |
0x40038000 |
Electrical pad controls (drive strength, pulls, Schmitt) |
UART0 |
0x40070000 |
Serial UART interface (115200 8N1 telemetry console) |
SIO |
0xd0000000 |
Single-cycle I/O (fast CPU core check, GPIO controls) |
When analyzing a raw binary, neither GDB nor Ghidra understands what 0x40070018 or 0x40028004 mean. To standard tools, they are just arbitrary hexadecimal numbers. Reverse engineers are left manually cross-referencing thousands of pages of microcontroller datasheets.
In this tutorial, you will master the two industry-standard tools that eliminate this blind spot:
- PyCortexMDebug for live, dynamic hardware awareness in GDB.
- SVD-Loader-Ghidra for automatic struct mapping and clear decompilation in Ghidra.
Part 2: The Target Application: 0x0001b_ctf (Operation Black Start)
Mission Briefing
Our primary target is the emergency firmware build from 0x0001b_ctf (CTF-01: Operation Black Start).
In this scenario, a critical cyberattack severed the primary SCADA network coordinating regional power grid interconnections. An emergency firmware build was deployed to the GRID-7 fleet of relay controllers to manage an automated black-start restoration. However, the rushed build contains two critical defects:
- Miscalibrated Safety Threshold: The frozen grid frequency deviation latched at
0.87\text{ Hz}is evaluated against a corrupt threshold of95(0.95\text{ Hz}) instead of the hard engineering safety limit of60(0.60\text{ Hz}), causing the relay to report a false-safeSTABLEstatus. - Hardcoded False Status Line: The operator console line falsely asserts
SIGNAL: NORMALregardless of actual channel health.
The source code was overwritten during the crisis build process. The only surviving artifact is the compiled raw binary: CTF-01.bin.
The Source Code Behind the Binary
Let's review the firmware architecture from 0x0001b_ctf/src/ to understand what machine code the compiler generated:
main.c
#include "console.h"
#include "grid.h"
#include "pico/stdlib.h"
int main(void)
{
stdio_init_all();
retain_dispatch_frame();
evaluate_grid();
print_boot_banner();
while (true) {
print_status();
sleep_ms(1000);
}
return 0;
}
grid.c
#include "grid.h"
#include <stdint.h>
// Frozen grid frequency deviation reading latched when comms were severed.
volatile uint32_t grid_deviation = 87;
// Operator-facing classification of the frozen reading (drives GRID STATUS).
volatile uint32_t operator_state = 0;
// Automated dispatch authorization decision (drives DISPATCH PATH).
volatile uint32_t dispatch_state = 0;
// Quarantined black-start authorization frame, retained in flash, never sent.
static volatile const char dispatch_frame[] =
"WORLDGRID:BLACKSTART:GRID-7:WATER-3";
void retain_dispatch_frame(void)
{
volatile char frame_marker = dispatch_frame[0];
(void)frame_marker;
}
void evaluate_grid(void)
{
operator_state = (grid_deviation < SAFE_THRESHOLD) ? 1 : 0;
dispatch_state = (grid_deviation < SAFE_THRESHOLD) ? 1 : 0;
}
console.c
#include "console.h"
#include "grid.h"
#include <stdio.h>
void print_boot_banner(void)
{
printf("GLOBAL EMBEDDED RESPONSE NETWORK\r\n");
printf("BLACK START WINDOW: 27 MINUTES\r\n");
printf("UART0 115200 8N1 | AUTHORIZED LAB CONSOLE\r\n");
printf("SIGNAL: NORMAL\r\n");
printf("RESPONSE> ");
}
void print_status(void)
{
printf("GRID STATUS: %s\r\n", operator_state ? "STABLE" : "CRITICAL");
printf("DISPATCH PATH: %s\r\n", dispatch_state ? "AUTHORIZED" : "HELD");
printf("LAST FRAME: QUARANTINED\r\n");
printf("RESPONSE> ");
}
Hardware Actions Performed by This Code
stdio_init_all():- Releases the UART0, IO_BANK0, and PADS_BANK0 peripherals from reset via
RESETS(0x40020000). - Configures
GPIO0(TX) andGPIO1(RX) pin multiplexing to function select2(UART0) inIO_BANK0(0x40028000). - Configures pad electrical properties in
PADS_BANK0(0x40038000). - Programs baud rate divisors (
UARTIBRD,UARTFBRD) and line controls (UARTLCR_H) inUART0(0x40070000) for 115200 baud, 8 data bits, no parity, 1 stop bit (8N1).
- Releases the UART0, IO_BANK0, and PADS_BANK0 peripherals from reset via
retain_dispatch_frame():- Anchors the secret authorization token string
"WORLDGRID:BLACKSTART:GRID-7:WATER-3"at physical flash address0\text{x}100037A0.
- Anchors the secret authorization token string
evaluate_grid():- Compares the global
grid_deviationvariable (87) againstSAFE_THRESHOLD(compiled ascmp r3, #94).
- Compares the global
print_boot_banner()&print_status():- Streams formatted strings through the UART0 Transmit FIFO buffer (
UART0_UARTDR).
- Streams formatted strings through the UART0 Transmit FIFO buffer (
In your workspace, this challenge is located in 0x0001b_ctf/:
0x0001b_ctf/CTF-01.bin(Raw 15,920-byte stripped firmware)0x0001b_ctf/CTF-01.uf2(Packaged UF2 image)
For the remainder of this lesson, we assume you only have 0x0001b_ctf/CTF-01.bin.
Part 3: Flashing and Connecting OpenOCD with Raw Binaries
Flashing a Raw Binary via OpenOCD
When flashing an .elf file, OpenOCD reads target memory addresses directly from the ELF program headers. However, because a .bin file contains no header information, you must explicitly specify the physical base address (0\text{x}10000000 for RP2350 XIP flash).
Open a PowerShell terminal in your repository root:
& "$env:USERPROFILE\.pico-sdk\openocd\0.12.0+dev\openocd.exe" `
-s "$env:USERPROFILE\.pico-sdk\openocd\0.12.0+dev\scripts" `
-f interface/cmsis-dap.cfg `
-f target/rp2350.cfg `
-c "adapter speed 5000; program 0x0001b_ctf/CTF-01.bin 0x10000000 verify reset exit"
Notice the crucial parameter: 0x10000000. This instructs OpenOCD to write the binary bytes starting at the exact beginning of external flash memory.
** Programming Started **
[rp2350.dap.core0] target halted due to debug-request
wrote 16384 bytes from file 0x0001b_ctf/CTF-01.bin in 0.412s
** Programming Finished **
** Verify Started **
verified 15920 bytes in 0.082s
** Verified OK **
** Resetting Target **
shutdown command invoked
Starting OpenOCD as a Live Debug Server
To debug the running firmware interactively, launch OpenOCD without the exit command. Keep this terminal open:
& "$env:USERPROFILE\.pico-sdk\openocd\0.12.0+dev\openocd.exe" `
-s "$env:USERPROFILE\.pico-sdk\openocd\0.12.0+dev\scripts" `
-f interface/cmsis-dap.cfg `
-f target/rp2350.cfg `
-c "adapter speed 5000; init"
OpenOCD initializes the SWD hardware connection and listens for GDB connections on TCP port 3333.
Info : Hardware thread awareness created
Info : Listening on port 3333 for gdb connections
Part 4: Dynamic Analysis in GDB Without an ELF
Because we are analyzing a raw binary, we do not pass a file name to GDB on startup.
Open a second terminal window:
arm-none-eabi-gdb
Step 1: Set Target Architecture and Connect
Inside the GDB prompt, tell GDB what processor architecture to expect, connect over TCP to OpenOCD, and immediately halt the processor:
set architecture armv8-m.main
target extended-remote :3333
monitor reset halt
The target architecture is assumed to be armv8-m.main
Remote debugging using :3333
target halted due to debug-request, current mode: Thread
xPSR: 0x01000000 pc: 0x1000015a msp: 0x20082000
Step 2: Decode the Hardware Vector Table
How did the CPU know to halt at 0x1000015a?
On ARM Cortex-M processors, the first entries of flash memory (0\text{x}10000000) define the Vector Table:
- Word 0 (
0\text{x}10000000): Initial Main Stack Pointer (msp). - Word 1 (
0\text{x}10000004): Reset Vector (address of the first instruction to run upon reboot).
Let's inspect the first four 32-bit words of flash using GDB's examine command (x/4wx):
x/4wx 0x10000000
0x10000000: 0x20082000 0x1000015b 0x1000011b 0x1000011d
Let's analyze what these words reveal:
+-----------------------------------------------------------------+
| RP2350 Cortex-M33 Vector Table Decoding |
| |
| Address Value Meaning |
| 0x10000000: 0x20082000 Initial MSP (Top of 512KB SRAM) |
| 0x10000004: 0x1000015b Reset Vector (Thumb Execution Bit 0) |
| Execution begins at: 0x1000015a |
+-----------------------------------------------------------------+
Note
The Reset Vector value is
0x1000015b. In ARM architecture, bit 0 indicates Thumb instruction mode (0\text{x}1000015A + 1). The processor automatically clears bit 0 and begins executing Thumb instructions at address0x1000015a.
Step 3: Why Symbolic Commands Fail
If you try to use symbolic commands, GDB cannot help you:
break main
disassemble main
No symbol table is loaded. Use the "file" command.
There are no symbols! To reverse engineer this firmware dynamically, we must navigate using instruction addresses.
Step 4: Disassemble by Address Range
Disassemble 12 instructions starting at the Reset Handler address (0\text{x}1000015A):
x/12i 0x1000015a
=> 0x1000015a: mov.w r0, #3489660928 ; 0xd0000000 (SIO base)
0x1000015e: ldr r0, [r0, #0] ; SIO_CPUID (reads core id)
0x10000160: cbz r0, 0x10000166 ; if Core 0, branch to init
0x10000162: movs r0, #0
0x10000164: b.n 0x1000014e ; Core 1 sleeps
0x10000166: add r4, pc, #52 ; loads init table pointer
0x10000168: ldmia r4!, {r1, r2, r3}
0x1000016a: cmp r1, #0
0x1000016c: beq.n 0x10000174
0x1000016e: bl 0x10000196 ; copy data section to SRAM
0x10000172: b.n 0x10000168
0x10000174: ldr r1, [pc, #84] ; loads BSS bounds
Notice instruction 0x1000015a: it immediately reads SIO register 0xd0000000 (CPUID) to check whether execution is occurring on Core 0 or Core 1!
Now look further down the boot sequence at address 0x10000186:
x/6i 0x10000184
0x10000184: blx r1
0x10000186: ldr r1, [pc, #80] ; loads address from 0x100001d8
0x10000188: blx r1 ; calls main()!
0x1000018a: ldr r1, [pc, #80]
0x1000018c: blx r1
0x1000018e: bkpt 0x0000
Inspect the pointer stored at 0x100001d8:
x/wx 0x100001d8
0x100001d8: 0x100001e1
Value 0x100001e1 is Thumb address 0\text{x}100001E0 + 1. This reveals that main() is located at address 0x100001e0!
Step 5: Setting Hardware Breakpoints on Flash Memory
In RAM, GDB can set software breakpoints by temporarily replacing instructions with a breakpoint opcode (bkpt). However, external XIP flash (0\text{x}10000000 - 0\text{x}1FFFFFFF) is read-only. GDB cannot write to flash memory while the processor is running.
Therefore, you must use hardware breakpoints (hb):
hb *0x100001e0
continue
Hardware assisted breakpoint 1 at 0x100001e0
Continuing.
Breakpoint 1, 0x100001e0 in ?? ()
We are now stopped at the entry point of main() inside a completely stripped binary!
Disassemble the instructions in main() from 0x100001e0 to 0x10000216:
disassemble 0x100001e0, 0x10000216
Dump of assembler code from 0x100001e0 to 0x10000216:
=> 0x100001e0: push {r7, lr}
0x100001e2: sub sp, #8
0x100001e4: bl 0x1000308c ; stdio_init_all()
0x100001e8: ldr r3, [pc, #124] ; [0x10000268] -> 0x100037a0 (dispatch_frame)
0x100001ea: ldr r2, [pc, #128] ; [0x1000026c] -> 0x200005d8 (grid_deviation)
0x100001ec: ldrb r3, [r3, #0] ; retain_dispatch_frame(): reads dispatch_frame[0] ('W')
0x100001ee: ldr r6, [pc, #128] ; [0x10000270] -> 0x20000844 (operator_state)
0x100001f0: strb.w r3, [sp, #7] ; store frame_marker
0x100001f4: ldrb.w r3, [sp, #7] ; reload frame_marker (volatile)
0x100001f8: ldr r3, [r2, #0] ; evaluate_grid(): read grid_deviation (87)
0x100001fa: ldr r5, [pc, #120] ; [0x10000274] -> 0x20000834 (dispatch_state)
0x100001fc: cmp r3, #94 ; compare site A: if grid_deviation <= 94
0x100001fe: ite hi
0x10000200: movhi r3, #0
0x10000202: movls r3, #1
0x10000204: str r3, [r6, #0] ; operator_state = 1 (STABLE)
0x10000206: ldr r3, [r2, #0] ; read grid_deviation again
0x10000208: ldr r0, [pc, #108] ; string pointer
0x1000020a: cmp r3, #94 ; compare site B: if grid_deviation <= 94
0x1000020c: ite hi
0x1000020e: movhi r3, #0
0x10000210: movls r3, #1
0x10000212: str r3, [r5, #0] ; dispatch_state = 1 (AUTHORIZED)
0x10000214: bl 0x1000311c ; puts("GLOBAL EMBEDDED RESPONSE NETWORK")
End of assembler dump.
Notice what reverse engineering revealed right before our eyes:
0x100001e4:bl 0x1000308cinitializes UART0 serial communications.0x100001e8: Loads pointer0x100037a0. Inspectingx/s 0x100037a0reveals the secret token"WORLDGRID:BLACKSTART:GRID-7:WATER-3"!0x100001fc&0x1000020a: Both threshold checks comparer3against#94(0x5e). Becausegrid_deviationis87,87 \le 94, so bothoperator_stateanddispatch_stateare incorrectly set to1!
Step 6: The MMIO Blindness in Action
Now step past 0x100001e4 (stdio_init_all) using nexti or stepi.
In standard GDB, if you want to inspect what happened to UART0, you are forced to type raw hex addresses:
x/wx 0x40070018
0x40070018: 0x00000090
What does 0x90 mean?
- Is the Transmit FIFO empty?
- Is the Receive FIFO full?
- Is the UART transmitter currently busy?
- What baud rate divisor was written to
0x40070024?
Standard GDB has no way to tell you. Let's fix that right now.
Part 5: Installing and Configuring CMSIS-SVD in GDB (PyCortexMDebug)
What is CMSIS-SVD?
CMSIS-SVD (Common Microcontroller Software Interface Standard - System View Description) is an open XML specification developed by ARM. Silicon vendors publish an .svd file for every chip they manufacture.
An SVD file contains a complete, machine-readable description of:
- Every peripheral on the chip
- Its physical base address
- Every register and offset
- Every bitfield, bitmask, access permission, and human-readable description
+-----------------------------------------------------------------+
| CMSIS-SVD Hierarchy Tree |
| |
| Device: RP2350 |
| +-- Peripheral: UART0 (Base: 0x40070000) |
| | +-- Register: UARTDR (Offset: 0x000) |
| | +-- Register: UARTFR (Offset: 0x018) |
| | +-- Register: UARTIBRD (Offset: 0x024) |
| | +-- Register: UARTLCR_H (Offset: 0x02c) |
| +-- Peripheral: IO_BANK0 (Base: 0x40028000) |
| | +-- Register: GPIO0_CTRL (Offset: 0x004) -> UART0 TX |
| | +-- Register: GPIO1_CTRL (Offset: 0x00c) -> UART0 RX |
| +-- Peripheral: PADS_BANK0 (Base: 0x40038000) |
| | +-- Register: GPIO0 (Offset: 0x004) |
| +-- Peripheral: SIO (Base: 0xd0000000) |
| +-- Register: CPUID (Offset: 0x000) |
+-----------------------------------------------------------------+
Step 1: Obtain rp2350.svd
A pre-downloaded copy of rp2350.svd is included directly in your course repository under WEEK04/rp2350.svd. You can also download the latest version directly from the official open-source cmsis-svd-data repository.
Open a PowerShell terminal and create a dedicated svd directory in your user profile:
New-Item -ItemType Directory -Force -Path "$env:USERPROFILE\.svd"
# Option A: Copy from local course folder
Copy-Item "WEEK04\rp2350.svd" "$env:USERPROFILE\.svd\rp2350.svd"
# Option B: Download directly from GitHub
Invoke-WebRequest `
-Uri "https://raw.githubusercontent.com/cmsis-svd/cmsis-svd-data/main/data/RaspberryPi/rp2350.svd" `
-OutFile "$env:USERPROFILE\.svd\rp2350.svd"
Verify that the file is in place:
Get-Item "$env:USERPROFILE\.svd\rp2350.svd"
Step 2: Install PyCortexMDebug
PyCortexMDebug is an open-source Python extension for GDB created by Bill Nahill. It parses SVD XML files and exposes high-level peripheral inspection commands inside your GDB session.
Clone the repository into your user profile:
cd "$env:USERPROFILE"
git clone https://github.com/bnahill/PyCortexMDebug.git
Step 3: Load PyCortexMDebug in GDB
In your active GDB session, load the Python script and parse rp2350.svd:
source ~/PyCortexMDebug/scripts/gdb.py
svd_load ~/.svd/rp2350.svd
Loading SVD file /Users/username/.svd/rp2350.svd...
Loaded 52 peripherals
Just like that, GDB now understands every single register, offset, and bitfield on the RP2350 microcontroller!
Tip
You can automate this process so SVD support is always active. Add the following lines to your
~/.gdbinitfile:source ~/PyCortexMDebug/scripts/gdb.py svd_load ~/.svd/rp2350.svd
Part 6: Live Dynamic Peripheral Inspection & Manipulation in GDB
Now that GDB is hardware-aware, let's explore what we can do on our stripped CTF-01.bin firmware without touching source code.
Command 1: List All On-Chip Peripherals
Type svd with no arguments to list all 52 peripherals on the RP2350:
svd
Available Peripherals:
ACCESSCTRL ADC BUSCTRL CLOCKS
DMA GLITCH_DETECTOR I2C0 I2C1
IO_BANK0 IO_QSPI OTP PADS_BANK0
PADS_QSPI PIX_RP2040 PIO0 PIO1
PIO2 PLL_SYS PLL_USB POWMAN
PWM QMI RESETS ROSC
SHA256 SIO SPI0 SPI1
SYSINFO SYSCFG TBMAN TICKS
TIMER0 TIMER1 TRNG UART0
UART1 USB VREG_AND_CHIP_RESET WATCHDOG
XIP_AUX XIP_CTRL XIP_QMI XOSC
Command 2: Inspect a Peripheral Live (svd UART0)
Inspect the UART0 peripheral. GDB automatically reads the physical hardware registers across SWD and displays their live state:
svd UART0
UART0 @ 0x40070000:
UARTDR : 0x00000000
UARTRSR : 0x00000000
UARTFR : 0x00000090
UARTILPR : 0x00000000
UARTIBRD : 0x00000043
UARTFBRD : 0x00000035
UARTLCR_H : 0x00000070
UARTCR : 0x00000301
UARTIFLS : 0x00000012
UARTIMSC : 0x00000000
UARTRIS : 0x00000000
UARTMIS : 0x00000000
UARTICR : 0x00000000
UARTDMACR : 0x00000003
Look at what this dump proves:
UARTIBRD(0\text{x}43 = 67) andUARTFBRD(0\text{x}35 = 53): These are the exact integer and fractional divisors for115200\text{ baud}with a125\text{ MHz}reference clock!UARTLCR_H = 0x00000070: Bits 6:5 are11(8\text{ data bits}) and bit 4 is1(FIFOs enabled).UARTCR = 0x00000301: Bit 0 isUARTEN(1), Bit 8 isTXE(Transmit Enable), Bit 9 isRXE(Receive Enable).
Command 3: Decode Bitfields (svd /r UART0 UARTFR)
Remember the mysterious 0x00000090 we saw when reading 0x40070018? Let's use the /r (raw/register decode) flag to decode it:
svd /r UART0 UARTFR
UART0.UARTFR @ 0x40070018: 0x00000090
[7] TXFE : 1 (Transmit FIFO empty)
[6] RXFF : 0 (Receive FIFO full)
[5] TXFF : 0 (Transmit FIFO full)
[4] RXFE : 1 (Receive FIFO empty)
[3] BUSY : 0 (UART busy transmitting)
[2] DCD : 0 (Data carrier detect)
[1] DSR : 0 (Data set ready)
[0] CTS : 0 (Clear to send)
In a single command, GDB completely decoded the raw hardware byte into human-readable FIFO and serial transmission states!
Now inspect the pin multiplexer for GPIO0:
svd /r IO_BANK0 GPIO0_CTRL
IO_BANK0.GPIO0_CTRL @ 0x40028004: 0x00000002
[4:0] FUNCSEL : 2 (Function 2: UART0 TX)
This verifies that GPIO0 has been successfully multiplexed to function as the hardware UART0 TX pin.
Command 4: Live Hardware Manipulation via SWD (svd /w)
You can also write to registers by name using svd /w.
While the processor is halted in GDB, look at your physical Pico 2 hardware board. Let's toggle the LED or write to a register directly:
svd /w SIO GPIO_OUT_SET 0x00010000
You have full, symbolic, hardware-level control over a running target directly through GDB without having an ELF file or compiling a single line of code.
Part 7: Transitioning to Static Analysis in Ghidra
Dynamic analysis in GDB lets us verify hardware state while stepping through execution. However, to understand the overall architecture, algorithms, and logic of a stripped firmware binary, we must perform static analysis in Ghidra.
Step 1: Import the Stripped Binary into Ghidra
- Launch Ghidra:
ghidraRun - Create a new project named
CTF-01 - Drag and drop
0x0001b_ctf/CTF-01.bininto the Active Project window. - In the Import dialog:
- Language: Click
..., search forCortex, and select ARM:LE:32:Cortex (default). - Click Options...:
- Change Block Name to
.text - Change Base Address to
10000000(XIP Flash base)
- Change Block Name to
- Click OK, then click OK to complete the import.
- Language: Click
- Double-click the file to open the CodeBrowser.
- When prompted to analyze, click Yes, accept default analyzers, and click Analyze.
Step 2: The Decompiler Before SVD (The Pointer Maze)
Navigate to main at address 0x100001E0 in the Ghidra decompiler.
Before adding SVD support, the decompiler output looks like this:
void FUN_100001e0(void)
{
byte extraout_DL;
FUN_1000308c();
FUN_1000311c(DAT_10000278);
FUN_1000311c(DAT_1000027c);
FUN_1000311c(DAT_10000280);
FUN_1000311c(DAT_10000284);
FUN_10003218(DAT_10000288);
do {
FUN_10003218(DAT_10000294,extraout_DL);
FUN_10003218(DAT_100002a0,extraout_DL);
FUN_1000311c(DAT_100002a4);
FUN_10003218(DAT_10000288);
FUN_10000cc4(1000);
} while( true );
}
Now navigate to subroutine FUN_100002a8 (gpio_set_function):
void FUN_100002a8(uint param_1, uint param_2)
{
*(uint *)(0x40038000 + param_1 * 4 + 4) =
*(uint *)(0x40038000 + param_1 * 4 + 4) & 0xffffff7f | 0x40;
*(uint *)(0x40028000 + param_1 * 8 + 4) = param_2;
return;
}
Look at that decompilation:
*(uint *)(0x40038000 + param_1 * 4 + 4)*(uint *)(0x40028000 + param_1 * 8 + 4) = param_2
Ghidra doesn't know that 0x40038000 is PADS_BANK0, nor that 0x40028000 is IO_BANK0. In fact, if you double-click 0x40038000, Ghidra warns that the address is unmapped because the .bin import only created a memory block for flash (0\text{x}10000000).
Part 8: Installing and Running SVD-Loader in Ghidra
Step 1: Install SVD-Loader-Ghidra
SVD-Loader-Ghidra is an open-source Ghidra script created by Leveldown Security. It parses CMSIS-SVD files and automatically reconstructs microcontroller memory maps and data structures inside Ghidra.
Open a PowerShell terminal and clone the repository:
cd "$env:USERPROFILE"
git clone https://github.com/leveldown-security/SVD-Loader-Ghidra.git
Important
Ghidra 11/12+ Runtime Compatibility Fix (
#@runtime Jython): Modern Ghidra versions default toPyGhidra(CPython 3) for.pyscripts. If Ghidra was not launched viapyghidraRun, runningSVD-Loader.pywill fail with:Unable to load script: SVD-Loader.py - detail: Ghidra was not started with PyGhidra. Python is not available
SVD-Loaderwas developed for Ghidra's built-in Jython interpreter. To instruct Ghidra to use the built-in Jython engine, ensure#@runtime Jythonis present at the top ofSVD-Loader.py:# Load specified SVD and generate peripheral memory maps & structures. #@runtime Jython #@author Thomas Roth <thomas.roth@leveldown.de>, Ryan Pavlik <ryan.pavlik@gmail.com>(You can add this line using any text editor, or in Ghidra by right-clicking
SVD-Loader.pyin the Script Manager and selecting Edit with basic editor).
Step 2: Add the Script to Ghidra Script Manager
- In Ghidra's CodeBrowser, open the Script Manager:
- Go to menu Window -> Script Manager (or click the Script Manager toolbar icon).
Note
Script Manager Toolbar Icons Explained:
- New Script (White Paper Icon): To create a new script from scratch directly in Ghidra, you click the piece of white paper ("Create New Script") icon on the toolbar. Ghidra then prompts you to choose the script type:
PyGhidra(Python 3 in Ghidra 11+),Java, orJython(Python 2.7).- Manage Script Directories (Folder with List Icon): Because
SVD-Loaderis an existing multi-file package that relies on the bundledcmsis_svdparser library, we do not need to create a blank script. Instead, we register its cloned directory.
- In the top-right toolbar of the Script Manager window, click the Manage Script Directories icon (looks like a small folder with a list).
- In the "Ghidra Script Directories / Bundle Manager" window that appears, click the Display file chooser to add bundles to list icon (the green
+/ folder icon on the top right). - Browse to and select your cloned directory:
C:\Users\<username>\SVD-Loader-Ghidra - Click OK / Select, then close the Script Directories window.
(Alternatively, you can copy both SVD-Loader.py and the cmsis_svd folder directly into your default ~/ghidra_scripts or C:\Users\<username>\ghidra_scripts directory, which Ghidra discovers automatically).
Step 3: Run SVD-Loader
- In the Script Manager search filter box, type:
SVD - Locate
SVD-Loader.pyin the list. - Check the checkbox in the In Tool column next to
SVD-Loader.py. This bindsSVD-Loaderdirectly to your CodeBrowser toolbar and menu for convenient access! - Select
SVD-Loader.pyand click the green Run Script button in the top right (or double-click the script entry). - A file picker dialog opens:
- Navigate to:
C:\Users\<username>\.svd\rp2350.svd - Click Open.
- Navigate to:
+-----------------------------------------------------------------+
| What SVD-Loader Does Automatically in Ghidra |
| |
| 1. Memory Blocks: Creates mapped, volatile memory blocks for |
| UART0, IO_BANK0, PADS_BANK0, SIO, CLOCKS, and RESETS. |
| 2. Symbol Labels: Creates global symbol labels at the exact |
| address of every register (e.g., UART0_UARTFR). |
| 3. C Structs: Generates full peripheral data structures in |
| the Data Type Manager (e.g., struct UART0_Type). |
+-----------------------------------------------------------------+
Check the Ghidra Console window at the bottom of the screen. You will see:
Loaded SVD: rp2350.svd
Created peripheral block: SIO at 0xd0000000 (size: 0x1000)
Created peripheral block: PADS_BANK0 at 0x40038000 (size: 0x1000)
Created peripheral block: IO_BANK0 at 0x40028000 (size: 0x1000)
Created peripheral block: UART0 at 0x40070000 (size: 0x1000)
...
Successfully imported all peripherals!
Step 4: Re-Run Auto-Analysis to Propagate References
When you initially imported CTF-01.bin, Ghidra performed auto-analysis against only the initial Flash block (0\text{x}10000000). Now that SVD-Loader.py has created all 52 on-chip peripheral memory blocks, re-run analysis so Ghidra evaluates references against the newly created regions:
- Click menu Analysis -> Auto Analyze 'CTF-01.bin'... (or press keyboard shortcut
A). - Ensure Reference, Subroutine References, and Constant Reference Analyzer are enabled.
- Click Analyze.
Part 9: Decompiler Transformation: Before and After
Now that the SVD structures are loaded, let's examine subroutine FUN_100002a8 (gpio_set_function) again.
Side-by-Side Decompilation Comparison
+-----------------------------------------------------------------+
| Decompilation of gpio_set_function() |
| |
| BEFORE SVD-Loader: |
| void FUN_100002a8(uint param_1, uint param_2) |
| { |
| *(uint *)(0x40038000 + param_1 * 4 + 4) = |
| *(uint *)(0x40038000 + param_1 * 4 + 4) & 0xffffff7f |
| | 0x40; |
| *(uint *)(0x40028000 + param_1 * 8 + 4) = param_2; |
| return; |
| } |
| |
| AFTER SVD-Loader: |
| void gpio_set_function(uint gpio, uint fn) |
| { |
| PADS_BANK0->GPIO[gpio] = |
| (PADS_BANK0->GPIO[gpio] & ~PADS_BANK0_OD) |
| | PADS_BANK0_IE; |
| IO_BANK0->GPIO[gpio].CTRL = fn; /* 2 = UART0 */ |
| return; |
| } |
+-----------------------------------------------------------------+
Look at the difference:
*(uint *)(0x40038000 + param_1 * 4 + 4)is recognized as indexing intoPADS_BANK0electrical pad controls.& 0xffffff7f | 0x40is clearly revealed as clearing theOD(Output Disable) bit and setting theIE(Input Enable) bit.*(uint *)(0x40028000 + param_1 * 8 + 4) = param_2immediately resolves in Ghidra's decompiler to:Why(&Peripherals::IO_BANK0.GPIO0_CTRL)[param_1 * 2] = param_2;[param_1 * 2]? In RP2350'sIO_BANK0, each GPIO pin has two 32-bit registers (8 bytes total):GPIOx_STATUS(offset+0) andGPIOx_CTRL(offset+4). BecauseGPIO0_CTRLis a pointer to a 4-byteuint32_t, indexing by[param_1 * 2]steps forward by2 \times 4\text{ bytes} = 8\text{ bytes}per pin, landing directly on each pin'sCTRLregister to assignparam_2(2forUART0)!
Note
Understanding Assembly Listing vs. Decompiler Resolution: You may notice that in the raw disassembly Listing view, line
100002bestill appears as:100002be 00 f1 80 40 add.w r0, r0, #0x40000000Why does
#0x40000000not resolve to a peripheral label here?
- Arithmetic Immediates vs. Memory Operands:
add.wis an ALU integer addition, not a load or store instruction. In assembly listings, immediate scalar constants remain literal numbers.- Intermediate Math vs. Target Address:
0\text{x}40000000is the APB/AHB bridge base. The actual peripheral register address (0\text{x}40028004forIO_BANK0_GPIO0_CTRL) is calculated dynamically at runtime by adding the pin index offset (gpio \times 8), bridge base (0\text{x}40000000), peripheral offset (0\text{x}28000), and register offset (+4).- Where Resolution Appears: Ghidra resolves this in the Decompiler window via data-flow analysis, and in the Listing window as XREF annotations on the subsequent
str/ldrinstructions that dereference the calculated pointer. If you want#0x40000000to show a name in the Listing, right-click the number and select Set Equate... (pressE) to label itPERIPHERALS_BASE.
Exploring Structs in the Data Type Manager
In Ghidra's Data Type Manager panel (bottom-left):
- Expand the tree node for
CTF-01.bin. - Expand the
rp2350.svdcategory. - Locate
UART0_Type:- Double-click
UART0_Typeto open Ghidra's Structure Editor. - You can see every field, its byte offset, and its data type:
0x000:UARTDR(uint32_t)0x018:UARTFR(uint32_t)0x024:UARTIBRD(uint32_t)0x028:UARTFBRD(uint32_t)0x02c:UARTLCR_H(uint32_t)0x030:UARTCR(uint32_t)
- Double-click
You can apply these struct types to any pointer in Ghidra by right-clicking a variable in the decompiler and selecting Retype Variable -> UART0_Type *.
Part 10: The Complete Hardware-Aware Reverse Engineering Workflow
By combining SVD in both GDB and Ghidra, you achieve a seamless reverse engineering loop:
+-----------------------------------------------------------------+
| The Hardware-Aware Reverse Engineering Loop |
| |
| +-----------------------------------------------------------+ |
| | 1. GHIDRA (Static Analysis + rp2350.svd) | |
| | - Identifies functions, call graph, and MMIO | |
| | - Pinpoints exact register addresses to watch | |
| +-----------------------------------------------------------+ |
| | |
| v |
| +-----------------------------------------------------------+ |
| | 2. GDB + OpenOCD (Dynamic Analysis + rp2350.svd) | |
| | - Sets hardware breakpoint (hb *0x100001e0) | |
| | - Steps through instructions with si / ni | |
| | - Inspects peripheral bitfields live (svd /r) | |
| +-----------------------------------------------------------+ |
| | |
| v |
| +-----------------------------------------------------------+ |
| | 3. LIVE HARDWARE INTERACTION | |
| | - Validates UART console telemetry (115200 8N1) | |
| | - Proves binary patches on physical silicon | |
| +-----------------------------------------------------------+ |
+-----------------------------------------------------------------+
Applying the Loop to Solve CTF-01
- Locate Defects via Ghidra Static Analysis:
- In
main()(0\text{x}100001E0), identify Compare Site A at0\text{x}100001FC(cmp r3, #94) and Compare Site B at0\text{x}1000020A(cmp r3, #94). - Notice that while
0.87\text{ Hz}is dangerously high, it passes because87 \le 94. - To enforce the
0.60\text{ Hz}safety limit,x < 60is equivalent tox \le 59. The comparison immediate must be patched from0x5E(94) to0x3B(59).
- In
- Locate the Quarantined Flag in Flash:
- Trace the pointer loaded at address
0\text{x}100001E8to address0\text{x}100037A0. - Inspecting that memory reveals the token:
"WORLDGRID:BLACKSTART:GRID-7:WATER-3".
- Trace the pointer loaded at address
- Patch and Export in Ghidra:
- Use the Bytes Window workflow to prevent ARM Thumb IT-block context conflicts:
- Open the Bytes window (Window -> Bytes: CTF-01.bin).
- In the Bytes window toolbar, click the pencil icon (Toggle Edit Mode).
- In the Listing window, click
0x100001FCand pressC(Clear Code Bytes). - In the Bytes window at offset
100001fc, click on byte5Eand change it to3B. - In the Listing window, click back on
0x100001FCand pressD(Disassemble). - Repeat at
0x1000020A: click0x1000020Ain the Listing, pressC, change5Eto3Bin the Bytes window, click back in the Listing, and pressD.
- Export the patched binary as
CTF-01_fixed.binvia File -> Export Program -> Format: Raw Bytes.
- Use the Bytes Window workflow to prevent ARM Thumb IT-block context conflicts:
- Verify on Live Hardware via OpenOCD & GDB:
- Flash the patched binary to the Pico 2.
- Attach your USB-UART adapter to GPIO 0 (TX) and GPIO 1 (RX) at 115200 baud.
- Observe the corrected, truthful telemetry:
GRID STATUS: CRITICAL DISPATCH PATH: HELD LAST FRAME: QUARANTINED RESPONSE>
Part 11: Summary, Cheatsheets & Review
GDB Raw Binary Debugging Cheatsheet
| Task | Command | Description |
|---|---|---|
| Set Architecture | set architecture armv8-m.main |
Configures GDB for ARM Cortex-M33 cores. |
| Connect to OpenOCD | target extended-remote :3333 |
Connects to OpenOCD debug server. |
| Reset & Halt | monitor reset halt |
Sends reset signal and halts CPU at vector table. |
| Read Vector Table | x/4wx 0x10000000 |
Displays Initial Stack Pointer and Reset Vector. |
| Disassemble at PC | x/10i $pc |
Disassembles 10 instructions at current Program Counter. |
| Disassemble Range | disassemble 0x100001e0, 0x10000216 |
Disassembles instructions between two hex addresses. |
| Hardware Breakpoint | hb *0x100001e0 |
Sets hardware breakpoint on read-only flash memory. |
| Read Memory Word | x/wx 0x40070018 |
Reads one 32-bit hexadecimal word from memory. |
| Write Memory Word | set *0xd0000014 = 0x10000 |
Writes 32-bit value directly to memory address. |
PyCortexMDebug SVD Cheatsheet
| Task | Command | Description |
|---|---|---|
| Load Plugin | source ~/PyCortexMDebug/scripts/gdb.py |
Imports PyCortexMDebug into GDB Python engine. |
| Load SVD File | svd_load ~/.svd/rp2350.svd |
Parses chip peripheral XML definition. |
| List Peripherals | svd |
Lists all on-chip hardware peripheral blocks. |
| Dump Peripheral | svd UART0 |
Reads and displays all registers in a peripheral. |
| Decode Bitfields | svd /r UART0 UARTFR |
Decodes individual bitfields and named flags. |
| Write Register | svd /w SIO GPIO_OUT_SET 0x10000 |
Writes to peripheral register by symbolic name. |
RP2350 Peripheral Memory Map Quick Reference
| Peripheral | Base Address | Size | Primary Purpose |
|---|---|---|---|
XIP_FLASH |
0x10000000 |
Up to 16MB | External QSPI Flash execution memory |
SRAM |
0x20000000 |
512KB | On-chip data memory (stack, heap, .data, .bss) |
RESETS |
0x40020000 |
4KB | Subsystem reset controller |
IO_BANK0 |
0x40028000 |
4KB | GPIO pin function multiplexing (FUNCSEL) |
PADS_BANK0 |
0x40038000 |
4KB | Electrical drive strength, pulls, and enables |
UART0 |
0x40070000 |
4KB | Serial communication interface 0 (115200 8N1) |
UART1 |
0x40078000 |
4KB | Serial communication interface 1 |
SIO |
0xd0000000 |
4KB | Single-cycle I/O fast GPIO controls |
Key Takeaways
- A raw
.binis not an ELF: It has no headers, no symbols, and no entry point metadata. You must supply the base address (0\text{x}10000000) when flashing and debug using explicit addresses. - Flash requires hardware breakpoints: External flash is read-only during execution. Always use
hb *addressinstead of software breakpoints (b). - The Vector Table tells all: Even without symbols,
x/4wx 0x10000000gives you the initial stack pointer and the reset vector within seconds. - SVD bridges the hardware gap: CMSIS-SVD turns raw hexadecimal registers into named peripherals, registers, and bitfields across both GDB and Ghidra.
- GDB handles live dynamic manipulation:
PyCortexMDebuglets you inspect peripheral registers live, decode bitfields withsvd /r, and toggle pins directly over SWD usingsvd /w. - Ghidra handles static comprehension:
SVD-Loader-Ghidracreates mapped memory blocks and C structs, converting raw pointer arithmetic into readable code. - Arithmetic instructions remain arithmetic: Instructions like
add.w r0, r0, #0x40000000calculate base addresses across multiple steps; resolution appears in the decompiler and at the load/store instructions that dereference the address.
Glossary
| Term | Definition |
|---|---|
| CMSIS-SVD | Cortex Microcontroller Software Interface Standard - System View Description; an XML format describing microcontroller hardware peripherals. |
| DWARF | Standardized debugging data format embedded in ELF binaries that maps machine code to source code lines and symbols. |
Hardware Breakpoint (hb) |
A breakpoint implemented using dedicated CPU comparator registers, required for debugging code in read-only flash memory. |
| MMIO | Memory-Mapped Input/Output; a hardware architecture where peripheral registers are mapped into the CPU's regular memory address space. |
| MSP | Main Stack Pointer; the primary ARM Cortex-M stack pointer register. |
| OpenOCD | Open On-Chip Debugger; a software bridge that connects GDB to physical hardware via a Debug Probe. |
| PyCortexMDebug | A GDB Python extension that parses SVD files to provide live peripheral inspection and register manipulation. |
| Reset Vector | The address stored at offset 0\text{x}00000004 in the vector table that points to the first instruction executed upon CPU reset. |
| SIO | Single-Cycle I/O; a dedicated RP2350 hardware block providing zero-wait-state GPIO manipulation. |
| SVD-Loader | A Ghidra script that imports SVD files to create memory blocks, symbol labels, and C structs for decompilation. |
| SWD | Serial Wire Debug; a two-wire physical debug protocol (SWCLK, SWDIO) used to debug ARM microcontrollers. |
| UART | Universal Asynchronous Receiver-Transmitter; a physical hardware communication protocol used for serial data transfer. |
| XIP | eXecute In Place; running code directly from external flash memory without copying it to RAM first. |
Happy Hacking!