Every other hardening feature in this course leaves a bit in a header: a segment flag, a dynamic tag, a property note. FORTIFY leaves nothing, and that is why it is the one most often assumed rather than verified. There is no FORTIFY bit anywhere in an ELF file. What there is, is a different set of imports.
$ cd courses/sec/assets/samples && ./build_samples.sh 2>&1 | sed -n '/H4/,/H5/p' which libc entry points each level calls: level 0 printf read strcpy strlen level 1 printf __read_chk __strcpy_chk strlen level 2 __read_chk __strcpy_chk strlen __vprintf_chk level 3 __read_chk __strcpy_chk strlen __vprintf_chk
That table is the whole feature. Level 1 replaces strcpy with __strcpy_chk and read with __read_chk. Level 2 adds __vprintf_chk, because %n is a write primitive wearing a format-string costume. And if your binary calls __strcpy_chk you are fortified; if it calls strcpy you are not, whatever your build script says.
That makes FORTIFY the cheapest thing in this course to audit and the easiest to get wrong. One objdump and a pattern, no flags, no build system, no cooperation from the person who compiled it.
What the compiler is actually able to do, and why the mechanism is arithmetic rather than inspection:
And that immediately gives the precondition, which is the part that decides whether FORTIFY applies to your code at all:
So the feature has a shape worth memorising: it is a compile-time technique with a compile-time precondition. It cannot be retrofitted, it cannot be applied dynamically, and it declines to act whenever the information it needs is not there. Declining to act is the correct behaviour — a check against a guessed size would be worse than none.
The levels, from a specimen whose destination is a real local:
$ ./build_samples.sh 2>&1 | sed -n '/H4/,/H5/p'
level 0 no checks at all
level 1 strcpy -> __strcpy_chk, read -> __read_chk (size known)
level 2 adds printf -> __vprintf_chk, because %n is a write primitive
level 3 adds compile-time DIAGNOSIS for a known-too-large count;
this specimen has no such call, so 2 and 3 agree here.
Level 3 agreeing with level 2 is a fact about this specimen, not about level 3, and the distinction is the point. Level 3 differs from level 2 for a set of functions this program does not call. A reader who concluded “level 3 adds nothing” from this table would be wrong in general and right by accident, which is the most dangerous way to be wrong.
And now the retraction, which is in the build script and the crosscheck and here:
A RETRACTION, kept because it is the honest result: a known-size destination was expected to produce __memcpy_chk. clang 21 does NOT emit it -- both rows call plain memcpy. strcpy becomes __strcpy_chk reliably; memcpy does not, even with a statically known 4096-byte destination. The rule was not determined from source and is not claimed here.
The claim retracted was: a compile-time-known destination size makes FORTIFY substitute __memcpy_chk. Measured, it does not on clang 21. char b[4096]; memcpy(b, s, 2048); compiles to a plain memcpy call at _FORTIFY_SOURCE=3. strcpy is reliable; memcpy was not, for a reason this course did not determine from source and therefore does not assert.
This is worth dwelling on, because it is the most important methodological point in the course. “FORTIFY works” is a claim about a set of functions, and the set is not the set you would guess from the function names. If this course had asserted the tidy version, every reader would have learned a false thing that happens to be false in a way that is invisible until you look for the __memcpy_chk that is not there. A retraction in a build script is more useful than a caveat in prose, because the crosscheck asserts the retraction and will fail if a future compiler starts emitting it.
Since the trace is the imports, the audit is one pattern. And it is worth doing on binaries you have no build system for:
$ objdump -d /usr/bin/somebinary | grep -oE '<[a-z_]+@plt>' \
| sort -u | grep -E 'str(cpy|cat|cmp|ncpy)|mem(cpy|set)|sprintf|read|printf'
$ python3 harden.py /usr/bin/somebinary | grep FORTIFY
FORTIFY _chk imports __memcpy_chk, __printf_chk, __sprintf_chk, __strcpy_chk
Two observations worth making about that output. The set is mixed — a binary can import both strcpy and __strcpy_chk, because FORTIFY applies per call site and only where the size is known. And the presence of one _chk import says nothing about the calls that were left alone. A report that prints “FORTIFY: yes” is summarising four _chk calls as if they covered the program. The honest report is the list, which is what the artifact prints.
Now connect it to the canary, because the H2 table gives the comparison and it is the most useful thing in the course:
That table is the argument for reading a binary rather than a build script, stated as a mechanism. A policy that says “FORTIFY on” is a policy about an unknown fraction of your call sites; the table tells you which fraction.
$ cd courses/sec/assets/samples $ ./build_samples.sh 2>&1 | sed -n '/H4/,/H5/p' $ python3 harden.py fort_0 fort_2 $ python3 crosscheck.py 2>&1 | sed -n '/H4/,/H5/p'
Then find the boundary, which is the better exercise:
1. Take the FORTIFY table and make level 3
differ from level 2. You need a call
whose count the compiler knows and can
see is too large:
char b[8];
memcpy(b, huge_known_array, 4096);
Level 3 should DIAGNOSE it rather than
emit a check. What exactly does it say,
and is it an error or a warning? (This is
the difference between the two levels and
the table in H4 could not show it.)
2. Now make FORTIFY vanish without touching
any flag. Add -U_FORTIFY_SOURCE, or
compile at -O0. Which one removes it?
(Optimisation level: at -O0 the compiler
does not know the object sizes, so
FORTIFY has nothing to work with. FORTIFY
REQUIRES optimisation, which is a strange
and important dependency: your hardening
gets weaker when you compile for
debuggability.)
3. Heap buffers. Write:
char *p = malloc(16);
strcpy(p, argv[1]);
at every level. What appears in the
imports? (Nothing changes. FORTIFY cannot
help here, and this is the single most
important limitation to know -- because
heap overflows are what most real
exploitation looks like.)
4. Take a real system binary and count its
fortified and unfortified string calls
separately:
objdump -d /usr/bin/ls | grep -c 'strcpy@plt'
objdump -d /usr/bin/ls | grep -c '__strcpy_chk@plt'
Both may be non-zero. Report the ratio,
not a verdict.
5. Read the FORTIFY header on this system --
/usr/include/bits/fortify*.h -- and find
which functions are redirected at which
level. Then check your level-1 specimen
against that list. Does the specimen
match the header's table? (It should, and
if it does not, the header is describing a
different compiler than the one you have.
That is a real and common situation, and
it is why the course measures rather than
cites.)
Exercise 2 has the most valuable answer in the course, and it is not a bug. FORTIFY gets weaker when you lower the optimisation level. At -O0 the compiler does not track object sizes the way it does at -O1, so it has nothing to pass to __strcpy_chk and the substitution does not happen. Your debug build is therefore less fortified than your release build, using the same source, the same flags, and the same headers — and the difference is invisible unless you look at the imports.
Exercise 3 is the limitation that matters most in practice, and the course states it plainly rather than leaving it implied. FORTIFY is close to useless against heap overflows, because malloc’s size is a run-time value and the compiler has nothing to check against. If your threat model is heap corruption — and for most real exploitation it is — then the canary and RELRO are doing the work and FORTIFY is a bonus. Knowing which mechanism covers which bug class is the point of the H2 table, and it is a better answer than a list of flags.
The connection to the canary concept is the whole of the previous unit and it is the closest thing in this course to a genuine result rather than a survey. Two mechanisms, two different kinds of check, measurably different reach. That is only visible because both were turned on in one build and off in another, and the same three overflows were run against both. The general lesson is the one this collection keeps teaching in a new costume: to know what a feature covers, measure it against the thing it is supposed to catch — do not read its documentation.
The connection to the symbol-resolution course’s versioned-symbols concept is a nice piece of ABI plumbing. That course measured __strcpy_chk@GLIBC_2.3.4 as a versioned symbol and asked why the version matters. Here is the answer in a different key: __strcpy_chk is only a legal call if the runtime libc is new enough to provide it, and the version tag is how the dynamic linker enforces that. Adding a fortified call to a binary changes its minimum glibc requirement — a real deployment consequence of a compile-time flag, and one that belongs to the ABI rather than to the security story.
Two connections that close the module. The RELRO concept explained that -z now is what makes sealing the GOT possible, because the loader must finish writing first. FORTIFY is the same shape one layer down: it makes a check possible by moving a fact across a boundary. Both are “do the expensive thing at compile time so a cheap thing is possible at load or run time”, and both have a precondition that is easy to forget — a loader that has finished, a compiler that knew the size. And the posture concept is where this row of the report comes from, and it is a row that has to be an import list rather than a bit, which is the awkward one.
Finally, the honest boundary. This concept is glibc and x86-64 Linux. The __*_chk naming, which functions are covered, and the level structure are all glibc conventions; a different libc spells the idea differently and may cover a different set. The mechanism — pass a compile-time-known size across the ABI boundary so the library can check — is portable and the method transfers, but the retraction above is a reminder that the specific set is a property of a specific compiler version and has to be re-measured when that changes. The crosscheck does exactly that, which is the point of having written it.