IERG 4851 · Week 6

In-Class Lab: Heap

Two blocks — observe the heap, then turn reuse into control. Seven puzzles.

0. How this lab runs

Seven puzzles on the same local machine, in two blocks that match the lecture: Block A shows you how the heap behaves; Block B turns that behaviour into control. Most tasks print a marker when you get it right, and the first two in Block B are pure demonstrations. Nothing in class is handed in.

Predict before you run. Each task asks for a prediction first — write it down before you run anything.
Submission: the seven in-class tasks are not submitted — there is nothing to hand in for them. What you submit is the two after-class exercises at the end of this page: required, but not graded.
Evidence and integrity: for the two after-class exercises, include your own terminal output or screenshots showing the commands you ran and what you got. Do not submit text copied from AI tools, classmates, or writeups.
Where to work: log in to the course lab machine and work under ~/ierg4851/week6/in-class-lab. If you do not know your lab-machine username or password, ask the TA or instructor.
⚠️ These targets are built for glibc 2.27 on purpose.

Modern glibc (2.32+) has two protections that make the classic heap techniques much harder:

ProtectionSinceWhat it does
tcache double-free checkglibc 2.29free(a); free(b); free(a) aborts with "double free detected in tcache 2"
safe-linkingglibc 2.32the fd pointer of a free chunk is mangled with ptr >> 12, so you cannot simply write an address there

Real CTF heap challenges ship the libc they were built against, and that is very often 2.27. This lab does the same, so the techniques work exactly as the lecture describes. You can check it yourself: ldd ./chunk | head -3 shows .../glibc-2.27/libc.so.6. Ask in class to see the same program abort on the new glibc — that is the protection doing its job, and it is why real exploits today usually need an extra leak first.

Block#DirectoryPuzzleLevel
A — observeA1A1-chunk-headerRead the chunk header malloc hides in front of your data.Warm-up
A1bA1b-prev-sizeFind the byte where writing at a starts to touch b’s header.Warm-up
A2A2-tcache-lifoPredict which freed chunk comes back first.Easy
A3A3-uaf-readPredict what a stale pointer reads after a free.Easy
B — controlB1B1-double-freeWatch one chunk go on the free list twice. (no marker)Easy
B2B2-uaf-writeWatch a write land on a pointer the program already freed. (no marker)Easy
B3B3-double-free-exploitEscalate an ordinary account to root with one note.Medium
After classE101-int-truncationWalk around a length check that only looks at 16 bits.Submit
E202-off-by-nullOne NUL byte lands on the next chunk’s size field.Submit
Ready to run: every task ships a prebuilt binary for glibc 2.27 — just ./chunk. The Makefile only documents how it was built.
Looking inside: every task also ships ./dbg. It starts gdb with the breakpoint already set — it finds the interesting call in the C file for you, so there is no line number to type and nothing to keep in sync. It also disables symbol downloading, so it works offline and starts immediately. Everything ./dbg prints is in colour; the program’s own output is not, so you can always tell which is which.

A1. The Chunk Header

Lecture connection: malloc hands you a pointer, but it also stores bookkeeping in front of it — that is the header the free list and the allocator rely on.
Predict: malloc(16) returns a pointer. How many bytes of bookkeeping sit before it, and what do they say?
cd ~/ierg4851/week6/in-class-lab/A1-chunk-header
./chunk

# to read the header yourself:
./dbg
#   it stops right after the malloc and prints the chunk header
#   then try:   p/x p      x/4gx p-16      continue
Checkpoint: the program prints IERG4851_HEAP_CHUNK_OK when you type the size field it asks for.
Think about: the size field, why it is 0x21 and not 0x20, and how much memory malloc(16) really consumes.

A1b. The Next Chunk’s prev_size Is Yours

Lecture connection: a chunk is 0x20 bytes, but you can write 24 — because only one of the header’s two words is needed while the chunk before it is in use.
Predict: malloc(16) asked for 16 bytes and the header is 16 bytes. How many bytes can you write at a before you touch the next chunk’s header?
cd ~/ierg4851/week6/in-class-lab/A1b-prev-size
./prevsize

# to watch the header word change:
./dbg
#   it stops before and after the write; the next chunk's prev_size
#   ends up holding your 0xA5 pattern while its size field is untouched
Checkpoint: IERG4851_HEAP_PREVSIZE_OK.
Think about: which header word a live chunk actually pays for, why usable size is chunksize - 8 and not chunksize - 16, and what byte 25 would hit.

