56 KiB
Embedded Systems Reverse Engineering
Week 2
Hello, World - Debugging and Hacking Basics: Debugging and Hacking a Basic Program for the Pico 2
🎯 What You'll Learn This Week
By the end of this tutorial, you will be able to:
- Connect to a live embedded system using OpenOCD and GDB
- Step through code instruction by instruction and watch the stack change
- Examine memory, registers, and decode little-endian values
- Set strategic breakpoints to pause execution at key moments
- Understand why direct string assignment fails in bare-metal systems
- Write custom data directly to SRAM memory
- Hijack register values to redirect program behavior
- Modify a running program's output in real-time
🔄 Review from Week 1
This week builds directly on Week 1 concepts. You should already be comfortable with:
- Registers (
r0-r12, SP, LR, PC) - We'll watch them change and manipulater0to change program behavior - Memory Layout (Flash at
0x10000000, RAM at0x20000000) - Critical for understanding where we can write - The Stack and how
push/popwork - We'll watch this in action - Little-Endian byte ordering - We'll decode values live
- GDB basics from Week 1's dynamic analysis section
- Ghidra basics from Week 1's static analysis section
📚 Part 1: Understanding Live Hacking
What is Live Hacking?
Live hacking means modifying a program while it's running on real hardware. Instead of changing the source code and recompiling, we intercept the program mid-execution and change what it does on the fly.
Think of it like this: imagine a train is heading to New York City. Live hacking is like switching the tracks while the train is moving so it goes to Los Angeles instead!
Why is This Important?
Live hacking techniques are used for:
- Security Research: Finding vulnerabilities in embedded systems
- Penetration Testing: Testing if systems can be compromised
- Malware Analysis: Understanding how malicious code works
- Debugging: Fixing bugs in systems that can't be easily reprogrammed
Real-World Application
"With great power comes great responsibility!"
Imagine you're a security researcher testing an industrial control system at a power plant. You need to verify that an attacker couldn't:
- Change the values being displayed to engineers
- Make dangerous equipment appear safe
- Hide malicious activity from monitoring systems
The techniques you'll learn today are exactly how this would be done. Understanding these attacks helps us build better defenses!
📚 Part 2: Review - Memory Layout (from Week 1)
🔄 REVIEW: In Week 1, we learned about the RP2350's memory layout. This knowledge is essential for our hack!
Before we hack, let's remember where things live in memory on the RP2350:
The Code We're Hacking
Remember our 0x0001_hello-world.c program from Week 1:
#include <stdio.h>
#include "pico/stdlib.h"
int main(void) {
stdio_init_all();
while (true)
printf("hello, world\r\n");
}
This simple program:
- Initializes I/O with
stdio_init_all() - Enters an infinite
while(true)loop - Prints
"hello, world\r\n"forever
Our goal: Make it print something else WITHOUT changing the source code!
Memory Map
┌─────────────────────────────────────────────────────┐
│ Flash Memory (XIP) - READ ONLY │
│ Starts at: 0x10000000 │
│ Contains: Program code, constant strings │
│ NOTE: We CANNOT write to flash during runtime! │
├─────────────────────────────────────────────────────┤
│ SRAM - READ/WRITE │
│ Starts at: 0x20000000 │
│ Contains: Stack, Heap, Variables │
│ NOTE: We CAN write to SRAM during runtime! │
└─────────────────────────────────────────────────────┘
🔄 REVIEW: In Week 1, we saw the stack pointer (SP) was around
0x20081fc8- that's in the SRAM region! The stack "grows downward" from the top of SRAM.
Why This Matters for Our Hack
The string "hello, world" is stored in flash memory (around 0x100019cc). Flash memory is read-only during normal operation - we can't just overwrite it.
But SRAM (starting at 0x20000000) is read-write! This is where we'll create our hacked string.
📚 Part 3: The Attack Plan
Here's our step-by-step attack strategy:
┌─────────────────────────────────────────────────────┐
│ STEP 1: Start the debug server (OpenOCD) │
├─────────────────────────────────────────────────────┤
│ STEP 2: Connect with GDB and halt the program │
├─────────────────────────────────────────────────────┤
│ STEP 3: Set a breakpoint right before puts() │
├─────────────────────────────────────────────────────┤
│ STEP 4: When we hit the breakpoint, r0 contains │
│ the address of "hello, world" │
├─────────────────────────────────────────────────────┤
│ STEP 5: Create our malicious string in SRAM │
├─────────────────────────────────────────────────────┤
│ STEP 6: Change r0 to point to OUR string │
├─────────────────────────────────────────────────────┤
│ STEP 7: Continue execution - HACKED! │
└─────────────────────────────────────────────────────┘
📚 Part 4: Setting Up Your Environment
Prerequisites
Before we start, make sure you have:
- A Raspberry Pi Pico 2 board with debug probe connected
- OpenOCD installed and configured
- GDB (arm-none-eabi-gdb) installed
- A serial monitor application (like PuTTY, minicom, or screen)
- The "hello-world" binary loaded on your Pico 2
What You'll Need Open
You will need THREE terminal windows:
- Terminal 1: Running OpenOCD (the debug server)
- Terminal 2: Running your serial monitor (to see output)
- Terminal 3: Running GDB (where we do the hacking)
🔬 Part 5: GDB Deep Dive - Exploring the Binary
Before we start hacking, let's use GDB to thoroughly understand our program. This hands-on tutorial will teach you to examine memory, step through code, and watch the stack in action.
Starting the Debug Environment
Step 0a: Start OpenOCD (Terminal 1)
OpenOCD is the bridge between your computer and the Pico 2's debug interface. It creates a server that GDB can connect to.
Open Terminal 1 and type:
openocd ^
-s "C:\Users\flare-vm\.pico-sdk\openocd\0.12.0+dev\scripts" ^
-f interface/cmsis-dap.cfg ^
-f target/rp2350.cfg ^
-c "adapter speed 5000"
What this command means:
openocd= the OpenOCD program-s ...= path to OpenOCD scripts folder-f interface/cmsis-dap.cfg= use the CMSIS-DAP debug probe configuration-f target/rp2350.cfg= configure for the RP2350 chip-c "adapter speed 5000"= set the debug speed to 5000 kHz
You should see output like:
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
Info : Listening on port 3333 for gdb connections
Info : CMSIS-DAP: SWD supported
Info : CMSIS-DAP: Interface ready
Important: Leave this terminal running! Don't close it.
Step 0b: Start Your Serial Monitor - PuTTY (Terminal 2)
PuTTY will show us the output from our Pico 2. When we hack the program, we'll see the results here!
To set up PuTTY:
- Open PuTTY
- In the Session category:
- Connection type: Select Serial
- Serial line: Enter your COM port (e.g.,
COM3- check Device Manager to find yours) - Speed: Enter
115200
- Click Open
💡 Finding your COM port: Open Device Manager → Ports (COM & LPT) → Look for "USB Serial Device" or "Pico" - note the COM number.
You should see:
hello, world
hello, world
hello, world
hello, world
...
The program is running and printing "hello, world" in an infinite loop!
Important: Leave PuTTY running! We'll watch it change when we hack the system.
Step 0c: Start GDB (Terminal 3)
Open Terminal 3 and start GDB with your binary:
arm-none-eabi-gdb build/0x0001_hello-world.elf
What this command means:
arm-none-eabi-gdb= the ARM version of GDBbuild/0x0001_hello-world.elf= our compiled program with debug symbols
You should see:
GNU gdb (Arm GNU Toolchain 13.2) 13.2
Reading symbols from build/0x0001_hello-world.elf...
(gdb)
The (gdb) prompt means GDB is ready for commands!
Step 0d: Connect GDB to OpenOCD
Now we need to connect GDB to OpenOCD. OpenOCD is listening on port 3333.
Type this command:
(gdb) target extended-remote :3333
You should see:
Remote debugging using :3333
main () at 0x0001_hello-world/0x0001_hello-world.c:5.
5 stdio_init_all();
We're connected! GDB shows us the program is currently in the main function.
Step 0e: Halt the Running Program
The program is still running (you can see "hello, world" still printing in PuTTY). Let's stop it:
Type this command:
(gdb) monitor reset halt
What this command means:
monitor= send a command to OpenOCD (not GDB)reset= reset the processorhalt= stop execution immediately
You should see:
[rp2350.cm0] halted due to debug-request, current mode: Thread
xPSR: 0xf9000000 pc: 0x00000088 msp: 0xf0000000
[rp2350.cm1] halted due to debug-request, current mode: Thread
xPSR: 0xf9000000 pc: 0x00000088 msp: 0xf0000000
Check PuTTY: The "hello, world" messages should have stopped! The processor is now frozen, waiting for our commands.
Exploring the Binary
Now that we're connected and the processor is halted, let's explore!
Step 1: Examine Memory Starting at XIP Base Address
Our program code starts at address 0x10000000. Let's look at the first 1000 instructions to find our main function.
Type this command in GDB:
(gdb) x/1000i 0x10000000
What this command means:
x= examine memory/1000i= show 1000 instructions0x10000000= starting address
What you're looking for:
Scroll through the output and look for something like this:
0x10000234 <main>: push {r3, lr}
The <main> label tells us we found our main function! The address 0x10000234 is where main starts.
Step 2: Examine the Main Function in Detail
Now let's look at just the main function. We'll examine 5 instructions starting at the main function address:
Type this command:
(gdb) x/5i 0x10000234
You should see:
(gdb) x/5i 0x10000234
=> 0x10000234 <main>: push {r3, lr}
0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
Let's understand each instruction:
| Address | Instruction | What It Does |
|---|---|---|
0x10000234 |
push {r3, lr} |
Save r3 and the return address onto the stack |
0x10000236 |
bl 0x1000156c <stdio_init_all> |
Call the stdio_init_all function |
0x1000023a |
ldr r0, [pc, #8] |
Load the address of our string into r0 |
0x1000023c |
bl 0x100015fc <__wrap_puts> |
Call puts to print our string |
0x10000240 |
b.n 0x1000023a |
Jump back to the ldr instruction (infinite loop!) |
Step 3: Set a Breakpoint at Main
A breakpoint is like a stop sign for your program. When the processor reaches that address, it will pause and let us examine things.
Type this command:
(gdb) b *0x10000234
You should see:
Breakpoint 1 at 0x10000234: file C:/Users/.../0x0001_hello-world.c, line 5.
Note: automatically using hardware breakpoints for read-only addresses.
What this means:
b= set breakpoint*0x10000234= at this exact memory address- GDB confirms the breakpoint is set and even tells us which line of C code this corresponds to!
Step 4: Continue Execution Until Breakpoint
Now let's run the program until it hits our breakpoint:
Type this command:
(gdb) c
You should see:
Continuing.
Thread 1 "rp2350.cm0" hit Breakpoint 1, main ()
at C:/Users/.../0x0001_hello-world.c:5
5 stdio_init_all();
What happened:
- The processor ran until it reached address
0x10000234 - It stopped right before executing the instruction at that address
- GDB shows us we're at line 5 of our C source code
Step 5: Examine Instructions with Arrow
Let's look at our instructions again:
Type this command:
(gdb) x/5i 0x10000234
You should see:
(gdb) x/5i 0x10000234
=> 0x10000234 <main>: push {r3, lr}
0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
Notice the arrow =>! This arrow shows which instruction we're about to execute. We haven't executed it yet - we're paused right before it.
Understanding the Stack in Action
Step 6: Examine the Stack Before Push
Before we execute the push instruction, let's see what's on the stack:
Type this command:
(gdb) x/10x $sp
What this command means:
x= examine memory/10x= show 10 values in hexadecimal$sp= starting at the stack pointer address
You should see:
0x20082000: 0x00000000 0x00000000 0x00000000 0x00000000
0x20082010: 0x00000000 0x00000000 0x00000000 0x00000000
0x20082020: 0x00000000 0x00000000
What this shows:
- The stack pointer is at address
0x20082000 - The stack is empty (all zeros)
- This is the "top" of our stack in RAM
Step 7: Execute One Instruction (Step Into)
Now let's execute just ONE assembly instruction:
Type this command:
(gdb) si
What this command means:
si= step instruction (execute one assembly instruction)
You should see:
0x10000236 5 stdio_init_all();
Let's verify where we are:
Type this command:
(gdb) x/5i 0x10000234
You should see:
(gdb) x/5i 0x10000234
0x10000234 <main>: push {r3, lr}
=> 0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
Notice: The arrow => has moved! We've executed the push instruction and are now about to execute the bl (branch with link) instruction.
Step 8: Examine the Stack After Push
Now let's see what the push instruction did to our stack:
Type this command:
(gdb) x/10x $sp
You should see:
0x20081ff8: 0xe000ed08 0x1000018f 0x00000000 0x00000000
0x20082008: 0x00000000 0x00000000 0x00000000 0x00000000
0x20082018: 0x00000000 0x00000000
What changed:
- The stack pointer moved from
0x20082000to0x20081ff8 - That's 8 bytes lower (2 × 4-byte values)
- Two new values appeared:
0xe000ed08and0x1000018f
Step 9: Verify What Was Pushed
Let's prove that these values came from r3 and lr:
Check r3:
(gdb) x/x $r3
You should see:
0xe000ed08: Cannot access memory at address 0xe000ed08
This error is expected! The value 0xe000ed08 is in r3, and when we try to examine it as an address, that memory location isn't accessible. But we can see the value matches what's on the stack!
Check lr (Link Register):
(gdb) x/x $lr
You should see:
0x1000018f <platform_entry+8>: 0x00478849
The value 0x1000018f is in lr - this is the return address! This matches the second value on our stack.
Step 10: Verify Stack Layout
Let's look at each pushed value individually:
First pushed value (r3):
(gdb) x/x $sp
You should see:
0x20081ff8: 0xe000ed08
This is the value from r3, pushed first.
Second pushed value (lr):
(gdb) x/x $sp+4
You should see:
0x20081ffc: 0x1000018f
This is the value from lr (the return address), pushed second.
Understanding the Stack Diagram
Before push {r3, lr}: After push {r3, lr}:
Address Value Address Value
───────────────────── ─────────────────────
0x20082000 (empty) ← SP 0x20082000 (old SP location)
0x20081ffc 0x1000018f (lr)
0x20081ff8 0xe000ed08 (r3) ← SP
Key Points:
- The stack grows DOWNWARD (addresses get smaller)
- The SP always points to the last item pushed
r3was pushed first, thenlrwas pushed on top of it
Continuing Through the Program
Step 11: Step Over the stdio_init_all Function
We don't need to examine every instruction inside stdio_init_all - it's just setup code. Let's "step over" it:
First, verify where we are:
(gdb) x/5i 0x10000234
You should see:
(gdb) x/5i 0x10000234
0x10000234 <main>: push {r3, lr}
=> 0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
Now step over the function call:
(gdb) n
What this command means:
n= next (step over function calls, don't go inside them)
You should see:
8 printf("hello, world\r\n");
Verify where we are now:
(gdb) x/5i 0x10000234
You should see:
(gdb) x/5i 0x10000234
0x10000234 <main>: push {r3, lr}
0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
=> 0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
The arrow has moved past the function call!
Understanding the LDR Instruction
We're now at:
ldr r0, [pc, #8] @ (0x10000244 <main+16>)
What does this instruction do?
- Take the current Program Counter (PC) value
- Add 8 to it
- Go to that memory address (
0x10000244) - Load the value stored there into
r0
This is loading a pointer - the address of our "hello, world" string!
Step 13: Execute the LDR and Examine r0
Execute one instruction:
(gdb) si
You should see:
0x1000023c 8 printf("hello, world\r\n");
Now examine what's in r0:
(gdb) x/x $r0
You should see:
0x100019cc: 0x6c6c6568
Step 14: Decoding the Mystery Value
The value 0x6c6c6568 looks strange, but it's actually ASCII characters! Let's decode it:
ASCII Table Reference:
| Hex | Character |
|---|---|
0x68 |
h |
0x65 |
e |
0x6c |
l |
0x6c |
l |
So 0x6c6c6568 = "lleh" backwards!
Why is it backwards?
This is called little-endian byte order. The RP2350 stores bytes in reverse order in memory. When we read them as a 32-bit value, they appear reversed.
Step 15: View the Full String
Let's tell GDB to show this as a string instead of a hex number:
(gdb) x/s $r0
What this command means:
x= examine memory/s= show as a string$r0= at the address stored inr0
You should see:
0x100019cc: "hello, world\r"
There's our string! The \r is a carriage return character (part of \r\n).
🎯 Key Discovery: The string
"hello, world"is stored at address0x100019ccin flash memory. This is the value that gets loaded intor0before callingputs(). We'll use this knowledge in our hack!
🔬 Part 6: Starting the Debug Session for the Hack
Step 1: Start OpenOCD (Debug Server)
OpenOCD is the bridge between your computer and the Pico 2's debug interface. It creates a server that GDB can connect to.
Open Terminal 1 and type:
openocd ^
-s "C:\Users\flare-vm\.pico-sdk\openocd\0.12.0+dev\scripts" ^
-f interface/cmsis-dap.cfg ^
-f target/rp2350.cfg ^
-c "adapter speed 5000"
What this command means:
openocd= the OpenOCD program-f interface/cmsis-dap.cfg= use the CMSIS-DAP debug probe configuration-f target/rp2350.cfg= configure for the RP2350 chip-c "adapter speed 5000"= set the debug speed to 5000 kHz
You should see output like:
Open On-Chip Debugger 0.12.0
Licensed under GNU GPL v2
Info : Listening on port 3333 for gdb connections
Info : CMSIS-DAP: SWD supported
Info : CMSIS-DAP: Interface ready
Important: Leave this terminal running! Don't close it.
Step 2: Start Your Serial Monitor
Open PuTTY and start your serial monitor to watch the Pico 2's output.
Example using screen (macOS/Linux):
screen /dev/tty.usbmodem* 115200
Example using minicom:
minicom -D /dev/ttyACM0 -b 115200
You should see:
hello, world
hello, world
hello, world
hello, world
...
The program is running and printing "hello, world" in an infinite loop!
Important: Leave this terminal running! We'll watch it change when we hack the system.
Step 3: Start GDB and Load the Binary
Open Terminal 3 and start GDB with your binary:
arm-none-eabi-gdb build/0x0001_hello-world.elf
What this command means:
arm-none-eabi-gdb= the ARM version of GDBbuild/0x0001_hello-world.elf= our compiled program with debug symbols
You should see:
GNU gdb (Arm GNU Toolchain 13.2) 13.2
Reading symbols from build/0x0001_hello-world.elf...
(gdb)
The (gdb) prompt means GDB is ready for commands!
Step 4: Connect to the Remote Debug Server
Now we need to connect GDB to OpenOCD. OpenOCD is listening on port 3333.
Type this command:
(gdb) target extended-remote :3333
What this command means:
target remote= connect to a remote debug server:3333= on localhost, port 3333 (where OpenOCD is listening)
You should see:
Remote debugging using :3333
warning: multi-threaded target stopped without sending a thread-id, using first non-exited thread
0x1000023c in main () at 0x0001_hello-world.c:8
8 printf("hello, world\r\n");
We're connected! GDB shows us the program is currently in the main function.
Step 5: Halt the Running Program
The program is still running (you can see "hello, world" still printing in your serial monitor). Let's stop it:
Type this command:
(gdb) monitor reset halt
What this command means:
monitor= send a command to OpenOCD (not GDB)reset= reset the processorhalt= stop execution immediately
You should see:
(gdb) monitor reset halt
[rp2350.cm0] halted due to debug-request, current mode: Thread
xPSR: 0xf9000000 pc: 0x00000088 msp: 0xf0000000
[rp2350.cm1] halted due to debug-request, current mode: Thread
xPSR: 0xf9000000 pc: 0x00000088 msp: 0xf0000000
Check your serial monitor (Terminal 2): The "hello, world" messages should have stopped! The processor is now frozen, waiting for our commands.
🔬 Part 7: Analyzing the Target
🔄 REVIEW: We're using the same GDB commands we learned earlier. The
xcommand examines memory, and/5ishows 5 instructions.
Step 6: Examine the Main Function
Let's look at the main function to understand what we're dealing with:
Type this command:
(gdb) x/5i 0x10000234
What this command means:
x= examine memory (Week 1 review!)/5i= show 5 instructions0x10000234= the address of main (we found this in Week 1!)
You should see:
(gdb) x/5i 0x10000234
0x10000234 <main>: push {r3, lr}
0x10000236 <main+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <main+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
0x1000023c <main+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <main+12>: b.n 0x1000023a <main+6>
🔄 REVIEW: This is the same disassembly we analyzed in Week 1! Remember:
push {r3, lr}saves registers to the stackblis "branch with link" - it calls a function and saves the return address in LRb.nis the infinite loop that jumps back to theldrinstruction
Understanding the Code Flow
🔄 REVIEW: In Week 1, we learned that
r0-r3are used to pass arguments to functions. The first argument always goes inr0!
Let's break down what happens each time through the loop:
| Address | Instruction | What Happens |
|---|---|---|
0x1000023a |
ldr r0, [pc, #8] |
Load the address of "hello, world" into r0 |
0x1000023c |
bl __wrap_puts |
Call puts() - it reads the string address from r0! |
0x10000240 |
b.n 0x1000023a |
Jump back to the ldr instruction (loop forever) |
How This Maps to Our C Code
while (true)
printf("hello, world\r\n"); // The compiler optimized this to puts()
The compiler:
- Loads the string address into
r0(first argument) - Calls
puts()(optimized fromprintf()since we're just printing a string) - Loops back forever with
b.n
The Key Insight: Right before the bl __wrap_puts instruction (at 0x1000023c), the register r0 contains the address of the string to print!
If we can change what r0 points to, we can make it print anything we want!
🔬 Part 8: Setting the Trap
🔄 REVIEW: In Week 1, we used
b mainandb *0x10000234to set breakpoints. Now we'll use the same technique at a more strategic location!
Step 7: Set a Strategic Breakpoint
We want to stop the program RIGHT BEFORE it calls puts(). That's at address 0x1000023c.
Type this command:
(gdb) b *0x1000023c
What this command means:
b= set a breakpoint (same as Week 1!)*0x1000023c= at this exact memory address (the asterisk means "address")
You should see:
Breakpoint 1 at 0x1000023c: file 0x0001_hello-world.c, line 8.
Note: automatically using hardware breakpoints for read-only addresses.
What does "hardware breakpoints" mean?
Because our code is in flash memory (read-only), GDB can't insert a software breakpoint by modifying the code. Instead, it uses a special feature of the ARM processor called a hardware breakpoint. The processor has a limited number of these (usually 4-8), but they work on any memory type.
Step 8: Continue Execution and Hit the Breakpoint
Now let's run the program until it hits our breakpoint:
Type this command:
(gdb) c
What this command means:
c= continue (run until something stops us)
You should see:
Continuing.
Thread 1 "rp2350.cm0" hit Breakpoint 1, 0x1000023c in main ()
at 0x0001_hello-world.c:8
8 printf("hello, world\r\n");
The program has stopped RIGHT BEFORE calling puts()! The string address is loaded into r0, but the function hasn't been called yet.
Step 9: Verify Our Position with Disassembly
Let's double-check where we are using the disas command:
Type this command:
(gdb) disas
What this command means:
disas= disassemble the current function
You should see:
(gdb) disas
Dump of assembler code for function main:
0x10000234 <+0>: push {r3, lr}
0x10000236 <+2>: bl 0x1000156c <stdio_init_all>
0x1000023a <+6>: ldr r0, [pc, #8] @ (0x10000244 <main+16>)
=> 0x1000023c <+8>: bl 0x100015fc <__wrap_puts>
0x10000240 <+12>: b.n 0x1000023a <main+6>
0x10000242 <+14>: nop
0x10000244 <+16>: adds r4, r1, r7
0x10000246 <+18>: asrs r0, r0, #32
Notice the arrow => pointing to 0x1000023c! This confirms we're about to execute the bl __wrap_puts instruction. Perfect!
🔬 Part 9: Examining the Current State
🔄 REVIEW: In Week 1, we used
x/s $r0to view the "hello, world" string. We also learned about little-endian byte ordering - remember how0x6c6c6568spelled "lleh" backwards?
Step 10: Examine What's in r0
Let's see what string r0 is currently pointing to:
Type this command:
(gdb) x/s $r0
What this command means:
x= examine memory (Week 1 review!)/s= display as a string$r0= the address stored in registerr0
You should see:
0x100019cc: "hello, world\r"
There it is! The register r0 contains 0x100019cc, which is the address of our "hello, world" string in flash memory.
🔄 REVIEW: This is the same address
0x100019ccwe discovered in Week 1, Step 15 when we usedx/s $r0after executing theldrinstruction!
🔬 Part 10: The Failed Hack Attempt (Learning Why)
Step 11: Try to Directly Change the String (This Will Fail!)
Your first instinct might be to just assign a new string to r0. Let's try it and see what happens:
Type this command:
(gdb) set $r0 = "hacky, world\r"
You should see an error:
evaluation of this expression requires the program to have a function "malloc".
Oh no! It didn't work!
Why Did This Fail?
This is a very important lesson! Here's what happened:
-
When you type
"hacky, world\r"in GDB, GDB interprets this as: "Create a new string and give me its address" -
To create a new string at runtime, GDB would need to allocate memory using
malloc() -
But our embedded system has no operating system and no C runtime library loaded! There's no
malloc()function available. -
GDB can't create the string because there's nowhere to put it!
Let's verify nothing changed:
(gdb) x/s $r0
You should see:
0x100019cc: "hello, world\r"
The original string is still there. Our hack attempt failed... but we're not giving up!
🔬 Part 11: The Real Hack - Writing to SRAM
Step 12: Understanding the Solution
Since we can't use malloc(), we need to manually create our string somewhere in memory. Remember our memory map?
- Flash (
0x10000000): Read-only - can't write here - SRAM (
0x20000000): Read-write - we CAN write here!
🔄 REVIEW: In Week 1, we saw the stack pointer was at
0x20081fc8. The stack lives at the TOP of SRAM and grows downward. We'll write our string at the BOTTOM of SRAM (0x20000000) to avoid conflicts!
We'll write our malicious string directly to SRAM, then point r0 to it.
Step 13: Create Our Malicious String in SRAM
We need to write 14 bytes (13 characters + null terminator) to SRAM:
| Character | ASCII Hex |
|---|---|
| h | - |
| a | - |
| c | - |
| k | - |
| y | - |
| , | - |
| (space) | - |
| w | - |
| o | - |
| r | - |
| l | - |
| d | - |
| \r | - |
| \0 | - |
Type this command:
(gdb) set {char[14]} 0x20000000 = {'h','a','c','k','y',',',' ','w','o','r','l','d','\r','\0'}
What this command means:
set= modify memory{char[14]}= treat the target as an array of 14 characters0x20000000= the address where we're writing (start of SRAM)= {...}= the characters to write
No output means success!
Step 14: Verify Our String Was Written
Let's confirm our malicious string is in SRAM:
Type this command:
(gdb) x/s 0x20000000
You should see:
0x20000000 <ram_vector_table>: "hacky, world\r"
OUR STRING IS IN MEMORY!
GDB shows it's at the ram_vector_table location - that's just a label from the linker script. The important thing is our string is there and ready to use.
🔬 Part 12: Hijacking the Register
🔄 REVIEW: In Week 1, we learned that
r0holds the first argument to a function. Whenputs()is called, it expectsr0to contain a pointer to the string it should print. By changingr0, we change what gets printed!
Step 15: Change r0 to Point to Our String
Now for the magic moment! We'll change r0 from pointing to the original string to pointing to OUR string:
Type this command:
(gdb) set $r0 = 0x20000000
What this command means:
set= modify a value$r0= ther0register= 0x20000000= change it to this address (where our string is)
No output means success!
Step 16: Verify the Register Was Changed
Let's confirm r0 now points to our malicious string:
First, check the raw value:
(gdb) x/x $r0
You should see:
0x20000000 <ram_vector_table>: 0x68
The value 0x68 is the ASCII code for 'h' - the first character of "hacky"!
Now check it as a string:
(gdb) x/s $r0
You should see:
0x20000000 <ram_vector_table>: "hacky, world\r"
THE HIJACK IS COMPLETE! When puts() runs, it will read the string address from r0 - which now points to our malicious string!
🔬 Part 13: Executing the Hack
Step 17: Continue Execution
This is the moment of truth! Let's continue the program and watch our hack take effect:
Type this command:
(gdb) c
You should see:
Continuing.
Thread 1 "rp2350.cm0" hit Breakpoint 1, 0x1000023c in main ()
at 0x0001_hello-world.c:8
8 printf("hello, world\r\n");
The program ran through one loop iteration and hit our breakpoint again.
Step 18: Check Your Serial Monitor!
Look at Terminal 2 (your serial monitor)!
You should see:
hello, world
hello, world
hello, world
hacky, world <-- OUR HACK!
🎉 BOOM! WE DID IT! 🎉
You just modified a running program on real hardware! The processor executed code that was supposed to print "hello, world" but instead printed "hacky, world" because we hijacked the data it was using!
🔬 Part 14: Static Analysis with Ghidra - Understanding the Hack
Now that we've performed the hack dynamically with GDB, let's use Ghidra to understand the same concepts through static analysis. This shows how you could plan such an attack without even connecting to the hardware!
Opening the Project in Ghidra
If you haven't already set up the Ghidra project from Week 1:
- Launch Ghidra
- Select File → New Project → Non-Shared Project
- Name it
0x0001_hello-world - Drag and drop
0x0001_hello-world.elfinto the project - Double-click to open in CodeBrowser
- Click Yes to auto-analyze
Step 1: Navigate to Main
- In the Symbol Tree panel (left side), expand Functions
- Find and click on
main
What you'll see in the Listing View:
*************************************************************
* FUNCTION
*************************************************************
int main (void )
assume LRset = 0x0
assume TMode = 0x1
int r0:4 <RETURN>
main XREF[3]: Entry Point (*) ,
_reset_handler:1000018c (c) ,
.debug_frame::00000018 (*)
0x0001_hello-world.c:4 (2)
0x0001_hello-world.c:5 (2)
10000234 08 b5 push {r3,lr}
0x0001_hello-world.c:5 (4)
10000236 01 f0 99 f9 bl stdio_init_all _Bool stdio_init_all(void)
LAB_1000023a XREF[1]: 10000240 (j)
0x0001_hello-world.c:7 (6)
0x0001_hello-world.c:8 (6)
1000023a 02 48 ldr r0=>__EH_FRAME_BEGIN__ ,[DAT_10000244 ] = "hello, world\r"
= 100019CCh
1000023c 01 f0 de f9 bl __wrap_puts int __wrap_puts(char * s)
0x0001_hello-world.c:7 (8)
10000240 fb e7 b LAB_1000023a
10000242 00 ?? 00h
10000243 bf ?? BFh
DAT_10000244 XREF[1]: main:1000023a (R)
10000244 cc 19 00 10 undefine 100019CCh ? -> 100019cc
What you'll see in the Decompile View:
int main(void) {
stdio_init_all();
do {
__wrap_puts("hello, world");
} while (true);
}
Step 2: Identify the Attack Point
In our GDB hack, we set a breakpoint at 0x1000023c - right before bl __wrap_puts. Let's understand why this was the perfect attack point:
Click on address 0x1000023c in the Listing view.
Notice:
- The instruction is
bl __wrap_puts- a function call - The previous instruction at
0x1000023aloadedr0with the string address - Ghidra shows
= "hello, world\r"right in the listing!
🎯 Key Insight: Ghidra already tells us the string value! In the Listing, you can see
= "hello, world\r"and= 100019CCh. This is the exact address we discovered through GDB!
Step 3: Find the String in Memory
Let's trace where the string actually lives:
-
In the Listing view, look at address
0x1000023a:LAB_1000023a XREF[1]: 10000240 (j) 0x0001_hello-world.c:7 (6) 0x0001_hello-world.c:8 (6) 1000023a 02 48 ldr r0=>__EH_FRAME_BEGIN__ ,[DAT_10000244 ] = "hello, world\r" = 100019CCh -
Double-click on
DAT_10000244to go to the data reference -
You'll see:
DAT_10000244 XREF[1]: main:1000023a (R) 10000244 cc 19 00 10 undefine 100019CCh ? -> 100019cc -
Double-click on
100019CChto navigate to the actual string
You'll arrive at the string data:
//
// .rodata
// SHT_PROGBITS [0x100019cc - 0x10001b17]
// ram:100019cc-ram:10001b17
//
__init_array_end XREF[5]: Entry Point (*) ,
__boot2_start__ frame_dummy:10000218 (*) ,
__boot2_end__ main:1000023a (*) ,
__EH_FRAME_BEGIN__ runtime_init:1000138a (R) ,
_elfSectionHeaders::0000005c (*)
100019cc 68 65 6c ds "hello, world\r"
6c 6f 2c
20 77 6f
Step 4: Understand Why We Needed SRAM
Look at the string address: 0x100019cc
This starts with 0x10... which means it's in Flash memory (XIP region)!
| Address Range | Memory Type | Writable? |
|---|---|---|
0x10000000+ |
Flash (XIP) | NO - Read Only |
0x20000000+ |
SRAM | YES - Read/Write |
🎯 This is why our direct string modification failed in GDB! The string lives in flash memory, which is read-only at runtime. We had to create our malicious string in SRAM (
0x20000000) instead.
Step 5: Examine Cross-References
Ghidra's cross-reference feature shows everywhere a value is used:
- Navigate back to
main(press G, typemain, press Enter) - Click on
__wrap_putsat address0x1000023c - Right-click and select References → Show References to __wrap_puts
This shows every place that calls puts(). In a larger program, you could find ALL the print statements and potentially modify any of them!
Step 6: Use the Decompiler to Plan Attacks
The Decompile view makes attack planning easy:
int main(void) {
stdio_init_all();
do {
__wrap_puts("hello, world"); // <-- Attack target identified!
} while (true);
}
From this view, you can immediately see:
- The program loops forever (
do { } while (true)) - It calls
__wrap_puts()with a string argument - To change the output, you need to change what's passed to
puts()
Step 7: Viewing the String in the .rodata Section
When you navigate to the string address 0x100019cc, you'll see the string stored in the .rodata (read-only data) section:
//
// .rodata
// SHT_PROGBITS [0x100019cc - 0x10001b17]
// ram:100019cc-ram:10001b17
//
__init_array_end XREF[5]: Entry Point (*) ,
__boot2_start__ frame_dummy:10000218 (*) ,
__boot2_end__ main:1000023a (*) ,
__EH_FRAME_BEGIN__ runtime_init:1000138a (R) ,
_elfSectionHeaders::0000005c (*)
100019cc 68 65 6c ds "hello, world\r"
6c 6f 2c
20 77 6f
This shows the raw bytes of our string: 68 65 6c 6c 6f 2c 20 77 6f... which spell out "hello, world\r" in ASCII.
Step 8: Patching Data in Ghidra (Preview)
Ghidra allows you to modify data directly in the binary! Here's how to patch the string:
- Navigate to the string at address
0x100019cc - Right-click on the string
"hello, world\r"in the Listing view - Select Patch Data from the context menu
- Type your new string:
"hacky, world\r" - Press Enter to apply the patch
⚠️ Important: The new string must be the same length or shorter than the original! If your new string is longer, it will overwrite adjacent data and likely crash the program.
| Original String | Patched String | Result |
|---|---|---|
hello, world\r (14 bytes) |
hacky, world\r (14 bytes) |
✅ Works perfectly |
hello, world\r (14 bytes) |
PWNED!\r (7 bytes) |
✅ Works (shorter is OK) |
hello, world\r (14 bytes) |
this is a much longer string\r |
❌ Overwrites other data! |
After patching, you'll see the change reflected in the Listing view:
100019cc 68 61 63 ds "hacky, world\r"
6b 79 2c
20 77 6f
Notice how the bytes changed: 68 65 6c 6c 6f ("hello") became 68 61 63 6b 79 ("hacky")!
Looking Ahead: Persistent Binary Patching
🔮 Coming in Future Lessons: What we've done in Ghidra so far is just a preview of the patch - it modifies the data in Ghidra's view, but doesn't save it back to the actual binary file.
In future lessons, we will learn how to:
- Export the patched binary from Ghidra to create a modified
.elfor.binfile - Flash the patched binary to the Pico 2, making the hack persistent
- Understand patch verification - how defenders detect modified binaries
- Bypass integrity checks that try to prevent patching
The key difference:
| Technique | Persistence | When It's Useful |
|---|---|---|
| GDB Live Hacking (this week) | Temporary - lost on reset | Testing, debugging, quick exploitation |
| Ghidra Patch Preview (this step) | None - just visualization | Planning and verifying patches |
| Binary Patching (future lessons) | Permanent - survives reboot | Persistent backdoors, firmware mods |
This step helps you understand the mechanics of modifying binary data. Once you're comfortable with this concept, you'll be ready to create truly persistent modifications!
Comparing GDB and Ghidra Approaches
| Task | GDB (Dynamic) | Ghidra (Static) |
|---|---|---|
| Find main address | x/1000i 0x10000000 + search |
Symbol Tree → Functions → main |
| Find string address | Step through ldr, examine $r0 |
Click on ldr - shows = 100019CCh |
| See string content | x/s $r0 |
Double-click address → see ds "hello, world" |
| Identify attack point | Set breakpoints, step, observe | Read decompiled code, find function calls |
| Verify memory type | Know address ranges | Check address prefix (0x10... vs 0x20...) |
Why Use Both Tools?
- Ghidra helps you plan the attack by understanding code structure
- GDB lets you execute the attack and modify live values
- Together, they form a complete reverse engineering workflow!
Ghidra Tips for Attack Planning
- Use the Decompiler - It shows you the high-level logic without decoding assembly
- Follow Cross-References - Find all places a function or variable is used
- Check Address Ranges - Quickly identify Flash vs SRAM locations
- Add Comments - Press
;to annotate what you discover for later - Rename Variables - Right-click → Rename to give meaningful names
📊 Part 15: Summary and Review
What We Accomplished
We successfully performed a live memory injection attack:
- Connected to a running embedded system using OpenOCD and GDB
- Analyzed the program flow to find the perfect attack point
- Set a breakpoint right before the critical function call
- Discovered that direct string assignment doesn't work without
malloc() - Wrote our malicious data directly to SRAM
- Hijacked the
r0register to point to our data - Executed the hack and watched the output change!
Week 1 Concepts We Applied
| Week 1 Concept | How We Used It This Week |
|---|---|
| Memory Layout (Flash vs RAM) | We knew flash is read-only, so we wrote to SRAM |
Registers (r0) |
We hijacked r0 to point to our malicious string |
GDB x command |
We examined memory and verified our injected string |
GDB breakpoints (b) |
We set a strategic breakpoint before puts() |
Disassembly (disas) |
We found the exact instruction to target |
| Little-endian | We understood how our string bytes are stored |
The Attack Flow Diagram
BEFORE OUR HACK:
┌─────────────────┐ ┌──────────────────────────────┐
│ r0 = 0x100019cc│ ───> │ Flash: "hello, world\r" │
└─────────────────┘ └──────────────────────────────┘
│
▼
puts() prints "hello, world"
AFTER OUR HACK:
┌─────────────────┐ ┌──────────────────────────────┐
│ r0 = 0x20000000│ ───> │ SRAM: "hacky, world\r" │
└─────────────────┘ └──────────────────────────────┘
│
▼
puts() prints "hacky, world"
New GDB Commands We Learned
| Command | What It Does | New/Review |
|---|---|---|
target remote :3333 |
Connect to OpenOCD debug server | New |
monitor reset halt |
Reset and halt the processor | New |
disas |
Disassemble the current function | Review |
x/Ni ADDRESS |
Examine N instructions at ADDRESS | Review |
x/s ADDRESS |
Examine memory as a string | Review |
b *ADDRESS |
Set breakpoint at exact address | Review |
c |
Continue execution | Review |
set $r0 = VALUE |
Change a register's value | New |
set {char[N]} ADDR = {...} |
Write characters directly to memory | New |
Key Memory Addresses
| Address | What's There | Read/Write? |
|---|---|---|
0x10000234 |
Start of main() function |
Read-only |
0x1000023c |
The bl __wrap_puts call |
Read-only |
0x100019cc |
Original "hello, world" string |
Read-only |
0x20000000 |
Start of SRAM (our hack target) | Read-Write |
✅ Practice Exercises
Exercise 1: Change the Message
Try creating a different message! Write your name to SRAM and make the program print it:
(gdb) set {char[20]} 0x20000000 = {'Y','o','u','r',' ','N','a','m','e','!','\r','\0'}
(gdb) set $r0 = 0x20000000
(gdb) c
Exercise 2: Use a Different SRAM Address
The SRAM region is large. Try writing your string to a different address:
(gdb) set {char[14]} 0x20001000 = {'h','a','c','k','e','d','!','!','!','\r','\0'}
(gdb) set $r0 = 0x20001000
(gdb) c
Exercise 3: Examine Memory Around Your String
Look at the bytes around your injected string:
(gdb) x/20b 0x20000000
What do you see? Can you identify each character?
Exercise 4: Automate the Hack
Create a GDB command sequence that does the full hack. You can use GDB's command feature:
(gdb) define hack
> set {char[14]} 0x20000000 = {'h','a','c','k','y',',',' ','w','o','r','l','d','\r','\0'}
> set $r0 = 0x20000000
> c
> end
(gdb) hack
Now you can just type hack each time!
🎓 Key Takeaways
Building on Week 1
-
Memory layout knowledge is power - Understanding that flash is read-only and SRAM is read-write was essential for our hack. This directly built on Week 1's memory map lesson.
-
Registers control everything - In Week 1, we watched registers change during execution. This week, we CHANGED them ourselves to alter program behavior.
-
GDB is a hacking tool - The same commands we used for learning (
x,b,c,disas) are the same commands used for exploitation.
New Concepts
-
Flash is Read-Only at Runtime - You can't modify code or constant strings in flash memory while the program runs. You must use SRAM.
-
Bare-Metal Means No Runtime - Without an operating system, there's no
malloc(), no dynamic memory allocation. You have to manage memory manually. -
Registers Are the Key - Function arguments are passed in registers (
r0,r1, etc.). By changing these registers at the right moment, you can change what functions do. -
Timing is Everything - We had to set our breakpoint at exactly the right instruction. One instruction earlier, and
r0wouldn't be loaded yet. One instruction later, andputs()would already have the wrong address. -
This is Real Hacking - The techniques you learned today are used by security researchers, penetration testers, and yes, attackers. Understanding these attacks helps us build more secure systems.
🔐 Security Implications
How Would This Work in the Real World?
Imagine an attacker with physical access to an industrial control system:
| Scenario | Attack |
|---|---|
| Nuclear Centrifuge | Change the displayed RPM from dangerous (15,000) to safe (10,000) |
| Medical Device | Modify dosage readings to hide an overdose |
| Vehicle ECU | Alter speedometer reading while car actually speeds |
| Smart Lock | Change the "locked" status to "unlocked" |
How Do We Defend Against This?
- Disable Debug Ports - Production devices should have JTAG/SWD disabled
- Secure Boot - Verify firmware hasn't been tampered with
- Memory Protection - Use ARM's MPU to restrict memory access
- Tamper Detection - Hardware that detects physical intrusion
- Encryption - Keep sensitive data encrypted in memory
📖 Glossary
New Terms This Week
| Term | Definition |
|---|---|
| Bare-Metal | Programming directly on hardware without an operating system |
| CMSIS-DAP | A standard debug interface protocol for ARM processors |
| Hijack | Taking control of a value or flow that was intended for something else |
| Hardware Breakpoint | A breakpoint implemented in CPU hardware, works on any memory |
| Memory Injection | Writing attacker-controlled data into a program's memory space |
| OpenOCD | Open On-Chip Debugger - software that interfaces with debug hardware |
| Register Manipulation | Changing the values stored in CPU registers |
| SRAM | Static Random Access Memory - fast, volatile, read-write memory |
| Software Breakpoint | A breakpoint implemented by modifying code (requires writable memory) |
Review Terms from Week 1
| Term | Definition | Where We Used It |
|---|---|---|
| Breakpoint | A marker that pauses program execution at a specific location | Part 7 - Setting the trap |
| Register | Fast storage inside the processor | Part 11 - Hijacking r0 |
| Stack Pointer | Register that points to the top of the stack | Part 2 - Memory layout |
| XIP | Execute In Place - running code directly from flash | Part 2 - Why we can't write to flash |
| Little-Endian | Storing the least significant byte at the lowest address | Part 10 - String storage |