Interactive course · about 8 hours

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.

How this works

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.

If you have not done the assembly course

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.

Why offence is on the syllabus

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

Step 1

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.

Lab 1 · Ask for an item that is not there
Try this firstDrag the slider to 4. The read succeeds, and the number that comes back is 4173, which is nobody's score.
Drag the index past the end. Four scores are in memory, and something else is stored immediately after them. Items 0 to 3 answer with a score. Item 4 answers with the neighbour, because the address arithmetic does not know the array ended. Switch to the second layout and read the neighbour as characters instead of as a number.
There is no end of the array anywhere in memory

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.

Lab 2 · Put the fence in yourself
Try this firstPress Check against hidden indexes before you change anything. It fails, and the message names the value that came back instead.
Write the check the language does not write for you. Look up the index in 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.
A program reads scores[7] from a four-element array. It runs fine on your laptop and prints a plausible number. What have you learned?
Nothing at all. The read returned whatever was stored at that address on that run, with that compiler, on that machine. Change any one of those and the number changes with it. A bug that produces a plausible answer is worse than one that crashes, because a crash tells you where to look and a plausible answer waits.
Step 2

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.

Lab 3 · Type a name that does not fit
Try this firstPress "eight letters", then "nine letters". Watch the second row of the byte map: on one of them it is still zero, and on the other it is not.
Start with a short name, then add letters one at a time. The frame holds an eight-byte name box and, immediately above it, a flag that decides whether this user is an administrator. Nothing in the program ever writes to that flag. Watch the byte map: the ninth character is the first one that lands outside the box, and the flag stops being zero.
The flag was never assigned

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.

Lab 4 · The same name, two copy loops
Try this firstChoose one of the two predictions, then press Run both copies. Two stack frames appear side by side, one from each loop.
Commit to a prediction, then run both. The same input goes into a loop that stops at the end of the input and a loop that stops at the end of the box. One of them changes a variable it was never given permission to touch. Note what the safe version costs you: the name comes out cut short, and a truncated name is a bug you can see, argue about, and fix.
A team fixes an overflow by making the buffer 256 bytes instead of 8, because "nobody has a name that long". What is wrong with that fix?
The input is not theirs to choose. Whoever is sending the name decides how long it is, and 257 bytes is exactly as easy to send as 9. A bigger box moves the failure, it does not remove it. The only fix is a copy that knows how much space it has and stops there.
Step 3

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.

Lab 5 · One chunk at a time, towards the return slot
Try this firstDrag the slider marked "chunks copied" from 0 up to 4, one notch at a time. A chunk is eight bytes and the buffer holds two of them. The frame map marks each slot as the copy reaches it, and the reading below changes at every notch.
Turn the dial up one chunk at a time and read the frame map. Two chunks fill the buffer and nothing is wrong. Three reach the saved frame pointer. Four reach the return address, and the function no longer comes back to its caller. Try both payload endings: letters, which send ret somewhere meaningless, and the position of a function that nothing in the program ever calls.
Nothing illegal happened

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.

Lab 6 · Make the copy stop at the buffer
Try this firstPress Check against hidden payload sizes before you edit anything. It fails on the four-chunk payload and tells you where the function returned to.
Fix the loop so the payload cannot reach anything. The buffer is 16 bytes, which is two chunks. Whatever the payload asks for, the loop must write at most two, and it must still copy the chunks that do fit. It is checked against payload sizes you cannot see, from zero chunks up to six.
A crash report says the program died jumping to address 0x4141414141414141. What does that tell an engineer immediately?
0x41 is the letter A. Eight of them in a row in a return address means text was copied over it, and the length of the text decided how far it reached. This pattern has been the fingerprint of a stack overflow for decades, which is why test inputs are so often long runs of the same character: it makes the wreckage readable.
Step 4

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.

