Skip to content

fix(build): put the whole GOT inside the RELRO window on ELF targets - #1914

Open
DeusData wants to merge 1 commit into
mainfrom
fix/elf-hardening-relro-now
Open

fix(build): put the whole GOT inside the RELRO window on ELF targets#1914
DeusData wants to merge 1 commit into
mainfrom
fix/elf-hardening-relro-now

Conversation

@DeusData

Copy link
Copy Markdown
Owner

Distilled from #1138 with Co-authored-by: credit to @PR9000. Takes the two flags worth taking, leaves the rest, and asserts the result on the artifact rather than on the compiler accepting a flag.

Every Linux binary we have shipped had part of the GOT left writable

Measured on a binary built the way .github/workflows/_build.yml builds the Linux release (STATIC=1, gcc 13.3 / GNU ld 2.42, Ubuntu 24.04 aarch64). PT_GNU_RELRO ends at 0x11DA0000 in both cases:

without -z now:  .got     0x11d9ec20 +0x13c8
                 .got.plt 0x11d9ffe8 +0x40   -> ends 0x11DA0028
                 ...40 bytes PAST the RELRO window: those GOT slots stayed
                 writable for the entire process lifetime.

with    -z now:  .got.plt folds into .got, ends 0x11D9FFE8
                 ...the whole GOT is inside the window and re-mapped read-only.

Both flags are free at runtime here — a static binary resolves everything at link time, so there is no lazy binding left to pay for. -z relro is already this toolchain's default and changes nothing on it; it is named anyway so the property stops depending on one distro's spec file, since the musl/portable and glibc-floor images are different toolchains and a default is not a guarantee.

The assertions are the point

A1c-relro and A1d-bind-now in check-binary-composition.sh read the produced binary with readelf and assert the outcome — the same reason A1 exists beside the .note.GNU-stack annotation.

#1138 is the direct evidence for why that matters. Two of its flags passed every compile probe and did nothing, or worse:

  • -pie was silently discarded under -static. Probe passed, flag added, artifact unchanged (Type: EXEC, no PIE).
  • -D_FORTIFY_SOURCE=2 silently DOWNGRADED Ubuntu 24.04's default of 3, with no diagnostic, on the exact platform that builds the shipped Linux binaries. A hardening flag that weakened hardening.

A probe answers "does this flag compile", never "did the binary gain anything".

The readers use awk rather than a short-circuiting reader deliberately: a reader that exits at the first match leaves the upstream taking EPIPE, and under set -o pipefail the satisfied case gets reported as the failing one. That is the same defect fixed in #1879 earlier today, avoided here by construction.

Deliberately not taken from #1138

flag why not
-D_FORTIFY_SOURCE=2 measured downgrade from Ubuntu's 3. If fortification is touched at all it may only be raised, never lowered
-fPIE / -pie inert under STATIC=1, which is how Linux release binaries are built. Adding it would be false assurance
-fstack-protector-strong real but unmeasured cost, and #1138 injected it into SQLite, tree-sitter, mimalloc, lz4 and zstd — the indexing hot loop, on a project that indexes 8.5M-node graphs. Wants a same-machine A/B index run first
the $(shell) probe machinery ~10 compiler spawns per Makefile parse (measured 209 ms, paid on every make including clean and -n), and it makes the produced binary a function of whichever toolchain the builder happens to have
everything else already merged via #1421 / #1423 / #1467, or superseded — main's TSan define fix and its FreeBSD-guarded /home alias are both better than the versions in that PR

No new opt-in knob: main already applies ELF_HARDENING_FLAGS unconditionally on ELF and ships that way, so two more free linker flags join the existing set rather than growing a PRODUCTION_HARDENING-style variable.

Honest limit: macOS cannot show ELF program headers, so the RELRO/BIND_NOW outcome is verified by the measurement above and by the new assertions running in CI's Linux legs — not on this machine.

Adds -Wl,-z,relro and -Wl,-z,now to ELF_HARDENING_FLAGS, and asserts the
resulting property on the produced artifact rather than on the compiler's
willingness to accept a flag.

The shipped Linux binaries measurably lacked the property. Built the way
.github/workflows/_build.yml builds them (STATIC=1, gcc 13.3 / GNU ld 2.42,
Ubuntu 24.04 aarch64), PT_GNU_RELRO ends at 0x11DA0000 in both cases:

  without -z now:  .got 0x11d9ec20+0x13c8, .got.plt 0x11d9ffe8+0x40
                   -> .got.plt ends at 0x11DA0028, 40 bytes PAST the window.
                      Those GOT slots stayed writable for the whole process
                      lifetime, in every Linux binary shipped to date.
  with    -z now:  .got.plt folds into .got, the whole GOT sits inside the
                   window and is re-mapped read-only after startup.

Both flags are free at runtime for a static binary -- everything resolves at
link time, so there is no lazy binding left to pay for. -z relro is already
this toolchain's default and changes nothing here; it is named anyway so the
property stops depending on one distro's spec file, since the musl/portable
and glibc-floor images are different toolchains and a default is not a
guarantee.

A1c/A1d in scripts/ci/check-binary-composition.sh assert the OUTCOME on the
binary via readelf, for the same reason A1 exists beside the .note.GNU-stack
annotation: a flag the linker accepts is not evidence the artifact gained
anything. PR #1138 demonstrated that directly -- its -pie was silently
discarded under -static, and its -D_FORTIFY_SOURCE=2 silently DOWNGRADED
Ubuntu's default of 3, both while every compile probe passed.

The readers use awk rather than a short-circuiting reader on purpose: a
reader that exits at the first match leaves the upstream taking EPIPE, and
under `set -o pipefail` the satisfied case is reported as the failing one.

Deliberately NOT taken from #1138: -D_FORTIFY_SOURCE=2 (a downgrade on the
release platform), -fPIE/-pie (inert under STATIC=1), -fstack-protector-strong
(real but unmeasured cost, and #1138 injected it into SQLite, tree-sitter and
mimalloc -- the indexing hot loop), and the $(shell) probe machinery (~10
compiler spawns per Makefile parse, making the binary a function of the
builder's toolchain).

Co-authored-by: PR9000 <119280965+PR9000@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant