Memory Exploits
A program copies a name into a box that holds eight letters. Somebody types eleven. The extra three letters do not vanish and they do not raise an error: they land on whatever was stored next to the box, and if what was stored next to the box was the answer to "where should I go when this function finishes", a stranger has just chosen where your program goes next. Here you build that bug, watch each byte land, and then switch on the defences one at a time and watch the identical input stop working.
Every attack on these pages is carried out inside a simulated 64-bit machine whose memory is an array of numbers with a made-up layout. Nothing is executed and nothing leaves the simulation: when a payload here overwrites a return address, the thing being overwritten is a number in that array. The machine is small enough that you can watch every byte of a stack frame at once, which is the whole reason for using one.
Do that one first: Assembly & Debugging. The first
half of this course reads and writes assembly, and it takes five things from that one as already
known. Registers, the named slots inside the processor such as rax and
rdi. mov and the habit of writing the destination first, so that
mov rax, 5 puts 5 into rax.
How an instruction works out the address of an array item, as in [rbx+rdi*8].
cmp, the flags it leaves behind and the conditional jumps je,
jl and jge that read them. And the stack: rsp,
push, call and ret.
You can carry on without it. Every one of those words is explained again where it first turns up here, either in a note or in the sentence that needs it. Two or three sentences each, rather than a step. Parts 3 and 4 leave assembly behind and need none of it.
You cannot defend against a mechanism you have only read about. Every attacking step here ends by switching on the defence that stops it, and the defences are the point: bounds checks, canaries, non-executable memory, address randomisation and the tools that find leaks and stale pointers before anyone else does. Nothing here is a recipe against a real system. The addresses, the layout and the instruction set belong to a toy machine and match no real one.
The steps
Reading past the end
An array in C is a run of values and a promise. The promise is that you will only ask for the ones that
are there. There is no fence at the end, no guard, and no check when you ask for item seven of a
four-item array. The processor works out an address the same way it always does, reads eight bytes, and
hands them back. The lab below shows the three instructions that do it. mov copies a value
from one place to another. The square brackets in [rbx+rcx*8] mean the memory at that address,
reached by starting at rbx and stepping along by rcx items of eight bytes. The paragraphs after the lab set
out the rest of the assembly this course uses.
What comes back is whatever the program happened to store just after the array. That might be another variable, a length, a password, or a pointer. In C this is called undefined behaviour, which sounds like a warning and is really a shrug.
What "undefined behaviour" actually means
The C standard describes what a correct program does. When a program breaks a rule, such as reading past the end of an array, the standard says nothing at all about what happens next. It does not say "you get a crash" or "you get garbage": it withdraws its description entirely, and anything the compiler and the machine do from that point is allowed.
That matters more than it sounds. A compiler is permitted to assume the rule was never broken, so it may delete a check you wrote on the grounds that it could only have mattered in a case the standard says cannot happen. This is why "it worked when I tested it" is not evidence. The read below returns a specific, predictable number, and one recompile could change it.
Nothing is stored beside an array saying how long it is. The length lives in the source code, in the programmer's head, and sometimes in a separate variable that can be wrong. The machine sees one flat run of bytes, so "past the end" is a fact about your intentions, not about the memory.
The programs in the first half of this course are written in assembly: one instruction per line,
the smallest steps a processor takes. mov copies a value from one place to another,
add and sub do the arithmetic, and inc adds one. What those
instructions work on is registers. A register is a slot inside the processor itself that holds one
number. There are sixteen for general use, with names rather than numbers. This course uses
rax, rbx, rcx, rdx, rdi,
rsi and rsp, and rbp joins them in Step 3. The row of boxes under
each program shows what the registers that program uses are holding, and marks the ones that have just
changed. The Step button runs exactly one instruction, so you can watch a value move rather than only see
where it ended up.
Two spellings to have before the next lab. Instructions are written destination first, so
mov rax, 5 puts 5 into rax and mov rax, rbx copies rbx into rax. Square brackets
mean the memory at that address, and the processor will do a little arithmetic inside them for nothing.
[rbx+rdi*8] means "start at the address in rbx, then go along by rdi items of eight bytes
each". That one form is what an array access becomes. scores[i] in C and
[rbx+rdi*8] in assembly are the same arithmetic, written twice.
There is no if down here, and the lab below needs one. cmp rdi, 0 compares two
numbers by subtracting them and throwing the answer away. It keeps only a few facts about the result:
whether it came out zero, and whether it came out negative. Those facts are kept in the flags register. The
jump instructions read them and decide where to go next. je goes if the two numbers were
equal, jne if they were not, jl if the first was less, and jge if it
was greater or equal. jmp goes whatever happened. Each of them aims at a label: a name
you choose, followed by a colon, on a line of its own, such as done:. A label is not an
instruction. Nothing happens when the machine reaches one. It is only a name for a place, so that a
jump has something to aim at.
What are the lines beginning with a dot at the top of the program?
They are not instructions. An assembler is the program that turns your typed lines into the numbers
a processor runs. These lines are directions to it about how to lay the program out, and they have
finished their work before anything runs. .data says "what follows is values
to put in memory, not code". .quad 90, 72, 65, 88 puts those four numbers into memory,
eight bytes each, one after the other. .text says "back to instructions now", and
main: is a label marking where to begin.
A label written in front of a value names the address of its first byte. So scores on
its own is that address, and mov rbx, scores puts the address into rbx. Square brackets ask
for the value stored there instead, so [count] is the number sitting at the label
count. That is how the lab below finds out how long the array is, rather than being told
four. Anything after a semicolon is a comment, a note for whoever is reading, which the machine skips
over entirely.
Is [rbx+rdi*8] the only shape an address comes in?
No, but there is only one general form, and every address in this course is a case of it. A base, plus an index times a scale, plus a fixed distance called the displacement. The base and the index are registers. The scale is 1, 2, 4 or 8, because those are the sizes values come in. The displacement is a plain number. It is allowed to be negative.
So [rbx+rdi*8] uses the base, the index and the scale, while [rsp+8] uses
a base and a displacement to reach one particular slot. Step 3 uses all four at once, in
[rbp-16+rcx*8]: start from the address in rbp, come down sixteen bytes, then go along by
rcx items of eight. Written out longhand that is four operations, and the processor does all of it
inside the instruction at no cost, which is why compiled code is full of it.
rdi, answer with the element when the index is inside the array, and answer with -1 when
it is not. It is tested with indexes you cannot see, including a negative one and one far past the
end.scores[7] from a four-element array. It runs fine on your
laptop and prints a plausible number. What have you learned?The overflow, seen live
Reading past the end gives you somebody else's data. Writing past the end gives you somebody else's variable. An out-of-bounds read may disclose data. An out-of-bounds write may corrupt data or control information, which can change the program's behaviour.
The classic shape is a copy loop that stops when it reaches the end of the input, rather than when it reaches the end of the space it is copying into.
Why C strings end with a zero byte, and what strcpy does
A C string is a run of characters followed by a byte with the value zero. Nothing records the
length: to find the end you walk forward until you hit the zero. That is what the copy loop below does,
one byte at a time, and it is what strcpy does in the standard library.
So a "seven character name" needs eight bytes of space, seven for the letters and one for the terminator. Off-by-one bugs where a program allocates exactly as many bytes as there are characters are common enough to have their own name: the off-by-one overflow, and one byte is sometimes plenty.
Who is typing this name, and why would anyone type nonsense?
Almost no interesting program gets its input from the person sitting in front of it. A program answering web pages gets a name out of a form somebody filled in on the other side of the world. A game gets one out of a message from another player's machine. A phone gets one out of a file it has just downloaded. In every case the bytes were chosen by a stranger and arrived down a wire, and nothing about them tells the program whether they came from a friendly user or from somebody who counted the letters first.
That is all "an attacker" means on these pages. Not somebody breaking into the building: just whoever gets to decide what the input says. And it turns the question the right way round. The question is never "would a person really type that?" It is "what is the worst thing this program does for the worst input somebody could send it?", and a program is only correct if the answer is "nothing much".
Where does the name box itself come from?
rsp holds the address of the top of the stack, and the stack grows downwards, towards
lower addresses. So a function makes room for its own local variables by moving rsp down.
sub rsp, 16 takes sixteen off it and the sixteen bytes that opens up belong to this
function until it finishes. That gap is the whole of what a local buffer is, and
add rsp, 16 at the end hands the space back.
Which means the number in that sub is the size of the box, and it is the only place the
size is written down anywhere. No byte of memory records it, so the copy loop cannot look it up and
neither can anything else. Lab 9 and Lab 12 both let you change that number and watch how much has to
be typed before a name reaches its neighbour.
Why do some lines say qword or byte?
mov rax, [rsp] needs no help: rax is eight bytes wide, so eight bytes are fetched.
mov [rsp+8], 0 is not so clear, because an address does not say how wide it is. A zero
would fit in one byte as happily as in eight. So the width is written out. qword means
eight bytes, byte means one, and dword and word mean four and
two. You write one only when neither side of the instruction is a named register to settle the question.
That is why mov qword [rsp+8], 0 says it and mov rax, [rsp] does not.
A register can also be used in part. rax is all 64 bits of it, eax is the
low 32, ax the low 16, and al the low 8. Those are not four separate registers
but four windows onto the same one. That is what makes mov [rsp+rcx], al a one-byte write:
it asks for the bottom eighth of rax and leaves everything above it out.
What is movzx, and why not just mov?
The copy loop fetches one character at a time, and a character is a single byte. A register is eight.
mov rax, byte [rsi+rcx] would be asking to put a one-byte thing into an eight-byte thing and the assembler refuses, because it cannot guess what you meant to happen to the seven bytes left
over.
movzx is move-with-zero-extend, and it answers that question: fetch the small thing, put
it at the bottom of the register, and set every bit above it to zero. So after
movzx rax, byte [rsi+rcx] the whole of rax is the single character that was fetched and the cmp rax, 0 on the next line is a fair test for the zero byte that ends a C string.
Going the other way needs no such help: mov [rsp+rcx], al writes al, the name
for the bottom byte of rax, so the two sides are already the same size.
Read the whole program above. The administrator flag is touched exactly once, by
mov qword [rsp+8], 0, which sets it to zero before the copy starts. There is no second line.
Whatever the flag ends up holding arrived as the tail of a name, which is why reading the code looking for
"where does this get set" finds nothing, and why the answer to a question like that is always a memory
tool rather than a careful reader.
Reaching the return address
A local array lives on the stack. So does the address the function will jump back to when it finishes,
pushed there by the call instruction. The stack grows downwards while an array fills upwards,
which puts the return address a short distance above the end of every local buffer in the program.
From here on, the input being copied in is called a payload, which is the word attackers use for it. A payload is not a name somebody typed by accident. It is bytes chosen on purpose, with a particular value put at a particular distance from the start, so that when the copy runs off the end of the buffer that value lands exactly where the attacker wants it. There is nothing special about the bytes themselves: a payload is ordinary data, and what makes it a payload is that somebody counted first.
That is the arrangement, and it is not an accident anybody chose. It means a write that runs off the end of a local array walks towards the single most useful number on the stack. The lab below moves eight bytes at a time rather than one byte at a time, because eight bytes is the size of a return address and it makes the picture readable at a glance. One of those eight-byte pieces is called a chunk, so the sixteen-byte buffer in the lab is two chunks wide and the return address is one chunk tall.
What is actually stored in the return slot, here and on real hardware
On a real x86-64 machine, call pushes the address of the instruction that comes after
it, a 64-bit number pointing into the program's code, and ret pops that number and jumps
to it. There is no check of any kind: whatever eight bytes are on top of the stack when ret
runs, that is where the processor goes.
The machine on this page pushes the position of the next instruction rather than its byte address, so the numbers you see in the return slot are small. The mechanism is identical and the numbers are easier to read. Everywhere a real payload would carry an eight-byte address, the payload here carries an instruction position. The one exception comes in Step 5: a payload aimed at the buffer rather than at existing code carries a real address, because the buffer is in memory and a position in the code list would not name it.
What are the prologue and the epilogue, and what is a frame pointer?
Almost every compiled function opens with the same two instructions. push rbp saves the
caller's copy of rbp onto the stack, and mov rbp, rsp parks a copy of where the stack was
at the moment this function began. Those opening instructions are the function's prologue. rbp
then stays still for the whole call while rsp goes on moving, so [rbp-16] names the same
slot from the first instruction to the last. A register used that way is a frame pointer, and the
caller's copy of it, sitting on the stack where the prologue pushed it, is the saved frame
pointer you will see labelled in the frame map below.
The closing instructions are the epilogue: put rsp and rbp back the way they were, then
return. leave is one instruction that does both halves of that undoing, and ret
follows it. What matters here is the saved frame pointer's position. It sits between the local
buffers and the return address, so a copy running off the end of a buffer reaches it one chunk before it
reaches the thing it is really after. That is why chunk 3 in the lab breaks something and chunk 4
breaks everything.
ret somewhere meaningless, and the position of a function that nothing in the program ever
calls.Every instruction in that copy loop is a perfectly ordinary write to a perfectly ordinary address the
program owns. No rule of the processor was broken, so no fault was raised and no message was printed. The
program only comes apart later, at ret, a long way from the instruction that caused it.
0x4141414141414141.
What does that tell an engineer immediately?Nothing was checked
Everything in the last three steps came from one absence. Not a clever trick, not an obscure processor feature: a copy loop that was told where to start and never told where to stop.
Say the fix another way
A safe copy needs two numbers, not one. It needs where the input ends, which it finds by walking to the zero byte, and it needs where the destination ends, which nothing in memory can tell it. That second number has to be carried along by the programmer, from wherever the buffer was created to wherever it is written into.
Most memory-safe languages are exactly this idea made compulsory. An array carries its length with it, every index is compared against that length, and the comparison costs a few instructions that the processor often predicts correctly. Bounds checks add a small per-access cost and prevent many out-of-bounds reads and writes. Measure the cost in the actual workload before removing a check.
copy(char *dst, int n). A caller
passes a length that came from the network without checking it. Where is the bug?Three switches
Bounds checks fix the bug. The defences in this step are for everything else: the code you did not write, the library you cannot change, and the bug nobody has found yet. Each one takes a working attack and breaks one of the things it depends on.
The attack needs three things. It needs to reach the return address, it needs somewhere useful to point it, and it needs to know the address of that useful thing. Take away any one of those and it stops.
Each switch below is one real defence, and it is worth having the names before you press anything. A stack canary is a number the compiler stores on the stack between a function's local buffers and its saved return address, and compares against the original just before the function returns. A copy walking from a buffer up towards the return address has to write over that number on the way past, so a canary that has changed is proof that something crossed it and the program stops itself rather than returning. A non-executable stack means the processor refuses to run instructions that live in the stack, so a payload cannot carry its own code. Address randomisation means the program is put somewhere different every time it starts, so an address written into a payload last week is wrong today. The three notes below say how each of those actually works.
Where the canary value comes from, and why it works
When a compiler protects a function, it stores a random value on the stack between the local buffers and the saved return address, and adds a comparison just before the function returns. The value is chosen once when the program starts and never printed anywhere. A copy that walks from a buffer to the return address has to cross that value on the way, so the comparison fails and the program stops itself. The number itself has a name of its own: the cookie. That is what the switch marked "the attacker knows the cookie" in the first lab below turns on. A payload that happens to carry the right number, which is the one thing that makes a canary useless. It is also why the cookie is chosen freshly each time the program starts and is never printed anywhere.
The name comes from the caged bird once carried into coal mines, which stopped singing before the air became dangerous to the miners. The bird is not the defence. Noticing the bird is.
What does "non-executable" mean, and what is a page?
The operating system does not hand out memory a byte at a time. It hands it out in fixed slabs, usually 4096 bytes each, and one slab is called a page. Beside every page the hardware keeps a few bits saying what may be done with it: may it be read, may it be written, and may the processor fetch instructions out of it. Those three answers are set separately, which is the part that makes this defence possible.
Marking the stack's pages "not executable" turns off only the third one. The stack reads and writes exactly as before, so every honest program keeps working, but if a return address ever points into the stack the processor refuses to fetch anything there and the process dies on the spot. One bit per page, no cost while the program runs, and an entire style of attack gone: the style where the payload carries its own instructions and the return address jumps into them. What it does not remove is the other style, pointing the return address at code the program already contains, because that code lives in pages that are meant to be executed. That is why the lab below has two targets to choose between.
Why does moving the program about help at all?
A payload that redirects a return has to carry a number: where to go. The attacker has to know that number before they send it, which means it has to be the same on your machine as it was on the machine they worked it out on. For decades it was. A program was loaded at the same address every time it started, so an address written down last week still worked today, and payloads could be published.
Address randomisation chooses a fresh random starting position each time the program is launched. The program itself does not care, because everything inside it is worked out relative to wherever it landed. The attacker's number is now wrong, and a wrong jump usually kills the process rather than doing what they wanted. Notice the shape of that: it is a lottery, not a wall. The more places the program could be, the smaller the chance any one guess lands, which is exactly what the second lab counts and an attacker who can retry cheaply against a service that restarts after every crash still gets there in the end.
cmp rax,
rdx line is next. Read rax and rdx before you let it run: those are the two numbers the
canary comparison is about to weigh against each other.payload line, change the sub rsp that
sizes the buffer, change how many chunks the loop copies, or delete the comparison in the epilogue
and see what stops being caught. The frame is redrawn after every instruction.Never giving it back
The stack is automatic: a function's locals appear when it starts and vanish when it returns. The heap
is the other kind of memory, the kind you ask for by size at the moment you need it, and it is not
automatic in either direction. You ask with malloc, you give it back with free,
and if you forget the second one nothing complains.
One word of warning before the rest of this part. Steps 1, 2 and 5 used leak to mean a secret escaping: a read past the end that hands somebody's password to a stranger, or a bug that tells an attacker one real address. From here to the end of the course leak means the other thing entirely: memory that was asked for and never given back. The two share a name and nothing else, and both are common enough that the word has simply had to do two jobs. Nothing complains for a long time, which is the problem. Most programs that matter are servers. A server starts once and then runs for weeks, doing one small job over and over: answering one page, handling one message, storing one photograph. Each of those small jobs is called a request. A server that leaks a few hundred bytes per request works perfectly on the day somebody tests it, because a test does a few dozen requests, and falls over three days after it is switched on, because by then it has done a few million. The bug did not change. The number of times it happened did.
From here on you write the programs yourself, and not in assembly. The listings in this part are written
in a small language of the kind most programs are written in today: let name = value makes a
variable, function name(x) { ... } makes a function, while (test) { ... } repeats
for as long as the test holds, return hands an answer back, say(x) prints one
line, and anything after // is a note to a human that the machine ignores. Four more words are
the heap itself: malloc(n) asks for a block of n bytes and answers its address,
free(p) hands that block back, store(p, k, v) writes v into slot k of the block at
p, and load(p, k) reads it out again. A slot is eight bytes, so slot 0 is the first eight bytes
of a block and slot 1 the next eight.
What an allocator actually keeps track of
The heap is one large region of memory and a bookkeeper. When you ask for 24 bytes, the bookkeeper finds a free stretch at least that big, marks it used, and hands you the address of its first byte. When you free that address, it marks the stretch free again and remembers it for the next request. The list of what is used and what is free is the allocator's own data, kept beside or inside the blocks it manages.
Two consequences follow, and both matter later. The allocator hands out addresses it has seen before, so freed memory gets reused quickly. And its bookkeeping lives in the same memory as your data, so a program that writes past the end of a block can corrupt the bookkeeper itself.
Why has the code stopped being C?
Nothing here would be different in C, and the bugs are C's bugs. What changes is only how the writing looks. It changes because you are about to write programs rather than read them. C has to be turned into machine instructions by a compiler before it can run, and there is no compiler on this page. This language is read and carried out one statement at a time by the page itself, which is how a Check button can call your function forty times and count what each call did to the heap.
The translation back is short. let total = 0; is C's int total = 0;.
function add(a, b) { return a + b; } is C's
int add(int a, int b) { return a + b; } with the types left off. while,
return and // are the same in both, and malloc and
free are the names C uses. If you could read the C in Bits, Pointers and Memory you can
read every line in this part.
What is a slot, and why not just count bytes?
store and load work in eight-byte slots because eight bytes is the size of
the numbers being kept in these blocks, and counting in slots keeps the arithmetic out of the way of the
point. A block of 24 bytes therefore holds three slots, numbered 0, 1 and 2.
store(p, 1, 99) writes 99 into the second eight bytes of the block at p.
Slot 3 of a 24-byte block is past the end, and nothing stops you asking for it. That is the same absence Step 1 was about, moved from the stack to the heap and the report printed under every lab in this part is what notices.
Finding a leak
A leaked block is not damaged. It sits there, still holding its contents, perfectly valid memory. What makes it a leak is that no pointer anywhere in the program still holds its address, so no line of code that will ever run can reach it or free it.
That gives a detector something precise to test. Stop the program, collect every variable that is still alive, and ask of each allocated block whether any of those variables holds its address.
How a real leak detector finds pointers, and why it is a guess
A detector such as LeakSanitizer or Valgrind cannot ask the program which of its numbers are pointers, because nothing in memory records that. It scans the stack, the registers and the global area, and treats any value that happens to equal the address of a live block as a pointer to it. A long integer that coincidentally holds a block's address will make a leaked block look reachable.
So detectors report in grades."Definitely lost" means nothing points at it."Still reachable" means something does, and the program simply never freed it before exiting, which may be fine. The detector here does the same scan over the variables the program left behind, and reports the definitely lost ones.
Use after free
Freeing a block does not change your pointer. The variable still holds the same address, the bytes at that address are still readable, and for a short while they still contain what they contained. Everything keeps working, which is why this bug survives so much testing.
Then the allocator hands that same address to the next caller who asks, and now two parts of the program believe they own the same bytes. One of them is usually holding data somebody else chose.
Another way to see it
You return a hotel key card at reception and keep the room number written on your hand. For an hour the room is empty and the number is harmless. Then somebody else checks in, and now the number on your hand refers to their room and their belongings. You have not done anything to the room. You are simply holding a way to reach it that stopped being yours.
The fix has the same shape as the real-world one: cross the number off when you hand the key back. Setting a pointer to null after freeing costs one instruction, and turns a silent corruption into an immediate, obvious crash at the exact line that made the mistake.
Why the detector below says nothing about the worst line
A leak detector or an address sanitizer knows which blocks are in use and how big they are. Once the freed block has been handed to a new owner, a read through the old pointer is a read of a live block, at an offset inside it, through the same number the new owner is using. There is nothing left to notice. The detector is not being lax: the information it would need was thrown away by the allocator when it reused the address.
Real tools buy the difference back by refusing to reuse a freed block straight away. The block goes into a holding area, poisoned, and any access to it while it is there is reported with the line that freed it. A sanitizer build can therefore report a use-after-free at the access site. Its quarantine is finite, so very old freed blocks may eventually be reused; tests should still exercise the suspect lifetime promptly and repeatedly.
session appears at step 1 and never changes again. Everything else does.free, because a block can only reach
the free list twice while it is still on it. Two later allocations then both get handed it, and this
time the report does name what happened.free(NULL) is explicitly required to be
a no-op, which is what makes "free it, then null it" such a cheap habit: it turns a double free into
a non-event and a use-after-free into an immediate crash on a null pointer. Two whole categories of bug
answered by one assignment.Two bugs, one hole
Serious holes are rarely one mistake. They are a small, boring bug that makes a second bug reachable. The most common pairing in memory-handling code starts with arithmetic, of all places.
To allocate room for a list you multiply the number of items by the size of one item. On a 32-bit size that multiplication wraps: pass a large enough count and the product comes out small, the allocation succeeds, and the loop that fills it writes the full number of items into a block that fits almost none.
What "wraps" means here
A 32-bit unsigned number can hold values from 0 up to 4294967295. Arithmetic that produces something larger keeps only the low 32 bits of the answer, exactly as a car odometer with five digits rolls from 99999 back to 00000. It is not an error and no flag is checked: the multiply completes and gives a wrong, small answer.
So 536870913 items of 8 bytes each needs 4294967304 bytes, and the multiply returns 8. The allocation for 8 bytes succeeds easily. Everything after that is a program confidently writing four gigabytes of data into eight bytes of space. Bits, Pointers & Memory covers where the missing bits go in detail.
count is larger than 4294967295 divided by size, using the
machine's own div32 and mul32 rather than this interpreter's wider
arithmetic. It is tested at the exact wrap point, one item either side of it, with a zero count, a
zero size, a size bigger than the whole heap, and sizes that are fine.Audit a program
Here is the job as it actually arrives: thirty lines that run, produce a number, and exit without complaint. Four things are wrong with it. The checkers below run the program and report on what it did rather than on how it looks, which is the only kind of report worth having.
How to read a program looking for memory bugs
Four questions, asked of every pointer. Where did it come from, and how big is the block it points at? Who else holds a copy of it? When is it freed, and is anything still holding it then? And for every index or size that reaches an allocation or a write: could that number have come from outside, and what is the largest value it could be?
Reading for bugs is worth exactly as much as running the tools, and no more. Turn the checkers on first, get a list, then read the code around each finding. A tool that reports on behaviour finds the bug you actually wrote, which is the one your eyes are least able to see.
What is orders, and what does orders.length mean?
inputOrders() hands back a list: several values in a row under one name. It is
the array from Step 1 with one thing added: this one knows how long it is. orders[0] is
the first value, orders[1] the second, exactly as scores[0] was in Step 1, and
orders.length is how many there are. That last part is what C does not give you: a C array
hands over the values and leaves you to remember the count yourself.
Which is why the count is written down twice in the program below. Once by the list, and once by hand
as let cap = 4;. Two records of the same fact, kept in two places, and only one of them
changes when the data does. A surprising share of real capacity bugs are exactly that shape, and it is
worth learning to notice a hand-written number sitting next to a value that already knows the
answer.
What you can do now
- Explain, byte by byte, how a copy without a length limit reaches a neighbouring variable and then a return address.
- Write the bounds check that stops it, and test it at the boundary rather than in the middle.
- Say what a stack canary, a non-executable stack and address randomisation each remove from an attack, and what each one misses.
- Read a heap block map: what is live, what is free, what is lost, and what is about to be handed out again.
- Recognise a use-after-free and a double free from their symptoms, and know why nulling the pointer disarms them only when nothing else holds a copy of the address.
- Spot an allocation size that came from a multiplication, and check it before it wraps.
- Audit a small program with tools first and eyes second.
Where this goes
- Operating System Engineering. Where the no-execute bit actually lives, how one program is stopped from reading another's memory at all, and what the kernel does when a program touches an address it does not own.
- Computer Networks. Length fields arriving from strangers, which is where most of these bugs get their input, and the parsing discipline that keeps them out.
- Software Construction. Testing at the boundary, property-based testing, and the habits that catch an off-by-one before it becomes a security advisory.
Classify the failure before fixing it
The earlier labs covered spatial bugs, which cross an object's bounds, and temporal bugs, which use an object outside its lifetime. There are more. An uninitialised read uses bytes before a value has been written. Type confusion treats an object as a different shape. A data race lets threads access shared state without the required ordering. Integer mistakes can make a checked size differ from the size later allocated or copied.
Some API errors become memory bugs. A format string supplied by an attacker can make a formatting function read arguments that were never passed; in unsafe interfaces, certain conversions can also write. A mismatch between a length, a pointer and the allocation they describe can cross a boundary even when every individual arithmetic operation looks reasonable.
Bug, crash, vulnerability and exploit are different words
A bug is an implementation mistake. A memory-safety violation is an invalid access. A vulnerability is a bug that crosses a security boundary. An exploit is a reliable way to use that vulnerability to gain an effect the program did not intend. One invalid read may merely crash in one build and expose a secret in another, so classification starts the investigation rather than ending it.
Write down the object, its valid bounds and lifetime, the invalid operation, who controls the input, and the first tool that observed it. Those facts survive even if the exploitability judgement changes.
Protect control flow as well as bytes
A non-executable stack stopped injected instructions, so attackers learned to reuse instructions already
present in executable pages. Return-oriented programming chains short sequences ending in
ret. Jump- and call-oriented variants use indirect branches. These techniques still require
a corruption primitive or another control-data bug; they explain why NX alone is not a complete defence.
Control-flow integrity restricts indirect calls and jumps to approved targets. A shadow stack keeps a
protected copy of return addresses and checks it on ret. Intel CET combines shadow stacks
with indirect branch tracking. Arm pointer authentication attaches an authentication code to selected
pointers. Each mechanism protects a part of control flow, and each depends on operating-system, compiler
and binary support.
Backward edges and forward edges
A return is a backward edge because it goes back to a caller. Shadow stacks protect that edge by comparing the ordinary return address with a protected copy. An indirect call or jump is a forward edge. CFI, Control Flow Guard or CET indirect branch tracking restrict where it can land. Direct calls already contain their destination in the instruction.
Intel's description of CET and its two mechanisms is available in the CET technical guide. The important engineering point is unchanged: these checks contain control-flow corruption but do not repair the out-of-bounds write that caused it.
Tags and capabilities check pointers
Arm Memory Tagging Extension, or MTE, gives memory and pointers small tags. An access is accepted when the pointer tag matches the allocation tag. Reusing an address with a different tag can therefore expose a stale pointer, and stepping into a neighbouring allocation with another tag can expose an overflow. The tag is four bits, so this is probabilistic detection rather than a proof that every bad access fails.
CHERI capabilities take a different approach. A pointer carries hardware-protected bounds, permissions and validity. Code cannot forge a wider capability from a narrower one, and a load or store must fit the capability and its permissions. Temporal safety needs an allocation and revocation scheme as well; bounds alone do not say whether an object has been freed.
What tags and capabilities do not replace
Neither mechanism checks business rules, authentication or whether the program chose the correct object. MTE can miss a violation when tags happen to match, and deployment modes may report asynchronously. Capability systems need careful interfaces and revocation for reuse. Hardware protection belongs beside safe languages, tests and isolation.
See Arm's MTE user guide for the lock-and-key model and the CHERIoT concepts guide for bounds, permissions and unforgeable capability tags.
Keep unsafe code behind a small boundary
Memory-safe languages prevent broad bug classes by construction. Rust ties references to object lifetimes and ownership, checks indexing, and restricts simultaneous mutation. Managed languages use bounds checks and garbage collection. They can still have logic bugs, excessive resource use, unsafe extensions, native libraries and incorrect concurrency protocols.
Rust's unsafe does not disable every check. It permits a small set of operations, such as
dereferencing raw pointers or calling foreign functions, whose safety the compiler cannot prove. The
engineering job is to put those operations behind a safe interface, document the required invariants,
validate them at the boundary, and test the wrapper with hostile values.
An FFI contract needs more than a pointer
For every foreign function call, state whether a pointer may be null, how many elements it covers, who owns the allocation, how long it remains valid, whether the callee may write or retain it, which thread may use it, and how errors are reported. Turn a pointer-plus-length pair into a slice only after those facts have been checked.
The official Unsafe Rust chapter explains raw pointers and safe abstractions. The Rust FFI guide shows how a small unsafe wrapper can expose a safe high-level interface.
Combine fuzzing with the right detector
A fuzzer mutates inputs and keeps those that reach new behaviour. A sanitizer changes the program so a bad operation produces a useful report. AddressSanitizer targets many out-of-bounds and use-after-free errors. MemorySanitizer targets uses of uninitialised data. UndefinedBehaviorSanitizer checks selected language rules. ThreadSanitizer finds data races. LeakSanitizer reports allocations that remain unreachable at shutdown.
The combination matters. A fuzzer without a detector may see silent corruption as a successful run. A sanitizer without varied inputs sees only the paths in ordinary tests. Start with a small deterministic harness, a representative seed corpus and a clear invariant. Save every reproducer, minimise it, and add it to regression tests after the fix.
Coverage is guidance, not a safety certificate
Edge coverage tells the fuzzer that an input reached a new transition. It does not prove the code on that edge handled every value correctly, and 100 percent line coverage does not enumerate all lengths, states or thread schedules. Dictionaries, structure-aware mutators and separately seeded protocol states can help it reach deep parsers.
Google's ClusterFuzzLite guide documents a practical continuous fuzzing and sanitizer workflow. Use the compiler and platform documentation for exact supported combinations.
Triage, patch and prove the fix
A sanitizer report establishes an invalid operation, not the final security impact. Reproduce it in the affected build, identify whether untrusted input reaches it, and describe the primitive: disclosure, overwrite, lifetime reuse or control-data corruption. Then account for process privilege, sandboxing, exposed interfaces, reliability and enabled mitigations. Do not publish an exploit while users lack a fix.
Patch the violated invariant at its source. Check the size before arithmetic, carry lengths with buffers, make ownership explicit, or add the missing synchronization. Add the minimized reproducer as a regression test, expand to neighbouring boundary cases, run the relevant sanitizer and fuzzer again, and backport the fix to every supported branch that contains the bug.
Why a one-line patch can need a large test plan
A new bounds check can reject valid edge inputs, truncate data, change timing or move a failure to a caller that never handled errors. Test exact-fit, empty, one-over, maximum and malformed cases. For lifetime fixes, test cancellation and error paths. For concurrency fixes, stress the ordering rather than running one friendly schedule.
Record the affected versions, root cause, attack surface, fix commit, tests, residual risk and rollout signal. That record lets another team verify the backport without reverse engineering your reasoning.
Plan a memory-safety migration
Large systems cannot usually rewrite every unsafe component at once. Start new native components in a memory-safe language where platform support allows it. Put parsers and other attacker-facing code high on the list. Isolate legacy components, shrink privileges and interfaces, inventory unsafe and FFI blocks, enable compiler and hardware hardening, and keep sanitizers and fuzzing in continuous integration.
Generated code and assistant suggestions follow the same rule as handwritten code: they are inputs to review, compilation, static analysis, sanitizers, fuzzing and tests. An explanation that says a copy is safe is not evidence that the length matches the allocation. Ask the tool to help enumerate invariants and boundary tests, then let executable checks decide.
What current platform practice looks like
Modern deployments combine prevention and detection rather than betting on one defence: safe languages for new code, small reviewed unsafe islands, process or component isolation, ASLR/NX/CFI and shadow stacks, tagged-memory testing or deployment where supported, continuous sanitizer builds, fuzzing and crash telemetry. Legacy C and C++ remain, so migration plans need measurable risk reduction before a full rewrite is possible.
Android's current memory-safety guidance describes this combination of Rust, Arm memory tagging and development tools. Continue with Software Construction for API and testing discipline and Systems That Fail Well for rollout and incident evidence.
What you can do now
- Classify spatial, temporal, initialisation, type, arithmetic and concurrency causes.
- Explain which control-flow, tagging and capability mechanisms stop which consequences.
- Design a safe wrapper around an unsafe or foreign interface.
- Pair fuzzing with detectors and keep minimized inputs as regression tests.
- Triage exploitability from evidence and ship a root-cause fix across affected versions.
- Plan measurable migration work instead of treating memory safety as an all-or-nothing rewrite.