Back to course

CET: The Instructions Are Not the Request

23 minadvancedModule 3: PermissionsContinues from sec-wx
23 min · Module 3: Permissions · Advanced

Why This Matters

Control-flow Enforcement Technology is the newest feature in this course and the only one where the obvious measurement is wrong. The obvious measurement is “does the binary contain endbr64”. Measured on a binary built with protection explicitly disabled, the answer is yes — five of them.

$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H6/,/H7/p'
  the property note -- what the binary ASKS the hardware for:
   none    note=0x00000010   properties: x86 ISA needed: x86-64-baseline
   branch  note=0x00000020   properties: x86 feature: IBT
   full    note=0x00000020   properties: x86 feature: IBT, SHSTK

  endbr64 in the binary, and in MY functions only:
   none    total=5   in main/helper=0
   branch  total=7   in main/helper=1
   full    total=7   in main/helper=1

Look at the first row carefully. Protection off. Five endbr64 in the binary, zero in main or helper, and a property note that claims nothing but the instruction set. Those five come from somewhere else entirely, and finding out where is the best single result in this course.

A Simple Model

CET has two independent halves, and they are enforced by two different mechanisms:

IBT INDIRECT BRANCH TRACKING the attack: overwrite a function POINTER, so an indirect call goes somewhere you chose. the defence: every legitimate branch target BEGINS with a landing pad. endbr64 f3 0f 1e fa a NOP on old CPUs so a misdirected indirect branch lands on something that is not a landing pad, and the CPU faults. enforcement: the CPU checks that the TARGET of an indirect branch starts with endbr64. that is HARDWARE. SHSTK SHADOW STACK the attack: overwrite a RETURN ADDRESS. the defence: the return address is also kept in a second stack the program cannot address with an ordinary write. a write updates the normal stack and NOT the shadow stack return compares the two mismatch -> fault enforcement: the CPU maintains a second stack, and the LOADER turns it on by writing the shadow stack configuration into the thread control block. so SHSTK is configured by the LOADER, and IBT is configured by the CPU reading a NOTE in the binary. different switches.

And that is the whole reason the obvious measurement fails. endbr64 is a compile-time instruction; enforcement is a load-time decision made from a different part of the file. A binary can be full of landing pads and request nothing, in which case the hardware will not check them. Or — and this is the subtle direction — a binary could request IBT with a note and contain no endbr64 at all, in which case the hardware would fault on every indirect call.

the instruction is NECESSARY. the note is SUFFICIENT. neither implies the other on its own, and the security property needs both: instruction present + note absent -> no enforcement. SAFE BUT USELESS. the landing pads are just NOPs. instruction absent + note present -> the program crashes immediately. BROKEN BUT LOUD. both present -> the property holds.

So a posture report has to check the note, and an instruction count is at best a secondary signal. That is not a quibble about a tool. It is the difference between a binary that is protected and a binary that is prepared to be protected.

Where the Five endbr64 Come From

The obvious follow-up is to ask which functions they are in, and the answer is the useful one:

$ ./build_samples.sh 2>&1 | sed -n '/H6/,/H7/p'
  with protection OFF my functions have none, yet the binary still
  has 5. They come from the C runtime, which the distro built with
  protection ON:
     crt1.o    endbr64 = 2
     crti.o    endbr64 = 2
     crtn.o    endbr64 = 0

crt1.o and crti.o ship pre-hardened. They are the C runtime startup files — _start, register_tm_clones, __do_global_dtors_aux, frame_dummy, _init, _fini — and the distribution built them with CET enabled, months or years before this compiler existed.

So the consequence is worth stating as a rule: -fcf-protection=none removes the protection from your code and leaves the startup files alone. You cannot un-harden the C runtime with a compiler flag, because the C runtime was compiled separately and is linked in as an object file you did not build. And that is the correct default — it means the process entry path is protected regardless of what your build does.

Now the note itself, which is the check that matters:

