hacking track
week 14 · day 37

Assembly Refresher for Exploitation

Registers, the stack, and calling conventions, through an attacker's eyes

Insane 150 min 180 xp
After this you can
  • Name the registers that matter for exploitation and what each one holds
  • Explain, instruction by instruction, how call and ret move execution
  • Read a stack frame and locate the buffer, saved rbp, and return address
  • Map a small C function to its disassembly and spot the vulnerable layout

Why an attacker learns assembly

Everything in binary exploitation comes down to one sentence: you change the CPU's next instruction to one you chose. To do that you have to see the machine the way it sees itself — as registers, a stack, and a stream of instructions — because that is the level at which the attack happens. High-level code hides all of this; exploitation drags it back into the light.

This is also, honestly, the best way to actually *learn* assembly. Studied on its own it's a dry list of mnemonics. Studied here it has teeth: every register and instruction is a lever you're about to pull to hijack a program. If you did the Metal-to-C track, this is that knowledge sharpened to a point; if you didn't, this lesson teaches you what you need from scratch, with a reason to care about every line.

We use x86-64 (the 64-bit Intel/AMD architecture on almost every server) and Intel syntax (destination first, no sigils), which reads cleaner.

The registers, ranked by how much you'll abuse them

A register is a tiny, ultra-fast storage slot inside the CPU — 64 bits wide. There are sixteen general-purpose ones, but for exploitation they are not equal. Learn them in order of how often they'll matter to you.

RegisterRoleWhy the attacker cares
ripInstruction pointerTHE target. Whatever rip holds is the next instruction. Control rip, control the program.
rspStack pointerAlways points at the top of the stack. Your overflow travels through the stack toward the return address.
rbpBase/frame pointerAnchors the current function's frame. Sits right below the saved return address.
rdi, rsi, rdx, rcx, r8, r9The 6 argument registers, in orderTo call system() with '/bin/sh', you must get that string's address into rdi. This is why ROP exists.
raxReturn value / syscall numberA function's result lands here; a syscall's number goes here (59 = execve).
rbx, r10–r15General purposeScratch space and ROP-gadget fodder.
Key idea
Two of these are the whole game. rip is what you want to control — it decides what runs next, and you never write it directly; instructions like ret and call write it for you, which is exactly the mechanism you'll hijack. rsp is how you get there — memory corruption on the stack lets your data flow up to where a return address is stored, and ret will load that address into rip. Everything else is detail around those two.

The six instructions that carry every exploit

You do not need hundreds of instructions. Binary exploitation runs on a tiny core. Learn these six cold and you can read the disassembly of almost any function.

the exploitation core, dissectedasm
1mov rax, rbx ; COPY: rax = rbx (dest first, always)
2lea rdi, [rsp+8] ; LOAD ADDRESS: rdi = the address rsp+8 (NOT the value there)
3push rbp ; put rbp on the stack; rsp moves DOWN by 8
4pop rbp ; take the top of the stack into rbp; rsp moves UP by 8
5call func ; push the address of the NEXT instruction, then jump to func
6ret ; pop an address off the stack into rip (return)

The two that matter most: call and ret

call func does two things atomically: it pushes the return address (the address of the instruction right after the call) onto the stack, then sets rip to func. That saved return address is a breadcrumb so the function knows where to come back to.

ret is the reverse and it is the single most important instruction in all of memory-corruption exploitation: it pops the top of the stack into rip. It trusts, completely, that whatever sits on top of the stack is a legitimate return address put there by a matching call. It has no way to verify that. So if you can overwrite that value on the stack with an address of your choosing, ret will happily load *your* address into rip and execute from there. The entire stack-overflow attack is: get your address onto the stack where ret will find it.

The stack, drawn exactly

The stack is a region of memory the CPU uses for the bookkeeping of function calls, and it has one quirk everyone must internalise: it grows DOWNWARD. As you push data, rsp *decreases*. "Top of the stack" is the lowest address. Here is a function's frame, the way you'll see it in every overflow:

A stack frame during a call (high address at top)
  HIGH addresses
  +----------------------+
  |  ...caller's frame   |
  +----------------------+
  |  return address      |  <- 8 bytes: where ret will send rip (into the caller)
  +----------------------+
  |  saved rbp           |  <- 8 bytes: caller's frame pointer, pushed by the prologue
  +----------------------+   <- rbp points here during the function
  |  local variables     |
  |  char buffer[64]     |  <- a stack buffer. an overflow here writes UPWARD...
  |  ...                 |         ...toward saved rbp, then the return address.
  +----------------------+   <- rsp points at the top (lowest address)
  LOW addresses

  overflow direction:  buffer  --->  saved rbp  --->  return address
Watch out
Read that diagram until it's automatic, because it is the map of the attack. The buffer sits at a lower address than the saved rbp and the return address. Functions like gets or a bad strcpy write into the buffer upward (toward higher addresses) with no limit — straight into the saved rbp and then the return address. That's not a metaphor; it's the literal memory layout you'll overwrite in the next lesson.

The calling convention: the contract you'll exploit

When one function calls another, both sides must agree on where arguments go and who cleans up. On Linux x86-64 this is the System V ABI, and two of its rules are exploitation gold:

1. The first six integer/pointer arguments go in `rdi, rsi, rdx, rcx, r8, r9`, in that order. So system("/bin/sh") means: put the address of "/bin/sh" in rdi, then call system. When you build a ROP chain later, this rule is *why* you hunt for a pop rdi; ret gadget — it's how you load rdi with the value you want. 2. The return value comes back in `rax`.

And every function typically opens and closes with the same ritual, which creates the exact frame you just drew:

