Control Flow


Computing 101.

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 setnz 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. Since rdi is the register used for the exit code in the exit system call, this effectively makes your exit code 1 (equal!) or 0 (not equal!).

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 BYTE PTR [rsp], 42). But it cannot compare two memory locations at once --- at most one operand can be a memory dereference. This is a general rule in x86 and, actually, in almost all CPU architectures.

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:

  1. Compares argc with 42 (whether by first moving argc into a register or comparing against the memory directly).
  2. Uses setz dil to set the exit code: 1 if argc equals 42, 0 otherwise.
  3. Exits.

Connect with SSH

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

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:

  1. Load the argv[1] pointer from [rsp+16] into a register.
  2. Compare BYTE PTR at that address against 'p'.
  3. Use setz dil to capture the result.
  4. Set up the exit syscall number.
  5. syscall.

Connect with SSH

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

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:

  1. Load the argv[1] pointer from [rsp+16] into a register.
  2. Compare BYTE PTR at that address against 'p'.
  3. jne fail --- jump to the failure case if the characters aren't equal.
  4. Write the "fall-through" success case (exit(0)).
  5. 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!

Connect with SSH

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

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":

  1. Load the pointer for the first argument from [rsp+16].
  2. Compare byte at offset 0 against 'p' --- jne fail if it doesn't match.
  3. Compare byte at offset 1 against 'w' --- jne fail if it doesn't match.
  4. Compare byte at offset 2 against 'n' --- jne fail if it doesn't match.
  5. Implement the success path: exit(0).
  6. Implement the fail: label with exit(1).

Connect with SSH

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

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.

Connect with SSH

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

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:

xor    eax, eax                  ; 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 with xor eax, eax: 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!


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)

HINT: You can also print out several jump table entries at the same time:

(gdb) x/3a 0x1337000
0x1337000:  0x400100
0x1337008:  0x400100
0x1337010:  0x400100
(gdb)

Connect with SSH

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

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!

Connect with SSH

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

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:

  1. Pushes the address of the next instruction after the call instruction (the return address) onto the stack.
  2. 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.

To instead watch the real run with the real flag: /challenge/check is a SUID binary, so launching it under a debugger drops its privileges and it can't read your flag. Launch this challenge in practice mode (which gives you sudo) and debug the whole flow as root, passing your .so to check via gdb's run --- exactly like you did back in the running-with-arguments level:

hacker@dojo:~$ sudo gdb /challenge/check
(gdb) r your-solve.so

Connect with SSH

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

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:

  1. 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".
  2. Your function does some work.
  3. Your function puts its result in rax.
  4. Your function executes ret, which pops the saved return address off the stack and jumps back to the challenge.
  5. 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!

Connect with SSH

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

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!

Connect with SSH

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

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!

Connect with SSH

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

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:

  1. rdi: a pointer to a clobber_function that will clobber all caller-saved registers
  2. 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!

Connect with SSH

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

You've practiced caller-saved registers, and now we'll cover callee-saved ones. The callee-saved registers (rbx, rbp, r12, r13, r14, r15) come with the promise of being returns unclobbered by the callee. 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/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, r12, r13, r14, r15 that it expects back untouched.

Your job:

  1. Save off all the callee-saved registers.
  2. Clobber them all by setting them to 0x1337.
  3. Call check_callee_clobbered, which confirms you clobbered them.
  4. Restore the callee-saved registers.
  5. ret.

The challenge then checks that rbx, r12-r15 came back exactly as it 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.

Connect with SSH

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

30-Day Scoreboard:

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

Rank Hacker Badges Score