$ readelf -n cet_none | sed -n '/GNU_PROPERTY/,/^$/p'
    GNU                  0x00000010	NT_GNU_PROPERTY_TYPE_0
        Properties: x86 ISA needed: x86-64-baseline

$ readelf -n cet_full | sed -n '/GNU_PROPERTY/,/^$/p'
    GNU                  0x00000020	NT_GNU_PROPERTY_TYPE_0
        Properties: x86 feature: IBT, SHSTK
  	x86 ISA needed: x86-64-baseline

Two things to read here. The note grows from 0x10 to 0x20 bytes when a feature is claimed, which is a coarse but reliable signal — and a coarse signal is better than none, because a fixed-size expectation is what a parser can assert on. And the ISA requirement is present in both. That is a different property: it says “this binary needs at least x86-64-baseline”, which is a compatibility statement rather than a hardening one, and the two live in the same note for the same reason — both are things the loader needs to know before running the binary.

The instruction itself, for completeness:

$ objdump -d cet_full | grep -A1 '<main>:' | tail -1
    1130:	f3 0f 1e fa          	endbr64

f3 0f 1e fa. On a CPU without CET it decodes as a four-byte NOP with an unusual prefix, so a binary full of landing pads still runs on old hardware — which is what makes the feature deployable at all, and is the same design choice as the NX bit being honoured only on hardware that has it.

Parsing the Note

The note is a nested structure, and the nesting is the interesting part. Three levels, each with its own alignment rule:

PT_GNU_PROPERTY points at a note: namesz descsz type name descriptor | | | | | | | | | +-- the x86 property array | | | +-------- "GNU\0" padded to 4 | | +-------------- 5 = NT_GNU_PROPERTY_TYPE_0 | +--------------------- the descriptor length +---------------------------- the name length and the descriptor is ITSELF an array of (type, size, value...) triples: pr_type pr_datasz the 32-bit value -------- ---------- ------------------ c0000002 00000004 0x3 = IBT | SHSTK ^^^^^^^^ ^^^ so "IBT and SHSTK" is ONE property whose value is a BITMASK, not two properties. and the size must be >= 4 or there is no value to read.

Two things there are load-bearing and both were learned the hard way by the artifact. Each triple’s size field is rounded up to 8 for the next entry, so stepping by the declared size rather than the rounded size desynchronises the walk. And a triple with size 0 advances the cursor by nothing, which is an infinite loop — and which is exactly what harden.py did on its first run against a real binary, hanging with no output at all.

That bug is worth more than the parser, because it is a category. Any loop whose step is computed from the data it is reading can fail to advance, and the failure mode is a hang rather than an error. For a security tool a hang is arguably the worst outcome available: not a wrong answer, but no answer, and a CI job that hangs looks like infrastructure trouble rather than a parse bug. The fix is to make the step unconditionally positive, and the fix is in the code with a comment explaining why, because the next person to simplify it will reintroduce the hang.

step = (psize + 7) & ~7 # round up to 8 if step == 0: step = 8 # NEVER zero o += step the second line is not defensive programming. it is a documented bug that this parser had, and removing the guard removes the bug.

Try It Yourself

$ cd courses/sec/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/H6/,/H7/p'
$ python3 harden.py cet_none cet_full
$ python3 crosscheck.py 2>&1 | sed -n '/H6/,/H7/p'

