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.
CET has two independent halves, and they are enforced by two different mechanisms:
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.
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.
endbr64 Come FromThe 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.
The note is a nested structure, and the nesting is the interesting part. Three levels, each with its own alignment rule:
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.
$ 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.
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.