Back to course

Three Layers, Two Answers

24 minadvancedModule 1: The Defaults
24 min · Module 1: The Defaults · Advanced

Why This Matters

There are two hardening concepts in this collection already, and both are honest about what they are. macho-hardening covers NULL-page protection, ASLR and PIE on Mach-O. pe-security-flags covers DllCharacteristics: the ASLR bit, the NX bit, the CFG bit. Both are lists of flag names, on platforms this chain has not otherwise touched.

That is a real gap and it is not a small one. A flag name tells you the feature exists. It does not tell you whether the bit is set in your binary, whether the toolchain set it for you, or what the bit does when it is. So this course moves the same subject to x86-64 ELF and moves it down from names to bytes.

And the first thing the measurement found is the reason the course exists. Here is one source file, compiled with -O1 and no flags of any kind, by two compilers on this machine:

$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H1/,/H2/p'
     no flags at all, -O1, identical source:
   clang        %fs:0x28 references = 0
   gcc          %fs:0x28 references = 2

One of them emits a stack canary and the other does not. Same source. Same machine. Same command line, minus the compiler name. If you have a hardening policy written down, this is the first thing it has to answer, and the answer is not in your build system — it is in which compiler you happened to install.

A Simple Model

It gets worse, or better, depending on your temperament. Hardening is not decided in one place. Three separate decisions, made by three different pieces of software, any of which can differ between two machines running the same project.

1. CODEGEN the COMPILER decides does it emit a stack canary? does it emit endbr64? does it turn strcpy into __strcpy_chk? 2. LINKER YOU decide, with flags -z relro -z now -z noexecstack -z execstack -z text 3. DRIVER the compiler DRIVER decides which of those flags it passes to ld on your behalf, with no flag from you at all layer 3 is the one nobody thinks about, and it is the one that made the two binaries on the previous page differ in a second, independent way.

Layer 3 is worth dwelling on because it is genuinely surprising as a category. The compiler driver is the program you invoke, and it decides which linker flags to add behind your back. Ask it:

$ gcc -O1 -### -o /dev/null canary.c 2>&1 | grep -oE '\-z [a-z]+' | sort -u
  -z now
  -z relro
$ clang -O1 -### -o /dev/null canary.c 2>&1 | grep -oE '\-z [a-z]+' | sort -u
  (nothing)

gcc's driver passes -z relro -z now. clang's passes nothing. So the gcc binary gets full RELRO and the clang binary gets only the implicit partial — and under clang a tail of the GOT stays writable. The RELRO concept measures exactly which bytes, and the reason it is eight bytes is the interesting part.

The three layers are independent, which is what makes this a build-system problem rather than a compiler problem:

"our policy is -fstack-protector-strong and -Wl,-z,now" read literally, that pins layer 1 and layer 2. it says NOTHING about layer 3. so two teams with the SAME written policy, on two distros, ship different binaries -- and neither is wrong, and neither noticed.

Measured, in the Files

Not in the driver flags — in the output. The artifact reads the two binaries with no compiler involved:

$ ./build_samples.sh 2>&1 | sed -n '/H8/,$p'
     what each driver passes to ld with NO flags of your own:
   gcc        -z now -z relro
   clang      nothing

     the consequence, in the FILES. Same source, same -O1, no flags:
   drv_gcc     RELRO tier full  covered by RELRO yes
   drv_clang   RELRO tier partial  covered by RELRO NO

Two hardening properties, from one source file and one command line:

$ python3 harden.py drv_gcc 2>/dev/null | sed -n '5,20p'
  stack executable?                  no   [PT_GNU_STACK flags RW-]
  RELRO region                       0x3da8..0x4000
  RELRO tier                         full
    .got.plt section                 absent
    covered by RELRO                 yes
  stack canary refs (%fs:0x28)       2

$ python3 harden.py drv_clang 2>/dev/null | sed -n '5,20p'
  RELRO tier                         partial
    .got.plt section                 present
    covered by RELRO                 NO
  stack canary refs (%fs:0x28)       0

Read those two blocks side by side. The gcc binary has a stack canary and full RELRO with the GOT entirely sealed. The clang binary has neither, and its .got.plt extends past the end of the read-only region. Neither team passed a hardening flag. One of them is substantially more hardened, by accident, because of which compiler is installed.

And the canary count is 2 rather than 1, which is worth a moment. A canary is read once and compared once, so two references to %fs:0x28 is one tripwire, not two. The canary concept takes the three instructions apart; here the point is only that the number is small and countable, which is what makes it measurable from the file at all.

Doing Something About It

The obvious move is to pass the flags explicitly, and that is correct and insufficient. The problem is that some of the decisions are not reachable by a flag you can add to a compiler command — they are defaults you have to check.

the flags you CAN pin, and should: -fstack-protector-strong layer 1 -fcf-protection=full layer 1 -D_FORTIFY_SOURCE=3 layer 1 -Wl,-z,relro -Wl,-z,now layer 2 -Wl,-z,noexecstack layer 2 -Wl,-z,ibtplt layer 2 the decision you CANNOT pin, only check: does the driver add -z flags behind my back, and will it next year? you cannot turn that off. you can only MEASURE it -- and measuring is a script, which is module 4.

