Back to course

W^X, and Why TEXTREL Died

23 minadvancedModule 3: PermissionsContinues from sec-fortify
23 min · Module 3: Permissions · Advanced

Why This Matters

Every hardening feature so far has been about making something unwritable: the GOT, the canary’s frame. This one is the other direction, and it is the oldest idea in the set: a page of memory should be either writable or executable, never both. The reason is not abstract. A write primitive plus an executable page is arbitrary code execution; either one alone is much less.

On Linux the mechanism is a single bit in a segment header, and it is worth being precise about which bit, because there are two and they are different things.

$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H5/,/H6/p'
  PT_GNU_STACK flags, which is the stack's permission request:
   default  0x000000 RW 0x10
   noexec   0x000000 RW 0x10
   exec     0x000000 RWE 0x10
  (the flags column is RWE -- the 'E' is executable STACK)

One bit, and it is a request.PT_GNU_STACK is a program header that says “when you map the stack, use these permissions”, and the kernel is free to grant less. On this system the default is RW, so the stack is not executable, and -Wl,-z,execstack is the flag that gives it away.

So the practical check is one character in one segment header, and the practical advice is: grep your build system for execstack and be suspicious when you find it. It is a flag that exists for two legitimate reasons — a JIT, and hand-written shellcode — and it is also the kind of thing that gets copied between projects until nobody remembers why it is there.

A Simple Model

W^X has two halves, and the second one is the interesting half because it is a historical problem that has been solved so thoroughly that it has almost disappeared from the vocabulary.

W^X, half 1: the STACK a function that maps shellcode onto the stack and jumps to it needs the stack to be executable. so: shellcode --> [w]rite to stack --> [x]ecute from stack break either link and the chain dies. PT_GNU_STACK breaks the second. W^X, half 2: the TEXT SEGMENT a RELATIVE relocation in a shared library writes a pointer value into a segment. if that segment is READ-ONLY (because it holds code or constants), the loader cannot apply the relocation. the old resolution: let the loader write to the text segment, and record that fact in the file with a DT_TEXTREL tag so everyone knows this library is special. the cost: a shared library that needs TEXTREL is a library that CANNOT be fully write-protected, ever, and every consumer of it inherits that.

Half 2 is the interesting one because it is a genuine historical accident that got fixed, and the fix is a section name. When the linker knows a segment will be written during load but should be read-only afterwards, it puts the contents in .data.rel.ro — “relocatable data, read-only” — places it in a segment that is writable during load, and lets RELRO seal it once the loader is done.

the loader needs to write a pointer here | v not .rodata -- the segment is R-only and always will be not .data -- writable forever, so no protection at run time .data.rel.ro -- writable DURING load, sealed by RELRO after

That third option is the whole trick, and it is why TEXTREL is nearly extinct. The loader always had somewhere legal to write; the only question was whether the linker gave it a section there.

The Experiment That Failed, and Why That Was the Result

So: try to build a shared library that needs a text relocation. The obvious specimen is a table of pointers to string literals, which is exactly the case TEXTREL existed for.

$ ./build_samples.sh 2>&1 | sed -n '/H5/,/H6/p'
   TEXTREL in the plain build:        0
   TEXTREL even with -z text:         0
   -z text refused to link:           no

   TEXTREL could not be forced, and the reason IS the finding:
     [13] .rodata           PROGBITS   0000000000002000 002000 000011 01 AMS
     [18] .data.rel.ro      PROGBITS   0000000000003e20 002e20 000018 00  WA
     GNU_RELRO      0x002e10 0x0000000000003e10 0x0000000000003e10 0x0001f0 0x0001f0 R   0x1

The experiment failed, and the failure is the finding. Passing -z text — which tells the linker to refuse any text relocation — did not refuse anything, because there were none to refuse. The pointer table went to .data.rel.ro instead of .rodata.

And the arithmetic confirms it, which is the check worth writing:

$ python3 - <<'PY'
    .data.rel.ro   0x3e20..0x3e38  INSIDE  RELRO range 0x3e10..0x4000
    .rodata        0x2000..0x2011  OUTSIDE RELRO range 0x3e10..0x4000
    .got           0x3fc8..0x3fe8  INSIDE  RELRO range 0x3e10..0x4000
  PY

.data.rel.ro is inside the sealed range. .rodata is outside, and always will be. So the section is writable at exactly the moment the loader needs to write it, and read-only at exactly the moment anybody might want to. That is the entire mechanism, and it is the same “writable during load, sealed after” idea that RELRO applies to the GOT — doing a second job on a data section.

So the honest statement about TEXTREL is this: it is a tag you will rarely see, and the reason is a linker feature rather than a policy. A reader who went looking for it and concluded “my library is safe because it has no TEXTREL” has proved very little — the absence is the default, not the achievement. The useful check is the positive one: is the pointer table in .data.rel.ro, and is that section inside the RELRO range?

Checking It

Both halves of W^X are one field and one section, and the artifact reads both:

# half 1: the stack gs = seg(PT_GNU_STACK) stack_exec = bool(gs.flags & PF_X) # PF_X == 1 # half 2: is anything writable that should not be? d = dict(dynamic()) textrel = d.get(DT_TEXTREL, 0) # and the positive version, which is the one that means something: dro = section('.data.rel.ro') relro = seg(PT_GNU_RELRO) inside = dro.addr >= relro.vaddr and dro.addr + dro.size <= relro.vaddr + relro.memsz

