What this module is about
A CPU does not understand C, Python, or any language people write. It runs
machine code: a long list of small numeric commands called
instructions, such as "add these two numbers" or "jump to that
address". Assembly is the same instructions written as short words so
a person can read them. A compiler (such as gcc) turns C into machine
code. A disassembler (such as objdump) turns machine code back into
assembly.
When you reverse engineer a program, you usually have only the compiled file, never the source code, so assembly is what you read. When you exploit a bug, you need to know where data sits in memory and what the CPU will do next, and both are written in assembly. This module teaches you to read it.
The lesson pages are in order. Read them before the challenges, and come back to them while you work:
| Page | Topic |
|---|---|
| 1 | What assembly is, how to read one instruction, hex |
| 2 | Registers |
| 3 | The core instructions |
| 4 | Flags and conditional jumps (signed versus unsigned) |
| 5 | The stack and stack frames |
| 6 | The calling convention (how functions get their arguments) |
| 7 | Byte order (little-endian) |
| 8 | Loops |
| 9 | Worked example: one program, from C to objdump to gdb |
| 10 | How to read objdump and gdb output |
| 11 | Glossary |
| 12 | Common mistakes |
| 13 | Where to go next, and a one-page cheat sheet |
The practice program used in these pages
Every output in these pages is real. It comes from this small program, which is not one of the challenges. It was compiled and run in the same environment as the challenges.
#include <stdio.h>
/* Straight-line math: two arguments, one local, one return value. */
int mix(int a, int b) {
int t = a * b;
t = t + 100;
return t - a;
}
/* A loop: add up the numbers 1..n. */
int sum_to(int n) {
int total = 0;
for (int i = 1; i <= n; i++) {
total += i;
}
return total;
}
/* A pointer argument: change the caller's variable. */
void bump(int *p) {
*p = *p + 1;
}
int main(void) {
long big = 0x1122334455667788;
int m = mix(5, 7);
int s = sum_to(4);
bump(&s);
printf("%d %d %lx\n", m, s, big);
return 0;
}
It prints 130 11 1122334455667788. You can build the same thing in your
dojo workspace: save it as toy.c and run
gcc -O0 -fno-stack-protector -no-pie -fno-pie -o toy toy.c.
One line of C becomes a few instructions
Here is mix next to the assembly gcc made for it. The text after each
; is a comment added for you; objdump does not print it.
C assembly
------------------- ------------------------------------------------------
int t = a * b; mov eax,DWORD PTR [rbp-0x14] ; eax = a
imul eax,DWORD PTR [rbp-0x18] ; eax = eax * b
mov DWORD PTR [rbp-0x4],eax ; t = eax
t = t + 100; add DWORD PTR [rbp-0x4],0x64 ; t = t + 100 (0x64 is 100)
return t - a; mov eax,DWORD PTR [rbp-0x4] ; eax = t
sub eax,DWORD PTR [rbp-0x14] ; eax = eax - a (the answer)
Three things to notice:
- Variable names are gone.
a,bandtbecame places in memory:[rbp-0x14],[rbp-0x18]and[rbp-0x4]. Reading assembly is mostly working out which place holds which variable. Page 5 explainsrbp. - Numbers are in hex.
100is written0x64. - The CPU works through registers.
eaxis a register, a small storage slot inside the CPU (page 2). Most instructions can touch memory in at most one operand, so values pass through registers on the way.
How to read one instruction
mov DWORD PTR [rbp-0x4],eax
^^^ ^^^^^^^^^^^^^^^^^^^ ^^^
| | +-- source: where the value comes from
| +---------------- destination: what gets changed
+----------------------------- mnemonic: the instruction's name
This module uses Intel syntax: the destination comes first, like an
assignment in C. mov eax, 5 means eax = 5. add eax, ebx means
eax = eax + ebx. Ask objdump for it with -M intel.
An operand (one of the things after the mnemonic) is one of three kinds:
| Kind | Example | Means |
|---|---|---|
| register | eax |
the value in register eax |
| immediate | 0x64 |
the number itself, written inside the instruction |
| memory | DWORD PTR [rbp-0x4] |
the value stored in memory at address rbp - 4 |
The square brackets mean "go to this address in memory". The word in front says how many bytes to read or write there:
| Size word | Bytes | Bits | Typical C type |
|---|---|---|---|
BYTE PTR |
1 | 8 | char |
WORD PTR |
2 | 16 | short |
DWORD PTR |
4 | 32 | int, unsigned int |
QWORD PTR |
8 | 64 | long, any pointer |
AT&T syntax is the other common style. It reverses the operands and
adds % and $ signs. If objdump output looks like the second line
below, you forgot -M intel:
Intel: mov DWORD PTR [rbp-0x14],edi
AT&T: mov %edi,-0x14(%rbp)
Hex in one minute
Hex (base 16) uses the digits 0-9 and then a-f for 10 to 15. A
0x in front marks a hex number. One hex digit is exactly 4 bits, so two
hex digits are exactly one byte (8 bits). That is why programmers and
tools use hex for memory: 0x1122 is the two bytes 11 and 22.
Python converts for you:
$ python3
>>> hex(100)
'0x64'
>>> 0x64
100
>>> hex(0xff & 0x0f)
'0xf'
Negative numbers. A 32-bit register holds a pattern of 32 bits and
nothing else. The same pattern 0xffffffff means 4294967295 if you treat
it as unsigned (never negative) and -1 if you treat it as signed
(this way of storing negatives is called two's complement). The CPU does
not record which one you meant. The instruction that uses the value
decides. Page 4 shows why this matters.
Wrap-around. A 32-bit register keeps only the lowest 32 bits of a
result. 0xffffffff + 1 becomes 0, like a car odometer rolling over. To
copy that in Python, keep the low 32 bits with & 0xffffffff.
Why -O0
The challenges here were compiled with -O0 (no optimisation). Each C
statement then becomes its own short group of instructions, and every
local variable lives in memory on the stack. Optimised code (-O2) keeps
values in registers and reorders work, which is much harder to read. You
will meet it later, not in this module.