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.
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.
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.
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.
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?
Both halves of W^X are one field and one section, and the artifact reads both:
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.
$ 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.
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.