A2. Which Freed Chunk Comes Back

Lecture connection: freed chunks are not thrown away — they are pushed onto a per-size free list, and the next malloc pops from it.
Predict: the program frees a, then b. Which pointer does the next malloc(32) return? Write it down first.
cd ~/ierg4851/week6/in-class-lab/A2-tcache-lifo
./lifo

# to see the free list before you answer:
./dbg
#   it stops just after the two frees and prints both chunks
#   the first user qword of a freed chunk is its forward pointer
Checkpoint: IERG4851_HEAP_LIFO_OK.
Think about: the order of the free list, what the fd pointer holds, and what happens if you allocate three chunks after the two frees.

A3. What a Stale Pointer Reads

Lecture connection: free returns memory to the allocator. If the program keeps the pointer and uses it again, that is a use-after-free.
Predict: the old pointer was never changed. After the memory is reused, does it still see the old contents or the new ones?
cd ~/ierg4851/week6/in-class-lab/A3-uaf-read
./uaf

# to watch the stale pointer follow the memory:
./dbg
#   it stops after the new allocation and prints n, m and what n reads
Checkpoint: IERG4851_HEAP_UAF_OK.
Think about: why the new note landed on the same address, and what the program should have done after the free.

B1. The Same Chunk, Twice on the Free List

Lecture connection: free() hangs a chunk on the free list for its size by writing the current head into the chunk’s own payload. It does not check whether the chunk is already on that list.
This task has no marker. It is a demonstration: you run it, you look at the memory, and you say what you saw.
cd ~/ierg4851/week6/in-class-lab/B1-double-free
./twice demo     # the whole sequence, done for you
./dbg            # the same, printing both chunks and the 0x30 free list at every step

#   note 0 A ; note 1 B       two 0x30 chunks, one after the other
#   free 0 ; free 1 ; free 0  chunk a goes on the list a second time
Checkpoint: no marker — the 0x30 free list should hold 0x…260 -> 0x…290 -> 0x…260 with count = 3.
Think about: the count after each of the three frees and why it is 1, 2, 3; which eight bytes of each chunk changed and which did not; and why the chain reads a -> b -> a rather than a -> a.

B2. A Write Through a Pointer the Program Already Freed

Lecture connection: the read side of a use-after-free is a leak; the write side is how you change something the program still trusts.
No marker here either — this is the observation that B3 turns into an exploit.
cd ~/ierg4851/week6/in-class-lab/B2-uaf-write
./dangle demo
./dbg

#   login alice      a session: a 0x30 chunk, level = "user"
#   logout           free()s it -- and does NOT clear the pointer
#   msg BBBB...      same 0x30 chunk, so the message lands on the session
#   show             current = 0x…260  level="BBBB"
Checkpoint: no marker — the session should report level="BBBB", though the program created it with level="user".
Think about: the address malloc(40) returned and the address current held; why the message had to be the same size; and the one-line fix (current = NULL; after the free) — plus why that alone is not enough in general.

B3. Escalate an Ordinary Account to root

Lecture connection: the free list is keyed on size and not on type, and it has no idea a chunk is already on it. Free the same chunk twice, and two later allocations of that size are handed the same chunk.
Predict: struct acct { unsigned int uid; char name[36]; } and struct note { unsigned int id; char text[36]; } are both 40 bytes, and both put an integer in their first four bytes. What happens if a note and an account end up on the same chunk?
cd ~/ierg4851/week6/in-class-lab/B3-double-free-exploit
./escalate demo

# the account is root when uid == 0xdeadbeef -- a magic value, not 0
#   acct bob            a normal account: uid = 1000 (0x3e8), name "bob"
#   free ; free         the same chunk, twice -- 2.27 does not stop you
#   acct bob            sign in again: the SAME chunk comes back
#   note deadbeef root  the note's id and text land on uid and name
#   whoami              uid = 0xdeadbeef -> root
#   check               uid and name both changed; the program wrote neither

./dbg                # the chunk and the 0x30 free list at every step
Where the account actually changes: make_acct() is the only line in the program that writes uid, and it writes 1000. The change comes from the note — note->id and note->text sit at the same offsets as uid and name, and the chunk the note landed on is the account’s chunk. Both fields change, and neither is written by the program.
Checkpoint: IERG4851_HEAP_ESCALATED.
Think about: the uid the account started and ended with, and which line wrote each; why a single free is not enough; and why 0xdeadbeef rather than 0 — name two ways an account could hold 0 without anyone meaning root.

