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]