Lab 7 · The same inputs through both copies, counted
Try this firstPress Run every length through both. A chart appears with one bar per input length. The leftmost bar with any height at all is the answer you are looking for.
Every write is counted as it happens. Inputs from empty up to twenty characters run through the unchecked copy and the checked copy, and the bars show how many bytes each one wrote outside the eight-byte box. Find the shortest input that writes outside it, and hold that number up against the eight bytes the box has.
Lab 8 · Write the safe copy
Try this firstPress Check against hidden inputs before you edit anything. It fails on the input that fits the box exactly, which is the one worth thinking about.
Copy at most eight bytes, terminator included. The name box is eight bytes and the flag next to it must still be zero when you are done. It is tested with an empty string, a string that fits exactly, one that is one byte too long, and one that is far too long.
Lab 9 · A copy with the limit in your hands
Try this firstSlide the limit to 7 and then to 8. One of those two leaves the neighbours alone and the other does not.
Nothing here is marked. Choose the size of the box, choose where the loop stops or leave it running to the end of the input, and type whatever you like. Every byte the copy writes is counted as it lands, inside the box or outside it. Worth finding: the largest text a box of eight can hold, what a limit equal to the box size does to the terminator, and whether a box of sixteen makes the bug go away or only moves it.
A function receives a buffer and a length: copy(char *dst, int n). A caller passes a length that came from the network without checking it. Where is the bug?
At the boundary. The copy function does exactly what it was told; the length it was told is a lie. Data that arrives from outside has to be checked at the moment it arrives, because after that it is indistinguishable from a number your own program worked out. Most real memory bugs are this shape rather than a missing bounds check in a loop.
Step 5

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.

Lab 10 · The same attack, against every combination
Try this firstPress Run the attack with every switch off. rbx comes out 999, which is the attacker winning. Then press stack canary and press Run the attack again: rbx comes out 1234 instead, and the process stopped itself.
Run it with everything off first, so you know it works. Then bring the switches up one at a time and read the three lines under what each defence actually did. The canary line names the two numbers that were compared. The randomisation line says where the code moved to this run and what the payload still says. The no-execute line only has something to test while the target is jump into the buffer, where the payload sits, so press that button before you switch no-execute on: with the other target the return address is a position in the code list, no page holds it, and the bit has nothing to say, which is precisely what reusing code already in the program buys an attacker. Last, press the attacker knows the cookie with the canary still on, and watch that defence fail.
Lab 11 · How much randomness is enough
Try this firstPress Run them with the slider at 4. Roughly one attempt in sixteen lands. Slide it to 10 and run it again.
A hundred attempts, counted for real. The attacker's payload holds one guessed address. Every attempt puts the code somewhere new, and the guess is right only when the new place happens to match. Slide the randomness up and watch the success count fall, then give the attacker four hundred attempts instead of a hundred and watch it come back.
Lab 12 · The attack, opened up
Try this firstPress Step until the 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.
Nothing here is marked. The whole attack, editable, one instruction at a time. Change the five values on the 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.
A program is compiled with stack canaries, a non-executable stack and address randomisation. Is the buffer overflow in it still worth fixing?
Fix the bug. A canary catches a write that crosses it, and misses one that jumps over it or corrupts something else entirely. Randomisation is defeated by any other bug that leaks one address. Non-executable memory is answered by reusing code that is already there. Defences buy time and raise cost, which is worth a great deal, and none of them turns a memory bug into a correct program.
Step 6

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.

Lab 13 · A server that forgets
Try this firstPress Handle 20, then press it again. The meter climbs and the map fills from the left. Keep pressing until something is refused.
Handle one request, then twenty. Each request takes a block and, with the switch off, never gives it back. The block map fills from the left and the meter climbs. Keep going and the failure arrives somewhere else entirely: an allocation in code that has nothing to do with the leak is the one that gets refused. Then turn the switch on and run hundreds of requests through a heap that never grows.
Lab 14 · Balance the books
Try this firstPress Check the answers, then the books, before you change anything. Every answer is right and the check still fails, which is the whole point of this one.
Write a request handler that leaves nothing behind. It gets a block, uses it, and gives it back before returning the answer. The checker calls it a handful of times to see whether the answers are right, then forty more times to see whether the heap is where it started.
A service uses 200 MB after an hour and 900 MB after four hours, and the graph is a straight line. Which is the most useful thing to know?
The slope names the leak. Divide the growth by the number of requests handled and you have the size of the block being lost each time, which is usually enough on its own to point at the allocation. The function that allocates the most is almost never the culprit: the culprit is usually small, frequent, and unremarkable.
Step 7

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.