After class — two exercises to submit on Blackboard

Everything above is done in class and is not submitted. These two you do at home and submit — required, but not graded. They are reproduction exercises: the programs are short, the steps are exact, and you are not asked to develop an exploit — run each one, watch what happens, and explain what you saw.

Both are already on the lab machine, next to the in-class material:

cd ~/ierg4851/week6/after-class
ls      #  01-int-truncation/   and   02-off-by-null/   -- each has a Makefile

Neither exercise needs glibc 2.27, and a normal gcc on any Linux behaves the same way — that is the point: these are bug classes, not version-specific tricks.

Each exercise gives you one thing to look at. Run the commands exactly as written, compare your output with the sample below, and answer that exercise’s questions. The listings here are the same files that are on the machine.

E1. An integer check you can walk around

The program takes a length on the command line, checks it, and then copies that many bytes from stdin into a 32-byte array. A tag field sits immediately after the array:

struct msg { char data[32]; unsigned long tag; };   /* tag sits right after data */

checked = (unsigned short)len;      /* only the low 16 bits survive   */
if (checked > sizeof m.data) { reject; }
read(0, m.data, len);               /* the copy uses len, not checked */

Do this. Three commands, exactly as written — the printf sends the letters into the program’s stdin:

cd ~/ierg4851/week6/after-class/01-int-truncation && make

printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' | ./trunc 32
printf 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' | ./trunc 65568
printf 'A' | ./trunc 65569

What you will see. The first two are both accepted, because the check only ever sees 32. The last line of each run is the tag field — watch it:

# ./trunc 32     -- 32 bytes copied: exactly fits, tag untouched
  len     = 32  (0x00000020)
  checked = 32  (0x0020)   <- this is the value the check sees
  accepted
  tag     = 0x1122334455667788   (started at 0x1122334455667788)

# ./trunc 65568  -- 40 bytes copied into a 32-byte array
  len     = 65568  (0x00010020)
  checked = 32  (0x0020)   <- this is the value the check sees
  accepted
  tag     = 0x4141414141414141   (started at 0x1122334455667788)

# ./trunc 65569  -- the low 16 bits are 33, so this one is caught
  len     = 65569  (0x00010021)
  checked = 33  (0x0021)   <- this is the value the check sees
  rejected

0x41 is 'A', and 0x4141414141414141 is eight of them. Your letters walked past the end of the 32-byte array and became the tag field. The check never saw them coming, because the check was looking at a 16-bit value while the copy used a 32-bit one.

Submit for E1: the three commands and their output, plus three sentences — (1) why do ./trunc 32 and ./trunc 65568 print the same checked? (2) why is 65569 caught while 65568 is not? (3) what one-line change fixes it?

E2. One NUL byte, one chunk header

The same kind of integer mistake, inside the heap. Two 0x20 chunks, one after the other. The buffer is exactly the usable size of its chunk — 24 bytes — and the guard permits a length of 24:

struct blk { char data[24]; };          /* sizeof 24 -> malloc(24) -> a 0x20 chunk */

if (len > sizeof a->data) { reject; }   /* 24 is allowed          */
memcpy(a->data, text, len);
a->data[len] = 0;                       /* ... but this needs 25  */

Do this. One command, one line of input:

cd ~/ierg4851/week6/after-class/02-off-by-null && make
printf '24\nABCDEFGHIJKLMNOPQRSTUVWX\n' | ./offby

What you will see. The program prints the next chunk’s header straight out of memory, before and after. (Your addresses will differ; the values are the point.)

a = 0x...2a0   (a's payload; usable size is 24)
b = 0x...2c0   (b's header starts at 0x...2b0 = b - 16)
  before:  b's header:  prev_size = 0x4141414141414141   size = 0x0000000000000021
  accepted: len = 24
  after:   b's header:  prev_size = 0x5857565554535251   size = 0x0000000000000000
  b's first bytes are now: "BBBBBBBB"

Three things to look at:

Submit for E2: the command and its output, plus three sentences — (1) what are the two parts of 0x21, and which of them did the NUL destroy? (2) which chunk wrote 0x4141414141414141 into prev_size, and why was it allowed to? (3) with size = 0x0, what does the allocator now believe about b?
Required, but not graded. These two exercises are not credited towards your grade — but you must submit them. The deadline is announced in class; submit on Blackboard. One file containing both sections is fine. Include the commands you ran and the output you got.