Then separate the two things the concept separates:

  1. Build the four combinations by hand and
     observe what each does:
       -fcf-protection=none,   note stripped
       -fcf-protection=none,   note present
       -fcf-protection=branch, note stripped
       -fcf-protection=branch, note present
     (Stripping the note from a CET build is a
     few bytes of objcopy. Then RUN all four.
     The "instructions but no note" one runs
     perfectly -- unprotected. The "note but no
     instructions" one, if the CPU enforces IBT,
     faults on the first indirect call. This is
     the clearest demonstration in the course
     that the two mechanisms are independent.)

  2. Count endbr64 per FUNCTION rather than per
     binary, and list the functions that have
     none in a CET build. Any? (On this build,
     no -- but on a mixed-toolchain one, yes: an
     object compiled by an older compiler and
     linked into a CET binary is a hole. That
     is what a linker script and a hardened
     CI check are for.)

  3. Read the note of a real system binary:
       readelf -n /usr/bin/ls | grep -A2 GNU_PROPERTY
     Does your distribution's libc request CET?
     Does /usr/bin/ls? If the libc does and the
     binary does not, that is a deliberate
     choice -- enforcement requires EVERY object
     to comply, so a partial rollout is worse
     than none.

  4. Check whether the CPU you are on supports
     it at all:
       grep -o 'ibt\|shstk' /proc/cpuinfo | sort -u
     (An empty result means the note is a
     request the hardware will ignore. The
     binary is still correct; the property is
     simply not enforced on this machine. A
     report should distinguish "not requested"
     from "requested and not available", and
     most do not.)

  5. Finally: the shadow stack half has not been
     measured in this course at all. Find out
     whether your kernel supports it, read what
     the loader does, and decide whether to
     claim anything. (The honest answer here is
     "not measured" -- SHSTK is configured through
     the thread control block rather than the
     note, so the binary is not the whole story
     and reading it would not settle it.)

Exercise 1 is the one that makes the independence argument real rather than rhetorical, and stripping a note with objcopy is a five-minute experiment with a genuinely surprising result. A binary with every landing pad in place and no note runs perfectly, and is completely unprotected. Anyone who has counted endbr64 as a hardening metric has measured nothing, and the experiment makes that unarguable in a way that prose cannot.

Exercise 3 is the deployment lesson, and it is the reason this feature is harder to roll out than RELRO. Enforcement requires every object in the process to comply — the binary, every shared library, the C runtime. A partial rollout is not a partial protection; it is either inert (nobody requests it) or broken (something requests it and something else does not comply). That all-or-nothing property is why CET adoption is a distribution decision rather than a per-project one, and it is a good contrast with -z now, which is entirely local to one binary.

Connect

The connection to the auxiliary vector concept is the deepest one in the course and it is worth spelling out, because it is a different enforcement path for the same class of property. That concept measured AT_HWCAP and the capability bitmask, and noted that a program should use it rather than parse /proc/cpuinfo. Here is a property where the hardware capability and the binary’s request are different files: the CPU publishes what it can do, the binary publishes what it wants checked, and the loader configures the shadow stack using both. CET is therefore not one mechanism but three cooperating decisions in three different places, and the posture concept can only see the middle one from the file.

The connection to the PIC-violation concept is about what CET does not fix. That concept measured the cost of position independence and the R_X86_64_32S overflow at 0x80000000. CET’s shadow stack stops a corrupted return address; IBT stops a corrupted function pointer. Neither stops a corrupted data pointer that is dereferenced rather than called. So a full CET deployment still leaves data-only attacks untouched, and the mitigation for those is what the canary and RELRO do. Four mechanisms, four different attack shapes, and the honest summary is that hardening is a set of partial mitigations rather than a single switch — which is the correct mental model and the reason this course is seven concepts rather than one.

Two connections to close the course. The ELF header-fields concept taught that every field has a job, and CET is a nice late example of a field that exists because the file is not the only thing that matters — the same reason AT_BASE exists. And The image course’s pointer-versus-size warning is directly relevant to a parser of this note: a property descriptor is a bitmask in a uint32 and you must know which bits mean what, exactly as AT_PAGESZ is a size that looks like an address. A parser that reads the property value as a boolean is wrong in a way that a type system cannot catch, because the format gives you no way to tell.

One limit, stated rather than buried. The shadow-stack half of CET is not measured in this course. The property note carries the request, but SHSTK enforcement is configured by the loader through the thread control block, so reading the binary does not settle whether the property holds. Exercise 5 hands that to the reader with the reason written down, because a course that claimed it would be claiming something its own instrument cannot see.

render_lesson_js(&mut page)