Lab 15 · Run it with the detector on
Try this firstPress Run. The total comes out right and the report below names two lost blocks, with the size of each and the line that asked for it.
Get the report down to nothing without breaking the answer. The program builds a small report and prints a total. It also loses two blocks along the way. The detector names the size of each lost block and the line that asked for it, and the check wants three things at once: an empty report, an empty heap at the end, and the right total, because freeing everything early is easy and wrong.
Lab 16 · Leak, or not
Try this firstRead the first program, then choose It leaks or It is clean. The detector then runs on that exact program and shows you what it found.
Say which way each one goes before you run it. Five short programs. Two of them look almost identical and only one leaks. The one with a second pointer to the same block is worth sitting with: it is the opposite mistake, and Step 8 is about what it costs.
A detector reports a 64-byte block as "still reachable" at exit. Is that a leak?
Something still points at it. A block held in a global that lives as long as the program is not lost, and the operating system reclaims everything when the process ends anyway. The report worth chasing is "definitely lost", especially inside a loop, because that is the one that grows without limit while the program is still running.
Step 8

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.

Lab 17 · Watch the allocator hand it over
Try this firstPress Next step, slowly, seven times. The address in session appears at step 1 and never changes again. Everything else does.
Step through it and watch one address. A session record is created, used, and freed. The next allocation asks for a block the same size and gets the same address back. Whatever is written through the new pointer is what the old pointer reads, and the report at the bottom has nothing to say about it, which is the part to sit with. Then press the double free: that button winds the program back to the moment just after the first 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.
Lab 18 · Make the stale pointer harmless
Try this firstPress Check under two different histories before you edit anything. It fails on the very first run: the function answers 0 where it should answer 4242, because the value is read out of the block after the block has been handed back.
Get the value out before the block goes back. The program has to report the number it stored, free the block, and end with nothing outstanding. The checker runs it twice with different allocation patterns underneath, so a version that reads through the stale pointer gets two different answers and fails on the second one.
A program frees a pointer and sets it to null immediately afterwards. Later a bug calls free on it a second time. What happens?
Nothing happens, by design. 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.
Step 9

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.

Lab 19 · Make the multiply come out small
Try this firstDrag the count slider one notch at a time. Watch the two size lines: they agree, and agree, and then at one notch they do not.
Push the count up until the product wraps. The exact size and the 32-bit size are shown side by side, and they agree until they suddenly do not. When they part company, the allocation succeeds and the filling loop starts writing into blocks belonging to other parts of the program. The loop is stopped early, with a count of how many writes had already landed outside.
Lab 20 · Allocate safely, or refuse
Try this firstPress Check at the wrap point before you edit anything. It fails at 536,870,913 items and shows you the size the multiply handed over.
Work out the limit, do not measure the damage. Refuse the request when 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.
Lab 21 · The heap and the arithmetic, unmarked
Try this firstPress "A count that wraps the multiply", then Run. Then change the count by one and run it again.
Nothing here is marked. A heap, a block map, an event log, a detector, and the machine's 32-bit arithmetic. Everything from this part of the course can be caused here on purpose: lose a block, read through a pointer you already gave back, wrap a size, or ask for more than the heap holds and write through the zero that comes back. Watch which tool notices which mistake, and which one notices nothing.
Which check catches the overflow before it happens, for unsigned 32-bit values?
Divide first. Dividing the largest representable value by the item size gives the largest count that can possibly fit, and comparing against it costs one division and never overflows. The second option is the addition overflow trick used on a multiply, where it does not hold: 512 items of 8388609 bytes comes to 4,294,967,808, which wraps to exactly 512, and 512 is not smaller than 512, so the check passes and the allocation goes ahead. The third never fires at all, because unsigned values are never negative.
Step 10

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.