Note the asymmetry, which is the transferable part. “No TEXTREL” is a weak claim; “.data.rel.ro is inside RELRO” is a strong one. The first is the absence of a marker, and the absence of a marker is the default. The second states the positive property that actually provides the protection, and it would catch a linker that emitted the section outside the sealed range.

That is the same habit the RELRO concept taught with covered by RELRO rather than tier, and the same habit the image course taught by checking a mapped address against a file offset rather than trusting a value. Check the geometry, not the label. Three courses, three mechanisms, one rule.

Try It Yourself

$ cd courses/sec/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/H5/,/H6/p'
$ python3 harden.py wx_default wx_exec
$ python3 crosscheck.py 2>&1 | sed -n '/H5/,/H6/p'

Then look for the cases the model does not cover:

  1. Find a REAL library that still emits
     TEXTREL. Not on this system -- try
     searching a large /usr/lib for the tag:
       for f in /usr/lib/x86_64-linux-gnu/*.so*; do
         readelf -dW "$f" 2>/dev/null | grep -q TEXTREL && echo "$f"
       done
     When you find one, check whether its
     .data.rel.ro is inside its RELRO. (A
     library can have BOTH: TEXTREL for the
     leftovers and RELRO for the rest, and
     the tag tells you which parts are still
     exposed.)

  2. Make the stack executable on purpose and
     run code from it. Write a function that
     writes shellcode to a local buffer and
     calls it, build once with
     -Wl,-z,execstack and once without, and
     observe: the second one SEGFAULTS. (That
     is PT_GNU_STACK being enforced, and it is
     the clearest demonstration in the course
     of a header field changing what the
     hardware will do.)

  3. Now the modern replacement. Most JITs
     no longer need an executable stack --
     they mmap a page with PROT_READ|PROT_WRITE
     and mprotect it to PROT_READ|PROT_EXEC
     after writing. Write one and read its
     /proc/self/maps lines. (It shows up as
     an r-xp region with no file, exactly
     like the vDSO from the img course. The
     kernel grants execute permission for a
     SPECIFIC range, which is W^X-compatible in
     a way an executable stack never was.)

  4. Check a JIT you use. If it is on this
     system, look for rwxp or rwx in
     /proc/PID/maps. A region that is
     simultaneously writable and executable
     is a region where W^X has been suspended,
     and it is worth knowing which and why.

  5. Finally, the check that will save you
     time: grep a whole source tree for
     execstack.
       grep -rn 'execstack' --include=Makefile --include='*.mk' .
     (Expect the hits to be in a JIT, a
     shellcode test, or a copy-paste. The
     fourth kind is a project that enabled it
     in 2015 to fix one problem and never
     turned it off -- and nobody can now say
     what would break.)

Exercise 3 is the most important one, because it shows that the flag is a historical mechanism rather than a permanent necessity. A JIT today does not need an executable stack; it allocates a page, writes, and asks for execute permission on that page alone. That is strictly better than an executable stack — the permission is narrow and temporary rather than global and permanent — and it means the -z execstack hit in a modern codebase is far more likely to be legacy than necessary. The exception is hand-written shellcode, where the shellcode genuinely arrives on the stack.

Exercise 1 is the one that makes the historical claim concrete, and the answer on a current system is usually nothing, or a handful of exotic libraries. That absence is the achievement, and it was an accident of a linker feature rather than a security decision. Which is a good note to end the concept on: the strongest result in this area came from giving the loader somewhere better to write, not from asking anyone to be careful.

Connect

This concept closes a loop that the linker-script course opened without being able to close. That course found the default script’s rule for .got.plt and noted that the section can be empty on this toolchain. It could not say why a section would be emptied, and the answer is here: a linker that resolves everything eagerly has no lazy-binding table to give a section. The link between “which sections exist” and “which protections are available” is not obvious until you see that -z now changes both, and it is the same flag in both cases.

The connection to Where the Kernel Put Everything is about who grants. PT_GNU_STACK is a request and the same concept that measured the MAP_FIXED floor measured that a hint below 0x10000 is simply relocated rather than refused. W^X is therefore a request, not a guarantee, and the kernel is entitled to grant less than you asked for. That is the correct design — a security policy that the kernel can tighten unilaterally is a policy that survives kernel updates — but it means a reader should check what was granted in /proc/<pid>/maps, not what was requested in the file. The image course’s central lesson again: the file and the running process are different objects.

Two connections outward. The dlopen concept is where this bites in practice: loading a library you did not audit gives it MAP_FIXED, and a library with an executable stack hands you a page you can write and execute. That is the argument for a plugin sandbox, and it is an argument about permissions rather than about validation. And the Mach-O hardening concept covers the same guarantee on a different platform — it names NULL-page protection, which is the Darwin spelling of the mmap_min_addr floor the image course measured. Same idea, different kernel, and the fact that two kernels converged on the same defence is a decent argument that it is the right one.

One honest limit. Nothing here was measured on AArch64, and the PT_GNU_STACK bit is portable while .data.rel.ro is a linker convention. The mechanism transfers; the spelling may not. The course claims the mechanism, the specific offsets, and the specific section name, and stops there.

render_lesson_js(&mut page)