RELRO is mentioned eight times across the concept files written before this one, and the words “partial RELRO” and “full RELRO” appear in none of them. That is the gap. Almost every reference to RELRO treats it as a thing you either have or do not have, and it is neither: it is a range, and the question is how far the range reaches.
The consequence is not academic. The dynamic linking course measured that .got.plt disappears under -z now and said so without saying why. The why is that under lazy binding the loader still has to write the resolved address into that slot — so the page holding it cannot be read-only, and partial RELRO cannot cover it. The eight writable bytes are not an oversight. They are the loader’s workspace.
$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H3/,/H4/p'
mode flags memsz got.plt dynamic tags
none <none> 0x000208 present none
partial -Wl,-z,relro 0x000208 present none
full -Wl,-z,relro,-z,now 0x000230 ABSENT BIND_NOW,FLAGS_1),
the arithmetic that matters:
none RELRO 0x403df8..0x404000 ends on a page: YES
.got.plt 0x403fe8..0x404008 CROSSES the RELRO end: YES -> 8 bytes stay writable
full RELRO 0x403dd0..0x404000 ends on a page: YES
.got.plt ABSENT -- absorbed into .got, which is inside RELRO
Three rows, and the first one is the surprise: -z relro changes nothing. Same size, same range, same everything. So on this toolchain there are not three states, there are two — and the flag people reach for is a no-op.
What RELRO is, in one paragraph, and why it is a range:
That is the entire design, and it explains why the two tiers are not a “more versus less” choice. Partial RELRO is not a weaker version of full; it is the same mechanism with a different answer to “when does the loader stop writing”. Under lazy binding the answer is “whenever that function is first called”, which is unpredictable and possibly never. So partial has to leave those pages writable.
And the second half of the model is the part that surprises people, which is that RELRO is measured in pages, not bytes.
Now the arithmetic, which is the whole concept. Under partial RELRO on this build:
RELRO 0x403df8 .. 0x404000 (sealed, read-only)
|---- 0x404000 ----|
.got.plt 0x403fe8 .. 0x404008 |
| 0x3fe8 | |<- read-only: 24 bytes
|---- 0x4000 ----|----|
|<- WRITABLE: 8 bytes
Read it as three overlapping statements. The RELRO range ends at 0x404000. The .got.plt section ends at 0x404008. The difference is eight bytes, and those eight bytes are writable at run time while everything before them is not.
Now the question that makes this a concept rather than a curiosity: what are those eight bytes? They are the tail of the lazy-binding table — the last PLT stub’s slot, plus padding. And the loader writes to that table every time a function is resolved for the first time. So:
So -z now is not an extra hardening measure bolted on after RELRO. It is the thing that makes RELRO complete.The dynamic linking course called -z now a change of timing with no code difference, and that is true and incomplete. This is the missing half: the timing change is what creates the opportunity to seal, and the section disappearing is the visible consequence of the sealing being possible.
And the file agrees, in two independent ways. Under -z now the dynamic array grows tags — DF_BIND_NOW in DT_FLAGS, the NOW bit in DT_FLAGS_1 — and the .got.plt section vanishes, because a table with one state does not need its own section. The tag is the loader’s instruction to resolve early; the missing section is the proof that it did.
$ python3 harden.py relro_partial 2>/dev/null | sed -n '6,11p'
RELRO region 0x403df8..0x404000
RELRO tier partial
.got.plt section present
covered by RELRO NO
ends on a page yes
$ python3 harden.py relro_full 2>/dev/null | sed -n '6,11p'
RELRO region 0x403dd0..0x404000
RELRO tier full
.got.plt section absent
covered by RELRO yes
ends on a page yes
Note that covered by RELRO is the check that matters and it is the one that is easy to get wrong. The tier is what the dynamic array claims; the coverage is what the segment table proves. A reader that only checks the tags is trusting a promise. A reader that computes whether the GOT section actually fits inside the RELRO range is checking. The artifact does both and the concept treats the second as the real one — and that habit, checking the geometry rather than the flag, is the transferable part.
Since the tier is a promise and the coverage is the fact, the coverage check is the one to build. It is four lines and it needs the section table and the segment table, which is the whole point of having read both in earlier courses:
The second check is nearly free and it is a genuine invariant, not a coincidence: the sealed range must end on a page boundary, because that is how the kernel applies it. If a future toolchain produced a RELRO range that did not end on a page, that would mean the sealing had changed shape, and it is worth an assertion that fires rather than a surprise discovered later.
Now the honest complication, because it is a good one. The .got.plt section is absent under -z now, so a reader that looks only for .got.plt concludes “no GOT, nothing to protect”. The section was not eliminated; it was absorbed into .got. So the reader has to fall back:
That is a small thing that generalises to every parser in this collection: a missing thing is ambiguous. Absent .got.plt could mean full RELRO, or a static binary, or a file that is not what you think it is. Disambiguating it costs one more lookup, and skipping the lookup produces a report that says “nothing to protect” about the best-hardened binary in the set.
$ cd courses/sec/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/H3/,/H4/p' $ python3 harden.py relro_none relro_partial relro_full $ python3 crosscheck.py 2>&1 | sed -n '/H3/,/H4/p'
Then push on the claim, especially the part that is a flag with no effect:
1. Compare -z relro against NO flag, byte for
byte, not size for size:
cmp relro_none relro_partial
(The earlier version of this course compared
SIZES, found them equal at 0x208, and
concluded -z relro does nothing. Comparing
the files is the stronger claim and it
should hold -- but CHECK it, because a
size match does not prove a byte match.)
2. Vary the environment size. Build the
partial binary with a tiny environment and
with a 200-entry one, and re-read the RELRO
range both times. Does the writable tail
grow? (It should not -- the tail is a
function of the PLT stub count, not the
environment. Confirming a non-dependence is
as useful as finding a dependence.)
3. Add a shared library that exports 300
symbols, link it, and re-measure the
writable tail. (This is where the argument
gets sharp: under lazy binding each export
has a slot, so the .got.plt grows, so the
writable region grows. Plot tail size
against export count. Now do the same
with -z now. Under -z now the tail is
ZERO at every export count, because there
is no .got.plt to have a tail of. That is
the whole argument for -z now as a
SECURITY measure rather than a startup
optimisation.)
4. Take a real system binary and run
harden.py on it. Then find the writable
tail with the same arithmetic and confirm
it with readelf. Does the distribution's
hardening documentation agree with what
the file says?
5. Break the invariant. Hand-write a linker
script that emits a PT_GNU_RELRO with a
memsz that is NOT page-aligned, link with
it, and run the artifact. (The page-aligned
check fires, and it fires because the
kernel rounds -- so you learn what the
rounding hides: a range that LOOKS like it
covers the GOT in the file may not cover
it at run time. This is the same gap between
the file and the running process that the
img course found with AT_ENTRY.)
Exercise 3 is the one that makes the security argument quantitatively, and it is worth doing because it turns “full RELRO is better” into a number. The writable tail scales with the number of exported symbols, and -z now makes it zero at every size. That is a scaling argument rather than an aesthetic one: the exposure under partial RELRO grows with the size of your dependency tree, and the mitigation is a flag. It also explains why “we already have RELRO” is not an answer to a security review — it may be partial, and you cannot tell from the feature name.
Exercise 5 is the deepest, and it is the same lesson the image course taught at a different layer. Everything you verify by reading the file is a statement about the file, and the kernel acts on a rounded version of it. The auxv said AT_ENTRY and the mapping agreed; here the segment says a range and the kernel applies a page-rounded range. A parser that reports what the file says is not lying, but it may not be describing what is true, and knowing which one you are reporting is the difference between a tool and a guess.
This concept completes a debt owed by three earlier courses, and it is worth naming all three. The symbol resolution course built the PLT: one stub per imported function, one GOT slot each. The dynamic linking course measured that -z now changes no instructions and deletes .got.plt. The linker script course found where .got.plt is placed and why it needs its own output section rule. None of them could say why the section is allowed to be writable, and the answer is that it is the loader’s scratch space until lazy binding is done. Each course had a correct local account; this is the one that makes them a single mechanism.
The connection to Where the Kernel Put Everything is the direct parent. That concept measured a process map and noted that a GNU_RELRO region appears as r--p because the loader asked for read-only after finishing its writes — and it said “after” without knowing why the timing was choosable. This is the why: the loader chooses when to ask, and -z now is how it chooses earlier. A flag in a link line becomes a mprotect call in the first milliseconds of a process’s life, and the r--p in /proc/<pid>/maps is its result.
Two connections that are about limits rather than mechanism. First, the W^X concept shows RELRO doing a second job: .data.rel.ro is the same sealing idea applied to a data section, and it is the reason TEXTREL is nearly extinct. RELRO is not one feature about the GOT; it is a general “writable during load, sealed afterwards” mechanism that happens to have been applied to the GOT first. Knowing that is what lets you predict the second use instead of being surprised by it. Second, the posture concept turns all of this into a check you run in CI, and the check it runs is this one — so this concept is the load-bearing row of that report.
One connection to a course not yet written, which is worth flagging as a real limit. This is Linux and glibc. The mechanism is general — resolve before sealing, or do not seal — but the tag names, the section names and the *_chk symbol names are all platform conventions. The Mach-O hardening concept in this collection gets the same guarantee a different way, and comparing the two is genuinely instructive: the mechanism is portable, the spelling is not.