Your First Program
Computers run computer programs to achieve different goals.
One program might be your favorite video game, another is the web browser you're using to access this website, and so on.
A program is made of computer code, and this code is made of a huge amount of individual instructions that cause the computer to carry out computation and take certain actions based on the results.
Each individual instruction is typically very simple, and only in aggregate do they enable awesome things like letting you look at memes on the internet.
This computation is done by the Central Processing Unit (CPU), in tandem with other pieces of hardware inside your computer.
Instructions are specified to the CPU in something called Assembly Language, and each CPU architecture uses a different flavor of this language.
Any program, no matter what language it is originally written in (e.g., C, C++, Java, Python, etc.), is eventually converted to or interpreted by Assembly instructions.
Most of pwn.college's material uses the x86 CPU architecture, which is Zardus' favorite architecture.
x86 was created by Intel in the dawn of the PC age, and has continued to evolve over the years.
Together, x86 and ARM (a different, less cool architecture) make up the majority of PC CPUs out there.
In this module, we will start out with the simplest x86 program that we can imagine, which we will write in x86 assembly, and build up from there!
Let's dig in, and write your first program!
The CPU thinks in very simple terms.
It moves data around, changes data, makes decisions based on data, and takes action based on data.
Most of the time, this data is stored in registers.
Simply put, registers are containers for data.
The CPU can put data into registers, move data between registers, and so on.
These registers, at a hardware level, are implemented using very expensive chips, crammed into shockingly microscopic spaces, and accessed at a frequency where even physical concepts such as the speed of light impact their performance.
Hence, the number of registers that a CPU can have is extremely constrained.
Different CPU architectures have different amounts of registers, different names for these registers, and so on, but typically, there are between 10 and 20 "general purpose" registers that program code can use for any reason, and up to a few dozen other ones that are used for special purposes.
In x86's modern incarnation, x86_64, programs have access to 16 general purpose registers.
In this challenge, we will learn about our first one: rax.
Hi, Rax!
rax, a single x86 register, is a tiny piece of the massively complex design of the x86 CPU, but this is where we'll start.
Like the other registers, rax is a container for a small amount of data.
You move data into rax with the mov instruction.
Instructions are specified as an operator (in this case, mov), and operands, which represent additional data (in this case, it will be the specification of rax as a destination, and the value we will want to store there).
For example, if you wanted to store the value 1337 into rax, the x86 Assembly would look like:
mov rax, 1337
You can see a few things:
- The destination (
rax) is specified before the source (the value 1337).
- The operands are separated by a comma.
- It is really simple!
In this challenge, you will write your first assembly.
You must move the value 60 into rax.
Write your program in a file with a .s extension, such as rax-challenge.s (while not mandatory, .s is the typical extension for assembly files).
Pass that .s file to the checker as an argument:
hacker@dojo:~$ /challenge/check rax-challenge.s
The .s file is input to the checker; do not try to run it directly.
You can use either your favorite text editor or the text editor in pwn.college's VSCode Workspace to implement your .s file!
ERRATA:
If you've seen x86 assembly before, there is a chance that you've seen a slightly different dialect of it.
The dialect used in pwn.college is "Intel Syntax", which is the correct way to write x86 assembly (as a reminder, Intel created x86).
Some courses incorrectly teach the use of "AT&T Syntax", causing enormous amounts of confusion.
We'll touch on this slightly in the next module and then, hopefully, never have to think about AT&T Syntax again.
So, your first program crashed...
Don't worry, it happens!
In this challenge, you'll learn how to make your program cleanly exit instead of crashing.
Starting your program and cleanly stopping it are actions handled by your computer's Operating System.
The operating system manages the existence of programs and interactions between the programs, your hardware, the network environment, and so on.
Your programs "interact" with the CPU using assembly instructions such as the mov instruction you wrote earlier.
Similarly, your programs interact with the operating system (via the CPU, of course) using the syscall, or System Call instruction.
Like how you might use a phone call to interact with a local restaurant to order food, programs use system calls to request the operating system to carry out actions on the program's behalf.
As a bit of an overgeneralization, anything your program does that doesn't involve performing computation on data is done with a system call.
There are a lot of different system calls your program can invoke.
For example, Linux has around 330 different ones, though this number changes over time as syscalls are added and deprecated.
Each system call is indicated by a syscall number, counting upwards from 0, and your program invokes a specific syscall by moving its syscall number into the rax register and invoking the syscall instruction.
For example, if we wanted to invoke syscall 42 (a syscall that you'll learn about sometime later!), we would write two instructions:
mov rax, 42
syscall
Very cool, and super easy!
In this challenge, we'll learn our first syscall: exit.
The exit syscall causes a program to exit.
By explicitly exiting, we can avoid the crash we ran into with our previous program!
Now, the syscall number of exit is 60.
Go and write your first program: it should move 60 into rax, then invoke syscall to cleanly exit!
As you might know, every program exits with an exit code as it terminates.
This is done by passing a parameter to the exit system call.
Similarly to how a system call number (e.g., 60 for exit) is specified in the rax variable, parameters are also passed to the syscall through registers.
System calls can take multiple parameters, though exit takes only one: the exit code.
The first parameter to a system call is passed via another register: rdi.
rdi is what we will focus on in this challenge.
In this challenge, you must make your program exit with the exit code of 42.
Thus, your program will need three instructions:
- Set your program's exit code (move it into
rdi).
- Set the system call number of the
exit syscall (mov rax, 60).
syscall!
Now, go and do it!
So you've written your first program?
But until now, we've handled the actual building of it into an executable that your CPU can actually run.
In this challenge, you will build it!
To build an executable binary, you need to:
- Write your assembly in a file (often with a
.S or .s syntax. We'll use program.s in this example).
- Assemble your assembly file into an object file (using the
as command).
- Link one or more executable object files into a final executable binary (using the
ld command)!
Let's take this step by step:
Writing assembly.
The assembly file contains, well, your assembly code.
For the previous level, this might be:
hacker@dojo:~$ cat program.s
mov rdi, 42
mov rax, 60
syscall
hacker@dojo:~$
But it needs to contain just a tad more info.
We mentioned that we're using the Intel assembly syntax in this course, and we'll need to let the assembler know that.
You do this by prepending a directive to the beginning of your assembly code, as such:
hacker@dojo:~$ cat program.s
.intel_syntax noprefix
mov rdi, 42
mov rax, 60
syscall
hacker@dojo:~$
.intel_syntax noprefix tells the assembler that you will be using Intel assembly syntax, and specifically the variant of it where you don't have to add extra prefixes to every instruction.
It isn't actually an x86 instruction (like mov and syscall), and so it doesn't end up in our final executable binary or runs on the CPU.
We'll talk about other directives later, but for now, we'll let the assembler figure it out!
Assembling Assembly Code into Object Files.
Next, we'll assemble the code.
This is done using the assembler, as, as so:
hacker@dojo:~$ ls
program.s
hacker@dojo:~$ cat program.s
.intel_syntax noprefix
mov rdi, 42
mov rax, 60
syscall
hacker@dojo:~$ as -o program.o program.s
hacker@dojo:~$ ls
program.o program.s
hacker@dojo:~$
Here, the as tool reads in program.s, assembles it into binary code, and outputs an object file called program.o.
This object file has actual assembled binary code, but it is not yet ready to be run.
First, we need to link it.
Linking Object Files into an Executable.
In a typical development workflow, source code is compiled and assembly is assembled to object files, and there are typically many of these (generally, each source code file in a program compiles into its own object file).
These are then linked together into a single executable.
Even if there is only one file, we still need to link it, to prepare the final executable.
This is done with the ld (stemming from the term "link editor") command, as so:
hacker@dojo:~$ ls
program.o program.s
hacker@dojo:~$ ld -o program program.o
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
hacker@dojo:~$ ls
program.o program.s program
hacker@dojo:~$
This creates an program file that we can then run!
Here it is:
hacker@dojo:~$ ./program
hacker@dojo:~$ echo $?
42
hacker@dojo:~$
In the shell, $? holds the exit code of the last executed command.
Neat!
Now you can build programs.
In this challenge, go ahead and run through these steps yourself.
Build your executable, and pass it to /challenge/check for the flag!
_start?
The attentive learner might have noticed that ld prints a warning about entry symbol _start.
The _start symbol is, essentially, a note to ld about where in your program execution should begin when the ELF is executed.
The warning states that, absent a specified _start, execution will start right at the beginning of the code.
This is just fine for us!
If you want to silence the error, you can specify the _start symbol, in your code, as so:
hacker@dojo:~$ cat program.s
.intel_syntax noprefix
.global _start
_start:
mov rdi, 42
mov rax, 60
syscall
hacker@dojo:~$ as -o program.o program.s
hacker@dojo:~$ ld -o program program.o
hacker@dojo:~$ ./program
hacker@dojo:~$ echo $?
42
hacker@dojo:~$
There are two extra lines here.
The second, _start:, adds a label called start, pointing to the beginning of your code.
The first, .global _start, directs as to make the _start label globally visible at the linker level, instead of just locally visible at the object file level.
As ld is the linker, this directive is necessary for the _start label to be seen.
For all the challenges in this dojo, starting execution at the beginning of the file is just fine, but if you don't want to see those warnings pop up, now you know how to prevent them!
Okay, let's learn about one more register: rsi!
Like rdi, rsi is a place you can park some data.
For example:
mov rsi, 42
Of course, you can also move data around between registers!
Watch:
mov rsi, 42
mov rdi, rsi
Just like the first line there moves 42 into rsi, the second line moves the value in rsi to rdi.
Here, we have to mention one complication: by move, we really mean set.
After the snippet above, rsi and rdi will be 42.
It's a mystery as to why the mov was chosen rather than something reasonable like set (even very knowledgeable people resort to wild speculation when asked), but it was, and here we are.
Anyways, on to the challenge!
In this challenge, we will store a secret value in the rsi register, and your program must exit with that value as the return code.
Since exit uses the value stored in rdi as the return code, you'll need to move the secret value in rsi into rdi.
Run /challenge/check and pass it your code for the flag! /challenge/check will set the secret value in rsi before running your code.
Good luck!
Computer Memory
Wow, you are a budding x86 assembly programmer!
You've set registers, triggered system calls, and wrote your first program that cleanly exits.
Now, we have one more big concept for you: memory.
You, as (presumably) a human being, have Short Term Memory and Long Term Memory.
When performing specific computation, your brain loads information you've previously learned into your short term memory, then acts on that information, then eventually puts new resulting information into your long-term memory.
Societally, we also invented other, longer-term forms of storage: oral histories, journals, books, and wikipedia.
If there's not enough space in your long-term memory for some information, or the information is not important to commit to long-term memory, you can always go and look it up on wikipedia, have your brain stick it into long-term memory, and pull it into your short-term memory when you need it later.
This multi-level hierarchy of information access from "small but accessible" (your short term memory, which is right there when you need it but only stores 5 to 9 pieces of information to "large but slow" (remembering stuff from your massive long-term memory) to "massive but absolutely glacial" (looking stuff up on wikipedia) is actually the foundation of the Memory Hierarchy of modern computing.
We've already learned about the "small but accessible" part of this in the previous module: those are registers, limited but FAST.
More spacious than even all the registers put together, but much much MUCH slower to access, is computer memory, and this is what we'll dig into with this module, giving you a glimpse into another level of the memory hierarchy.
As seen by your program, computer memory is a huge place where data is housed.
Like houses on a street, every part of memory has a numeric address, and like houses on a street, these numbers are (mostly) sequential.
Modern computers have enormous amounts of memory, and the view of memory of a typical modern program actually has large gaps (think: a portion of the street that hasn't had houses built on it, and so those addresses are skipped).
But these are all details: the point is, computers store data, mostly sequentially, in memory.
In this level, we will practice accessing data stored in memory.
How might we do this?
Recall that to move a value into a register, we did something like:
mov rdi, 31337
After this, the value of rdi is 31337.
Cool.
Well, we can use the same instruction to access memory!
There is another format of the command that, instead, uses the second parameter as an address to access memory!
Consider that our memory looks like this:
Address │ Contents
+────────────────────+
│ 31337 │ 42 │
+────────────────────+
To access the memory contents at memory address 31337, you can do:
mov rdi, [31337]
When the CPU executes this instruction, it of course understands that 31337 is an address, not a raw value.
If you think of the instruction as a person telling the CPU what to do, and we stick with our "houses on a street" analogy, then instead of just handing the CPU data, the instruction/person points at a house on the street.
The CPU will then go to that address, ring its doorbell, open its front door, drag the data that's in there out, and put it into rdi.
Thus, the 31337 in this context is the memory address and serves to point to the data stored at that memory address.
After this instruction executes, the value stored in rdi will be 42!
Let's put this into practice!
I've stored a secret number at memory address 133700, as so:
Address │ Contents
+────────────────────+
│ 133700 │ ??? │
+────────────────────+
You must retrieve this secret number and use it as the exit code for your program.
To do this, you must read it into rdi, whose value, if you recall, is the first parameter to exit and is used as the exit code.
Good luck!
NOTE: To solve this challenge, assemble and link your code, then pass the executable binary to /challenge/check.
You look like you need just a tiny bit more practice.
In this level, we put the secret value at 123400 instead of 133700, as so:
Address │ Contents
+────────────────────+
│ 123400 │ ??? │
+────────────────────+
Go load it into rdi and exit with that as the exit code!
Did you prefer to access memory at 133700 or at 123400?
Your answer might say something about your personality, but it's not super relevant from a technical perspective.
In fact, in most cases, you don't deal with actual memory addresses when writing programs at all!
How is this possible?
Well, typically, memory addresses are stored in registers, and we use the values in the registers to point to data in memory!
Let's start with this memory configuration:
Address │ Contents
+────────────────────+
│ 133700 │ 42 │
+────────────────────+
And consider this assembly snippet:
mov rax, 133700
Now, what you have is the following situation:
Address │ Contents
+────────────────────+
┌▸│ 133700 │ 42 │
│ +────────────────────+
│
└────────────────────────┐
│
Register │ Contents │
+────────────────────+ │
│ rax │ 133700 │─┘
+────────────────────+
rax now holds a value that corresponds with the address of the data that we want to load!
Let's load it:
mov rdi, [rax]
Here, we are accessing memory, but instead of specifying a fixed address like 133700 for the memory read, we're using the value stored in rax as the memory address.
By containing the memory address, rax is a pointer that points to the data we want to access!
When we use rax in lieu of directly specifying the address that it stores to access the memory address that it references, we call this dereferencing the pointer.
In the above example, we dereference rax to load the data it points to (the value 42 at address 133700) into rdi.
Neat!
This also drives home another point: these registers are general purpose!
Just because we've been using rax as the syscall index in our challenges so far doesn't mean that it can't have other uses as well.
Here, it's used as a pointer to our secret data in memory.
Similarly, the data in the registers doesn't have an implicit purpose.
If rax contains the value 133700 and we write mov rdi, [rax], the CPU uses the value as a memory address to dereference.
But if we write mov rdi, rax in the same conditions, the CPU just happily puts 133700 into rdi.
To the CPU, data is data; it only becomes differentiated when it's used in different ways.
In this challenge, we've initialized rax to contain the address of the secret data we've stored in memory.
Dereference rax to load the secret data into rdi and use it as the exit code of the program to get the flag!
In the previous level, you dereferenced rax to read data into rdi.
The interesting thing here is that our choice of rax was pretty arbitrary.
We could have used any other pointer, even rdi itself!
Nothing stops you from dereferencing a register to overwrite its own content with the dereferenced value!
For example, here is us doing this exact thing with rax.
I've annotated each line with comments:
mov [133700], 42
mov rax, 133700 # after this, rax will be 133700
mov rax, [rax] # after this, rax will be 42
Throughout this snippet, rax goes from being used as a pointer to being used to hold the data that's been read from memory.
The CPU makes this all work!
In this challenge, you'll explore this concept.
Rather than initializing rax, as before, we've made rdi the pointer to the secret value!
You'll need to dereference it to load that value into rdi, then exit with that value as the exit code.
Good luck!
So now you can dereference pointers in memory like a pro!
But pointers don't always point directly at the data you need.
Sometimes, for example, a pointer might point to a collection of data (say, an entire book), and you'll need to reference partway into this collection for the specific data you need.
For example, if your pointer (say, rdi) points to a sequence of numbers in memory, as so:
Address │ Contents
+────────────────────+
┌▸│ 133700 │ 50 │
│ │ 133701 │ 42 │
│ │ 133702 │ 99 │
│ │ 133703 │ 14 │
│ +────────────────────+
│
└────────────────────────┐
│
Register │ Contents │
+────────────────────+ │
│ rdi │ 133700 │─┘
+────────────────────+
If you want the second number of that sequence, you could do:
mov rax, [rdi+1]
Wow, super simple!
In memory terms, we call these number slots bytes: each memory address represents a specific byte of memory.
The above example is accessing memory 1 byte after the memory address pointed to by rdi.
In memory terms, we call this 1 byte difference an offset, so in this example, there is an offset of 1 from the address pointed to by rdi.
Let's practice this concept.
As before, we will initialize rdi to point at the secret value, but not directly at it.
This time, the secret value will have an offset of 8 bytes from where rdi points, something analogous to this:
Address │ Contents
+────────────────────+
┌▸│ 31337 │ 0 │
│ │ 31337+1 │ 0 │
│ │ 31337+2 │ 0 │
│ │ 31337+3 │ 0 │
│ │ 31337+4 │ 0 │
│ │ 31337+5 │ 0 │
│ │ 31337+6 │ 0 │
│ │ 31337+7 │ 0 │
│ │ 31337+8 │ ??? │
│ +────────────────────+
│
└────────────────────────┐
│
Register │ Contents │
+────────────────────+ │
│ rdi │ 31337 │─┘
+────────────────────+
Of course, the actual memory address is not 31337.
We'll choose it randomly, and store it in rdi.
Go dereference rdi with offset 8 and get the flag!
Pointers can get even more interesting!
Imagine that your friend lives in a different house on your street.
Rather than remembering their address, you might write it down, and store the paper with their house address in your house.
Then, to get data from your friend, you'd need to point the CPU at your house, have it go in there and find the friend's address, and use that address as a pointer to their house.
Similarly, since memory addresses are really just values, they can be stored in memory, and retrieved later!
Let's explore a scenario where we store the value 133700 at the address 123400, and store the value 42 at the address 133700.
Consider the following instructions:
mov rdi, 123400 # after this, rdi becomes 123400
mov rdi, [rdi] # after this, rdi becomes the value stored at 123400 (which is 133700)
mov rax, [rdi] # here we dereference rdi, reading 42 into rax!
Wow!
This storing of addresses is extremely common in programs.
Addresses and data are stored, loaded, moved around, and, sometimes, mixed up with each other!
When that happens, security issues can arise, and you'll romp through many such issues during your pwn.college journey.
For now, let's practice dereferencing an address stored in memory.
I'll store a secret value at a secret address, then store that secret address at the address 567800.
You must read the address, dereference it, get the secret value, and then exit with it as the exit code.
You got this!
In the last few levels, you have:
- Used an address that we told you (in one level,
133700, and in another, 123400) to load a secret value from memory.
- Used an address that we put into
rax for you to load a secret value from memory.
- Used an address that we told you (in the last level,
567800) to load the address of a secret value from memory into a register, then used that register as a pointer to retrieve the secret value from memory!
Let's put those last two together.
In this challenge, we stored our SECRET_VALUE in memory at the address SECRET_LOCATION_1, then stored SECRET_LOCATION_1 in memory at the address SECRET_LOCATION_2.
Then, we put SECRET_LOCATION_2 into rax!
The result looks something like this, using 123400 for SECRET_LOCATION_1 and 133700 for SECRET_LOCATION_2 (not, in the real challenge, these values will be different and hidden from you!):
Address │ Contents
+────────────────────+
┌──▸│ 133700 │ 123400 │─┐
│ +────────────────────+ │
│ ┌▸│ 123400 │ 42 │ │
│ │ +────────────────────+ │
│ └────────────────────────┘
└──────────────────────────┐
│
Register │ Contents │
+────────────────────+ │
│ rax │ 133700 │─┘
+────────────────────+
Here, you will need to perform two memory reads: one dereferencing rax to read SECRET_LOCATION_1 from the location that rax is pointing to (which is SECRET_LOCATION_2), and the second one dereferencing whatever register now holds SECRET_LOCATION_1 to read SECRET_VALUE into rdi, so you can use it as the exit code!
That sounds like a lot, but you've done basically all of this already.
Go put it together!
The Stack
So far, you've been reading from memory addresses that we set up for you.
But your program already has a region of memory ready to go: the stack.
The stack is pointed to by the rsp register, and it contains useful data about how your program was launched and accumulates other data as the program executes.
Let's explore it!
So far, we've been loading data from memory at addresses that we gave you: either hardcoded (like 133700) or stored in a register (like rax).
But there's one region of memory that your program already has access to without any setup from us: the stack.
The stack is a region of allocated memory used as scratch space for your program, and the register rsp (the Stack Pointer) points to the top of it.
We'll explore the stack further later, but for now, the relevant detail is this: when a program starts, rsp points to data that represents the number of command-line arguments passed to the program (including the program name itself).
So if you run:
hacker@dojo:~$ /tmp/your-program hello world
Then the situation looks like this (the actual addresses are an example):
Address │ Contents
+───────────────────────+
│ ... │ ... │
+───────────────────────+
┌▸│ 1337000 │ 3 │ ◀── the argument count
| +───────────────────────+
| | 1337008 | ??? |
| +───────────────────────+
| | 1337016 | ??? |
│ +───────────────────────+
│
└────────────────────────────┐
│
Register │ Contents │
+────────────────────────+ │
│ rsp │ 1337000 │─┘
+────────────────────────+
rsp points to the stack, and the value there is 3: one for the program name, one for hello, and one for world.
The stack also has other data, as shown, but we won't worry about that for now!
In this challenge, read the argument count from [rsp] and use it as the exit code of your program.
We'll run your program a few times with different arguments to make sure you're reading it correctly!
In the previous challenge, you read the value at [rsp]: the very top of the stack.
But the stack has lots of data on it, and you can access any of it by adding an offset to rsp.
For example, [rsp+8] reads the 8-byte value right after [rsp], [rsp+16] reads the next one after that, and so on.
In general, [rsp+N] reads memory at the address rsp+N:
Address | Contents
+---------------------------+
| rsp | value 0 | <-- [rsp]
+---------------------------+
| rsp+8 | value 1 | <-- [rsp+8]
+---------------------------+
| rsp+16 | value 2 | <-- [rsp+16]
+---------------------------+
| ... | ... |
+---------------------------+
You'll notice these offsets go in multiples of 8.
That's because many values on the stack, such as numbers or memory addresses, tend to be 8 bytes (64 bits) wide, so consecutive values are 8 bytes apart.
But this is mostly convention: in reality, the stack, like any other region of memory, is a contiguous region of individual bytes, though for now we'll treat the stack as a bunch of 8-byte/64-bit values.
In this challenge, we've stashed a secret value on the stack at an offset of 128 bytes from rsp.
Read the value at [rsp+128] and use it as the exit code!
You've now read [rsp] to get the argument count, and [rsp+128] to get data at an offset.
Let's look at what else is on the stack!
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 /tmp/your-program Hi:
Register │ Contents
+───────────────────────────+
│ rsp │ 1337000 │─┐
+───────────────────────────+ │
│
┌──────────────────────────────┘
│
│ Address │ Contents
│ +────────────────────────+
│ │ ... │ ... │
│ +────────────────────────+
└▸ │ 1337000 │ 2 │ ◀── the ARGument Count (termed "argc")
+────────────────────────+
│ 1337008 │ 1234000 │──────┐
+────────────────────────+ │
│ 1337016 │ 1234560 │────┐ │
+────────────────────────+ │ │
│ 1337024 │ 0 │ │ │
+────────────────────────+ │ │
│ │
┌───────────────────────────────┘ │
│ │
│ Address │ Contents │
│ +──────────────────────────+ │
│ │ 1234000 │ "/tmp/..." │◀───┘ the program name
│ +──────────────────────────+
│ │ ... │ ... │
│ +──────────────────────────+
└▸│ 1234560 │ "Hi" │ the first argument!
+──────────────────────────+
To get the actual argument data, you need to dereference twice: once to get the pointer from the stack, and once to follow it to the data.
mov rdi, [rsp+16] # load the first argument pointer (e.g., 1234560) from the stack
mov rdi, [rdi] # follow the pointer to read the actual data (e.g., "Hi")
In this challenge, your program will be invoked with an argument.
Read the value of the first argument and exit with it!
Why is the stack called a stack?
So far, we've just used it as a region of memory that we read from with mov, like any other memory dereference.
But the stack is meant to be used as, well, a stack of data: you pop values off the top!
The pop instruction is purpose-built for this.
pop rdi does two things:
- Reads the value at
[rsp] into rdi (just like mov rdi, [rsp]).
- Adds 8 to
rsp, advancing the stack pointer to the next value.
Using the same example as before:
hacker@dojo:~$ /tmp/your-program hello world
Before the pop rdi:
Address │ Contents
+───────────────────────+
│ ... │ ... │
+───────────────────────+
┌▸│ 1337000 │ 3 │ ◀── the argument count
│ +───────────────────────+
│ | 1337008 | ??? |
│ +───────────────────────+
│
└────────────────────────────┐
│
Register │ Contents │
+────────────────────────+ │
│ rsp │ 1337000 │─┘
+────────────────────────+
│ rdi │ 0 │
+────────────────────────+
After the pop rdi:
Address │ Contents
+───────────────────────+
│ ... │ ... │
+───────────────────────+
│ 1337000 │ 3 │
+───────────────────────+
┌▸| 1337008 | ??? |
│ +───────────────────────+
│
└────────────────────────────┐
│
Register │ Contents │
+────────────────────────+ │
│ rsp │ 1337008 │─┘
+────────────────────────+
│ rdi │ 3 │
+────────────────────────+
The value 3 was popped off the top of the stack into rdi, and rsp advanced by 8 bytes to point to the next value.
The data at 1337000 is still there in memory, but as far as the stack is concerned, it's been removed: rsp has moved past it.
In this challenge, use pop to read the argument count and exit with it!
The stack is easiest to reason about if you remember that addresses are just numbers.
Diagrams often draw the stack vertically, but the numbers themselves still have a simple left-to-right order:
smaller addresses larger addresses
... rsp-0x10 rsp-0x08 rsp rsp+0x08 rsp+0x10 ...
Positive offsets from rsp, such as [rsp+8], read bytes at larger addresses.
Negative offsets, such as [rsp-8], read bytes at smaller addresses.
When a program starts, the kernel has already placed launch data at the starting rsp and at larger addresses to its right on this number line.
Nibbling on Numbers
A byte is eight bits; half of one (four bits) is a nibble.
This module nibbles at numbers from the bit up: how a pile of bits comes to mean a positive or a negative number in binary, hexadecimal, and decimal.
Understanding this is critical to truly knowing how the CPU processes bits into something more meaningful!
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 1100001, 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.
Computers store data as bytes, which are made of bits.
The registers you're used to so far, such as rax, are 64 bits wide, meaning that they can hold values from 0 (all 0 bits) to 2^64-1 (all 1 bits).
But what if you wanted to store negative numbers?
Early approaches to storing negative numbers included the use of a sign bit: a positive decimal 5 might be stored as the byte 00000101, while a negative 5 would be 10000101.
This makes sense, but it has a very important problem: math.
Deep inside your computer's CPU is a subcomponent called an Arithmetic Logical Unit (ALU), which is responsible for arithmetic.
This critical component needs to be very optimized, so the less complexity is needed to, say, add units, the better.
At the same time, the math needs to check out.
This leads to two issues:
- A signed bit system has two different values for zero:
00000000 (positive zero) and 10000000 (negative zero). This is terrible for many reasons, including having to complicate the ALU.
- In a signed bit system, different arithmetic algorithms need to be used for signed versus unsigned numbers. You can add
00000010 (decimal 2) and 00000001 (decimal 1) easily with normal binary math, but 10000010 (decimal -2) and 00000001 (decimal 1) need to instead be modeled partially as subtractive.
This is not great.
Luckily, some smart minds came up with a brilliant scheme called Twos Complement.
Twos complement solves both problems by modeling half of the bit space as negative in a way compatible with unsigned arithmetic.
Consider this subtraction:
00000001 a positive 1
- 00000010 a positive 2 (being subtracted)
There aren't enough bits to service this subtraction, so we introduce a borrow bit:
1 00000001 a positive 1 (with a borrow bit)
- 0 00000010 a positive 2 (being subtracted)
----------
0 11111111 the result of the normal *sign-agnostic* subtraction
So 11111111 is -1: add it to 1 and they cancel, with the leftover bit falling off the end of the byte and vanishing.
This has a few advantages:
- The top bit is still the sign bit: if it's set, the value is negative! Very easy to test.
- There is only one zero:
00000000.
- Arithmetic works exactly normally for both signed and unsigned numbers.
The downside is the slight human complexity: the actual magnitude (e.g., value after the sign) of a negative number is the unsigned byte value minus 256.
In the above 11111111, it's 255 - 256 = -1.
A bit complex, but you'll get the idea eventually.
Interestingly, if you treat the value as unsigned, you can happily use it as 255 in the arithmetic you want, and everything will still work out!
Now, put this to use.
This challenge will force you to understand twos-complement on bytes.
Run /challenge/decode and get the flag!
You've done two's complement one byte at a time, but nothing about it is special to 8 bits.
It works at any bit-width!
In this level, we'll practice twos complement on 16-bit values.
Remember, rax and its friends are 64 bits wide, and they're also twos-complement, so you have a ways to scale up!
A 16-bit value can be as big as 2^16-1 when unsigned (65535), but if you interpret that binary value (1111111111111111) as a signed integer, you will get -1!
To interpret 1000000000000000 (unsigned 32768) as signed, you must subtract 65536 from it, resulting in -32768, which is the smallest signed value that can be expressed in 16 bits (with the largest signed value, 0111111111111111 being 32767).
This might be starting to get slightly confusing and, indeed, the different maximum values of signed versus unsigned numbers lead to all sorts of bugs and security vulnerabilities!
The size of these numbers makes manual math difficult, and we don't expect you to do these conversions in your head.
Our advice: do the unsigned conversion using a tool, then do the twos complement subtraction if it's signed.
One tool you can use is the Python programming language.
You don't have to program anything in it yet (though we'll get there), just use its interactive mode as a calculator.
For example, to convert the binary 1001111101011100, you can do:
hacker@dojo$ ipython
In [1]: 0b1001111101011100
Out[1]: 40796
In [2]: 40796 - 65536
Out[2]: -24740
In [3]: exit
hacker@dojo$
A few notes:
In [1] is the input prompt for the ipython interactive python interpreter, and Out [1] is the result of the expression entered into In [1].
0b is Python's prefix to differentiate numbers written in binary from decimal (e.g., 0b101 is 5 and 101 is 101).
Run /challenge/decode and get the flag!
Let's go wider!
We'll try 4 bytes, 32 bits.
Use the same ipython calculator workflow from the 16-bit level: convert the binary as unsigned first, then subtract 2**32 if the top bit is set.
The maximum unsigned value of this, 11111111 11111111 11111111 11111111, is 2**32-1, or 4,294,967,295.
The maximum signed value, 01111111 11111111 11111111 11111111, is 2**31-1, which is 2,147,483,647, and the minimum signed value, 10000000 00000000 00000000 00000000, is -2**31, which is -2,147,483,648.
Run /challenge/decode and get the flag!
The first three levels had you read bits as a signed number. Now let's go the other way: given a number, write its two's-complement bits.
For a zero-or-positive number, that's just its plain binary, padded with leading zeros to fill the byte. For a negative number, recall the rule from the very first level --- the n-bit two's-complement pattern of a negative value is the same bits as the unsigned number (value + 2ⁿ). In a byte (8 bits), that means adding 256:
-5 -> -5 + 256 = 251 -> 11111011
42 -> 00101010
(Equivalently: flip the bits of +5 and add one, which gives you the same answer, 11111011. Either way of thinking about it works.)
Run /challenge/encode and get the flag!
Time to put the reading above into practice.
The key fact: a single hex digit is exactly 4 bits, so a byte --- 8 bits --- is just two hex digits.
To turn a byte from binary into hex, split its 8 bits into two groups of 4 and look each group up:
11100011 the byte, in binary
1110 0011 the byte, in binary, split into two groups of 4 bits
e 3 each group of 4 bits -> one hex digit
-> 0xe3 the hex!
Run /challenge/convert and get the flag!
Like decimal numbers, you can add arbitrary amounts of them to represent more and more bytes.
Every two hex digits are one additional byte.
One hex digit, for those curious, is called a nibble (har har!), but this is not used when specifying data.
We almost always work with data on the byte level, not less.
We'll practice this in this challenge.
Split each byte's 8 bits into two groups of 4, turn each group into its hex digit, and write the bytes left to right. For example:
1110 0011 0101 1010 1001 0000
e 3 5 a 9 0
-> 0xe35a90
Now it's your turn: run /challenge/convert and go earn that flag!
So far you've encoded binary data into hex. Now let's go the other way and decode it: that is, turn hex back into the bits it stands for.
It's the same mapping, just run in reverse.
Each hex digit becomes its 4 bits, written out in order.
Since a byte is two hex digits, decoding one byte means expanding two hex digits into 8 bits:
0x 0 a
0000 1010
-> 00001010
This is exactly what a program does when it receives hex-encoded data: it turns each pair of hex digits back into the byte it represents before working with it.
Write out the full 8 bits, leading zeros and all --- each hex digit is always exactly 4 of them.
Run /challenge/convert and get the flag!
Of course, the same value can be interpreted/reasoned about in multiple ways.
We'll explore that here across the four interpretations we've studied (unsigned decimal, signed decimal, hex, and twos-complement binary).
Work through every conversion to earn the flag.
Software Introspection
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!
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!
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!
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.
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.
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.
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.
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!
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:
- Start the program with
starti
- Step one instruction with
stepi (or si)
- 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.
In the previous level, you used print to read a register's value.
GDB can also change a register while the program is stopped.
The set command assigns a new value to a register:
(gdb) set $rax = 42
As with print, prefix the register name with $.
In this level, you will need to:
- Start the program with
starti and step past its first instruction.
- Set
rdi to 1337 and step once more.
- Print the resulting secret number, then submit it with
/challenge/submit-number.
Run gdb /challenge/debug-me and change that register!
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:
- Start the program.
- Step one instruction to execute just
pop rdi
print the resulting value in rdi before it gets overwritten
- Quit gdb and then submit the value with
/challenge/submit-number.
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!
- Start the program
- Examine the top of the stack
- 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).
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!
- Start the program
x/a $rsp+16 to get the address of the first argument
x/s <address> to read the flag string
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:
- Moves
1337 into rdi
- Executes
int3 to cooperatively hand control to the debugger
Assemble and link your code into an ELF executable, then submit that executable:
hacker@dojo:~$ /challenge/check /tmp/your-program
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!
In the previous level, you used gdb's run command for the first time: run starts the program and lets it execute freely.
But what if the program needs command-line arguments to work?
Outside gdb, you've been passing arguments by just typing them after the program name:
hacker@dojo:~$ /challenge/debug-me hello
Inside gdb, the analog is to pass them to run:
(gdb) run hello
Whatever you put after run becomes the inferior's argv[1], argv[2], and so on --- exactly as if you'd typed those arguments on the shell command line.
GDB also accepts the short form r:
(gdb) r hello
(Anywhere you see run in gdb's docs, r works too.)
In this challenge, /challenge/debug-me only prints your flag when you give it the string pwn as argv[1].
Do it!
In the previous level, you passed command-line arguments through gdb's run.
Programs can also read from stdin, and gdb lets you redirect stdin when you run the inferior.
The syntax is the same redirection you've seen in the shell, but it goes after run inside gdb:
(gdb) run < /path/to/input
In this challenge, /challenge/debug-me reads its required input from /challenge/secret.
Run it under gdb with /challenge/secret redirected into stdin, read the secret number it prints, and submit that number with /challenge/submit-number.
Output and Input
Until now, your program's single interaction with the wider world was changing its exit code when exiting.
Of course, more interaction is possible!
In this module, we will learn about the write system call, which is used to write output to the command-line terminal!
This is going to be an exciting journey: the logic of this program is going to be both as close as you can possibly get to the hardware itself (e.g., you are writing raw x86 assembly that the CPU directly understands!) and as close as you can possibly get to the Linux operating system (e.g., you are triggering system calls directly!).
Let's learn to write text!
Unsurprisingly, your program writes text to the screen by invoking a system call.
Specifically, this is the write system call, and its syscall number is 1.
However, the write system call also needs to specify, via its parameters, what data to write and where to write it to.
You may remember, from the Practicing Piping module of the Linux Luminarium dojo, the concept of File Descriptors (FDs).
As a reminder, each process starts out with three FDs:
- FD 0: Standard Input is the channel through which the process takes input. For example, your shell uses Standard Input to read the commands that you input.
- FD 1: Standard Output is the channel through which processes output normal data, such as the flag when it is printed to you in previous challenges or the output of utilities such as
ls.
- FD 2: Standard Error is the channel through which processes output error details. For example, if you mistype a command, the shell will output, over standard error, that this command does not exist.
It turns out that, in your write system call, this is how you specify where to write the data to!
The first (and only) parameter to your exit system call was your exit code (mov rdi, 42), and the first (but, in this case, not only!) parameter to write is the file descriptor.
If you want to write to standard output, you would set rdi to 1.
If you want to write to standard error, you would set rdi to 2.
Super simple!
This leaves us with what to write.
Now, you could imagine a world where we specify what to write through yet another register parameter to the write system call.
But these registers don't fit a ton of data, and to write out a long story like this challenge description, you'd need to invoke the write system call multiple times.
Relatively speaking, this has a lot of performance cost --- the CPU needs to switch from executing the instructions of your program to executing the instructions of Linux itself, do a bunch of housekeeping computation, interact with your hardware to get the actual pixels to show up on your screen, and then switch back.
This is slow, and so we try to minimize the number of times we invoke system calls.
Of course, the solution to this is to write multiple characters at the same time.
The write system call does this by taking two parameters for the "what": a where (in memory) to start writing from and a how many characters to write.
These parameters are passed as the second and third parameters to write.
In the kinda-C syntax that we learned from strace, this would be:
write(file_descriptor, memory_address, number_of_characters_to_write)
For a more concrete example, if you wanted to write 10 characters starting from some memory address to standard output (file descriptor 1), this would be:
write(1, memory_address, 10);
Wow, that's simple!
Now, how do we actually specify these parameters?
- We'll pass the first parameter of a system call, as we reviewed above, in the
rdi register.
- We'll pass the second parameter via the
rsi register.
The agreed-upon convention in Linux is that rsi is used as the second parameter to system calls.
- We'll pass the third parameter via the
rdx register.
This is the most confusing part of this entire module: rdi (the register holding the first parameter) has such a similar name to rdx that it's really easy to mix up and, unfortunately, the naming is this way for historic reasons and is here to stay.
Oh well...
It's just something we have to be careful about.
Maybe a mnemonic like "rdi is the initial parameter while rdx is the xtra parameter"?
Or just think of it as having to keep track of different friends with similar names, and you'll be fine.
And, of course, the write syscall index into rax itself: 1.
Other than the rdi vs rdx confusion, this is really easy!
Now, you know how to set the system call number and how to set the rest of the registers.
But where in memory is the data you need to write?
In this challenge, your program is invoked with a command-line argument, something like:
/tmp/your-program H
Recall that when a program is run with arguments, the stack stores pointers to each argument.
These are addresses stored in memory: [rsp+16] doesn't contain the argument text directly --- it contains the address where that text lives.
So, to get the memory address of the first argument, you simply load the pointer from the stack, as you've done before!
mov rsi, [rsp+16]
This puts the memory address of the first argument's text into rsi --- exactly what write needs as its second parameter!
Your program will be invoked with a single character as its first argument.
Call write to write that single character (for now! We'll do multiple-character writes later) to standard output, and we'll give you the flag!
Okay, our previous solution wrote output but then crashed.
In this level, you will write output, and then not crash!
We'll do this by invoking the write system call, and then invoking the exit system call to cleanly exit the program.
How do we invoke two system calls?
Just like you invoke two instructions!
First, you set up the necessary registers and invoke write, then you set up the necessary registers and invoke exit!
Your previous solution had 5 instructions (loading the first argument's address from the stack, setting rdi, setting rdx, setting rax, and syscall).
This one should have those 5, plus three more for exit (setting rdi to the exit code, setting rax to syscall index 60, and syscall).
For this level, let's exit with exit code 42!
Okay, we have one thing left for this run of challenges.
You've written out a single byte, and now we'll practice writing out multiple bytes.
In this level, the flag itself is passed as the first argument to your program!
Can you write all 64 characters of it to stdout?
Hint:
The only thing you should have to change compared to your previous solution is the value in rdx!
You now know how to output data to stdout using write.
But how does your program receive input data?
It reads it from stdin!
Like write, read is a system call that shunts data around between file descriptors and memory, and its syscall number is 0.
In read's case, it reads some amount of bytes from the provided file descriptor and stores them in memory.
The C-style syntax is the same as write:
read(0, some_address, 5);
This will read 5 bytes from file descriptor 0 (stdin) into memory starting from some_address.
So, if you type in (or pipe in) HELLO HACKERS into stdin, the above read call would result in the following memory configuration:
Address │ Contents
+───────────────────────────+
│ some_address │ 48 │
│ some_address+1 │ 45 │
│ some_address+2 │ 4c │
│ some_address+3 │ 4c │
│ some_address+4 │ 4f │
+───────────────────────────+
What are those numbers??
They are hexadecimal representations of ASCII-encoded letters.
If those words don't make sense, please run through the first half or so of the Dealing with Data module and then come back here!
In this level, we will combine read with our previous write abilities.
The flag will be piped into your program's stdin --- 128 bytes of it.
Your program should:
- first
read 128 bytes from stdin to your program's memory
write those 128 bytes from that memory location to stdout
- finally, exit with the exit code
42.
But what address should you use?
You need somewhere that's valid and writable, and you already know about one such place: the stack!
The rsp register points to the top of the stack, and there's plenty of writable space there.
So you can just use rsp as your memory address: mov rsi, rsp.
DEBUGGING:
Having trouble?
Recall the Introspection module!
Build your program and run it with strace to see what's happening at the system call level, or run it in gdb to inspect the values of registers and memory to see what's unexpected.
REMEMBER:
You've basically already written steps 2 and 3 (though in the previous challenges, you loaded rsi from [rsp+16] --- here, you'll set it to rsp directly with mov rsi, rsp!).
All you have to do is add step 1!
In the previous level, you knew the input was exactly 128 bytes, so you could read 128 and write 128.
Real input is rarely so tidy: often, you don't know up front how many bytes are coming.
Luckily, read tells you.
When a system call returns, Linux places its result in rax.
For read, that result is the number of bytes it actually read.
Ask it to read 128 bytes but only 50 are available, and it reads those 50 and leaves 50 in rax.
So the idiom is: read into your buffer using a count comfortably larger than you expect, then write back exactly the number of bytes read returned.
The only missing piece is that you need to move read's return value (rax) into write's size argument (rdx):
mov rdx, rax
Make sure to do this before clobbering rax with the syscall number of write!
This time the flag is piped in without padding.
read it, write back exactly what you read, and exit with code 42 to get the flag!
So far, your program has only interacted with stdin and stdout, but what about files on disk?
To access a file, you first need to open it using the open system call.
The open system call (syscall number 2) takes a pointer to a filename string and returns a brand-new file descriptor referring to that file:
open("/flag", 0);
The second argument specifies additional modes and permissions for the file, but 0 requests the default: read-only.
The registers for open follow the same convention:
| Register |
Purpose |
rax |
2 (syscall number for open) |
rdi |
pointer to the filename string in memory |
rsi |
0 (read-only) |
When open returns, rax contains the new file descriptor (fd) number.
Recall that file descriptor 0 is stdin, file descriptor 1 is stdout, and file descriptor 2 is stderr.
Other files that are open are just represented by other file descriptors, incrementing from 3 onwards!
You'll use this fd as the first argument to read, just like you did for stdin earlier, but this time read will read from your file.
How to load the filename into memory?
In this level, the path to the flag (/flag) will be passed as the first argument to your program.
You already know how to load that: mov rdi, [rsp+16].
Your program should:
- Load a pointer to the filename (stored at
[rsp+16], the first argument) into rdi
- Specify the default of read access for the second argument (set
rsi to 0).
open it (syscall 2)
read from the returned fd into memory. The fd open returned is in rax; move it to rdi for read's first argument (do this before you set the syscall number for write!). Read a comfortably large count --- the flag is shorter.
write to stdout exactly the number of bytes read returned (mov rdx, rax, just like in read-exact)
exit with code 42 (syscall 60)
DEBUGGING:
Having trouble?
Use strace to see your system calls in action --- it will show you exactly what arguments each syscall receives and what it returns.
If open is returning -1, double-check your filename pointer.
If read returns 0, the file descriptor from open might be wrong.
In the previous level, the filename was passed as an argument to your program.
But what if you need to open a file whose path you already know?
There are many ways to hardcode strings in your code, but for the purposes of the type of code you'll be writing in pwn.college, we are going to go with a "hacker" way designed more for use during software exploitation than real software development.
We will hardcode the filename string directly into your program by writing it onto the stack, byte by byte!
The open syscall needs a pointer to the filename, so you need the bytes / f l a g stored somewhere in memory.
You already know a writable memory address: rsp (the stack).
You can write each character one byte at a time:
mov BYTE PTR [rsp], '/'
mov BYTE PTR [rsp+1], 'f'
mov BYTE PTR [rsp+2], 'l'
mov BYTE PTR [rsp+3], 'a'
mov BYTE PTR [rsp+4], 'g'
mov BYTE PTR [rsp+5], 0
A few things to note here:
-
BYTE PTR: When you write to a memory address like [rsp] using an immediate value (a number or character), the CPU doesn't know how many bytes you intend to write --- one? two? eight? BYTE PTR is a size directive that tells the assembler "I mean exactly one byte." Without it, the assembler won't know what you want and will refuse to assemble the instruction.
-
Single quotes: In assembly, a single-quoted character like 'f' represents that character's one-byte ASCII value. So 'f' is just a convenient way of writing 0x66, and '/' is 0x2f.
-
The null byte: The last byte we write is 0 --- a special null byte. This is how Linux knows where a string ends: it reads bytes starting from the pointer you give it and stops when it hits a 0 byte. Without it, open would keep reading past "flag" into whatever else is on the stack, and you'd be trying to open a file with a nonsense name!
After writing these bytes, rsp points to the null-terminated string "/flag", ready to pass to open.
Your turn!
This time, no arguments are passed to your program.
You must construct the filename yourself.
Your program should:
- Write
"/flag\0" onto the stack byte by byte using mov BYTE PTR [rsp+N], ...
open it (syscall 2): rdi = rsp (the string you just wrote), rsi = 0
read from the returned fd into memory (syscall 0), using a comfortably large count
write to stdout exactly the number of bytes read returned (syscall 1)
exit with code 42 (syscall 60)
DEBUGGING:
Having trouble?
Use strace to trace your syscalls.
If open returns -1, your string pointer or encoding might be off.
Try x/s $rsp in gdb to see what string is actually on the stack.
In the previous level, you created the "/flag" filename by writing each byte onto the stack.
That is a useful technique, but it is frustrating to write and hard to reason about (imagine trying to spot a typo in a long sentence written this way!).
This challenge will show you a better way.
Luckily, your assembly can also contain bytes that are not meant to execute.
For example, if you put those bytes after your final exit syscall, the CPU will stop before it reaches them.
The bytes will still live in your program's memory, but will not crash your program by being interpreted as instructions.
For strings, the assembler gives you a convenient directive to specify these bytes:
_start:
...
mov rax, 60
syscall // exit!
path:
.asciz "/flag" // never executed, but still there!
The .asciz directive emits the bytes of the string along with the terminating zero byte that Linux expects at the end of a filename.
The path: label marks where those bytes start.
In later challenges, when you see a compiled binary load a pointer to a stored string, you are seeing the same idea from the other side: the bytes are stored in the program, and an instruction computes their address at runtime.
That leaves one problem: to pass this path into the open syscall, you need to set its address in rdi.
In the old days, programs would always be loaded to the same address in memory, and so you could hardcode this, as so:
_start:
...
mov rdi, path // this would tell the assembler to store the address of `path` in rdi
...
path:
.asciz "/flag"
Unfortunately, THIS DOES NOT WORK in cybersecurity contexts!
Modern software is compiled, for security reasons that we will cover in the Yellow belt, to be able to be loaded anywhere in memory.
This means that, at the time of assembly of the software, the assembler doesn't know the right address.
While this can be solved at start time for normal applications, modern CPUs have solved this problem in a different way: Instruction Pointer Relative Addressing.
On 64-bit x86, the instruction pointer (rip) is a register that always contains the address of the next instruction your CPU will execute.
However, it is not a normal register, in the sense that its usage is more limited than something like rdi (note that this is a quirk of x86; many other architectures let you directly access their instruction pointer).
That being said, 64-bit x86 does allow you to use addresses relative to rip for memory reads and writes.
For example:
_start:
...
mov rdi, [rip+path]
...
path:
.asciz "/flag"
THIS IS STILL NOT WHAT WE WANT!
Why? Because it reads the 8 bytes at [rip+path] into rdi rather than put the address of those bytes into rdi.
rdi would end up holding the values 'f', and 'l', and so on, but the open syscall needs the address and not the values.
Luckily, there is an instruction that is almost a read, but instead does put the address that would have been read into rdi (or whatever other register).
That instruction is load effective address (the word effective here refers to the CPU figuring out all the calculations it needs to do, such as adding an offset to the instruction pointer in this case):
_start:
...
lea rdi, [rip+path]
...
path:
.asciz "/flag"
This puts the address of the "/flag" string into rdi, rather than loading the contents of the string into rdi.
Think of path as the address where the first byte of "/flag\0" lives: mov copies bytes from there, while lea copies the address so the kernel can walk those bytes until the null byte.
Now, a quick note about the math here: though we write [rip+path] above, what actually gets added to rip is the delta in addresses between rip (which, again, is pointing to the instruction after lea) and the "/flag" string.
It's a weird syntax, and yet another little quirk of x86.
Use this in this challenge to set the path passed to open.
Your program should open the stored filename, read from the returned fd into memory, write back exactly the number of bytes read returned (mov rdx, rax, as in read-exact), and exit with code 42.
The new parts are:
- Store the filename with
.asciz after your code.
- Load the filename address for
open with lea rdi, [rip + path].
Run /challenge/check with your program, read the flag, and write it to stdout!
Control Flow
So far, your programs have been fairly straightforward: move some values around, read from memory, and invoke a system call.
But real programs need to make decisions: "if this condition is true, do one thing; otherwise, do something else."
This is the foundation of control flow, and it starts with being able to compare values.
In x86 assembly, comparisons are done with the cmp instruction.
cmp compares two values by subtracting the second operand from the first.
Crucially, cmp doesn't store the result of the subtraction anywhere you can see directly.
Instead, it updates the CPU's internal flags based on what the result looked like.
For example:
cmp rdi, 42
This internally computes rdi - 42, but rdi is not modified.
Instead, the CPU sets a special bit called the Zero Flag (ZF): if the result of the subtraction was zero (meaning the two values were equal), ZF is set to 1.
If rdi contains 42, then 42 - 42 = 0, and ZF becomes 1.
If rdi contains anything else, the result is non-zero, and ZF becomes 0.
Great, so after cmp, the CPU knows whether the values were equal.
But how do we actually use that information?
We can't directly mov the flags into a register.
Instead, x86 provides a family of "set on condition" instructions that write a 0 or 1 to a byte-sized destination based on the current flags.
The one we'll use here is setz ("Set if Zero"):
setz dil
This checks the Zero Flag and:
- If ZF = 1 (the values were equal, i.e., the subtraction result was zero), it writes
1 to dil.
- If ZF = 0 (the values were not equal), it writes
0 to dil.
Simple: 1 means "yes, they matched!" and 0 means "no, they didn't."
There's also a complementary instruction, setnz ("Set if Not Zero"), which does the opposite, but we won't need it here.
But what is dil?
So far, you've worked with 64-bit registers like rdi, rax, and rsp.
The setz instruction, however, only writes a single byte (8 bits).
Luckily, you can access smaller portions of the full 64-bit registers.
For rdi:
rdi is the full 64 bits
dil is just the lowest 8 bits --- the low byte of rdi
When you write setz dil, you're putting a 0 or 1 into just the lowest byte of rdi, leaving the upper bytes unchanged.
rdi is the value passed to the exit system call, but Linux reports only that value's low 8 bits as the process's exit status.
That is why changing only dil is enough here: the visible status becomes 1 (equal!) or 0 (not equal!), regardless of the upper bytes of rdi.
One more thing about cmp: it can compare a register with an immediate (cmp rdi, 42) or even a memory location with an immediate (cmp QWORD PTR [rsp], 42).
But it cannot compare two memory locations at once --- at most one operand can be a memory dereference.
Now, your challenge: recall from the Stack module that [rsp] contains argc --- the number of command-line arguments passed to your program, including the program name.
Write a program that:
- Compares
argc with 42 (whether by first moving argc into a register or comparing against the memory directly).
- Uses
setz dil to set the exit code: 1 if argc equals 42, 0 otherwise.
- Exits.
Now let's apply what you've learned to check a specific character in a command-line argument.
Recall from the Stack module that [rsp+16] holds a pointer to argv[1] --- the first command-line argument.
To actually look at the argument text, you first need to load that pointer into a register:
mov rax, [rsp+16]
Now rax holds the address of the argument string.
The first character of that string lives at [rax], the second at [rax+1], and so on.
To check whether the first character is, say, 'p':
cmp BYTE PTR [rax], 'p'
This reads one byte from the address in rax and compares it against the ASCII value of 'p'.
Remember: BYTE PTR tells the CPU you're working with a single byte, not a full 64-bit value.
You learned this back in the Output and Input module when you built strings on the stack byte by byte.
After the cmp, the Zero Flag reflects whether they matched, and you can capture that result with setz dil, just like before.
Your challenge: write a program that checks whether the first character of argv[1] is 'p'.
Exit with 1 if it is, 0 if it isn't.
Your program should use 5 instructions:
- Load the
argv[1] pointer from [rsp+16] into a register.
- Compare
BYTE PTR at that address against 'p'.
- Use
setz dil to capture the result.
- Set up the
exit syscall number.
syscall.
In the previous challenges, you used setz to capture a comparison result as a 0 or 1 in dil, then passed that directly as your exit code.
That was a neat trick --- but it has limitations.
What if you want your program to take entirely different actions depending on whether the values were equal?
This is where conditional jumps come in.
Instead of recording the comparison result into a register, you can tell the CPU to jump to a different part of your code based on the outcome of the cmp.
The most useful conditional jump for our purposes is jne (Jump if Not Equal):
cmp BYTE PTR [rax], 'p'
jne fail
After the cmp, if the values were not equal, the CPU jumps to the location labeled fail.
The terminology used for this is that it "takes the branch" (in the road/code).
If the values were equal, the CPU simply continues to the next instruction.
The terminology used for this is behavior of not taking the branch that it "falls through" to the next instruction.
Under the hood, jne checks the Zero Flag (ZF) that cmp set: jne jumps when ZF = 0 (meaning the subtraction result was non-zero, i.e., the values differed).
There's also je (Jump if Equal), which does the opposite: it jumps when the values are equal.
But what is fail?
It's a label --- a name you give to a location in your code.
Labels don't generate any machine instructions; they just mark a spot that jump instructions can refer to.
You define a label by writing its name followed by a colon:
fail:
mov rdi, 1
mov rax, 60
syscall
The assembler resolves the label to an address, so jne fail becomes something like jne <address> in the actual machine code.
You can name labels almost anything (fail, error, done, loop, etc.), but the name should describe what happens at that location.
With conditional jumps, your programs can now have two different paths of execution:
main:
[load and compare]
jne fail ← jump to fail if NOT equal
success:
mov rdi, 0
mov rax, 60
syscall
fail:
mov rdi, 1
mov rax, 60
syscall
If the comparison succeeds (the values are equal), execution falls through to the success path and exits with 0 --- the standard "success" exit code for Linux programs.
If the comparison fails (the values are not equal), execution jumps to the fail label and exits with 1 --- indicating program failure.
Of course, this is a simple example, but we'll start simple!
The challenge: write a program that checks whether the first character of argv[1] is 'p', using conditional jumps instead of setz:
- Load the
argv[1] pointer from [rsp+16] into a register.
- Compare
BYTE PTR at that address against 'p'.
jne fail --- jump to the failure case if the characters aren't equal.
- Write the "fall-through" success case (
exit(0)).
- Define the
fail: label and write the fail case (exit(1)).
The tricky thing is that your success case (jump not taken) is between your jne instruction and the fail case that the jne instruction refers to.
This can take a bit to wrap your head around, but you'll get used to it!
In the previous challenge, you used cmp and jne to check a single character and branch to a failure path.
But checking one character is rarely sufficient: passwords, commands, and filenames are all strings of multiple characters.
The good news: you already know everything you need to check a whole string!
You simply chain multiple cmp / jne pairs, one for each character, all jumping to the same fail label:
mov rax, [rsp+16] ; load argv[1] pointer
cmp BYTE PTR [rax], 'Y'
jne fail
cmp BYTE PTR [rax+1], 'E'
jne fail
cmp BYTE PTR [rax+2], 'S'
jne fail
Each comparison checks one character of the string.
Remember from the Computer Memory module that [rax+1] accesses the byte one past the address in rax, [rax+2] is two past, and so on.
Since strings are stored as contiguous bytes in memory, [rax] is the first character, [rax+1] is the second, [rax+2] is the third, etc.
If any character doesn't match, jne immediately jumps to fail --- the program doesn't bother checking the rest.
Only if all comparisons pass (all characters match) does execution fall through to the success path.
This is how many string comparisons work at the lowest level: compare byte by byte, bail out on the first mismatch.
Now, you will practice this.
Write a program that checks whether the first argument starts with the string "pwn":
- Load the pointer for the first argument from
[rsp+16].
- Compare byte at offset 0 against
'p' --- jne fail if it doesn't match.
- Compare byte at offset 1 against
'w' --- jne fail if it doesn't match.
- Compare byte at offset 2 against
'n' --- jne fail if it doesn't match.
- Implement the success path:
exit(0).
- Implement the
fail: label with exit(1).
In the previous challenge, you wrote assembly that compared strings character by character.
Well, the tables have turned!
We wrote a program, and you need to figure out what it does!
At /challenge/reverse-me, there's a SUID binary.
It takes a command-line argument, compares it against a hidden password one byte at a time (sound familiar?) and, if the password is correct, reads and prints the flag.
If any character is wrong, it silently exits.
How do you solve this?
You must read the disassembly of the program, analyze the cmp instructions, understand the password that the program needs, then run it with the correct argument.
You already have the tools for this!
From the Software Introspection module, remember: objdump -d -M intel /challenge/reverse-me disassembles the binary and shows its assembly instructions.
You'll see familiar cmp instructions similar to those you wrote in the last challenge, but instead of the familiar ''-quoted characters, the compared-against values will be written as hex.
The immediate values in those comparisons are the password characters, encoded as hexadecimal ASCII values.
For example, imagine that the disassembly shows:
cmp BYTE PTR [rax],0x70
Here, 0x70 is the ASCII code for 'p'.
You can get the full list of ASCII values by referencing the man ascii command.
Once you've recovered all the password characters, run the program directly:
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary --- it runs with elevated privileges so it can read /flag.
However, debugging a program will drop its SUID privileges, which means the open("/flag") syscall inside will silently fail if you run it under gdb.
You can use gdb or objdump to understand the binary and figure out the password, but make sure to run it directly (outside of gdb) to get the flag.
In the previous challenge, you reverse-engineered cmp/jne pairs to recover a password.
That technique checks each possibility one by one: compare, branch, compare, branch...
But what if a program needs to branch to one of many different destinations based on a single value?
There's a more efficient approach: a jump table.
A jump table is an array of addresses stored in memory, one for each possible destination (called a case).
Instead of comparing the input against every possibility, the program uses the input value as an index into the table, loads the address stored at that position, and jumps to it.
This pattern is called a switch, and it's a fundamental building block in programs.
In the disassembly, you'll see something like:
mov rax, 0 ; zero out rax
mov al, BYTE PTR [rcx] ; load the character into the low byte of rax
mov rax, [rax*8+0x1234000] ; load a stored address from the jump table at 0x1234000
jmp rax ; jump to it
You've seen dil (the low byte of rdi) before, and al is the same idea for rax.
Writing to al only changes the lowest 8 bits, leaving the rest of rax intact.
That's why the code first zeros rax: it ensures the upper bytes are 0, so after mov al, [rcx], rax holds just the character's value (0--255).
The character's value directly indexes a table of 256 entries (one per possible byte value).
Each entry is an 8 byte address pointing to code for that case.
In this way, the program implements conditional logic without any conditional control flow!
This challenge (at /challenge/reverse-me) has 256 possible cases, with only one of them (corresponding to an alphanumeric character) being different than the others.
Look at the jump table (you'll have to look at a lot of entries...), look at the program to understand how to influence the index, and get the flag!
When using gdb, give starti or run a one-byte placeholder argument (for example, starti A) so the program has an argv[1] to read.
NOTE:
Though you should look at the disassembly using objdump -d -M intel /challenge/reverse-me, objdump will try to interpret the jump table data as assembly instructions, which will result in garbage.
Ignore that section of the disassembly; you'll need to look at that data in gdb, instead.
HINT:
You'll likely want to use gdb extensively in this challenge, and x/a will be your friend.
For example, if you are in gdb at the instruction mov rax, [rax*8+0x1337000] (note, your address will differ), you can examine the jump table entries:
(gdb) print $rax
1
(gdb) x/a $rax*8+0x1337000
0x1337008: 0x400100
(gdb) x/a 2*8+0x1337000
0x1337010: 0x400100
(gdb) x/a 98*8+0x1337000
0x1337310: 0x400200
(gdb)
Once you find the table entry that points somewhere different, convert its table position back into the input byte.
Use the address of that table slot (the address on the left side of the x/a output), not the address stored inside it.
Subtract the table base from that slot address to get its offset into the table.
Then divide by 8, because each table entry is an 8-byte address.
In the example above, the unusual entry is at 0x1337310, so 0x1337310 - 0x1337000 = 0x310, and 0x310 / 8 = 98.
That means the input byte has value 98, which ASCII represents as 'b'.
HINT:
You can also print the whole jump table at the same time.
The output will be long, but it starts like this:
(gdb) x/256a 0x1337000
0x1337000: 0x400100 0x400100
0x1337010: 0x400100 0x400100
...
(gdb)
The number after x/ is how many entries gdb should print.
Since the input byte chooses one of 256 entries, and each entry is one 8-byte code address, this lets you scan the table for the one address that differs.
If gdb prints multiple entries on one line, the address on the left is the first entry on that line; the next entry is 8 bytes later.
As in the previous challenge, use gdb to understand the binary, then run /challenge/reverse-me directly with the recovered byte to get the flag.
So far, every control flow pattern you've seen executes in a straight line: compare, branch, done.
But what if you need to repeat the same operation many times?
That's what a loop is: a sequence of instructions that jumps backward to repeat itself.
In this challenge, /challenge/reverse-me compares argv[1] against the password using a loop:
loop:
mov al, BYTE PTR [rsi] ; load next password character
cmp al, BYTE PTR [rdi] ; compare against next argv[1] character
jne fail ; mismatch → jump to fail
cmp al, 0x0 ; reached the null terminator?
je success ; yes → all characters matched!
inc rdi ; **inc**rement rdi to advance to next argv[1] character
inc rsi ; **inc**rement rsi to advance to next password character
jmp loop ; jump back to the top — repeat!
The key instruction is jmp loop at the bottom.
Unlike jne (which only jumps when a condition is met), jmp unconditionally always jumps.
By jumping backward to the loop label, the program re-executes the same comparison logic on the next pair of characters.
The loop terminates when either a mismatch is found (jne fail) or the null terminator is reached at the end of the string after the other characters are successfully matched to the password (je success).
This is the fundamental pattern behind every for loop, while loop, and string operation you'll ever encounter in compiled code.
Analyze the binary, figure out the password, and get the flag!
In the previous challenge, you recognized a loop in a program someone else wrote.
Now you will write one yourself.
A loop needs three pieces of state: where the current work is, how much progress has been made, and a test that decides when the loop is finished.
For a string, the natural finish line is the null terminator: the 0 byte after the last character.
Each trip through the loop checks the current byte, advances to the next byte, updates the count, and jumps back to repeat.
Write a program that computes the length of argv[1] and exits with that length as its exit code.
Your program must inspect the string one byte at a time and use a backward jump to repeat the loop.
For example, if the argument is pwn, your program should exit with code 3.
If the argument is empty, it should exit with code 0.
Submit it to /challenge/check, and get the flag!
So far, every program you've written has been a complete executable: it starts at _start, runs from there, and exits with a syscall.
In this challenge, your code will be a single function inside a shared library, not a standalone executable.
A shared library (called a .so file on Linux) is a chunk of compiled code that some other program loads at runtime and calls into.
Typically, such libraries perform utility functions, such as parsing image files (e.g., libpng parses PNG files) or handling general system-facing tasks (libc provides a lot of memory management, file management, and system interaction code).
Deep inside, the actual interaction with the operating system takes place using system calls, but libraries provide a better interface to interact with than raw system calls.
This challenge plays the role of a program that loads your library (using libc's dlopen functionality), looks up your function by name, and calls it with arguments.
In Computer Science nomenclature, your code is the callee and the challenge is the caller.
The call instruction.
How does the grader get into your code in the first place?
It executes a new instruction you haven't met yet: call.
call <target> is x86's function-call instruction. It does two things:
- Pushes the address of the next instruction after the
call instruction (the return address) onto the stack.
- Jumps to
<target>.
In our case, the grader runs the equivalent of call solve, and execution lands at the top of your solve function.
You don't have to do anything special to "receive" the call --- you just start running.
For this first challenge, you also don't have to do anything special to finish the call.
We'll deal with the saved return address in the next challenge; for now, just end your code with the exit syscall you already know.
This is the same shape as every program you've written so far --- the only thing that has changed is who started executing you.
Writing the function.
Your assembly should look like this:
.intel_syntax noprefix
.global solve
solve:
<your code, ending in an exit syscall>
The .global solve line tells the assembler "expose this code so other code can find it" --- just like .global _start did for executables back in the building level.
The solve: label actually specifies where the code is.
Building a shared library.
You already know how to assemble a program with as and link it with ld.
To produce a shared library instead of an executable, pass -shared to ld:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
Then submit the .so to the grader:
hacker@dojo:~$ /challenge/check your-solve.so
The calling convention.
When the grader calls solve, it passes arguments in registers.
In the case of this challenge, your solve function takes two arguments:
| Register |
Role on entry |
rdi |
First argument (a pointer to a buffer of bytes) |
rsi |
Second argument (the length of that buffer) |
You've already seen rdi used to hold the first argument of a syscall (the exit code, a file descriptor, etc.).
That's because Linux syscalls and Linux functions use the same convention for the first few argument registers.
For this challenge, the challenge will pass you your flag as the buffer, with the flag's length in rsi.
Write the rsi bytes starting at rdi to file descriptor 1 (stdout) using the write syscall (just like before!), and then exit the process cleanly with code 0.
Get it right, and your solve will print your flag for you!
Hint: Keep in mind that write() takes arguments in the order of: file descriptor (1 in rdi for stdout), buffer (pointer to memory, in rsi), and size (in rdx).
This is different from the arguments your function will be called with, so you'll need to move some stuff around!
Debugging your solution.
Since your code is a function inside a shared library, there's no entry point to launch under gdb directly --- but you can give it one.
Add a tiny _start to your code that fakes the grader's call: point rdi at a stand-in buffer, set rsi to its length, and call solve.
Now you can step through your logic in plain gdb, with no flag and no privileges needed:
.global _start
_start:
push 0x41414141 // put four 'A' bytes (0x41) on the stack to stand in for the flag
mov rdi, rsp // first argument: a pointer to those bytes
mov rsi, 4 // second argument: how many bytes to print
int3 // optional: gdb breaks here without setting a breakpoint
call solve // your solve runs, prints the bytes, and exits on its own
Assemble and link it as a normal executable (no -shared --- this version has an entry point), then load it in gdb:
hacker@dojo:~$ as -o debug.o debug.s
hacker@dojo:~$ ld -o debug debug.o
hacker@dojo:~$ gdb ./debug
(gdb) run
Execution stops at your int3; step through with the techniques from Software Introspection, watching the registers and the buffer.
If your solve is correct, this prints AAAA --- and the same logic will print your real flag when you submit the .so to the grader.
You can also debug the native harness directly with stand-in bytes instead of the flag.
/challenge/check is the Python checker script, so do not load it as the executable in gdb.
The native program that loads your .so is /challenge/harness:
hacker@dojo:~$ gdb --args /challenge/harness your-solve.so
(gdb) run
The checker will run that same harness shape with the real flag when you submit your .so.
In the previous challenge, your solve function ended with an exit syscall.
That worked, but it also meant the caller never got control back --- once you exited, the whole process was gone.
In a real program, of course, this is not ideal.
A real callee is supposed to hand control back to whoever called it, so the caller can continue doing its own work.
That's what the ret (return) instruction is for.
Recall the call instruction from the previous challenge: it pushed a return address onto the stack before jumping into your code.
ret is the matching half: it pops that saved return address off the stack and jumps to it.
Together, call and ret form the basic function-call/function-return pair in x86 --- one to get into a function, one to get back out.
In this challenge, your solve function has to return a value back to the challenge.
In the Linux x86-64 calling convention, the return value of a function goes in rax.
You've already seen rax used in syscalls --- it holds the syscall number on entry and the syscall's result on exit.
The same register also holds the return value of a regular function.
The mechanics:
- The challenge executes
call solve, passing one argument in rdi. When it does this, the next instruction in the challenge after call solve gets pushed onto the stack as the "saved return address".
- Your function does some work.
- Your function puts its result in
rax.
- Your function executes
ret, which pops the saved return address off the stack and jumps back to the challenge.
- The challenge reads
rax as your function's result ("return value").
For this challenge:
rdi will contain a secret 64-bit value chosen at random by the challenge.
- Your function must return that same value back, in
rax.
Once the challenge receives the correct value, it will give you the flag!
So far you've been on the callee side of a function call: the challenge called your solve, and you did the work.
Now we flip it: your solve will receive a function pointer as an argument, and you have to call that function from your code.
A function pointer is just an address: a 64-bit value that names the location of some code in memory.
The challenge passes the pointer in rdi (the first argument to your function), so to call it you can use the register form of call:
call rdi
This pushes the address of the instruction after the call (the return address) onto the stack, then jumps to the address held in rdi.
When that callback eventually rets, your function's execution will continue right after your call rdi --- the same call/ret pair you learned in the previous two levels.
The callback prints the flag for you. Go for it!
In the previous level, you called the callback with call rdi --- easy, since the pointer was already in rdi.
This time, you have to call it with 1337 as its first argument.
The calling convention says the first argument goes in rdi --- the same register the function pointer is in (because it's passed in as the first argument to your solve function).
This means trouble:
- If you set
rdi to 1337, you'll clobber the pointer (and then call rdi will try to call a function at address 1337 and crash)
- If you
call rdi with rdi intact, you'll pass the function pointer as the first argument instead of 1337, and you won't get the flag.
This is an instance of a fundamental reality when dealing with assembly: your program has to share the same set of registers, and one function might want rdi for some different purpose than another.
To resolve this contention, caller functions typically will push important registers to their local stack frame before invoking callees.
Anyways, here, the fix is simple: you have to use another register for the call, for example, by moving the callback function address into rax and then invoking call rax.
That will free rdi for your argument!
In the last level you saw two functions fight over rdi.
That's a glimpse of a bigger problem: there are only sixteen general-purpose registers, and every function in the program shares them.
When you call a function, it's going to use registers for its own work, so what happens to the values you had in them?
The answer is determined by the Calling Convention of your architecture (in our case, 64-bit x86), which defines how functions pass arguments and share registers across function calls.
Generally, a calling convention splits registers into two groups so that separately-written functions can cooperate:
- caller-saved registers may be freely overwritten by any function you call. If you have a value in one of these that you need after a call, it's your job (the caller's) to save it first and restore it afterward. Typically, this is done by
pushing them to the stack before calling the callee and poping them off the stack later. On x86-64, these registers are rax (which callees will clobber by moving the return value to), rcx, rdx, rsi, rdi, r8, r9, r10, r11.
- callee-saved registers must be left untouched by the functions you call. Rather, callees can touch them, but they must restore them back to their original state. On x86-64, these are
rbx, rbp, r12, r13, r14, r15.
Note that this convention is just that, a convention.
Code can misbehave and violate this, though this doesn't really happen in practice with reasonable code.
This level forces you to explore caller-saved registers.
Your solve function is given two arguments:
rdi: a pointer to a clobber_function that will clobber all caller-saved registers
rsi: a pointer to a flag_function that will give you the flag if you call it with your registers un-clobbered
You must call clobber_function before flag_function.
But you must preserve your caller-saved registers before calling clobber_function and restore them afterwards.
Build your shared library and hand it to the grader:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Do it right, and the flag is yours!
You've practiced caller-saved registers, and now we'll cover callee-saved ones.
The callee-saved registers (rbx, rbp, r12, r13, r14, r15) must come back unclobbered when the callee returns.
When a function uses one of them, it is borrowing it from whoever called it, and it must return it in exactly the condition it was found.
This is what lets a caller keep long-lived values in rbx, rbp, and r12-r15 across a call and trust they'll still be there afterward.
The rule is, when your function wants to use these registers, save (push) them on entry to your function, restore (pop) them before you ret.
This level puts you on the callee side.
Your solve is called as solve(check_callee_clobbered) (the pointer is in rdi), and your caller has live values sitting in rbx, rbp, r12, r13, r14, r15 that it expects back untouched.
Your job:
- Save off all the callee-saved registers.
- Clobber them all by setting them to
0x1337.
- Call
check_callee_clobbered, which confirms you clobbered them.
- Restore the callee-saved registers.
ret.
The challenge then checks that rbx, rbp, and r12-r15 came back exactly as the caller left them.
Build your shared library and hand it to the grader:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Borrow them, give them back, and claim the flag.
Endian Escapades
x86 stores every multi-byte value little-endian (low byte first), and that fact turns up everywhere you look at raw memory: hex dumps, debuggers, exploits.
To work properly in these contexts, you need to understand byte order.
This module makes it second nature.
You'll crack a series of programs that hide a password in their own compiled code, reading it back out of the disassembly at every size, whether it's a "qword", a lone byte, or a structure.
By the end, byte order won't trip you up in a hex dump, a debugger, or an exploit ever again (or we'll add more challenges to make it so!).
Every value wider than a single byte (a 16, 32, or 64-bit number, a memory address, etc) has to be split into individual bytes to live in memory, because memory is addressed one byte at a time.
Being used to working with large decimal numbers (e.g., 1337) in real life, with the "least significant" digit on the right and the "most significant" digit on the left, you might expect something similar in the CPU.
For example, if you were storing the 16-bit (2-byte) value 0x1234, you might expect it to be stored as two consecutive bytes, first 12 and then 34.
Some CPUs do work like this, but most do not.
Most architectures store the least significant digit on the left and the most significant on the right.
In these architectures, the value 0x1234 would be stored in two consecutive bytes as 34 12.
Because the "little" (least significant) end goes first, these are called "Little Endian" (LE) architectures, and represent essentially all modern CPU architectures.
Of course, though this seems extremely silly to anyone that encounters it for the first time, there are number of solid reasons behind it:
- A lot of arithmetic is done from the little end. Consider long addition: you start from the small values and carry the 10 to the left. An Arithmetic Logical Unit does the same thing, and Little Endian is natural here.
- A value's address is the address of its low byte, so reading a 4-byte
int as a 1-byte char (or a 2-byte short) is the same address --- you just read fewer bytes. In Big Endian systems, this requires fixing up the address, which is complicated.
Hopefully, you're convinced.
Now, get familiar with some more examples:
| size of value |
decimal value |
hex value |
big endian bytes (NOT x86) |
little endian bytes (x86) |
| 8 (1 byte) |
65 |
0x41 |
41 |
41 |
| 16 (2 bytes) |
4660 |
0x1234 |
12 34 |
34 12 |
| 32 (4 bytes) |
1145258561 |
0x44434241 |
44 43 42 41 |
41 42 43 44 |
| 64 (8 bytes) |
1145258561 |
0x0000000044434241 |
00 00 00 00 44 43 42 41 |
41 42 43 44 00 00 00 00 |
A single byte is identical in both columns: with only one byte there's no order to pick, so endianness only matters once a value is two bytes or wider.
The 32- and 64-bit rows hold the same number --- the 64-bit version just pads with zero bytes, which, being the most-significant bytes, sit at the higher addresses.
Endianness is very much a CPU-level concept.
When you move beyond it (e.g., when sending data over the network), big-endian encoding of numbers rears its head.
And even most of the time when working in assembly, you don't really have to think about endianness.
For example, you've already stored and loaded multi-byte values without reversing byte order, because a value (say mov rax, 0x1234; push rax) written to memory and read straight back (e.g., pop rax) comes out unchanged: it's written in little-endian order on the stack by push and endian-corrected when it's read back into the register by pop.
Endianness only matters in memory, but the moment you look at memory as bytes (in a hex dump, in a debugger, in an exploit) the byte order is right there, and you have to read it the way the CPU wrote it.
The easiest place to get turned around is the boundary between memory order and the value printed from a register.
Suppose rdi points at these eight bytes:
Address Byte
[rdi+0] 41
[rdi+1] 42
[rdi+2] 43
[rdi+3] 44
[rdi+4] 45
[rdi+5] 46
[rdi+6] 47
[rdi+7] 48
A 64-bit load reads those bytes starting at the lowest address:
mov rax, [rdi]
Because x86 is little-endian, [rdi+0] becomes the low byte of rax, [rdi+1] becomes the next byte, and so on.
The register value is therefore 0x4847464544434241.
Written as hex, the most-significant byte prints on the left, so the bytes look reversed compared to address order:
memory address order: 41 42 43 44 45 46 47 48
register hex order: 48 47 46 45 44 43 42 41
rax value: 0x4847464544434241
The bytes did not move in memory.
The CPU interpreted the byte at the lowest address as the least-significant part of the number.
When you work in assembly, you're constantly choosing how many bytes an operation touches: one, two, four, or eight.
x86 has a name for each of these sizes, and they're worth knowing, because you'll meet them everywhere in disassembly, in assembler size directives, and baked into the register names you already use.
| Name |
Bits |
Bytes |
Partial rax Access |
Memory Access |
| byte |
8 |
1 |
mov al, [rdi] |
mov BYTE PTR [rdi], 0x11 |
| word |
16 |
2 |
mov ax, [rdi] |
mov WORD PTR [rdi], 0x1122 |
doubleword (dword) |
32 |
4 |
mov eax, [rdi] |
mov DWORD PTR [rdi], 0x11223344 |
quadword (qword) |
64 |
8 |
mov rax, [rdi] |
mov QWORD PTR [rdi], 0x1122334455667788 |
You've already met some of these registers: al is the low byte of rax, ax the low 2 bytes, eax the low 4, and rax all 8.
The size names are just another way of saying the same thing --- al holds a byte, eax holds a dword, rax holds a qword.
Why is a "word" 16 bits?
The names trace back to Intel's early chips, each working in the chunk of data that was natural to it.
The ancestors of the x86-64 processor (the 8008 (1972), and the 8080 after it) were 8-bit processors, moving data one 8-bit byte at a time, so the byte (8 bits) is where the sizes start.
Intel's next upgrade was the 8086 (1978), a 16-bit chip.
Its registers were 16 bits wide, and 16 bits was the natural chunk of data it handled.
But because the 8086 needed to be backwards compatible with the 8080 for commercial reasons (e.g., execute programs written and assembled for the 8080), the term "byte" had to remain 8-bits, and they needed a new term for the 16-bit width.
They used "word".
When 32-bit (the 386) and then 64-bit (x86-64) chips arrived, they again couldn't redefine "word" without breaking every program and assembler that already relied on it.
Instead, they named the new sizes relative to that original word: a doubleword (dword) is two words (32 bits), and a quadword (qword) is four words (64 bits).
The "word" collision.
Outside of x86's size directives, computer architects use "word" more loosely: the machine word (or "word size", or "word width") means the natural width a processor works in --- essentially its register width.
By that definition, x86-64 is a "64-bit word" machine.
So the same term points at two different sizes: in x86 assembly a WORD is 16 bits (no matter how wide the machine is), but "the machine's word" is the full register width --- 64 bits on x86-64.
When you read "word", work out which is meant: the fixed 16-bit x86 size, or the loose "how wide is a register" sense.
To avoid this confusion, there is another term often used for a 16-bit value: a short.
So, byte, short, dword, and qword don't have this problem --- they always mean 8, 16, 32, and 64 bits.
You have seen that byte, word, dword, and qword describe how many bytes an instruction reads or writes.
Now add one more wrinkle: a smaller value can be copied into a larger register as either unsigned or signed.
If the byte is unsigned, filling the high bytes with zero is fine: 0x7f becomes 0x000000000000007f.
But a signed byte uses two's complement.
The byte 0xff is -1, so extending it to 64 bits must fill the new high bits with 1s: 0xffffffffffffffff.
That is sign extension.
It copies the sign bit, not zeroes, into the new high bits.
On x86-64, the form you need here is:
movsx rax, BYTE PTR [rdi]
This reads one byte from the address in rdi, treats that byte as signed, and returns the 64-bit signed value in rax.
Write a function called solve that takes a pointer to one byte in rdi.
Load that byte as a signed 8-bit value, sign-extend it to 64 bits, return it in rax, and export it with .global solve.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
You just read how x86 stores multi-byte values little-endian --- low byte first.
Time to use it: /challenge/reverse-me hides an 8-character password in a single qword, deep in its own code.
It loads your input and compares all 8 bytes at once against a hard-coded value:
movabs rbx, 0x4847464544434241
mov rax, [rdi]
cmp rax, rbx
jne fail
(movabs is new, but it's just a mov: a normal mov's immediate maxes out at 32 bits, so the assembler uses this wider form --- "move absolute" --- when the constant fills all 64. Read it as a mov.)
That immediate is the password as the CPU read it from memory --- little-endian, so its bytes are the characters in reverse:
0x4847464544434241 -> bytes 48 47 46 45 44 43 42 41 (high to low, as printed)
-> low byte first: 41 42 43 44 45 46 47 48 -> "ABCDEFGH"
Disassemble it, read that one movabs immediate, reverse its eight bytes into the password, and run it:
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
You've seen that a multi-byte value sits in memory low byte first, so reading one back into a register reverses its bytes.
A single register tops out at eight bytes, but plenty of values are wider than that (and wider than the CPU can easily access at one time).
Depending on the program, such values might be accessed sequentially, register-width by register-width.
For example, consider a 16-byte password, as you will experience in this challenge.
The sixteen ASCII bytes, read as two 8-byte qwords, might be accessed like this:
| Address |
Value |
0x1337000 |
0x41 |
0x1337001 |
0x42 |
0x1337002 |
0x43 |
0x1337003 |
0x44 |
0x1337004 |
0x45 |
0x1337005 |
0x46 |
0x1337006 |
0x47 |
0x1337007 |
0x48 |
0x1337008 |
0x49 |
0x1337009 |
0x4a |
0x133700a |
0x4b |
0x133700b |
0x4c |
0x133700c |
0x4d |
0x133700d |
0x4e |
0x133700e |
0x4f |
0x133700f |
0x50 |
As a string value stored in memory byte by byte, that ordering is not affected by endianness.
What endianness flips is the bytes as they end up in a register after the mov: each one, being a multi-byte value, still reads back low byte first.
So if rdi is pointing to this buffer, a mov rsi, [rdi] would end up with the value 0x4847464544434241, and the next mov rsi, [rdi+8] would have the value 0x504f4e4d4c4b4a49.
So to rebuild a longer value: keep the qwords in the order you get them, but reverse the bytes inside each one.
Disassemble /challenge/reverse-me, read the two qword values it checks your input against, endian-correct each one, concatenate them in order, and run it with the result:
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
Endianness isn't special to 64-bit values.
Every integer read from memory comes back little-endian, and the bytes that get reversed are exactly the ones covered by that read.
For example, consider the following bytes, contiguously chilling in memory pointed to by rdi:
rdi -> 11 22 33 44 55 66 77 88
If you read all 8 bytes into, say, rsi with mov rsi, [rdi], rsi will have the value of 0x8877665544332211.
You could also read it as two 32-bit (4 byte) values (into, say, the 4-byte partial registers esi and edx, which are 32 bits of rsi and rdx, respectively):
mov esi, [rdi] // results in 0x44332211 in esi
mov edx, [rdi+4] // results in 0x88776655 in edx
This makes sense written out, but it can confuse some people.
Specifically, what does not happen is the reversal of the whole 8-byte value (in which case, esi above would have the 0x88).
You'll practice this in this challenge.
/challenge/reverse-me checks the same kind of password four bytes at a time, as 32-bit values (termed "dwords"), so you get four integers instead of two.
Each dword still reverses its own four bytes; the dwords themselves stay in address order, just like the qwords did.
Because a dword fits the normal immediate size, each check is a direct cmp eax, 0x........ --- the value to recover is right there in the instruction:
mov eax, [rdi+0]
cmp eax, 0x44434241
jne fail
Disassemble /challenge/reverse-me, read the four cmp eax immediates, endian-correct each dword, concatenate them in address order, and run it with the result:
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
Smaller still: /challenge/reverse-me now checks two bytes at a time, as words.
The rule never changes --- the bytes that reverse are exactly the ones in the read.
A word read swaps its two bytes; the words stay in address order.
(And a one-byte read has nothing to swap, which is why single bytes never need endian-correcting.)
Disassemble /challenge/reverse-me, read the eight cmp ax, 0x.... immediates, swap each pair, keep the words in address order, and run it:
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
One byte at a time, in the 8-bit register al:
mov al, [rdi+0]
cmp al, 0x41
jne fail
And here's the payoff. A single byte has no order to reverse, so the immediates are the characters, already in order --- 0x41 is just 'A'. Sixteen byte-compares, and no endian-correcting at all.
This is the far end of the rule you've been applying: the reversal unit is the read size, so a one-byte read reverses nothing.
Read the immediates straight down the disassembly and run it.
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
Real programs rarely read a buffer at one uniform size.
They read structs: a handful of fields of different sizes, laid out one after another in memory (in fact, struct is structure for short).
This /challenge/reverse-me treats your password as a struct.
You might not know the C programming language, but if you did, this is what the structure would be defined as:
struct { uint64_t a; uint32_t b; uint16_t c; uint8_t d; uint8_t e; };
The disassembly loads each field at its own width and offset:
movabs rbx, 0x................ ; a: 8-byte field at +0
mov rax, [rdi+0]
cmp rax, rbx
mov eax, [rdi+8] ; b: 4-byte field at +8
cmp eax, 0x........
mov ax, [rdi+12] ; c: 2-byte field at +12
cmp ax, 0x....
mov al, [rdi+14] ; d: 1-byte field at +14
cmp al, 0x..
mov al, [rdi+15] ; e: 1-byte field at +15
cmp al, 0x..
This is the whole module in one challenge.
For each field, read three things off the access: its width (from rax/eax/ax/al), its offset ([rdi+X]), and its value (endian-correct the immediate according to the field's width).
Reassemble the fields in offset order and you have the password.
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
The last struct read its fields top to bottom, in memory order, so reading the disassembly straight down handed you the password already in order.
Nothing guarantees that.
A program can check a struct's fields in any order it likes --- the order the compares appear in the code has nothing to do with where those bytes live in memory.
This /challenge/reverse-me checks the same five fields, but scrambled:
mov ax, [rdi+12] ; the +12 word might be checked first...
cmp ax, 0x....
mov al, [rdi+15] ; ...then a byte from the very end...
cmp al, 0x..
movabs rbx, 0x................ ; ...then the +0 qword, and so on.
mov rax, [rdi+0]
cmp rax, rbx
So you can no longer read the password straight down the disassembly.
Recover each field's value exactly as before, but now also read its offset from the [rdi+X] load --- that offset is where the bytes belong.
Place each field at its offset, concatenate in offset order, and you have the password.
hacker@dojo:~$ objdump -d -M intel /challenge/reverse-me
hacker@dojo:~$ /challenge/reverse-me YOUR_PASSWORD_HERE
WARNING:
/challenge/reverse-me is a SUID binary, so debugging it drops its privileges and the open("/flag") inside will silently fail under gdb.
Use objdump to read it, but run it directly to get the flag.
Assembly Assortment
You have a working knowledge of assembly from your journey thus far.
Let's broaden it!
This module will explore the effects of a number of different assembly instructions, teaching you to recognize them and not panic in their presence.
In most of these challenges, you'll reverse-engineer a binary to understand an instruction's effect on data and feed it input it will accept.
In a few others, you'll put an instruction to work yourself, writing a small function around it.
Either way, if you understand what the instruction does, you will get flags!
In the previous challenge, you reversed a program by finding password characters directly in the cmp instructions.
This time, the program transforms your input before comparing it.
You'll need to understand and mentally invert this operation to successfully pass the check!
At /challenge/reverse-me, there's a new SUID binary.
It will do some math on the first byte of the first program argument, and compare it against a hardcoded value.
If the comparison passes, it reads and prints the flag.
Otherwise, it silently exits.
The new instruction here is add, as so:
add rax, 42
This adds 42 to the rax register and updates rax with the result.
The following would result in rax having the value 99:
mov rax, 57
add rax, 42
Like many other instructions, add can handle memory, registers, or immediates, when you disassemble this binary with objdump -d -M intel /challenge/reverse-me, you might see something like:
add BYTE PTR [rax],0x2a
cmp BYTE PTR [rax],0x96
This adds 0x2a (42) to the first byte of your input (in memory), then checks if the result equals 0x96 (150).
To figure out what character you need, just reverse the math: 150 - 42 = 108 (6c).
Looking at man ascii, 0x6c is the character 'l'.
So the required input character in this case is l (remember, man ascii is your friend for converting between hex values and characters)!
Once you've figured out the character, run the program:
hacker@dojo:~$ /challenge/reverse-me YOUR_CHARACTER_HERE
Now it's your turn!
Go and get the flag.
WARNING:
/challenge/reverse-me is a SUID binary --- it runs with elevated privileges so it can read /flag.
However, debugging a program will drop its SUID privileges, which means the open("/flag") syscall inside will silently fail if you run it under gdb.
You can use gdb or objdump to understand the binary, but make sure to run it directly (outside of gdb) to get the flag.
In the previous challenge, the program used add to transform your input before checking it.
This time, it uses sub (as in, subtract) instead.
Analogous to add, sub rax, 42 will subtract 42 from rax and store the result in rax.
Otherwise, this challenge is the same as the previous one.
Go get it!
This challenge introduces a new type of operation: bitwise XOR.
Unlike add and sub, which do arithmetic, xor operates on individual bits.
The xor instruction computes the exclusive or of two values: for each bit position, the result is 1 if exactly one of the two input bits is 1, and 0 otherwise.
For example:
01100001 (0x61, 'a')
^ 00101010 (0x2a, 42)
---------
01001011 (0x4b, 75)
In diagrams and expressions such as 0x4b ^ 0x2a, ^ means XOR; in assembly, the instruction is xor.
The syntax is the same as add and sub: xor rax, 42.
A key property of XOR is that it's its own inverse: xoring a value with the same value twice gives back the original value.
So if you see:
xor BYTE PTR [rax],0x2a
cmp BYTE PTR [rax],0x4b
The program XORs your input byte with 0x2a and checks if the result is 0x4b.
To reverse this, XOR the target with the key: 0x4b ^ 0x2a = 0x61, which is 'a'.
Now: disassemble the binary, reverse the XOR, and get the flag!
Here's a new instruction for your toolkit: and.
A bitwise and compares two values bit by bit.
Each output bit is 1 only if both input bits are 1; otherwise it's 0.
Here is the rule for a single pair of bits:
0 & 0 = 0
0 & 1 = 0
1 & 0 = 0
1 & 1 = 1
and applies that rule to every bit position at once:
1011 0111 (your value)
& 0000 0001 (the mask)
---------
0000 0001 (only the lowest bit survives)
Notice the mask above is 1 in only one place: the lowest bit.
Wherever the mask is 0, the output is forced to 0, so every bit except the lowest is wiped out.
What survives is just the value's lowest bit, all on its own.
That lowest bit is special: it tells you whether the whole number is even or odd.
A number is even exactly when its lowest bit is 0, and odd exactly when its lowest bit is 1.
So masking off everything but the low bit --- the way you just did --- hands you the number's parity.
Write a function called solve that takes a 64-bit value in rdi and returns, in rax, 1 if the value is even and 0 if it is odd.
One thing to watch: the bit you isolate comes out 1 for odd, but solve has to return 1 for even.
So the low bit isn't quite your answer --- it's the answer turned around.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
You met and for the even/odd test; here you'll use it to keep only the bits you want.
A bitwise and compares two values bit by bit: each output bit is 1 only if both input bits are 1.
That makes and the tool for masking --- keeping the bits you want and forcing the rest to zero.
Wherever the mask has a 1, the original bit passes through; wherever it has a 0, the result bit is cleared:
1011 0110 (your value)
& 0000 1111 (the mask: keep the low 4 bits)
---------
0000 0110 (everything above the low 4 bits is gone)
In x86 that masking is a single and, with the mask as the second operand:
and rax, 0xF
A common use is isolating the lowest byte of a value --- the low 8 bits --- by masking with 0xFF.
Write a function that takes a 64-bit value in rdi and returns, in rax, just its lowest byte.
Call it LOBYTE, in capitals: standing for LOw BYTE, and often used as shorthand for this functionality in tools you'll become familiar with later.
Export it with .global LOBYTE.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o lobyte.o lobyte.s
hacker@dojo:~$ ld -shared -o lobyte.so lobyte.o
hacker@dojo:~$ /challenge/check lobyte.so
A bitwise or also compares two values bit by bit, but its rule is the opposite of and: each output bit is 1 if either input bit is 1.
That makes or the tool for setting bits --- turning specific bits on while leaving the rest alone.
Wherever the mask has a 1, the result bit is forced to 1; wherever it has a 0, the original bit passes through unchanged:
0100 0001 ('A', 0x41)
| 0010 0000 (turn on 0x20)
---------
0110 0001 ('a', 0x61)
That example is a real trick.
In ASCII, an uppercase letter and its lowercase partner differ only in the 0x20 case bit (the sixth bit from the right).
The lowercase value is the uppercase value with the case bit set:
| Uppercase |
Lowercase |
A = 0x41 |
a = 0x61 |
H = 0x48 |
h = 0x68 |
P = 0x50 |
p = 0x70 |
Z = 0x5A |
z = 0x7A |
or takes its operands the same way and does --- the value to modify, then the mask.
Set the case bit with or, and any uppercase letter becomes its lowercase form.
Write a function that takes an uppercase ASCII letter in rdi and returns its lowercase form in rax.
Call it chr_lower --- name your functions for what they do, and your code stays readable as it grows.
Export it with .global chr_lower.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o chr_lower.o chr_lower.s
hacker@dojo:~$ ld -shared -o chr_lower.so chr_lower.o
hacker@dojo:~$ /challenge/check chr_lower.so
Lowercasing set the case bit with or.
Uppercasing is the mirror image: you clear that same bit.
and is the bit-clearing tool --- you used it to mask bits down to zero.
A 0 in the mask forces the result bit off; a 1 lets the original bit through.
So to clear just the 0x20 bit and keep everything else, the mask is 0x20 flipped: 0xDF.
0110 0001 ('a', 0x61)
& 1101 1111 (0xDF: keep every bit except 0x20)
---------
0100 0001 ('A', 0x41)
and rax, 0xDF
That clears the case bit of one letter.
This time, though, you'll do it to a whole string.
A string is a run of bytes in memory, one after another, ending in a 0 byte --- the NUL terminator --- that marks where it stops.
To walk it you need a loop: the same jmp-back-to-the-top shape you practiced in Writing Loops.
So your loop is: look at the next byte; if it's the NUL (0), jump past the loop and you're done; otherwise clear its case bit, store the byte back, advance to the next one, and jump back to the top.
The high-level of the loop would be:
loop:
...
je done
...
jmp loop
done:
...
...
ret
Write a function that takes a pointer in rdi to a lowercase ASCII string and uppercases it in place, looping until the NUL.
It returns nothing.
Call it str_upper (it works on a whole string, not one character), and export it with .global str_upper.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o str_upper.o str_upper.s
hacker@dojo:~$ ld -shared -o str_upper.so str_upper.o
hacker@dojo:~$ /challenge/check str_upper.so
or always sets the case bit (forcing lowercase); and always clears it (forcing uppercase).
Neither one swaps case: each only pushes a letter one direction.
To flip a bit --- on to off, off to on --- you need xor.
Since xor sets a bit exactly when its two inputs differ, XORing a bit with 1 inverts it, and XORing with 0 leaves it alone.
So XORing a letter with 0x20 flips its case bit either way: uppercase becomes lowercase, and lowercase becomes uppercase.
0110 0001 ('a', 0x61) 0100 0001 ('A', 0x41)
^ 0010 0000 (flip 0x20) ^ 0010 0000 (flip 0x20)
--------- ---------
0100 0001 ('A', 0x41) 0110 0001 ('a', 0x61)
That trick works because every byte here is a letter.
The 0x20 bit only means "case" for letters; flip it on a space or a digit and you get a different, wrong character.
The strings you're handed are all A-Z and a-z, so a blind toggle of every byte is safe --- no need to check.
Walk the string the same way as before --- load a byte, flip its case bit, store it back, advance, and repeat until the NUL.
Write a function that takes a pointer in rdi to a mixed-case ASCII string and swaps the case of every letter in place, looping until the NUL.
It returns nothing.
Call it str_swapcase, and export it with .global str_swapcase.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o str_swapcase.o str_swapcase.s
hacker@dojo:~$ ld -shared -o str_swapcase.so str_swapcase.o
hacker@dojo:~$ /challenge/check str_swapcase.so
A bit shift moves every bit in a fixed-width value the same number of positions.
A left shift moves bits toward the high end, drops the bits that leave that end, and inserts zeros at the low end.
Here is a two-position left shift shown in one byte:
before: 1011 0010 (178)
dropped from high end: 10.. ....
inserted at low end: .... ..00
after shifting left by 2: 1100 1000 (200)
A logical right shift does the mirror image: it moves bits toward the low end, drops the low bits, and inserts zeros at the high end.
before: 1101 1011 (219)
dropped from low end: .... ..11
inserted at high end: 00.. ....
after shifting right by 2: 0011 0110 (54)
Each binary position is worth twice the position to its right.
Moving every bit left by one therefore doubles an unsigned value, and shifting left by n multiplies by 2^n as long as no 1 bit is lost past the high end.
Moving every bit right by one halves an unsigned value, discarding any remainder, so shifting right by n divides by 2^n.
On 64-bit x86, shl shifts left and shr performs the zero-filling right shift.
They use the same destination-first form as instructions such as add:
shl rax, 2
shr rax, 2
These examples use a byte so every bit fits on screen, but the same movement happens across all 64 bits of rax.
Now put the left shift you just studied into code.
Write a function called solve that takes a 64-bit value in rdi, shifts it left by 4 positions (multiplying it by 16), and returns the result in rax.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
Now use shr for positioning.
Move the input's second byte down to the low end, then reuse the mask from Masking Bits to discard everything outside that byte.
Write a function called solve that takes a value in rdi and returns its second byte --- bits 8 through 15, as a number from 0 to 255 --- in rax.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
Back in Opening the Flag, with RIP, you used a label and lea to pass the address of stored bytes to open.
In Examining Memory with GDB, you used x/s to display the string at an address.
The next challenge combines those ideas in a binary you are reversing.
The binary has already done the lea-style work for you: one of its registers will hold the runtime address of a stored string.
Instead of looking for every secret byte as an immediate inside a cmp, step through the code until a register points at the stored string, then examine that address as a string:
(gdb) x/s $rsi
Here, $rsi is just an example register.
Use the register that the binary loads with the stored string's address, then run /challenge/reverse-me directly with the string you found.
So far, the values you've been reversing have been embedded directly in instructions as immediate operands.
However, this challenge compares the first program argument against a hardcoded string inside the challenge.
The string lives in a different section of the program file: the binary's .rodata (read-only data) section, rather than in the instructions themselves.
There are several options to find it:
- The most familiar:
stepi to where the comparison is happening and x the registers pointing to the data.
- Use
strings /challenge/reverse-me to list all printable strings in the binary. There are a lot, but one of them will be the password.
- Use
objdump -s -j .rodata /challenge/reverse-me to dump the raw contents of the .rodata section.
Which you use is up to you!
The Stack, Revisited
Back when you first met the stack, it was just some data the kernel had set up for you: argc, argv, a few pointers.
Now that you've written your own functions, made your own calls, and seen rsp move in response to push and pop, it's time to revisit the stack with sharper eyes.
How does it actually get laid out? Why does it "grow downwards," and what does that mean for the data you can reach? And how does the way your program was launched shape the addresses of everything on it?
In addition to storing scratch data and return addresses, the stack stores the local variables of functions: data they use for functionality that's not necessarily needed by other functions of a program.
In security situations where a hacker gets ``code execution'' inside a process, these variables are an open book: there is nothing preventing code in a process from reading data from all over the stack!
This challenge explores this concept.
Once again, you write a solve function that the challenge calls, but the challenge passes you no arguments.
Instead, the challenge's caller function has stored the flag in its own local variables before calling you.
You have to reach over into the caller's "frame" (what we call the part of the stack including a function's local variables and the saved return address to which it will return) and grab those bytes.
Wait, what?
Let's walk through why this is possible.
In this challenge, the main function calls the caller function, which then calls your solve function.
Right before the challenge's caller function executed call solve, the stack looked like this:
[smaller addresses]
+───────────────────────────────────+ ◀── rsp, immediately before `call solve`
│ caller's local region │
│ ... your flag is in here ... │
+───────────────────────────────────+
│ caller's saved rbp │
+───────────────────────────────────+
│ return address (back to main) │
+───────────────────────────────────+
│ ... main's frame ... │
+───────────────────────────────────+
[larger addresses]
The call solve instruction does two things:
- Pushes the return address onto the stack (8 bytes). Pushing decrements
rsp, so the return address ends up at a smaller address than what was already on the stack.
- Jumps to your code.
That first step is critical: the stack grows backwards from what you might expect.
pop actually adds 8 to rsp, and push subtracts 8.
This is counter-intuitive and is a concept that often confuses learners.
If you think of the stack as a page that is 8 bytes wide, you would start writing in this page at the very bottom, and move one line upwards on the page every time you push.
In other words, say, pop rdi is equivalent to mov rdi, [rsp]; add rsp, 8 and push rdi is equivalent to sub rsp, 8; mov [rsp], rdi.
Note that this makes talking about the stack without confusion borderline impossible.
For example, people with a math background tend to think of a coordinate of 0 as being on the bottom or the left of a page, whereas people with a video game or web development background tend to think of 0 as being on the top or the left.
This leads to massive confusion about the definition of "higher address", "lower address", and so on.
Everyone has different ways of dealing with this.
In this document, because horizontal space is at a premium, we put diagrams from 0 (top) to 0xffffffff (bottom), but in everyday life when not restricted by horizontal space, we simply conceptualize memory from the "left" (0) to the "right" (0xffffffff).
Anyways, at the moment your solve starts running, the stack looks like this:
[smaller addresses, where rsp goes if you grow your own frame]
+───────────────────────────────────+
│ return address (back to caller) │ ◀── rsp points here
+───────────────────────────────────+
│ caller's stack frame │
│ ... your flag is in here ... │
+───────────────────────────────────+
│ return address (back to main) │
+───────────────────────────────────+
│ ... main's frame ... │
+───────────────────────────────────+
[larger addresses]
The caller's locals sit at larger addresses than your rsp --- below your rsp in the diagram. The data you want is somewhere in that region.
To find it, you index into memory with a positive offset from rsp.
If you go the other way --- negative offsets, at addresses smaller than rsp (above rsp in the diagram) --- you'll find unallocated stack space.
There's nothing useful for you up there (yet!).
When your solve starts running, the layout looks like this:
[rsp + 0x00] your return address (back into caller's code)
[rsp + 0x08] first byte of caller's local region
...
[rsp + 0x40] the flag (copied here by the caller)
...
[rsp + 0x110] caller's return address (back to main)
Your job: reach into the caller's frame, grab the flag at [rsp + 0x40], and write it to stdout (you already know how to issue a write syscall!).
Get it right, and your solve will print the flag for you!
In the last level, you reached rightward into your caller's frame.
Now you'll look leftward, at bytes left behind by a function that already returned.
When your code calls a function, that callee can move rsp left and use stack memory of its own.
When it returns, it moves rsp back right, but the bytes it wrote are not automatically erased.
They become stale stack data: ordinary memory left behind by code that already finished.
Software could erase those bytes before returning, but erasing data means running more instructions and writing more memory.
When the leftover data is sensitive, skipping that erasure can become a vulnerability.
This level starts with the smallest version of that issue: one stale 8-byte value.
This challenge passes your solve a function pointer named load_secret.
Call it first.
It stores one 8-byte secret in its own stack frame and returns, leaving those bytes at a negative offset from your current rsp.
The checker will tell you the exact offset.
Because the goal is to return the 8-byte value itself, load it with mov:
mov rax, qword ptr [rsp-0x40]
That example offset is hypothetical; use the offset printed by the checker.
Write a function called solve that calls load_secret, loads the stale 8-byte value into rax, and returns.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
For debugging a submitted function inside a shared library, refer back to Writing From a Shared Library.
In the last level, you loaded one stale 8-byte value from an old callee frame.
Now you'll use the same stale-stack idea on a byte buffer.
This challenge passes your solve a pointer to a function named read_flag.
Call that function first.
It puts the flag in its own stack frame and returns.
After it returns, that old frame is to the left of your current rsp, and the flag bytes are still there until something overwrites them!
Write a function called solve that calls the function pointer in rdi, then writes the stale flag bytes from the old callee frame to stdout.
From solve's perspective, those stale bytes sit at a negative offset from rsp; find the exact offset in gdb or read it from the checker output.
The important distinction is value versus address.
If you wanted to load one 8-byte value from that old frame, you would use mov:
mov rax, qword ptr [rsp-0x40]
But write needs the address of the first byte in rsi, not the qword stored there as a value.
For that, compute the address with lea:
lea rsi, [rsp-0x40]
That example offset is hypothetical; use the offset printed by the checker.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
For debugging a submitted function inside a shared library, refer back to Writing From a Shared Library.
In the last levels, you reached rightward into the caller's frame and leftward into stale data from an old callee.
Now you'll carve out a frame of your own.
So far, your functions have kept their temporary values in registers.
But a function can need more scratch space than registers can hold.
On 64-bit x86, a function makes stack scratch space by modifying the stack pointer (rsp) to point to a lower address: sub rsp, 256 reserves 256 bytes to the right of the new stack pointer.
Those bytes are then addressable as [rsp] through [rsp+255].
The stuff already on the stack is of course still there, but because rsp moved left, it now needs different offsets from rsp.
This does not give you freshly-zeroed bytes.
The bytes you just moved rsp across are ordinary stack memory, and they may contain bytes left behind by earlier code.
If you use those bytes as a table or a set of counters, stale values look exactly like values your function wrote.
In fact, failure to initialize stack data, and the subsequent use of resulting garbage by the program, is a common source of vulnerabilities in software!
That is why a stack frame that will hold scratch data normally starts with initialization: reserve the space, write known values into it, use it, then put rsp back before returning.
Initialization happens between allocation and deallocation of the stack frame:
sub rsp, 256 # allocate a 256-byte frame
... # initialize and use [rsp] through [rsp+255]
add rsp, 256 # deallocate the frame
ret
That last step matters as much as the first.
ret pops its return address from [rsp], so if rsp is not back where it started, ret will read the wrong bytes as an address and your program will crash.
Write a function called solve that reserves a 256-byte stack frame, clears every byte in it to zero, restores rsp, and returns.
The grader fills the would-be frame with nonzero bytes before calling your function, then checks that all 256 bytes were cleared after your function returns.
You may find mov byte ptr [rsp+rcx], 0 useful for clearing one byte at offset rcx.
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
For debugging a submitted function inside a shared library, refer back to Writing From a Shared Library.
In the last level, you reserved a stack frame, cleared it, restored rsp, and returned safely.
Now you'll put that frame to work.
Count how many distinct byte values appear in a buffer.
The natural way is to keep a table with one slot per possible byte value: 256 slots, indexed by the byte value itself.
Start with the same 256-byte stack frame you built before and clear it to zero.
Then, when you see byte value b, write 1 into slot b.
After you have scanned the buffer, count how many table slots are marked.
Write a function called solve that takes a pointer to a buffer in rdi and a length in rsi, and returns, in rax, the number of distinct byte values among those rsi bytes.
You might find the instruction mov byte ptr [rsp+rcx], 1 useful for marking a given value (stored in rcx) as "present" in your scratch table (based at rsp).
Build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o solve.o solve.s
hacker@dojo:~$ ld -shared -o solve.so solve.o
hacker@dojo:~$ /challenge/check solve.so
HINT:
You'll need three loops in this level, one after the other: one to clear the scratch table, one to mark each value you see, and one to count the marked slots afterwards.
For debugging a submitted function inside a shared library, refer back to Writing From a Shared Library.
The stack stores more than just argc and argv!
Right after the argument list, the kernel places the environment variables you learned about in the Linux Luminarium.
Just like argv, these are stored on the stack as an array of pointers to strings, where each string includes both the name and value of the variable, as so: PATH=/usr/bin:..., HOME=/home/hacker, or PWN=COLLEGE.
If a program is called with no arguments (e.g., argc is 1 and the only string in argv is the name of the program itself) and a single environment variable named FLAG, its starting stack layout might look like this:
Address │ Contents
+────────────────────────+
│ rsp + 0 │ 1 │ ◀─── argc
+────────────────────────+
│ rsp + 8 │ rsp + 128 │──┐ argv[0]: pointer to the program name
+────────────────────────+ │
│ rsp + 16 │ 0 │ │ NULL (end of argv)
+────────────────────────+ │
│ rsp + 24 │ rsp + 200 │──┼──┐ envp[0]: pointer to the first env var
+────────────────────────+ │ │
│ rsp + 32 │ 0 │ │ │ NULL (end of envp)
+────────────────────────+ │ │
│ │
Address │ Contents │ │
+────────────────────────+ │ │
│ rsp + 128 │ "/tmp/..."│◀─┘ │ the program name
+────────────────────────+ │
│ ... │ ... │ │
+────────────────────────+ │
│ rsp + 200 │ "FLAG=..."│◀────┘ the first env var: the `FLAG` variable
+────────────────────────+
Two new things to notice:
-
Both argv and envp are NULL-pointer-terminated: the kernel writes a NULL pointer at the end of each list of pointers.
That's how programs (and you!) know where each list ends --- walk the pointers until you hit a NULL.
In the diagram, you can see the NULL at rsp+16 marking the end of argv, and another at rsp+32 marking the end of envp.
-
The envp strings look like NAME=VALUE (e.g., PATH=/usr/bin:/bin).
So envp[0] points to a string that starts with the first env var's name.
In this challenge, we will set the FLAG environment variable to the actual flag and run your program with no arguments and no other env vars.
That means [rsp+24] will hold a pointer to the FLAG=... string, and you can get the flag by write()ing it out!
This is a whole-program level, so submit an executable, not a shared library.
Assemble and link your program, then pass that executable to the checker:
hacker@dojo:~$ as -o envp.o envp.s
hacker@dojo:~$ ld -o envp envp.o
hacker@dojo:~$ /challenge/check envp
In the previous level, you read envp[0] --- a pointer that the kernel placed on the stack, pointing into the strings region above the pointer tables.
The same layout applies here:
Address │ Contents
+────────────────────────+
│ rsp + 0 │ 1 │ ◀─── argc
+────────────────────────+
│ rsp + 8 │ rsp + 128 │───────┐ argv[0]: pointer to the program name
+────────────────────────+ │
│ rsp + 16 │ 0 │ │ NULL (end of argv)
+────────────────────────+ │
│ rsp + 24 │ rsp + 200 │─────┐ │ envp[0]: pointer to the first env var
+────────────────────────+ │ │
│ rsp + 32 │ 0 │ │ │ NULL (end of envp)
+────────────────────────+ │ │
│ │
┌───────────────────────────────│─┘
│ │
│ Address │ Contents │
│ +──────────────────────────+ │
│ │ rsp + 128 │ "/tmp/..." │◀─┘ the program name
│ +──────────────────────────+
│ │ ... │ ... │
│ +──────────────────────────+
└▸│ rsp + 200 │ "FOO=..." │ ◀─ the first env var
+──────────────────────────+
But where do the actual addresses (rsp, or the actual address that rsp+200 resolves to, etc.) come from?
When your program is launched, the kernel fills the stack backwards from some chosen starting address.
From there, it lays down the env strings ("growing" toward smaller addresses), then the arg strings, other metadata, then the envp[] and argv[] pointer tables, and finally argc on the leftmost side of the structure.
That's where rsp ends up pointing.
This has an interesting consequence: the more bytes you stuff into the environment (or the program arguments), the further "left" the stack the kernel pushes everything else.
An extra env byte means rsp ends up at a smaller address, the arg-strings region sits one byte further "left", and argv[0] (a pointer into that region) holds a one-byte-smaller value.
Here, env -i means "run the following command with an empty environment"; any NAME=VALUE pairs you put after -i are the only environment strings the child program receives.
In this challenge, take a clean baseline with env -i /challenge/program to see what address it wants argv[0] at.
Then run it again with exactly one environment variable, with just the right number of xs in its value, to shift argv[0] to that address.
Use env -i for both runs so your shell's own variables do not also land on the stack and throw off your count:
hacker@dojo:~$ env -i /challenge/program
hacker@dojo:~$ env -i FOO=xxxxxxxx /challenge/program
Remember that the whole environment string is placed on the stack, so FOO=, the value, and the trailing null byte count toward the shift.
You're not modifying the program at all, just changing how it's launched, which influences where its data ends up!
A common stack-related snafu is the shift in stack addresses that happens when launching a program under gdb.
By default, gdb passes its own environment (your shell's env, plus a few of gdb's own additions) to the debugged program, and these extra environment variables shift the stack to the left, so argv[0] ends up at a different address than it does when you run the program straight from your shell.
This isn't so important right now, but it becomes a big bother later on when you're trying to figure out why your bit-precise exploit code works in gdb but not on a target running normally.
In those cases, learning to "synchronize" the two environments is important.
This challenge will teach you the basics: making the addresses outside of gdb line up better with the addresses inside gdb.
- Run
/challenge/program under gdb (gdb /challenge/program, then run).
The program records its own argv[0] as your target.
- Quit gdb. Run
/challenge/program from your shell --- it'll tell you how far off your shell-context argv[0] is from the target.
- Use an environment variable to "pad" your shell environment until
argv[0] lands at the gdb-captured target.
- Flag!
The challenge in the previous level inherited your interactive shell's environment both in and outside of gdb.
In reality, the differences in environment are often more significant between your local setup and the target you're analyzing.
The binary you're debugging from your shell and the same binary running as a service, a cron job, or a remote script would see completely different environments (different HOME, different PATH, a different set of variables entirely).
This level explores this concept a bit more.
In this level, the gdb wrapper sets its own environment rather than inheriting it from your shell, and the challenge, when run directly forces you to do the same, requiring an environment with only a single variable.
For example:
hacker@dojo:~$ /challenge/program
You're running me with 8 environment variables, but I need exactly 1! Clear the environment and set one variable, then rerun me!
hacker@dojo:~$ /challenge/program
How do you clear the environment?
You can do so with the env command, which we've used before to print out all exported environment variables in the Linux Luminarium.
The env command can also be used as a wrapper to carefully control the environment of a program.
For example, you can clear the child program's environment completely using env -i:
hacker@dojo:~$ env -i /challenge/program
You're running me with 0 environment variables, but I need exactly 1! Clear the environment and set one variable, then rerun me!
hacker@dojo:~$ /challenge/program
You can also set variables after clearing the environment:
hacker@dojo:~$ env -i PWN=COLLEGE HACK=PLANET /challenge/program
You're running me with 2 environment variables, but I need exactly 1! Clear the environment and set one variable, then rerun me!
hacker@dojo:~$ /challenge/program
This allows you to have very finegrained control over your environment.
In this challenge, you'll use this finegrained control to line up addresses in a slightly more realistic setting, but keep the capability in mind for other situations!
Numbers as Strings
You've learned to read and write registers, walk through memory, and package your code as a function inside a shared library.
Now let's put those skills together and build some real algorithms.
When you need to debug one of these .so submissions, refer back to Writing from a Shared Library for the shared-library debugging pattern.
Through this module, we'll gradually build on our solutions until we create code that addresses a very common program need: turning text into numbers.
Along the way, we'll learn how to write reusable assembly code, how text and numbers relate to each other, and how to reason about algorithms (and their failings!).
You'll grow in knowledge, and in flags!
Computers receive a lot of their input as text.
When you pass 12345 to a program as a command-line argument, your code doesn't receive the number 12345 --- it receives five separate ASCII bytes: '1', '2', '3', '4', '5'.
The CPU can't add or multiply that text directly; first, someone has to turn those characters into the numbers they represent.
That job is traditionally done by a function called atoi (ASCII to integer) --- and we'll build it from the ground up, starting here with a single digit.
The key insight is how digits are encoded.
You've seen ASCII in prior levels, and we'll talk about ASCII numbers (the text encoding of numerical values) here.
In ASCII, the character '0' is the byte 0x30, '1' is 0x31, and so on up to '9' at 0x39.
The digits are consecutive, so the value of a digit character is simply the character minus '0':
'7' -> 0x37 - 0x30 = 7
In this level, you must implement a function that converts a text string containing one digit into the number.
Your function (which must be called atoi_digit) receives a pointer in rdi to a single digit character, and must return that digit's value (0 through 9) in rax.
Now, since we're making an atoi_digit function instead of solve, you'll need a .global atoi_digit so the challenge can find it!
Then, build it into a shared library and hand it to the grader:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Decode the digit, return its value, and grab the flag.
You can decode one digit with atoi_digit.
A two-digit number is just two of those, combined by place value: in "42", the 4 is in the tens place and the 2 is in the ones place, so the value is 4 * 10 + 2 = 42.
This is the algorithm we'll use to compute it in this level.
Here, you'll write two functions:
atoi_digit(s) --- exactly as before: the value of the single digit at s. You can (and should!) reuse your solution from the previous challenge.
atoi(s) --- takes a pointer to a two-character number and returns its value, by decoding each character with atoi_digit and combining them as first * 10 + second.
Both are real functions the grader calls, so both must follow the calling convention.
That means that if you use any callee-saved registers, you must properly restore them before returning.
And, since you're also calling atoi_digit from atoi, you must be careful to properly handle any caller-saved registers as well.
As before, each function takes its argument in rdi and returns its result in rax.
Now, how do you multiply?
x86's multiply instruction is imul.
It has a few different ways to use it, but we'll use it like we used add: imul rax, 10 multiplies rax by 10 in place (rax = rax * 10), so scaling the tens digit up by a place is a single instruction.
Of course, imul can use other registers than rax: for example, imul rbx, 10 multiplies rbx by 10.
One more thing!
In your assembly, you will need a .global atoi_digit and a .global atoi (along with the respective functions actually implemented with those labels) so that the solver can find it.
Build and submit as before, with both atoi_digit and atoi functions:
hacker@dojo:~$ /challenge/check your-solve.so
Multiply by ten, add the ones, and the flag is yours.
Your two-digit atoi did first * 10 + second.
What if there are more than two digits?
Of course, you'd keep a running total, and for each new digit do total = total * 10 + digit.
That repetition is a loop, which you practiced in Writing Loops and will adapt here.
Read the digits left to right:
"123":
total = 0
'1': total = 0*10 + 1 = 1
'2': total = 1*10 + 2 = 12
'3': total = 12*10 + 3 = 123
You would do this until the end of the string, which, by the convention of the C programming language (and used here), is represented by a byte with a value of 0x00 (that is, binary 00000000 or decimal value 0).
Note that this is distinct from the character '0', which, again, has a value of 0x30 (binary 00110000).
So, your loop is: look at the next byte, if it's 0, jump beyond the loop (look back at Writing Loops for reference), otherwise convert the digit just like atoi_digit did, multiply the total by 10, add the digit, and loop back to the head of the loop.
Your atoi receives a pointer to the string in rdi and must return the integer value in rax.
Loop the digits and return the number.
Debugging:
This can get tricky to get right.
To debug this challenge, our advice is to add a _start to your code that fakes the call, as so:
.global _start
_start:
push 0x333231 # "123" on the stack -- little-endian, so 0x31 ('1') is the first byte, and the high zero bytes terminate it
mov rdi, rsp # a pointer to that string, as the first argument to atoi
int3 # this is optional, if you want gdb to break here without having to set a breakpoint!
call atoi # there we go!
mov rdi, rax # atoi's result comes back in rax; exit with it so you can read it back with `echo $?`
mov rax, 60 # exit
syscall
Assemble and link it as a normal executable (no -shared --- this version has an entry point), then load it in gdb:
hacker@dojo:~$ as -o debug.o debug.s
hacker@dojo:~$ ld -o debug debug.o
hacker@dojo:~$ gdb ./debug
(gdb) run
Execution stops at your int3, and from there you can step through with the techniques you learned in Software Introspection, watching rdi walk the string and your running total build up in rax, until things work!
Your atoi handles positive numbers.
But numbers can be negative, and a negative number arrives with a leading minus sign: the string "-42" is the four bytes '-', '4', '2', NUL.
Extend your converter to handle that sign.
If the very first character is '-' (ASCII 0x2d), remember that the result should come out negative, step past the sign, and convert the digits that follow exactly as before.
Then negate your total at the end.
Of course, a positive number has no sign character, so it should still convert just like the previous level.
There are two ways you can negate a number: neg rax turns a register into its negative, and imul rax, -1 does the same.
Pick the one you like!
Real input is messy.
A number embedded in a larger string isn't always followed by a tidy NUL --- it might be followed by a space, a letter, a comma, or anything else: "42abc", "100 200", "7,".
A proper atoi reads digits until it sees something that isn't a digit, then stops, whatever that non-digit is (including 0x00).
Instead of "stop at the 0 byte", the rule becomes "stop at the first byte that isn't '0'-'9'".
A handy one-shot test for a character c: compute c - 0x30, then check whether the result is in the range 0-9 using an unsigned comparison.
Anything that isn't a digit --- punctuation, letters, a space, even the 0 value (which becomes a negative twos-complement number when you subtract '0', or 0x30 from it, and thus is a very large number when interpreted as an unsigned value), falls outside of this range.
To do an unsigned check, use the ja instruction, which stands for "jump if the last comparison was above (e.g., greater when unsigned)".
You must do the cmp (again, look back earlier in this dojo), and then:
ja done
...
done:
ret
Otherwise, keep your solution from the prior level: a leading '-' still means negative, math still works as you expect, etc, and you get the flag when you solve it!
Until now, you've been writing a loadable library: a function the challenge loaded and called for you.
This time, you'll write a whole program --- one that starts at _start, runs on its own, and exits when it's done.
Your program gets the number as a command-line argument.
When a program starts, the stack holds its arguments: argc (the count) sits at [rsp], and the argument pointers follow it --- argv[0] (the program's own name) at [rsp + 8], and argv[1] (the first real argument) at [rsp + 16].
So the number you want is the string pointed to by [rsp + 16].
Read it, convert it with your atoi, and hand the value back the way a program does: instead of returning it in rax, exit with it as your exit code, using the exit syscall with the value in rdi.
An exit code is a single byte, so the number you're given will be between 0 and 255.
This time, assemble and link it as a normal program (no -shared), then submit it:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ /challenge/check prog
Convert the argument and exit with its value.
You've taken text input and converted it to a number, but real programs also have to output numbers as text.
This inverse of atoi is called itoa (integer to ASCII).
Here, we'll start building it the same way, first with one digit, then moving on!
In the reverse of atoi, a digit's character is just its value plus '0' (0x30).
So if '7' (the ASCII character) became 7 (the value) by subtracting 0x30, then the same way, 7 (the value) would become '7' (the ASCII character) by adding 0x30.
7 -> 7 + 0x30 = 0x37 = '7'
We'll start with itoa_digit.
Your itoa_digit gets a value in rdi (a single digit, 0-9) and returns its ASCII character in rax.
Remember to .global itoa_digit so the challenge can find it.
The previous level was a whole executable.
This level returns to the shared-library workflow from the earlier atoi functions:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Add 0x30, return the character, and claim the flag.
One digit was easy.
A two-digit number like 42 needs splitting into its tens (4) and ones (2) --- and splitting is division: 42 / 10 = 4 (the quotient), and 42 % 10 = 2 (the remainder).
x86 gives you both results from one div, but div is a fussy instruction worth learning carefully.
div rcx divides the 128-bit value resulting by concatenating rdx:rax by rcx, leaving the quotient in rax and the remainder in rdx.
Three things follow from that:
- It divides
rdx:rax, not just rax, so you must clear rdx first (xor rdx, rdx) --- otherwise div treats leftover garbage as the high half of your number (and may crash).
- The divisor comes from a register, not an immediate, so load the
10 into one (e.g., mov rcx, 10; div rcx).
- You don't control the dividend: it's always
rdx:rax.
After the div, rax holds the tens and rdx holds the ones.
Turn each into a character the way itoa_digit did (add 0x30) and store the two of them.
Write itoa(value, buf), which we'll call from the challenge.
This function should take a value (10-99) in rdi and a pointer to the "output" buffer in rsi.
Split the number in rdi with div, convert the two digits as above, and write their characters to that buffer.
Then return the number of characters written (in this case, 2).
Remember to .global itoa.
Writing characters.
Your itoa_digit function from the last level returned the result (in rax), and you didn't have to deal with writing it to a buffer.
Now, you do.
Your actual character is one byte (8 bits), whereas the register you're holding it in is 64 bits (8 bytes) long.
You just want the last ("least significant") byte, and you can directly access it through partial register aliases, depending on the register:
| register |
least significant byte |
rax |
al |
rbx |
bl |
rcx |
cl |
rdx |
dl |
rsi |
sil |
rdi |
dil |
rbp |
bpl |
rsp |
spl |
r8 |
r8b |
r9 |
r9b |
r10 |
r10b |
r11 |
r11b |
r12 |
r12b |
r13 |
r13b |
r14 |
r14b |
r15 |
r15b |
So, if your character is in rax, and the buffer is pointed to by rsi, you'll need to do mov [rsi], al.
This is tricky, but do it carefully, and the flag is your reward!
Debugging:
This can get tricky to get right.
To debug this challenge, our advice is to add a _start in your code, as so:
.global _start
_start:
mov rdi, 42 # you'll pass 42 as the first argument to your function
push 0 # this pushes eight 0 bytes to the stack, clearing what will be your output buffer
mov rsi, rsp # the output buffer as the second argument to itoa
int3 # this is optional, if you want gdb to break here without having to set a breakpoint!
call itoa # there we go!
mov rax, 60 # exit cleanly, like a cultured individual
syscall
Assemble and link it as a normal executable (no -shared --- this version has an entry point), then load it in gdb:
hacker@dojo:~$ as -o debug.o debug.s
hacker@dojo:~$ ld -o debug debug.o
hacker@dojo:~$ gdb ./debug
(gdb) run
Execution stops at your int3, and from there you can step through with the techniques you learned in Software Introspection, looking at memory on the stack, registers, etc, until things work!
You can also debug the native harness that loads your .so:
hacker@dojo:~$ gdb --args /challenge/harness your-solve.so 42
(gdb) run
The first argument after /challenge/harness is your library, and the second is the stand-in number passed to itoa.
Your two-digit itoa always wrote two characters.
But 7 isn't 07, and 0 isn't 00 --- numbers tend to be written with no leading zeros, in as many digits as it actually has.
In this level, we'll strip the leading zeroes from our translation on the path to a nice itoa!
We'll still deal with values 99 and less, so a single div.
Divide by 10 as before: the quotient is the tens digit, the remainder is the ones.
If the quotient is 0, there is no tens digit, and we can drop the leading zero and output just the remainder.
For example:
7: 7 / 10 = 0 rem 7 -> quotient 0, so write just "7"
42: 42 / 10 = 4 rem 2 -> quotient 4, so write "42"
One value still needs care: 0 itself.
Its quotient is 0 too, but writing "nothing" is wrong, so we write a single '0'.
The rest is the same:
Write itoa(value, buf) for value in 0-99: write its decimal text (no leading zeros) to buf, and return how many characters you wrote (1 or 2) in rax.
Note:
The "check if the quotient is 0" test will be useful in the next level, where we'll finally support longer numbers!
Keep it in mind!
Now any length.
It's the same div-by-10 step as last level, just repeated: each div peels off the lowest digit (the remainder) and shrinks the number (the quotient), and you keep going until the quotient reaches 0 --- however many digits that takes.
123: 123 % 10 = 3, 123 / 10 = 12
12 % 10 = 2, 12 / 10 = 1
1 % 10 = 1, 1 / 10 = 0 (stop)
But notice the catch: the digits come out backwards --- ones first (3, 2, 1), the reverse of how you write them (1, 2, 3).
So you can't just append them as you go.
The usual fixes: stash each digit as it comes and write them out in reverse (the stack is perfect for this --- push them as they fall out, pop them to write, and LIFO reverses them for free), or write them into the buffer from the back toward the front.
And 0 is the same special case you handled last level: the loop runs zero times for it, so write a plain "0" yourself.
Write itoa(value, buf) for any non-negative value (in rdi, buffer in rsi): write its decimal digits to the buffer and return how many you wrote, in rax.
Remember to .global itoa.
Build and submit as before:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Reverse the digits and return the length.
Your itoa handles non-negative numbers.
But a sum can be negative (your atoi reads negative numbers, after all), and a negative number is written with a leading -.
The trick is to peel the sign off first, then let the work you already did handle the rest:
- If the input value is negative, write a
'-', move your buffer pointer one past it (e.g., add rsi, 1), and neg the input value to get its magnitude.
- You can
cmp rdi, 0 to compare, and jl is_negative (jl jumps if the previous compared left value was less than the right one, signed).
- Run your existing digit loop on that (now non-negative) magnitude.
- The total length is the digits you wrote, plus
1 for the sign.
A non-negative number has no sign, so it still prints exactly as before.
Extend itoa(value, buf) to handle negative values too.
The calling convention is the same: value argument in rdi, buffer argument in rsi, total length returned in rax.
Remember to .global itoa.
Build and submit as before:
hacker@dojo:~$ as -o your-solve.o your-solve.s
hacker@dojo:~$ ld -shared -o your-solve.so your-solve.o
hacker@dojo:~$ /challenge/check your-solve.so
Handle the sign and return the length.
The boss: put it all together.
Read the numbers from argv, convert each one with your atoi, add them into a total, turn that total back into text with your itoa, and write it to standard output.
With no number arguments, print 0.
argc is at [rsp]; the 8-byte argv pointer entries begin at [rsp + 8], and [rsp + 16] is the entry for argv[1].
- Keep a register pointing to each table entry from
argv[1] through argv[argc - 1].
- Dereference that table entry to obtain the string pointer that
atoi expects.
- Advance the register by 8 bytes after each number.
Remember, atoi is a function call, so any loop state you still need afterward must be preserved according to the calling convention rules you practiced earlier.
The numbers, or even the overall sum, might be negative, which is exactly why your atoi and itoa handle the sign.
Then itoa the total into a scratch buffer (e.g., on the stack) and write that many bytes to file descriptor 1.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ /challenge/check prog
Sum them, convert the total, print it, and you're done!
Debugging:
Don't forget about gdb!
Insert int3, use breakpoint in gdb, stepi the instructions, and try to deeply understand failures if they occur so that you can fix it!
The Calculator
You can turn text into a number with your atoi, and a number back into text with your itoa.
Now you'll use both at once to build a small command-line calculator.
It reads an expression from argv --- a left operand, an operator, and a right operand --- as in prog 6 + 7.
The operands are strings you already know how to handle: atoi each one.
The operator is the new piece: a single character you branch on to decide what to compute, quitting on any operator you don't support.
Then itoa the result and write it to standard output, exactly as your summing program did.
We'll add one operator group at a time --- addition, then subtraction, then multiplication, then the bitwise operators, and finally the unary operators --- each reusing the atoi and itoa you've already written.
You can read a number from text with your atoi, and write one back to text with your itoa.
Now you'll put both to work in a single program: a calculator.
A calculator reads an expression like 6 + 7 and prints the answer.
We'll hand you that expression three pieces at a time, on the command line:
prog 6 + 7
So argv[1] is the left operand ("6"), argv[2] is the operator ("+"), and argv[3] is the right operand ("7").
Each one is a string, just like the arguments you've already been reading off the stack.
Recall that argc sits at [rsp], and the argument pointers follow: argv[0] at [rsp + 8], argv[1] at [rsp + 16], argv[2] at [rsp + 24], and argv[3] at [rsp + 32].
The operand strings are easy: atoi each one to get its value, exactly as before.
The operator is the new piece.
It's a string too, but a one-character one, so the character you care about is its first byte: argv[2][0].
Load that pointer and read the byte it points at, and you have the operator as a single character to branch on.
For this level there's only one operator to handle, '+':
"6" -> atoi -> 6
"7" -> atoi -> 7
6 + 7 = 13
13 -> itoa -> "13"
But a real operator might be anything the user typed, and you only know how to add.
So check the operator byte first: if it's '+', do the addition; if it's anything else, you don't support it, so quit by exiting with a nonzero code (no answer to print).
That refusal is part of the job --- recognizing the one operator you handle, and bailing out on the rest.
When the operator is '+': atoi both operands, add them, itoa the sum into a scratch buffer, and write those bytes to file descriptor 1 (standard output).
Reserve that buffer on the stack (a sub rsp, 0x80 makes room, and rsp then points at it), the same way you stored the /flag string on the stack back in Hello, Hackers.
Then exit cleanly with code 0.
This is a whole program, so assemble and link it as one (no -shared), then submit it:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 6 + 7
13
hacker@dojo:~$ /challenge/check prog
Read the operands, dispatch on the operator, and print the sum.
Now, let's teach the calculator to subtract!
Add a second branch on the operator byte: if it's '-', sub the right operand from the left instead of adding.
Everything else is the same dispatch you already wrote --- '+' adds, '-' subtracts, and any other operator still makes you quit with a nonzero exit code.
The one thing to watch: a difference can be negative.
"3" -> atoi -> 3
"10" -> atoi -> 10
3 - 10 = -7
-7 -> itoa -> "-7"
That's exactly the case your signed itoa already handles --- it writes the leading '-' and the magnitude for you.
So feed the difference straight into the same itoa you've been using, and the sign takes care of itself.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 3 - 10
-7
hacker@dojo:~$ /challenge/check prog
Add the subtract branch and print the signed result.
Now, let's teach the calculator to multiply!
Multiplication has its own instruction: imul.
Just as you used add for '+' and sub for '-', you'll use imul for '*'.
You've already met it back in atoi-two-digits, where imul rax, 10 scaled your running total by ten; here it multiplies your two operands the same way.
"6" -> atoi -> 6
"7" -> atoi -> 7
6 * 7 = 42
42 -> itoa -> "42"
Add a third branch on the operator byte: if it's '*', imul the operands; '+' and '-' work as before, and any other operator still makes you quit with a nonzero exit code.
One shell wrinkle: * is special to the shell (it expands to filenames), so quote it when you run your program by hand --- ./prog 6 '*' 7 or ./prog 6 "*" 7.
The character your program receives is still a plain '*'.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 6 '*' 7
42
hacker@dojo:~$ /challenge/check prog
Add the multiply branch and print the product.
Now, let's teach the calculator the bitwise operators!
Every operator so far has been arithmetic. The next three combine their operands bit by bit, and you've met all of them back in assembly-assortment:
^ is XOR: each result bit is 1 when exactly one input bit is 1.
| is OR: each result bit is 1 when either input bit is 1.
& is AND: each result bit is 1 only when both input bits are 1.
Add three more branches to your dispatch --- '^' → xor, '|' → or, '&' → and --- alongside '+', '-', and '*'.
Any operator you still don't recognize makes you quit with a nonzero exit code.
A bitwise result is just a 64-bit number, so you print it like every other answer: feed it to your signed itoa and write the text.
"12" -> atoi -> 12 (0000 1100)
"10" -> atoi -> 10 (0000 1010)
12 ^ 10 = 6 (0000 0110)
12 | 10 = 14 (0000 1110)
12 & 10 = 8 (0000 1000)
Two of these are special to the shell --- | pipes commands together and & runs one in the background --- so quote them when you run your program by hand (^ is fine bare):
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 12 '|' 10
14
hacker@dojo:~$ /challenge/check prog
Add the three bitwise branches.
Every operator so far has been binary --- two operands with an operator between them.
The last two are unary: a single operand, with the operator in front.
- negates: - 5 is -5.
~ flips every bit (bitwise NOT): ~ 5 is -6, because two's complement makes ~x equal -x - 1.
The new idea is telling the two shapes apart.
A binary call passes three arguments after the program name (prog A OP B); a unary call passes two (prog OP A).
So the argument count decides which you're reading, and you already know where it lives: argc sits at [rsp].
Branch on it first:
argc == 4: the binary dispatch you already wrote (operator in argv[2]).
argc == 3: the new unary dispatch, operator in argv[1] and operand in argv[2].
This split is exactly what lets - mean two things: binary - subtracts (12 - 5 = 7), unary - negates (- 5 = -5).
The argument count tells them apart.
For the unary operators: neg the operand for -, and not it for ~.
Print the result with your signed itoa, like any other answer.
- 5 -> neg -> -5
~ 5 -> not -> -6
Add the argc split and the two unary branches; an unrecognized unary operator quits, just like a binary one.
The shell expands a bare ~ to your home directory, so quote it (- you can type straight):
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog - 5
-5
hacker@dojo:~$ ./prog '~' 5
-6
hacker@dojo:~$ /challenge/check prog
Split on the argument count and handle both unary operators.
printf
Programs rarely print just one fixed string.
They print messages assembled from fixed words and changing values: answer: 13, hello, hacker, opened 3 files, and so on.
You could write each message by hand, but then every new message shape needs its own copy loops, number conversions, and write calls.
A format string is a compact recipe for building those messages.
Ordinary characters in the recipe are printed as-is, while special markers say where the next value should go.
The code that follows such a recipe is called a formatter.
printf is the traditional name for a formatter that prints its result.
This mini-module will have you build printf.
Your version will be special: it's yours!
Many other versions of printf exist as well.
For example, the "standard C library", which includes useful functions to use when writing applications in the C programming language, includes an implementation, and your commandline has it too:
hacker@dojo:~$ printf Hello
Hello
hacker@dojo:~$
We'll build up that idea in small steps: literal text, the ASCII newline byte, escaped syntax characters, decimal numbers, repeated values, strings, and finally arbitrary bytes.
You can already write bytes to standard output.
Now you will put that in a loop and start building a small printf-style program.
The input is a format string in argv[1].
For this first level, there are no special markers yet.
Every byte in the format string is ordinary text, so your job is to write those bytes to standard output.
argv[1]: "score: "
output: "score: "
You can write one byte at a time as you scan, or find a run of ordinary bytes and write the whole run at once.
Either way, stop when you reach the format string's NUL byte, then exit cleanly.
Build and submit it as an executable:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'score: '
score: hacker@dojo:~$ /challenge/check prog
Note that in the above, prog doesn't print a terminal null byte, and the command prompt starts on the same line.
That's okay --- the next level will teach your formatter how to write a newline.
When testing, be aware that the commandline also has a built-in printf utility.
If you name your program printf, make sure to run it via a path (e.g., ./printf) to avoid the built-in one.
Literal output can copy visible characters, but text also needs a way to name bytes that are awkward to type directly.
A newline is the classic example: it moves the terminal to the next line instead of drawing a visible symbol.
You've seen ASCII before: it assigns byte values to text characters and text controls.
The ASCII newline byte is 0x0a, which is decimal 10.
The common standard for writing special characters in a format string is the \ prefix, and a newline is written as \n.
In this level, the two input bytes \n in the format string mean "write one output byte with value 0x0a".
The backslash starts an escape sequence, and the next byte says which special byte to write.
argv[1]: "hello\nworld"
output: "hello"
"world"
When your scan sees a backslash followed by n, skip both bytes and write one byte with value 0x0a.
Keep supporting ordinary text.
When testing your program yourself, beware that some shell syntax can interpret \n before your program sees it.
Use plain quotes around the format string so your program receives the two bytes \ and n, as in ./prog 'hello\nworld'.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'hello\nworld'
hello
world
hacker@dojo:~$ /challenge/check prog
Turn \n into a newline byte.
Now your format string has syntax in it.
Backslash starts escape sequences such as \n, and percent will start format markers such as %d.
That creates a practical problem: sometimes the output should contain a real backslash or a real percent sign.
The usual formatter convention is to double the syntax byte.
The two input bytes \\ write one output backslash byte, and the two input bytes %% write one output percent byte.
argv[1]: "path\\file"
output: "path\file"
argv[1]: "progress: 100%%"
output: "progress: 100%"
When your scan sees \\, skip both input bytes and write one backslash byte.
When your scan sees %%, skip both input bytes and write one percent byte.
Keep supporting ordinary text and \n escapes.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'progress: 100%%'
progress: 100%
hacker@dojo:~$ /challenge/check prog
Turn doubled syntax bytes into one literal byte.
Literal output, newline escapes, and escaped syntax give you the scan-and-write loop.
Now add the first marker: %d.
The % byte says "this is a marker".
The d byte says "take the next argument value, treat it as a signed decimal number, and print that number here."
The next command-line value starts at argv[2].
argv[1]: "value=%d"
argv[2]: "-42"
output: "value=-42"
When your scan reaches %d, skip both marker bytes, convert the next argv string with atoi, convert the resulting number back to text with signed itoa, and write those digits immediately.
Then continue scanning the format string.
For this level, the format string has at most one %d marker.
Keep handling ordinary text, \n, \\, and %% as before.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'value=%d' -42
value=-42
hacker@dojo:~$ /challenge/check prog
Replace decimal markers as you scan.
One %d marker lets the format string include one changing number.
Real messages often need more than one value.
Now support several %d markers in the same format string.
Each %d consumes the next command-line value, so your program needs to remember which argv entry comes next.
argv[1]: "opened %d files and skipped %d"
argv[2]: "7"
argv[3]: "3"
output: "opened 7 files and skipped 3"
The first %d uses argv[2], the second uses argv[3], and so on.
After printing one number, continue scanning the format string after that marker.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'opened %d files and skipped %d' 7 3
opened 7 files and skipped 3
hacker@dojo:~$ /challenge/check prog
Consume the decimal arguments in order.
Now add %s, the marker for inserting a string.
This is like %d, except the next argv value is already text, so you do not need atoi or itoa.
For %s, take the next command-line string, find its length, and write its bytes.
For %d, keep doing the number conversion from the previous levels.
Each marker consumes the next command-line value in order.
The format string is the only string with formatter syntax.
If the argument for %s contains bytes like \ or %, copy them literally instead of treating them as escapes or markers.
argv[1]: "%s has %d flags"
argv[2]: "hacker"
argv[3]: "3"
output: "hacker has 3 flags"
argv[1]: "%s"
argv[2]: "LITERAL\WITH\SLASHES"
output: "LITERAL\WITH\SLASHES"
This means your program now tracks two positions: where you are in the format string, and which argv value should be consumed next.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog '%s has %d flags' hacker 3
hacker has 3 flags
hacker@dojo:~$ /challenge/check prog
Consume values in order and write each piece on demand.
\n gives one named ASCII byte: newline, 0x0a.
For arbitrary bytes, use a hex escape: \xNN.
You've seen hex before: two hex digits describe one byte.
Here, the \x starts the escape, and the next two hex digits are the byte value to write.
format bytes: "\x41"
hex value: 0x41
output byte (ascii): "A"
Be careful about the conversion step: the format string contains ASCII characters, not numeric hex values yet.
For \x4a, your program sees four input bytes: backslash, x, 4, and a.
After recognizing the \x, the conversion uses the ASCII bytes for 4 and a.
First convert each ASCII hex character into a 4-bit number, called a nibble.
For 0 through 9, subtract the ASCII value of 0 to get 0 through 9.
For a through f, subtract the ASCII value of a and add 10; for A through F, subtract the ASCII value of A and add 10.
Then put the first nibble in the high half of the byte (using left shift, which you learned earlier!) and the second nibble in the low half: (first << 4) | second.
format text: \ x 4 a
hex digits: 4 10
combined byte: (4 << 4) | 10 = 0x4a
output byte: "J"
When your scan sees \xNN, convert the two hex digits into one byte and write that byte.
Keep supporting ordinary text, %d, %s, \n, \\, and %%.
Build and submit as before:
hacker@dojo:~$ as -o prog.o prog.s
hacker@dojo:~$ ld -o prog prog.o
hacker@dojo:~$ ./prog 'byte=\x2a'
byte=*
hacker@dojo:~$ /challenge/check prog
Decode the hex byte escape and write it.
Debugging Refresher
A critical part of working with computing is understanding what goes wrong when something inevitably does.
This module will build on your prior exposure to GDB with some more debugging of programs: digging in, poking around, and gaining knowledge.
This is one of the most critical skills that you will learn in your computing journey, and this module will hopefully help water the seed that we planted before.
As you know, GDB is a very powerful dynamic analysis tool which you can use in order to understand the state of a program throughout its execution.
You will become more familiar with some of its capabilities in this module.
There are a number of good gdb crash courses / reference manuals:
Challenges
This level gets you re-familiarized with gdb.
To get started with this level, and all the other levels of this module, run /challenge/embryogdb_levelXYZ, where XYZ is the level number.
That program will launch gdb.
Run the actual level logic with r, and follow the prompts to get that flag!
RELEVANT DOCUMENTATION:
Next, we'll learn about how to print out the values of registers.
You can see the values for all your registers with info registers. Alternatively, you can also just print a particular
register's value with the print command, or p for short. For example, p $rdi will print the value of $rdi in
decimal. You can also print its value in hex with p/x $rdi.
In order to solve this level, you must figure out the current random value of register r12 in hex.
As before, start the challenge, invoke the run gdb command, then follow the instructions.
When you've printed out what you need, remember to continue to move on to the next step of the challenge!
RELEVANT DOCUMENTATION:
Next, we'll learn to use gdb to peek into process memory!
You can examine the contents of memory using the x/<n><u><f> <address> parameterized command. In this format <u> is
the unit size to display, <f> is the format to display it in, and <n> is the number of elements to display. Valid
unit sizes are b (1 byte), h (2 bytes), w (4 bytes), and g (8 bytes). Valid formats are d (decimal), x
(hexadecimal), s (string) and i (instruction). The address can be specified using a register name, symbol name, or
absolute address. Additionally, you can supply mathematical expressions when specifying the address.
For example, x/8i $rip will print the next 8 instructions from the current instruction pointer. x/16i main will
print the first 16 instructions of main. You can also use disassemble main, or disas main for short, to print all of
the instructions of main. Alternatively, x/16gx $rsp will print the first 16 values on the stack. x/gx $rbp-0x32
will print the local variable stored there on the stack.
You will probably want to view your instructions using the CORRECT assembly syntax. You can do that with the command
set disassembly-flavor intel.
In order to solve this level, you must figure out the random value on the stack (the value read in from /dev/urandom).
Think about what the arguments to the read system call are.
RELEVANT DOCUMENTATION:
A critical part of dynamic analysis is getting your program to the state you are interested in analyzing.
So far, these challenges have automatically set breakpoints for you to pause execution at states you may be interested in analyzing.
It is important to be able to do this yourself.
There are a number of ways to move forward in the program's execution.
You can use the stepi <n> command, or si <n> for short, in order to step forward one instruction.
You can use the nexti <n> command, or ni <n> for short, in order to step forward one instruction, while stepping over any function calls.
The <n> parameter is optional, but allows you to perform multiple steps at once.
You can use the finish command in order to finish the currently executing function.
You can use the break *<address> parameterized command in order to set a breakpoint at the specified-address.
You have already used the continue command, which will continue execution until the program hits a breakpoint.
While stepping through a program, you may find it useful to have some values displayed to you at all times.
There are multiple ways to do this.
The simplest way is to use the display/<n><u><f> parameterized command, which follows exactly the same format as the x/<n><u><f> parameterized command.
For example, display/8i $rip will always show you the next 8 instructions.
On the other hand, display/4gx $rsp will always show you the first 4 values on the stack.
Another option is to use the layout regs command.
This will put gdb into its TUI mode and show you the contents of all of the registers, as well as nearby instructions.
In order to solve this level, you must figure out a series of random values which will be placed on the stack.
As before, run will start you out, but it will interrupt the program and you must, carefully, continue its execution.
You are highly encouraged to try using combinations of stepi, nexti, break, continue, and finish to make sure you have a good internal understanding of these commands.
The commands are all absolutely critical to navigating a program's execution.
RELEVANT DOCUMENTATION:
NOTE:
This challenge will require you to read and understand assembly!
Don't worry, this skill will come in quite handy later in pwn.college.
We write code in order to express an idea which can be reproduced and refined.
We can think of our analysis as a program which injests the target to be analyzed as data.
As the saying goes, code is data and data is code.
While using gdb interactively as we've done with the past levels is incredibly powerful, another powerful tool is gdb scripting.
By scripting gdb, you can very quickly create a custom-tailored program analysis tool.
If you know how to interact with gdb, you already know how to write a gdb script--the syntax is exactly the same.
You can write your commands to some file, for example x.gdb, and then launch gdb using the flag -x <PATH_TO_SCRIPT>.
This file will execute all of the gdb commands after gdb launches.
Alternatively, you can execute individual commands with -ex '<COMMAND>'.
You can pass multiple commands with multiple -ex arguments.
Finally, you can have some commands be always executed for any gdb session by putting them in ~/.gdbinit.
You probably want to put set disassembly-flavor intel in there.
Within gdb scripting, a very powerful construct is breakpoint commands. Consider the following gdb script:
start
break *main+42
commands
x/gx $rbp-0x32
continue
end
continue
In this case, whenever we hit the instruction at main+42, we will output a particular local variable and then continue execution.
Now consider a similar, but slightly more advanced script using some commands you haven't yet seen:
start
break *main+42
commands
silent
set $local_variable = *(unsigned long long*)($rbp-0x32)
printf "Current value: %llx\n", $local_variable
continue
end
continue
In this case, the silent indicates that we want gdb to not report that we have hit a breakpoint, to make the output a bit cleaner.
Then we use the set command to define a variable within our gdb session, whose value is our local variable.
Finally, we output the current value using a formatted string.
Use gdb scripting to help you collect the random values in this level.
This may feel difficult, but will serve you well in your journey ahead.
RELEVANT DOCUMENTATION:
As it turns out, gdb has FULL control over the target process.
Not only can you analyze the program's state, but you can also modify it.
While gdb probably isn't the best tool for doing long term maintenance on a program, sometimes it can be useful to quickly modify the behavior of your target process in order to more easily analyze it.
You can modify the state of your target program with the set command.
For example, you can use set $rdi = 0 to zero out $rdi.
You can use set *((uint64_t *) $rsp) = 0x1234 to set the first value on the stack to 0x1234.
You can use set *((uint16_t *) 0x31337000) = 0x1337 to set 2 bytes at 0x31337000 to 0x1337.
Suppose your target is some networked application which reads from some socket on fd 42.
Maybe it would be easier for the purposes of your analysis if the target instead read from stdin.
You could achieve something like that with the following gdb script:
start
catch syscall read
commands
silent
if ($rdi == 42)
set $rdi = 0
end
continue
end
continue
This example gdb script demonstrates how you can automatically break on system calls, and how you can use conditions within your commands to conditionally perform gdb commands.
In the previous level, your gdb scripting solution likely still required you to copy and paste your solutions.
This time, try to write a script that doesn't require you to ever talk to the program, and instead automatically solves each challenge by correctly modifying registers / memory.
RELEVANT DOCUMENTATION:
This level will expose you to some of the true power of gdb.
RELEVANT DOCUMENTATION:
The previous level showed you raw, but unrefined power.
This level will force you to refine it, as the win function will no longer work.
break at it, look around, and understand what is wrong.
RELEVANT DOCUMENTATION:
Building a Web Server
Now that you know how to write and debug assembly, it is time to do something real!
In this module, you will develop the skills needed to build a web server from scratch, starting with a simple program and progressing to handling multiple HTTP GET and POST requests.
Good luck!
As you proceed in your journey, remember your system call table.
Your first task is to create the simplest possible program—one that immediately terminates when run.
In this challenge, you will use the exit syscall, which is responsible for ending a process and returning an exit status to the operating system.
This syscall takes a single argument: the exit status (with 0 typically indicating success).
Understanding how to cleanly exit a program is crucial because it ensures your process communicates its completion state properly.
In this challenge, you’ll begin your journey into networking by creating a socket using the socket syscall.
A socket is the basic building block for network communication; it serves as an endpoint for sending and receiving data.
When you invoke socket, you provide three key arguments: the domain (for example, AF_INET for IPv4), the type (such as SOCK_STREAM for TCP), and the protocol (usually set to 0 to choose the default).
Mastering this syscall is important because it lays the foundation for all subsequent network interactions.
NOTE:
Looking through documentation, the arguments of the system calls are listed as names in all capitals.
For instance, we may wish to call socket(AF_INET, SOCK_STREAM, 0) but we cannot simply perform mov rdi, AF_INET: AF_INET is simply not a concept at the assembly level.
We need to find the integer which corresponds to AF_INET.
These numbers are not even found in the man pages, but these numbers do exist on your machine.
Check out the /usr/include directory.
All the system's general-use include files for C programming are placed here. (For those who have written C, think of any header files you've included in your code "#include <stdio.h>". All those Functions and constants are defined somewhere here).
Since C is compiled to assembly, these numbers are present somewhere in this directory.
Rather than manually searching, you can grep for them.
After creating a socket, the next step is to assign it a network identity.
In this challenge, you will use the bind syscall to connect your socket to a specific IP address and port number.
The call requires the socket file descriptor, a pointer to a struct sockaddr, and the size of that structure.
Back in Endian Escapades, you saw that x86 stores multi-byte values little-endian in memory, and that non-CPU contexts such as network protocols can use a different byte order.
For IP socket structures, multi-byte numbers use big-endian order: the most-significant byte comes first.
This convention is called network byte order.
For a 16-bit port number, that means port 80 (0x0050) is represented as bytes 00 50.
For IPv4, the structure is a struct sockaddr_in, and bind reads the 16 bytes at that pointer as fields:
bytes 0..1 address family, `AF_INET` (`2`)
bytes 2..3 port, in network byte order (`00 50` for port 80)
bytes 4..7 address (`0.0.0.0` is four zero bytes)
bytes 8..15 padding
Back in Opening the Flag, with RIP, you used stored bytes and passed their address to a syscall.
Here, build the sockaddr_in bytes on the stack, pass the stack address as the pointer, and pass 16 as the size.
On 64-bit x86, writing the word value 0x5000 stores the bytes 00 50, which is port 80 in network byte order.
Binding is essential because it ensures your server listens on a known address, making it reachable by clients.
With your socket bound to an address, you now need to prepare it to accept incoming connections.
The listen syscall transforms your socket into a passive one that awaits client connection requests.
It requires the socket’s file descriptor and a backlog parameter, which sets the maximum number of queued connections.
This step is vital because without marking the socket as listening, your server wouldn’t be able to receive any connection attempts.
Once your socket is listening, it’s time to actively accept incoming connections.
In this challenge, you will use the accept syscall, which waits for a client to connect.
When a connection is established, it returns a new socket file descriptor dedicated to communication with that client and fills in a provided address structure (such as a struct sockaddr_in) with the client’s details.
This process is a critical step in transforming your server from a passive listener into an active communicator.
Now that your server can establish connections, it’s time to learn how to send data.
In this challenge, your goal is to send a fixed HTTP response (HTTP/1.0 200 OK\r\n\r\n) to any client that connects.
You will use the write syscall, which requires a file descriptor, a pointer to a data buffer, and the number of bytes to write.
This exercise is important because it teaches you how to format and deliver data over the network.
In this challenge, your server evolves to handle dynamic content based on HTTP GET requests.
You will first use the read syscall to receive the incoming HTTP request from the client socket.
By examining the request line--particularly, in this case, the URL path--you can determine what the client is asking for.
Next, use the open syscall to open the requested file and read to read its contents.
Send the file contents back to the client using the write syscall.
This marks a significant step toward interactivity, as your server begins tailoring its output rather than simply echoing a static message.
Previously, your server served just one GET request before terminating.
Now, you will modify it so that it can handle multiple GET requests sequentially.
This involves wrapping the accept-read-write-close sequence in a loop.
Each time a client connects, your server will accept the connection, process the GET request, and then cleanly close the client session while remaining active for the next request.
This iterative approach is essential for building a persistent server.
To enable your server to handle several clients at once, you will introduce concurrency using the fork syscall.
When a client connects, fork creates a child process dedicated to handling that connection.
Meanwhile, the parent process immediately returns to accept additional connections.
With this design, the child uses read and write to interact with the client, while the parent continues to listen.
This concurrent model is a key concept in building scalable, real-world servers.
Expanding your server’s capabilities further, this challenge focuses on handling HTTP POST requests concurrently.
POST requests are more complex because they include both headers and a message body.
You will once again use fork to manage multiple connections, while using read to capture the entire request.
Again, you will parse the URL path to determine the specified file, but this time instead of reading from that file, you will instead write to it with the incoming POST data.
In order to do so, you must determine the length of the incoming POST data.
The obvious way to do this is to parse the Content-Length header, which specifies exactly that.
Alternatively, consider using the return value of read to determine the total length of the request, parsing the request to find the total length of the headers (which end with \r\n\r\n), and using that difference to determine the length of the body--this seemingly more complicated algorithm may actually be easier to implement.
Finally, return just a 200 OK response to the client to indicate that the POST request was successful.
In the final challenge, your server must seamlessly support both GET and POST requests within a single program.
After reading the incoming request using read, your server will inspect the first few characters to determine whether it is dealing with a GET or a POST.
Depending on the request type, it will process the data accordingly and then send back an appropriate response using write.
Throughout this process, fork is employed to handle each connection concurrently, ensuring that your server can manage multiple requests at the same time.
After completing this, you will have built a simple, but fully functional, web server capable of handling different types of HTTP requests.
This scoreboard reflects solves for challenges in this module after the module launched in this dojo.