Back to course

Does the Executable Actually Move?

18 minintermediateModule 2: Position Independent ExecutablesContinues from pie-flags
18 min · Module 2: Position Independent Executables · Intermediate

Why This Matters

“PIE enables ASLR” is a true sentence that is easy to believe and easy to over-apply. The useful question is not whether the feature exists but what it actually randomises, and that has a precise answer you can measure in six runs.

$ cat where.c
#include <stdio.h>
int main(void){ printf("%p\n", (void*)main); return 0; }

$ clang -O1 -o w_pie where.c
$ clang -O1 -fno-pie -no-pie -o w_nopie where.c

Nothing clever here — one function, one pointer, printed. The address of main is a fact about the load, and it is the most direct available proxy for “where did the loader put this image”.

A Simple Model

There are two different things that could be random, and confusing them is the source of most of the misconception.

WHAT IS RANDOMISED the BASE where the image is mapped. chosen by the kernel at exec time. THIS is what ASLR randomises, and it is the only thing that matters for an exploit that hard-codes an address. NOT the layout. Not the order of sections, not the internal offsets, not the page offset within the first mapping. CONSEQUENCE: every address inside the image shifts by exactly the same amount, and every DISPLACEMENT between two addresses inside it is unchanged. which is precisely why a relative relocation needs no runtime fixup at all: S - P is invariant under translation.

That is the deep reason relative relocations exist and why they are cheap. If the whole image is translated by Δ, then (S + Δ) - (P + Δ) = S - P. The displacement is an invariant of the layout, so a relative relocation can be computed entirely at link time and is still correct after the loader moves everything. ET_EXEC is not “the version without ASLR” in some special sense; it is the version where the loader declines to add Δ.

And that in turn explains why the absolute relocations are the dangerous ones. An absolute relocation stores S, not S - P, so it is not translation-invariant and the loader has to fix every one of them after choosing Δ. That is the difference between the two relocation groups the previous module drew, and it is why a PIE still has dynamic relocations at all.

Does It Actually Work?

Six runs each:

$ for f in w_pie w_nopie; do
    printf "%-8s " $f
    for i in 1 2 3 4 5 6; do printf "%s " $(./$f); done
    echo
  done

w_pie    0x631601ffb140  0x63c0b0b59140  0x5d2cde541140
         0x64ca65330140  0x614725b95140  0x5f2b0e7c1140
w_nopie  0x401130 0x401130 0x401130 0x401130 0x401130 0x401130

Six distinct PIE addresses. One non-PIE address, six times. That is ASLR, observed directly rather than inferred from a flag.

Now the detail that makes the model precise, and it is visible if you look at the last three hex digits:

  0x631601ffb140   low 12 bits:  140
  0x63c0b0b59140   low 12 bits:  140
  0x5d2cde541140   low 12 bits:  140
  0x64ca65330140   low 12 bits:  140

Every single run ends in 140. The page offset of main within the image is a link-time constant, and the loader randomises the base on a page boundary so that this offset stays valid. ASLR gives you a random base, not a random address — the entropy is in the high bits, and it has to be page-granular or the low bits would break every page-aligned assumption in the process.

And the non-PIE address explains itself:

$ readelf -hW w_nopie | awk '/Type:/{print "  e_type:", $2}'
  e_type: EXEC
$ readelf -lW w_nopie | grep -A1 LOAD | head -2
  LOAD  0x0000000000000000  ... vaddr 0x400000

0x401130 is below 231, because an ET_EXEC binary is mapped at a fixed address the linker chose, and the traditional choice is around 0x400000. The kernel honours that for a non-PIE executable, so the address is the same on every machine, every boot, forever. Any attacker who knows one such binary knows where its main is on every machine that runs it.

A Real Example

Why this mattered historically, because the reason PIE became the default is a specific bug class rather than a general principle.

  THE SHAPE OF THE ATTACK, and what each defence removes

  a. hard-coded address          the executable is at a
                                  KNOWN address, so the
                                  payload address is known
       -> defeated by a PIE base

  b. a code pointer in .data      the binary contains a
                                  pointer to its own code,
                                  at a known offset
       -> defeated by a PIE base, because the pointer
          is absolute and gets RELOCATED

  c. a return address on the      not address-dependent.
     stack                        NOT defeated by PIE.

  d. a vtable or function         the pointer lives in
     pointer in .rodata           RELOCATABLE memory
       -> defeated by RELRO, not by PIE

