Software Introspection


Computing 101.

As you write larger and larger programs, you (yes, even you!) might make mistakes when implementing certain functionality, introducing bugs into your programs. When this happens, you'll need to have a reliable toolbox of resources to understand what is going wrong and fix it. Of course, the exact same techniques can be used to understand what is wrong with code that you didn't write, and fix or exploit it as you might desire!

This module will introduce you to several ways to introspect, debug, and understand software. You'll carry this critical knowledge with you and use it throughout pwn.college, so harken well!


As you know, bytes are what is actually stored in your computer's memory. As you might also know, computers think in binary: just a bunch of ones and zeroes. For historical reasons, we express these ones and zeroes ("bits") in groups of 8, and each group of 8 (a "byte"). This number is purely arbitrary: early computers (pre-1960s or so) didn't have this grouping at all, or had other arbitrary groupings. It is very feasible for there to be an alternate universe in which a byte is 16, 32, or really any numbers of bits (though for math reasons, it'll likely remain a power-of-2).

A single binary digit (bit) can represent two values (0 and 1), two bits can represent four values (00, 01, 10, and 11), three bits can represent eight values (000, 001, 010, 011, 100, 101, 110, 111), and four bits can represent sixteen values. Comparatively, a single decimal digit can represent 10 values (from 0 to 9). Ten values are represented by roughly log2(10) == 3.3219... bits, and you get weird situations like binary 1001 being decimal 9, but binary 1100 (still 4 binary digits) being 12 (two decimal digits!). Another way of expressing this digit desynchronization between decimal and binary is that decimal does not have clean bit boundaries.

The lack of bit boundaries makes reasoning about the relationship between decimal and binary complex. For example, it is hard to spot-translate numbers between decimal and binary in general: we can work out that 97 is 110001, but it's hard to see that at a glance.

It's much easier to spot-translate between bases that have more alignment between digits. For example, a single hexadecimal (base 16) digit can represent 16 values (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, a, b, c, d, e, f): the same number of values that binary can represent in 4 digits! This allows us to have a super simple mapping:

Hex Binary Decimal
0 0000 0
1 0001 1
2 0010 2
3 0011 3
4 0100 4
5 0101 5
6 0110 6
7 0111 7
8 1000 8
9 1001 9
a 1010 10
b 1011 11
c 1100 12
d 1101 13
e 1110 14
f 1111 15

This mapping from a hex digit to 4 bits is something that's easily memorizable (most important: memorize 1, 2, 4, and 8, and you can quickly derive the rest). Better yet, two hex digits is 8 bits, which is one byte! Unlike decimal, where you'd have to memorize 16 mappings for 4 bits and 256 mappings for 8 bits, with hexadecimal, you only have to memorize 16 mappings for 4 bits and the same amount of mappings for 8 bits, since it's just two hexadecimal digits concatenated! Some examples:

Hex Binary Decimal
00 0000 0000 0
0e 0000 1110 14
3e 0011 1110 62
e3 1110 0011 227
ee 1110 1110 238

Now you're starting to see the beauty. This gets even more obvious when you expand beyond one byte of input, but we'll let you find that out through future challenges!

Now, let's talk about notation. How do you differentiate 11 in decimal, 11 in binary (which equals 3 in decimal), and 11 in hex (which equals 17 in decimal)? For numerical constants, we sometimes prepend binary data with 0b, hexadecimal with 0x, and keep decimal as is, resulting in 11 == 0b1011 == 0xb, 3 == 0b11 == 0x3, and 17 == 0b10001 == 0x11.

In the previous module, you wrote assembly programs and built them into executables. But what if someone gives you a program and you want to understand what it does? This is where disassembly comes in: the process of converting the binary machine code in an executable back into human-readable assembly instructions.

Though you will learn to use vastly more powerful tooling later in your journey, we will start with one of the most common tools for disassembly: objdump. Given a binary, objdump -d will disassemble the executable sections and show you the assembly instructions:

hacker@dojo:~$ objdump -d -M intel /tmp/your-program

/tmp/your-program:     file format elf64-x86-64


Disassembly of section .text:

0000000000401000 <_start>:
  401000:	48 c7 c7 39 05 00 00 	mov    rdi,0x539
  401007:	48 c7 c7 00 00 00 00 	mov    rdi,0
  40100e:	48 c7 c0 3c 00 00 00 	mov    rax,0x3c
  401015:	0f 05                	syscall

There are a few things to note here. First, by default, objdump uses the wrong assembly syntax, which is why we pass the -M intel option. Don't forget this option! Viewing assembly in non-Intel syntax can be confusing and harmful for your health.

Second, objdump displays the raw bytes of each instruction (e.g., the hexadecimal values 0f 05 is the syscall instruction) alongside the human-readable assembly. These are the actual values that are stored in computer memory to represent the instructions. For mathematical reasons, these are represented in "base 16" (hexadecimal) rather than the "base 10" (decimal) that we are used to counting with. If that does not make sense, please run through the first half or so of the Dealing with Data module and then come back here!

Third, the values that are being moved into registers are also represented as hexadecimal. This can make it slightly tricky to understand what the program is doing. Above, we can see that it is setting rax to the hexadecimal value 0x3c, which is 60 in decimal and, thus, is our familiar syscall number of exit! Right before that, it sets rdi to 0, which will be the exit code of the program.

But interestingly, right before that, it sets rdi to 0x539, which we can't really observe from the outside because it's overwritten to 0 immediately. While this "secret" is benign, by reading the code of software, we can extract many different such secrets, some of which are security relevant!

We'll practice this secret extraction in this challenge, using a binary at /challenge/disassemble-me. Use objdump to disassemble it and find the number being loaded into rdi before it's wiped out. Then, submit that number using /challenge/submit-number. The number will be displayed in hexadecimal in the disassembly, but /challenge/submit-number accepts both hexadecimal (e.g., 0x539) and decimal (e.g., 1337) values. Good luck!

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

The first one is pretty simple: the syscall tracer, strace.

Given a program to run, strace will use functionality of the Linux operating system to introspect and record every system call that the program invokes, and its result. For example, let's look at our program from the previous challenge:

hacker@dojo:~$ strace /tmp/your-program
execve("/tmp/your-program", ["/tmp/your-program"], 0x7ffd48ae28b0 /* 53 vars */) = 0
exit(42)                                 = ?
+++ exited with 42 +++
hacker@dojo:~$

As you can see, strace reports what system calls are triggered, what parameters were passed to them, and what data they returned. The syntax used here for output is system_call(parameter, parameter, parameter, ...). This syntax is borrowed from a programming language called C, but we don't have to worry about that yet. Just keep in mind how to read this specific syntax.

In this example, strace reports two system calls: the second is the exit system call that your program uses to request its own termination, and you can see the parameter you passed to it (42). The first is an execve system call. We'll learn about this system call later, but it's somewhat of a yin to exit's yang: it starts a new program (in this case, your-program). It's not actually invoked by your-program in this case: its detection by strace is a weird artifact of how strace works, that we'll investigate later.

In the final line, you can see the result of exit(42), which is that the program exits with an exit code of 42!

Now, the exit syscall is easy to introspect without using strace --- after all, part of the point of exit is to give you an exit code that you can access. But other system calls are less visible. For example, the alarm system call (syscall number 37!) will set a timer in the operating system, and when that many seconds pass, Linux will terminate the program. The point of alarm is to, e.g., kill the program when it's frozen, but in this case, we'll use alarm to practice our strace snooping!

In this challenge, you must strace the /challenge/trace-me program to figure out what value it passes as a parameter to the alarm system call, then call /challenge/submit-number with the number you've retrieved as the argument. Good luck!

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

Next, let's move on to GDB. GDB stands for the GNU Debugger, and it is typically used to hunt down and understand bugs. More specifically, a debugger is a tool that enables the close monitoring and introspection of another process. There are many famous debuggers, and in the Linux space, gdb is by far the most common.

We'll learn gdb step by step through a series of challenges. In this one, we'll focus on simply launching it. That's done as so:

hacker@dojo:~$ gdb /path/to/binary/file

In this challenge, the binary that holds the secret is /challenge/debug-me. Once you load it in gdb, the rest will happen magically: we'll handle the analysis and give you the secret number. In later levels, you'll learn how to get that number on your own!

Again, once you have the number, exchange it for the flag with /challenge/submit-number.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In the previous level, GDB automatically quit for you. Now it's your turn!

When you're done working in GDB, you exit it with the quit command (or just q):

(gdb) quit

In this level, we'll still handle the analysis for you. All you need to do is launch GDB, let the magic happen, and then type quit to exit.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

Debuggers, including gdb, observe the debugged program as it runs to expose information about its runtime behavior. In the previous level, we automatically launched the program for you. Here, we will tone down the magic somewhat: you must start the execution of the program, and we'll do the rest (e.g., recover the secret value from it).

When you launch gdb now, it will eventually bring up a command prompt, that looks like this:

(gdb) 

You start a program with the starti command:

(gdb) starti

starti starts the program at the very first instruction. Once the program is running, you can use other gdb commands to inspect its actual runtime state. We'll start with the code that's running, which you can disassemble using the disassemble command! For example:

(gdb) disassemble
Dump of assembler code for function main:
=> 0x0000000000401000 <+0>:     mov    rdi,0x539
   0x0000000000401007 <+7>:     mov    rdi,0x0
   0x000000000040100e <+14>:    mov    rax,0x3c
   0x0000000000401015 <+21>:    syscall
End of assembler dump.

This is the same program from the objdump challenge, now running in gdb. Like before, you can gleam its secrets by reading the disassembly, though later we'll dig even deeper! For now, run starti after loading the binary in gdb, and we'll take care of the rest.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In the previous level, we ran the disassemble command for you after you started the program. Now it's your turn!

After starting the program with starti, you will need to run the disassemble command yourself:

(gdb) starti
...
(gdb) disassemble
Dump of assembler code for function main:
=> 0x0000000000401000 <+0>:     mov    rdi,0x539
   0x0000000000401007 <+7>:     mov    rdi,0x0
   0x000000000040100e <+14>:    mov    rax,0x3c
   0x0000000000401015 <+21>:    syscall
End of assembler dump.

Read the output to find the secret number, then submit it with /challenge/submit-number.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

So far, you've been reading the secret from the program's disassembly. But what if the secret is hidden?

In this level, the disassembly is censored: the secret value is replaced with CENSORED. However, even though you can't read the value from the code, you can still execute the code! When the CPU executes mov rdi, CENSORED, it loads the actual secret value into the rdi register.

To execute a single instruction in GDB, use the stepi command (step one instruction, also abbreviated si):

(gdb) stepi

Once you step past the mov instruction, we'll read the rdi register for you and show the secret value. Submit it with /challenge/submit-number!

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In the previous level, we automatically read the register value for you after you stepped. Now it's your turn!

The disassembly is still censored, so you'll need to:

  1. Start the program with starti
  2. Step one instruction with stepi (or si)
  3. Read the register yourself with print $rdi

The print command displays the value of an expression. Register names in GDB are prefixed with $, so you can read rdi like this:

(gdb) print $rdi
$1 = 1337

Then submit the value with /challenge/submit-number.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In previous levels, the secret was hidden in the program's code (a hardcoded mov instruction). This time, the secret comes from the program's runtime state: it's the argument count (argc), which lives on the stack.

The program pops this value off the stack with pop rdi, but then immediately overwrites rdi with 0 before exiting:

pop    rdi          <- reads argc from the stack into rdi
mov    rdi,0x0      <- overwrites rdi with 0!
mov    rax,0x3c
syscall             <- exit(0) --- the secret is gone!

The code is fully visible, and nothing is censored, but you can't determine the secret just by reading the disassembly because argc depends on how many arguments the program was launched with. In this level, GDB handles that for you, but in the future, we'll show you how to set the program's arguments in gdb as well!

For now, you'll need to:

  1. Start the program.
  2. Step one instruction to execute just pop rdi
  3. print the resulting value in rdi before it gets overwritten
  4. Quit gdb and then submit the value with /challenge/submit-number.

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In the last level, you could stepi to execute pop rdi and then print $rdi to read the secret. This time, there's no pop at all --- the program just exits immediately:

mov    rdi,0x0
mov    rax,0x3c
syscall             <- exit(0) --- the secret was never read!

The secret is still argc, and it's sitting right on top of the stack, but the program never loads it into a register. You'll need to examine memory directly!

GDB's x (examine) command lets you look at the contents of memory. As you learned earlier, the stack pointer ($rsp) starts out pointing right at argc, so you can read it with:

x $rsp

Go and do that!

  1. Start the program
  2. Examine the top of the stack
  3. Quit gdb and submit the value with /challenge/submit-number

NOTE: x displays values in hexadecimal by default. You can change the display format by appending / to the command. For example, if you'd rather see decimal, use x/d $rsp. Either way, /challenge/submit-number accepts both hex (e.g., 0x2a) and decimal (e.g., 42).

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

In the last level, you used x to read argc from the top of the stack. But the stack holds more than just argc!

Right after the argument count, the stack stores pointers to each program argument. These are addresses stored in memory: $rsp+16 doesn't contain the argument text directly --- it contains the address where that text lives.

For example, if your program is run as /challenge/debug-me Hi:

     Address    │ Contents
   +────────────────────────────+
   │  rsp + 0   │ 2             │◀── argc
   +────────────────────────────+
   │  rsp + 8   │ 0x1234000     │──────┐
   +────────────────────────────+      │
   │  rsp + 16  │ 0x1234560     │────┐ │
   +────────────────────────────+    │ │
                                     │ │
                                     │ │
     Address    │ Contents           │ │
   +──────────────────────────────+  │ │
   │ 0x1234000  │ "/challenge/..."│◀─│─┘ the program name
   +──────────────────────────────+  │
   │ ...        │ ...                │
   +──────────────────────────────+  │
   │ 0x1234560  │ "Hi"            │◀─┘   the first argument
   +──────────────────────────────+

To get the actual argument data, you need two dereferences: one to get the pointer from the stack, and one to follow it to the string.

In this level, THE FLAG ITSELF is passed as the first argument! The program doesn't use it --- it just exits --- but the flag is right there in memory.

To find it, you'll need two x commands, with two different display modes:

First: You'll need the pointer the first argument. You've done this before, but now you're doing it in gdb.

x/a $rsp+16

/a tells x to display the value as a memory address. You'll see a very large hexadecimal number, something like 0x7ffc001c4750.

Second: Read the text of the first argument at that address:

x/s 0x7ffc001c4750

/s tells x to display the value as a string. Replace the address with whatever you got from step 1. This will show you the flag!

Go and do that!

  1. Start the program
  2. x/a $rsp+16 to get the address of the first argument
  3. x/s <address> to read the flag string

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

So far, the debugging you've done has been preemptive: you (the debugger) started the program with stepi, which immediately forces it to stop and let you debug it, without the program necessarily being aware of it. In this challenge, we'll learn another model for this, where the program decides when the debugger stop happens. We'll call this cooperative debugging.

On our now-familiar x86 architecture, the program can signal a desire to be debugged by using the int3 instruction. If a debugger is attached when int3 is executed, it stops the program. This is called a program breakpoint.

Later, we'll learn how to set breakpoints from the debugger itself, going back to the preemptive model. But in this challenge, the checker will run your program under gdb and expect your program to trigger its own breakpoint. To do this, rather than using starti to start your program and immediately stop it, we'll use gdb's run command, which will simply run it until a breakpoint is hit!

When your program executes int3, gdb will break and the checker script will inspect $rdi. If $rdi is 1337 at that point, you get the flag!

Go and write a program that:

  1. Moves 1337 into rdi
  2. Executes int3 to cooperatively hand control to the debugger

NOTE: When an int3 is executed by a program not running under a debugger, you will see:

hacker@dojo:~$ /tmp/your-program
Trace/breakpoint trap
hacker@dojo:~$

And the program will terminate... If you want the program to run outside a debugger, take out that int3!

Connect with SSH

Link your SSH key, then connect with: ssh [email protected]

30-Day Scoreboard:

This scoreboard reflects solves for challenges in this module after the module launched in this dojo.

Rank Hacker Badges Score