the prologue and epilogue — the frame's birth and deathasm
1vulnerable:
2 push rbp ; PROLOGUE: save caller's frame pointer
3 mov rbp, rsp ; set up our own frame: rbp now anchors this function
4 sub rsp, 0x40 ; reserve 0x40 = 64 bytes of locals (that's your buffer)
5 ; ... the function's body runs here ...
6 leave ; EPILOGUE: mov rsp, rbp ; pop rbp (tear the frame down)
7 ret ; pop return address into rip -> back to the caller

Reading it

push rbp saves the caller's frame pointer (that's the "saved rbp" box in the diagram). mov rbp, rsp makes rbp point at the current top, anchoring this frame. sub rsp, 0x40 moves rsp down 64 bytes to make room for locals — *that space is the buffer*. At the end, leave undoes it (mov rsp, rbp then pop rbp), and ret returns. Every stack overflow you'll ever write lives between that sub rsp and that ret: you fill past the reserved 64 bytes, over the saved rbp, into the return address, and ret does the rest.

From C to this — the map you must be able to draw

None of this is abstract. Here is a tiny C function and the assembly it compiles to. Being able to look at C and *see* the stack layout — and look at disassembly and *reconstruct* the C — is the core reading skill of the whole phase.

vuln.cc
1void vulnerable(void) {
2 char buffer[64]; // 64 bytes on the stack -> the 'sub rsp, 0x40'
3 gets(buffer); // writes into buffer with NO limit -> the overflow
4}
objdump -d --disassembler-options=intel vulnasm
1vulnerable:
2 push rbp ; save caller frame
3 mov rbp, rsp ; establish frame
4 sub rsp, 0x40 ; 64 bytes for buffer[64]
5 lea rax, [rbp-0x40] ; rax = &buffer (buffer starts 0x40 below rbp)
6 mov rdi, rax ; 1st arg to gets = &buffer (System V: arg in rdi)
7 call gets ; read unbounded input into buffer
8 nop
9 leave ; tear down frame
10 ret ; <- your overwritten return address lands in rip here
Trace it and everything connects: sub rsp, 0x40 is buffer[64]. lea rax, [rbp-0x40] is literally taking the *address of* buffer (64 bytes below rbp). mov rdi, rax puts that address in the first-argument register, and call gets fills it without bounds. The buffer is 64 bytes below rbp; the saved rbp is at rbp; the return address is 8 bytes above that. So from the start of the buffer to the return address is 64 + 8 = 72 bytes — the exact offset you'll need next lesson. You didn't memorise 72; you *derived* it from the assembly.

Reading disassembly is the skill

You will spend real time staring at disassembly, so get comfortable producing it. objdump -d gives a static listing; gdb (ideally with the pwndbg or GEF plugin) lets you watch it run — step one instruction at a time, and print the registers and the stack as they change:

watching the machine movesh
1objdump -d -M intel ./vuln | grep -A15 '<vulnerable>:' # static: read the function
2
3gdb ./vuln
4(gdb) disassemble vulnerable # the same listing, inside gdb
5(gdb) break vulnerable
6(gdb) run
7(gdb) info registers rip rsp rbp # watch the three that matter
8(gdb) x/8gx $rsp # examine 8 giant (8-byte) hex words at the stack top
9(gdb) stepi # execute ONE instruction; watch rsp/rip change

That's the assembly you need to start breaking binaries — and, not by coincidence, a genuinely solid grounding in x86-64 itself. You learned registers because rip is your target and rdi is how you pass arguments; you learned the stack because that's what you overflow; you learned call/ret because ret is the instruction you hijack; you learned the calling convention because it dictates how you'll set up the shell. Every piece earned its place by being something you're about to *use*. Next lesson, you point all of it at a real return address.

finished reading?
Your task, you write the code

Read the machine

Everything here was taught above — this is a review, not a new problem. Type vuln.c from this lesson, compile it with `gcc -fno-stack-protector -no-pie -g -o vuln vuln.c`, then in asm.md answer, using only what the lesson covered: (1) disassemble `vulnerable` with objdump -M intel and paste it; (2) label each instruction with what it does (map it to the prologue/epilogue and the C); (3) from the disassembly alone, state how many bytes lie between the start of buffer and the saved return address, and show your arithmetic; (4) in gdb, break on `vulnerable`, `stepi` through the prologue, and record how rsp changes after `push rbp` and after `sub rsp, 0x40`.

deliverable: asm.md
build & run
$ gcc -fno-stack-protector -no-pie -g -o vuln vuln.c
$ objdump -d -M intel ./vuln | grep -A15 '<vulnerable>:'
$ gdb ./vuln # break vulnerable; run; stepi; info registers rsp rbp
self-review before running
  • You mapped every disassembled instruction to the prologue/epilogue or the C line
  • You derived the buffer→return-address distance from the assembly (not memorised)
  • You watched rsp decrease by 8 on push rbp and by 0x40 on sub rsp, 0x40
  • You can name what rip, rsp, and rbp each hold, without looking
stretchChange buffer[64] to buffer[100], recompile, and re-derive the offset from the new disassembly. Confirm the `sub rsp` value changed and that your arithmetic still gives the right buffer→return distance. Deriving the offset from any binary — not memorising one number — is the skill that carries into every future target.

Self-check

01Which register is the ultimate target of a memory-corruption exploit, and why?
02What exactly does the `ret` instruction do?
03For a 64-byte buffer, why is the distance from the buffer to the saved return address 72 bytes?
04To make a called function receive '/bin/sh' as its first argument on Linux x86-64, you must place its address in:
0/4 correct · 0/4 checked