PIE removes (a) and (b), and only those. It does nothing for (c) or (d). A stack-based exploit works identically against a PIE; a vtable overwrite works identically against a PIE unless the page is also made read-only. That is not a criticism of PIE — it is the reason to understand what it does rather than to assume it is “ASLR” in the general sense.

Attack (b) is the subtler one and it is worth spelling out, because it is a direct consequence of the relative/absolute distinction from the model above. A non-PIE executable stores the address of its own code as an absolute value in .data.rel.ro. A PIE stores it as 0 plus a R_X86_64_RELATIVE relocation, and the loader writes base + 0 at load time. The bit pattern on disk is identical in a non-PIE binary and useless; in a PIE it is a template the loader fills in. Same source, same compiler flags for the code — the difference is entirely e_type.

And this is where the previous concept’s incoherent row becomes a security fact rather than a performance one. A binary compiled -fPIE and linked -no-pie is an ET_EXEC, so the loader will not move it — but the code was compiled to expect a GOT and the absolute self-references were emitted for a fixed layout. It runs, it is not randomised, and the developer believed they had asked for PIE. Four flags, two processes, and a security property that depends on all four agreeing.

Try It Yourself

$ cd courses/reloc/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/F4/,/F5/p'
$ python3 crosscheck.py 2>&1 | sed -n '/\[4\]/,/\[5\]/p'

Then check the claims rather than the slogan:

  1. Run w_pie 20 times. How many DISTINCT high parts
     (address >> 12) do you get? Is it 20?

  2. Print the address of a LOCAL variable, not main.
     Is its low 12 bits also constant? Should they be?

  3. Print an address in the HEAP (malloc). Is the low
     12 bits constant? If not, why is that different from
     the executable's case?

  4. cat /proc/self/maps on a PIE and a non-PIE. How many
     lines have a different start address across runs?
     Which sections are NOT randomised in either?

  5. Build with -Wl,-z,norelro as well. Does that change
     the base address at all? (It should not.)

Question 3 is the one that fixes the model, and the answer is instructive. The heap is page-granular random too, but an individual allocation’s low 12 bits depend on the allocator’s internal bookkeeping, not on where the mapping landed. The distinction you want is not “random address” against “fixed address” — it is “does the attacker know this offset in advance”. For the executable’s own code the answer was yes and is now no. For a heap object it depends on how the program allocates, and no linker flag changes that.

Connect

This is the empirical centre of the PIE module, and it exists to make the previous concept’s matrix mean something. The Flags, and Which One Is Which Kind established that you can end up with a binary that is neither, and warned that the dangerous one is silent. This concept supplies the test that tells you which binary you actually have, and it is a two-line program rather than a flag.

Forward, What Position Independence Costs is the other half of the decision. This one says what PIE buys — a random base, and with it attacks (a) and (b) — and that one says what it charges, in instructions and bytes. Neither alone justifies the flag; together they are the whole argument, and the conclusion that falls out of the pair is that for a modern program the cost is small and the benefit is a removed bug class.

The connection back to the vocabulary module is the sharpest technical thread in the course, and it is worth stating as a single sentence. Why There Are So Many Kinds divided the relocations into the linker’s and the loader’s. This concept is why that division exists at all: a relative relocation is correct at link time forever, so it never appears in a dynamic relocation section; an absolute one is only correct for one load address, so the loader must revisit every one of them after choosing Δ. The randomised base is not a property bolted onto a fixed binary — it is what forces the absolute forms to exist at all.

And the connection into the failure module is about a case this concept cannot produce. Every address in the experiments above is a code address, and code addresses are relative to the instruction stream. Thread-local storage is addressed relative to a thread pointer, which is per-thread and assigned at runtime, and there the “base Δ” model breaks down completely. The One Relocation That Calls the Loader is where the loader has to stop writing numbers into fields and start running a function, and it is the one place in this course where the answer to “which relocation is this” is “it depends when you ask”.