Debugger Flag¶
This tutorial will show you how to use the GDB debugger to walk through and interact with running hardware.
Download Challenge Files¶
Download the GDB challenge binary here: gdb_challenge.bin.
Download the GDB challenge elf here: gdb_challenge.elf.
Once downloaded, update the firmware running on one of your boards using the flash tool.
python -m ectf25.utils.flash <PATH_TO_CHALLENGE_IMG> <DEVICE_PORT>
Connecting the Debugger¶
Follow the instructions to get starting debugging with OpenOCD and GDB here: OpenOCD. Note that you should be running OpenOCD on your host OS and GDB can be running through a docker container. By default, it is installed into the Decoder container.
Finally, the GDB challenge firmware will print a flag to the UART port if you succeed, so you’ll need a way to read from the serial debug port to get the flag.
Getting Your Bearings¶
When the device finishes starting up, you should see output along the lines of: “Welcome to the GDB challenge” printed to the serial console. The LED will cycle for 3 seconds before running the GDB challenge function and repeating.
After starting an OpenOCD session and connecting to GDB, you should see an output similar to:
(gdb) target remote host.docker.internal:3333
Remote debugging using host.docker.internal:3333
0x1000efb6 in MXC_Delay (us=500000) at /root/msdk-2024_02/Libraries/CMSIS/../PeriphDrivers/Source/SYS/mxc_delay.c:233
233 while (SysTick->VAL > endtick) {}
(gdb)
You are now using GDB. The CPU should have halted once the debugger was connected and we’re now ready to start debugging!
Setting a Breakpoint¶
Now that we have control of the system, let’s continue to main. To do that, we must first set a breakpoint at main using break or b for shorthand:
To get to the start of the challenge function, set a breakpoint at the
gdb_challenge function and run to it using continue
or c
:
(gdb) b gdb_challenge
Breakpoint 1 at 0x1000e5d0: file src/debugger_challenge.c, line 102.
Note: automatically using hardware breakpoints for read-only addresses.
(gdb) c
Continuing.
[New Thread 1]
[Switching to Thread 1]
Thread 2 "max32xxx.cpu" hit Breakpoint 1, gdb_challenge () at src/debugger_challenge.c:102
102 void __attribute__((optimize("O0"))) gdb_challenge() {
Now you are stopped at the beginning of the gdb_challenge function.
Let’s view the current register values with info registers
:
(gdb) info registers
r0 0x40080400 1074267136
r1 0x4 4
r2 0x40080600 1074267648
r3 0x10010f4c 268504908
r4 0x0 0
r5 0x0 0
r6 0x0 0
r7 0x2001fff8 537001976
r8 0x0 0
r9 0x0 0
r10 0x0 0
r11 0x0 0
r12 0xf4240000 -198967296
sp 0x2001fff8 0x2001fff8
lr 0x1000e729 268494633
pc 0x1000e5d0 0x1000e5d0 <gdb_challenge>
xPSR 0x81000000 -2130706432
fpscr 0x0 0
msp 0x2001fff8 0x2001fff8
psp 0x0 0x0
primask 0x0 0
basepri 0x0 0
faultmask 0x0 0
control 0x0 0
We can also view values in memory with x
specified by address or symbol:
(gdb) x gdb_challenge
0x1000e5d0 <gdb_challenge>: 0xb084b580
(gdb) x 0x1000e5d0
0x1000e5d0 <gdb_challenge>: 0xb084b580
Write down the raw value of the instruction in memory at `to_hex` for later as value1
Use what you’ve learned so far to set a breakpoint at the start of the do_some_math
function.
Write down the raw value of the stack pointer (SP) after hitting this new breakpoint as value2. Hint: It should end with 0xE0.
Stepping Through Code¶
With execution paused at the breakpoint for do_some_math
, let’s inspect the disassembly
for the function using disass
(gdb) disass
Dump of assembler code for function do_some_math:
=> 0x1000e290 <+0>: push {r7}
0x1000e292 <+2>: sub sp, #20
0x1000e294 <+4>: add r7, sp, #0
0x1000e296 <+6>: str r0, [r7, #12]
0x1000e298 <+8>: str r1, [r7, #8]
0x1000e29a <+10>: str r2, [r7, #4]
0x1000e29c <+12>: str r3, [r7, #0]
0x1000e29e <+14>: ldr r2, [r7, #12]
0x1000e2a0 <+16>: ldr r3, [r7, #8]
0x1000e2a2 <+18>: add r3, r2
0x1000e2a4 <+20>: ldr r1, [r7, #8]
0x1000e2a6 <+22>: ldr r2, [r7, #4]
0x1000e2a8 <+24>: sdiv r2, r1, r2
0x1000e2ac <+28>: mul.w r2, r3, r2
0x1000e2b0 <+32>: ldr r3, [r7, #0]
0x1000e2b2 <+34>: ldr r1, [r7, #12]
0x1000e2b4 <+36>: sdiv r1, r3, r1
0x1000e2b8 <+40>: ldr r0, [r7, #12]
0x1000e2ba <+42>: mul.w r1, r0, r1
0x1000e2be <+46>: subs r3, r3, r1
0x1000e2c0 <+48>: mul.w r3, r2, r3
0x1000e2c4 <+52>: ldr r1, [r7, #12]
0x1000e2c6 <+54>: ldr r2, [r7, #8]
0x1000e2c8 <+56>: eors r1, r2
0x1000e2ca <+58>: ldr r2, [r7, #0]
0x1000e2cc <+60>: add r2, r1
0x1000e2ce <+62>: sdiv r1, r3, r2
0x1000e2d2 <+66>: mul.w r2, r1, r2
0x1000e2d6 <+70>: subs r3, r3, r2
0x1000e2d8 <+72>: mov r0, r3
0x1000e2da <+74>: adds r7, #20
0x1000e2dc <+76>: mov sp, r7
0x1000e2de <+78>: pop {r7}
0x1000e2e0 <+80>: bx lr
End of assembler dump.
Instead of using breakpoints, we can instead step through this function instruction by instruction using si for step instruction:
(gdb) si
0x1000e292 39 int __attribute__((optimize("O0"))) do_some_math(int a, int b, int c, int d) {
(gdb) si
0x1000e294 39 int __attribute__((optimize("O0"))) do_some_math(int a, int b, int c, int d) {
(gdb) si
0x1000e296 39 int __attribute__((optimize("O0"))) do_some_math(int a, int b, int c, int d) {
You can see that we are stepping through the instructions of the function.
If you run disass
again, you will see our position has changed:
(gdb) disass
Dump of assembler code for function do_some_math:
0x1000e290 <+0>: push {r7}
0x1000e292 <+2>: sub sp, #20
0x1000e294 <+4>: add r7, sp, #0
=> 0x1000e296 <+6>: str r0, [r7, #12]
0x1000e298 <+8>: str r1, [r7, #8]
0x1000e29a <+10>: str r2, [r7, #4]
0x1000e29c <+12>: str r3, [r7, #0]
0x1000e29e <+14>: ldr r2, [r7, #12]
0x1000e2a0 <+16>: ldr r3, [r7, #8]
0x1000e2a2 <+18>: add r3, r2
0x1000e2a4 <+20>: ldr r1, [r7, #8]
0x1000e2a6 <+22>: ldr r2, [r7, #4]
0x1000e2a8 <+24>: sdiv r2, r1, r2
0x1000e2ac <+28>: mul.w r2, r3, r2
0x1000e2b0 <+32>: ldr r3, [r7, #0]
0x1000e2b2 <+34>: ldr r1, [r7, #12]
0x1000e2b4 <+36>: sdiv r1, r3, r1
0x1000e2b8 <+40>: ldr r0, [r7, #12]
0x1000e2ba <+42>: mul.w r1, r0, r1
0x1000e2be <+46>: subs r3, r3, r1
0x1000e2c0 <+48>: mul.w r3, r2, r3
0x1000e2c4 <+52>: ldr r1, [r7, #12]
0x1000e2c6 <+54>: ldr r2, [r7, #8]
0x1000e2c8 <+56>: eors r1, r2
0x1000e2ca <+58>: ldr r2, [r7, #0]
0x1000e2cc <+60>: add r2, r1
0x1000e2ce <+62>: sdiv r1, r3, r2
0x1000e2d2 <+66>: mul.w r2, r1, r2
0x1000e2d6 <+70>: subs r3, r3, r2
0x1000e2d8 <+72>: mov r0, r3
0x1000e2da <+74>: adds r7, #20
0x1000e2dc <+76>: mov sp, r7
0x1000e2de <+78>: pop {r7}
0x1000e2e0 <+80>: bx lr
End of assembler dump.
Setting a Watchpoint¶
Instead of manually stepping through individual instructions, we can also
automatically run through code and break when a variable, register, or value in
memory changes by setting a watchpoint. Let’s set a watchpoint on register r2
and continue running:
(gdb) watch $r2
Watchpoint 3: $r2
By default, GDB will just print the old and new value in signed integer format,
so let’s tell GDB to inspect register r2
when it breaks on the
watchpoint in order to automatically see the hexadecimal representation:
(gdb) commands
Type commands for breakpoint(s) 3, one per line.
End with a line saying just "end".
>info registers r2
>end
Now, we can continue running until r2
changes:
(gdb) c
Continuing.
Thread 2 "max32xxx.cpu" hit Watchpoint 3: $r2
Old value = -889271554
New value = -559038737
0x1000e2b0 in do_some_math (a=-559038737, b=-17958194, c=-889271554, d=-1057017071) at src/debugger_challenge.c:40
40 in src/debugger_challenge.c
r2 0xdeadbeef -559038737
Continue running through the `do_some_math` function until the value of r2 starts with 0xe1 and record that value as value3
When we’re done watching r2
, we can delete the watchpoint. First, view the
existing breakpoints and watchpoints with info break
or i b
for shorthand:
(gdb) info break
Num Type Disp Enb Address What
1 breakpoint keep y 0x1000e5d0 in gdb_challenge at src/debugger_challenge.c:102
breakpoint already hit 1 time
2 breakpoint keep y 0x1000e290 in do_some_math at src/debugger_challenge.c:39
breakpoint already hit 1 time
3 watchpoint keep y $r2
breakpoint already hit 6 times
info registers r2
The break number of the r3
watchpoint is 3
, so we will delete that break
number, and check the break numbers again to make sure we successfully removed
the watchpoint:
(gdb) delete 3
(gdb) i b
Num Type Disp Enb Address What
1 breakpoint keep y 0x1000e5d0 in gdb_challenge at src/debugger_challenge.c:102
breakpoint already hit 1 time
2 breakpoint keep y 0x1000e290 in do_some_math at src/debugger_challenge.c:39
breakpoint already hit 1 time
Writing to Registers and Memory¶
Set a breakpoint at 0x1000e2e0 (the end of do_some_math) and continue there:
(gdb) b *0x1000e2e0
Breakpoint 4 at 0x1000e2e0: file src/debugger_challenge.c, line 42.
(gdb) c
Continuing.
Thread 2 "max32xxx.cpu" hit Breakpoint 4, 0x1000e2e0 in do_some_math (a=-559038737, b=-17958194, c=-889271554, d=-1057017071) at src/debugger_challenge.c:42
42 }
With the set command we can now modify registers (make sure to reset them):
(gdb) info registers r0
r0 0x0 0
(gdb) set $r0=111
(gdb) info registers r0
r0 0x6f 111
(gdb) set $r0=0
And memory:
(gdb) x 0x2001fff8
0x2001fff8: 0x00000000
(gdb) set *0x2001fff8=0x111
(gdb) x 0x2001fff8
0x2001fff8: 0x00000111
(gdb) set *0x2001fff8=0
Capturing the flag¶
With what you’ve learned, set a breakpoint at the first instruction of the check_flag function and continue up to there.
check_flag has five arguments; let’s check them out. The ARM calling convention is to place the first four arguments in registers (r0-r3) and further arguments are pushed to the stack.
Print the registers and then the top value on the stack to view the arguments:
(gdb) info registers
r0 0x11111111 286331153
r1 0x22222222 572662306
r2 0x33333333 858993459
r3 0x44444444 1145324612
r4 0x0 0
r5 0x0 0
r6 0x0 0
r7 0x2001ffe8 537001960
r8 0x0 0
r9 0x0 0
r10 0x0 0
r11 0x0 0
r12 0xf4240000 -198967296
sp 0x2001ffe0 0x2001ffe0
lr 0x1000e60b 268494347
pc 0x1000e4b0 0x1000e4b0 <check_flag>
xPSR 0x61000000 1627389952
fpscr 0x0 0
msp 0x2001ffe0 0x2001ffe0
psp 0x0 0x0
primask 0x0 0
basepri 0x0 0
faultmask 0x0 0
control 0x0 0
(gdb) x $sp
0x2001ffe0: 0x55555555
We can see that arguments 1-4 (0x11111111, 0x22222222, 0x33333333, and 0x44444444) are in registers r0 through r3, and the top value of the stack hold the fifth argument (0x55555555). Now, using what you have learned, change the values of the function arguments so that the first argument is set to value1, the third argument is set to value2, and the fifth argument is set to value3.
Next, continue program execution and check the serial console output. If you did everything correctly, you should see a flag, and if not, you should see an explanation of which argument was incorrect. If done correctly, When you’re done, type q to quit GDB.
(gdb) q
A debugging session is active.
Inferior 1 [Remote target] will be detached.
Quit anyway? (y or n) y
[Inferior 1 (Remote target) detached]