Lab 22 · Turn every checker green
Try this firstPress Run. Five checkers appear and all five are red, from four bugs: the wrong total and the write outside the block are the same mistake showing up twice. Take the first red one and read the code around what it reports.
Five checkers, four bugs, one correct total. Fix them in any order. The capacity comes from a constant that should have come from the data, one block is never given back, one read happens after the block was freed, and one block is freed twice. When they are all green, press Check: it runs your program again against a different list of orders that you cannot see.
Lab 23 · The heap, with nothing marked
Try this firstPress "Overflow into the neighbour", then Run. Then change a number in the program and run it again to see what moves.
Nothing here is graded. A heap, a block map, an event log and a detector. Break it deliberately: overflow a block into its neighbour, free something twice, write through a pointer you already gave back, and watch which tool notices which mistake.
You have one afternoon and an old C program with no tests. What buys the most safety per hour spent?
Turn the tools on first. A sanitizer build finds overflows, use-after-frees and leaks on inputs that already exist, and gives you a line number for each. Reading finds the bugs you would not have written; the tools find the ones that are there. Reading second, with a report in hand, is where the afternoon pays.

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.
Step 11

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.

Lab 24 · Name the broken rule
Try this firstSelect the stale callback. Classify bounds, lifetime, initialisation, type and thread ordering separately.
Several labels can apply at once. An arithmetic wrap can create a small allocation, followed by a spatial overflow; a race can expose a temporal use-after-free. Record the causal chain instead of choosing one headline word.
Two threads access the same object; one frees it while the other reads through a pointer. What chain best describes the failure?
The race enables the lifetime violation. Fixing only the final pointer read leaves the ownership protocol broken.
Step 12

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.

Lab 25 · Corrupt two kinds of target
Try this firstCorrupt the return address with every defence off. Then enable the shadow stack and compare it with forward-edge CFI.
Match the defence to the edge. A shadow stack catches the bad return but does not validate an indirect function pointer. Forward-edge CFI checks the pointer target but does not replace a bounds check.
A shadow stack detects a changed return address. Is the original overflow fixed?
Containment is not correction. The program should terminate at the mismatch, and the write still needs a root-cause fix.
Step 13

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.

Lab 26 · Try the same pointer under three machines
Try this firstMove the offset just beyond the allocation. Compare ordinary pointers, a matching four-bit tag, and a bounded capability.
Read the guarantee exactly. A capability bound rejects the demonstrated out-of-range access deterministically. MTE rejects a tag mismatch; a guessed matching tag remains a one-in-sixteen possibility in this simplified model.
Why can MTE detect a use-after-free when an allocator reuses the same address?
The address may match while the tag does not. That extra identity check catches many stale accesses, though four-bit tags make the guarantee probabilistic.
Step 14

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.

Lab 27 · Audit a native boundary
Try this firstSelect “length exceeds allocation.” Turn checks on until the wrapper can safely create a slice or refuses the call.
A safe wrapper earns its name for every input. Its callers should not need an unwritten rule such as “the length is probably right” or “the C library will not keep this pointer.”
What must be true of a safe Rust function implemented with an internal unsafe block?
The unsafe work is encapsulated. The public safe signature and its checks must make it impossible for an ordinary caller to violate the internal assumptions.
Step 15

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.

Lab 28 · Build a test matrix
Try this firstSelect the uninitialised length. Choose a detector and input generator, then check what the pair can and cannot establish.
Run more than one build. Sanitizers have overhead and compatibility limits, and one build rarely combines every detector. Keep a fast continuous target and schedule broader runs.
Why is a coverage-guided fuzzer stronger when paired with AddressSanitizer?
Exploration and detection are separate jobs. A minimized crashing input is evidence; it is not yet a root-cause fix.
Step 16

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.

Lab 29 · Build a vulnerability record
Try this firstSelect the network parser report. Add evidence one item at a time and watch “known”, “unknown” and priority change.
Unknown is a result, not permission to guess. Put the missing fact and the experiment that will obtain it in the record. Severity can rise or fall as evidence arrives.
A patch makes the crashing input stop crashing. What remains before the issue is closed?
A regression case is necessary but narrow. Root-cause and boundary tests show the patch fixed the class rather than one byte string.
Step 17

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.

Lab 30 · Spend a limited migration budget
Try this firstProtect the image parser first. Compare rewrite, isolation, hardening and continuous detection by cost and risk reduction.
The plan is a portfolio, not a slogan. A safe rewrite of new code can sit beside sandboxing for an old codec, MTE in supported test fleets, and fuzzing across both.
Which migration plan reduces risk while a full legacy rewrite is still years away?
Risk reduction can begin now. Track unsafe surface, exposed parsers, sanitizer coverage, fuzzing time, mitigation deployment and defect recurrence as separate measures.

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.