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. He 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 always 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]

30-Day Scoreboard:

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

Rank Hacker Badges Score