Back to course

The Two Tiers of RELRO

24 minadvancedModule 2: The GOTContinues from sec-canary
24 min · Module 2: The GOT · Advanced

Why This Matters

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.

A Simple Model

What RELRO is, in one paragraph, and why it is a range:

the GOT is a table of addresses the dynamic linker fills in. an attacker who can WRITE an entry controls where an indirect call goes. RELRO is the loader's answer: after it has finished filling the table, it tells the kernel "make this range read-only". the kernel does it with mprotect, and the program cannot write there afterwards. and the whole difficulty is the word "after". the loader must be able to write the GOT while it works. so the range can only be sealed once the loader is done. partial seal everything EXCEPT the slots the loader might still write full resolve everything FIRST, so the loader is done sooner and there is nothing left to write

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.

the loader hands the kernel a RANGE. the kernel rounds it OUT TO A PAGE and makes the whole page read-only. so the sealed region always ENDS on a page boundary. measured: all three builds end at 0x404000. which means -z now does not extend the range FORWARD. it extends it BACKWARD, so that the range's fixed forward end now covers strictly more. partial 0x403df8..0x404000 full 0x403dd0..0x404000 ^^^^^^ 28 bytes earlier ^^^^^^ same end

The Eight 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:

PARTIAL RELRO the loader MAY write to the GOT, later, at a moment the program does not control. so the page must stay writable. -> an attacker with ANY write primitive can overwrite a GOT entry and redirect an indirect call, for the life of the process. FULL RELRO the loader resolves everything BEFORE main() runs. by the time the program has a single instruction, every GOT slot has its final value and will never change. so the page can be sealed. -> the same write primitive now hits a read-only page and SIGSEGVs.

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.

The Check Worth Writing

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:

got = section('.got.plt') or section('.got') lo, hi = relro.vaddr, relro.vaddr + relro.memsz covered = got.addr >= lo and got.addr + got.size <= hi # and one more that costs nothing: page_aligned = (hi % 0x1000) == 0

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:

got = section('.got.plt') or section('.got') ^^^^^^^^^^^^^^^^ if this is None, the answer is not "no GOT". it is "the lazy table is gone", which is the GOOD case, and you have to check .got to know that .got is inside the sealed range.

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.

Try It Yourself

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

Connect

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.

render_lesson_js(&mut page)