So the practical answer has two halves, and the second half is the one teams skip. Pin what you can. Verify what you cannot. A one-line CI check that runs a posture reader over every binary you ship catches the whole class — a driver that changed, a distribution that changed a default, a new dependency built with different flags.

That check is worth writing for a second reason. It is a regression test, and these flags are exactly the kind of thing that regresses silently. Nobody removes -z now on purpose. A build rule gets refactored, a link line gets rebuilt from variables, and one -z falls out — and the binary still compiles, still runs, and is quietly weaker. The symptom of that change is not a build failure. It is nothing at all, which is why it needs a test rather than a review.

Try It Yourself

$ cd courses/sec/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/H1/,/H2/p'
$ ./build_samples.sh 2>&1 | sed -n '/H8/,$p'
$ python3 harden.py drv_gcc drv_clang
$ python3 crosscheck.py 2>&1 | sed -n '/H8/,/A /p'

Then go and find the rest of them:

  1. Compile the same file every way you can
     and diff the postures:
       for cc in gcc clang; do
         $cc -O1 -o t_$cc canary.c
         python3 harden.py t_$cc | tail -2
       done
     Now add -fstack-protector-all to both and
     repeat. Which columns changed, and which did
     not? (The canary column changes. The RELRO
     column does NOT, because -fstack-protector
     is a codegen flag and RELRO is a LINK-time
     property. Two of the three layers are
     visible in that one experiment.)

  2. Ask each compiler what it thinks the
     default IS, from its own documentation on
     disk, and compare with what you measured:
       gcc -Q --help=common 2>/dev/null | grep -i 'stack-protector\|fortify'
       clang -cc1 --help | grep -i 'stack-protector\|fortify'
     Did the documentation and the binary agree?
     (On this machine they do, and the course
     claims the measurement, not the docs.)

  3. Now the interesting one: what does
     -### say for a LINK that goes through a
     wrapper -- a response file, a cmake
     link.txt, a gold/lld invocation? Find the
     real link line in your build directory
     and read it. (This is where layer 3 hides
     when there are more than three layers.)

  4. Build the SAME source with -static and
     with -shared, both fully hardened, and run
     harden.py on each. Which checks still mean
     anything? (PIE is meaningless for the static
     one. RELRO still works. The canary is
     unaffected. A check that does not apply to
     your build type is a check that will
     eventually be reported as a FAILURE and
     then ignored -- so know which ones those
     are.)

  5. Take a real binary you did not build --
     /usr/bin/ls, or a system library -- and run
     harden.py on it. Then compare with what
     your distribution's hardening documentation
     claims. Any disagreement is either a
     documentation bug or a per-package build
     exception, and both are worth knowing about.

Exercise 1 is the one that makes the three-layer model concrete, and its result is counter-intuitive. Adding -fstack-protector-all fixes the canary column and leaves RELRO exactly where it was, because the canary is produced by the compiler and RELRO is produced by the linker. They are not two settings of one thing. That is why “we enabled hardening” is an incomplete sentence, and why the posture report has a row per layer rather than one score.

Exercise 5 is the one that changes how you read a hardening claim. Run the reader on a binary you did not build and the answer will not match the distribution’s hardening page — because most distributions apply hardening per package, and the packages are built by different rules. A hardening claim about “the distribution” is usually a claim about a subset, and the only way to know which subset is to read the binary.

Connect

This concept is the argument for the course, so it is worth being explicit about what it corrects. The relocations course measured randomisation and the PIE flags taught that -fPIE and -pie are two different flags at two different layers. That is layer 1 and layer 2 of this model, already established — and the missing third layer is the one that made the two binaries on this page differ. PIE is a codegen decision; -z now is a link decision; whether either reaches the linker by default is a driver decision. Three questions where most people have one.

The connection to the image-loading course is mechanical and total. Everything this course hardens is something the kernel grants.PT_GNU_STACK is a request in a segment header, and the kernel decides whether to honour the execute bit. PT_GNU_RELRO is a range the loader tells the kernel to seal once relocation is done. The stack canary lives in the thread control block, which the kernel builds and hands over in the auxv — which is why %fs:0x28 is a fixed offset with no setup code: the C runtime reads it from there. A hardening feature that is not a kernel-granted permission is a compiler convention, and the two categories have different failure modes.

The connection to the dynamic linking course is a debt that the RELRO concept pays. That course measured that .got.plt disappears under -z now and correctly declined to say why. The reason is that under lazy binding the loader still has to write the resolved address into the GOT slot — so a page containing that slot cannot be read-only, and full RELRO is only possible once the loader has finished writing. The section did not disappear to save space. It disappeared because sealing it made it unnecessary.

Two connections outward, both to concepts that exist and both at the flag level. macho-hardening and pe-security-flags list names; this course reads bits, and the first concept in each of those would be the same experiment run on a different platform. That is a good property of this course: the method transfers, and the specific findings do not. A reader who has learned to check rather than to trust will get the same value from a Mach-O or a PE binary, and that is the transferable skill — not the number 0x10000, which is x86-64 Linux and nothing else.

render_lesson_js(&mut page)