Back to course

Reading a Binary's Posture

25 minadvancedModule 4: Read It YourselfContinues from sec-cet
25 min · Module 4: Read It Yourself · Advanced

Why This Matters

Six concepts, one artifact, and a habit. The artifact is a reader: given a binary and no build system, it reports what hardening the file actually contains. It does not use readelf, objdump, or a compiler — it parses the ELF header, the program headers, the section table, the dynamic array, the string table and a note, with struct.unpack_from and nothing else.

$ cd courses/sec/assets/samples
$ python3 harden.py relro_partial
  ET_EXEC, EM_X86_64, 14 program headers, 30 sections
  stack executable?                  no   [PT_GNU_STACK flags RW-]
  RELRO region                       0x403df8..0x404000
  RELRO tier                         partial
    .got.plt section                 present
    covered by RELRO                 NO
    ends on a page                   yes
  writable text (TEXTREL)            no
  stack canary refs (%fs:0x28)       0
  FORTIFY _chk imports               none
  CET IBT (indirect branch tracking) False
  CET SHSTK (shadow stack)           False
  endbr64 instructions in the file   6
  position independent               NO (ET_EXEC)
  6/10 checks
  WEAKNESSES: NOT PIE, partial RELRO only, no stack canary

And the line that makes it a tool rather than a demo is the fourth from the bottom: covered by RELRO: NO. Not “partial”, which is a label the file claims about itself, but the geometric fact that the GOT section does not fit inside the sealed range.

A Simple Model

Every feature this course measured leaves its trace in a different part of the file, and the whole design of a posture reader follows from taking that seriously.

what to check where the answer is how it is stored ------------------ ------------------- --------------- stack executable? PT_GNU_STACK 1 bit in a flag RELRO tier PT_GNU_RELRO a range + DT_FLAGS a claimed bit GOT actually sealed section table GEOMETRY vs PT_GNU_RELRO TEXTREL DT_TEXTREL a tag stack canary the instruction BYTE PATTERN stream FORTIFY DT_STRTAB an IMPORT NAME CET PT_GNU_PROPERTY a nested note PIE e_type 2 bytes notice the pattern. only TWO of these are a single bit or byte in a fixed place. the rest need the file's geometry, or its string table, or a byte search.

That pattern is the reason “read the hardening flags” is not a method. Two of eight features are bits, and they are the two easiest to get wrong — because a bit is a promise and a byte pattern is a fact. A posture tool that reads only bits reports what a binary says about itself; one that also does the geometry and the searches reports what it contains.

So the artifact has two kinds of check and the distinction is deliberate:

CLAIMED a bit, a tag, a note. cheap, fast, and exactly as trustworthy as the producer. PROVEN the geometry, the coverage, the instruction count, the import list. costs a parse, and cannot be satisfied by setting a bit. a report that only has CLAIMED checks is an echo. the interesting rows are the PROVEN ones, and they are the ones this course spent its concepts on.

Why Each Check Names Its Source

Every line of the report says where its answer came from, and that is not decoration. A check that reads the same byte twice and calls it two checks is worse than no check, because it agrees with a wrong answer and you have no way to tell. This is the same trap the image course walked into with /proc/self in a pipeline, and the same one the RELRO concept hit when the tier said “full” and the geometry said otherwise.

the checks, and whether each is INDEPENDENT of the others or not: e_type -> ELF header PT_GNU_STACK -> segment table ] different DT_TEXTREL -> dynamic array ] tables, DT_STRTAB -> string table ] and they PT_GNU_PROPERTY -> note ] can .got.plt -> section table ] DISAGREE PT_GNU_RELRO -> segment table ] %fs:0x28 -> instruction bytes ] not a endbr64 -> instruction bytes ] table at __*_chk -> string table ] all the cross-TABLE comparisons are the valuable ones. "the note says IBT" and "there are endbr64" are two facts about two different structures, and a mismatch between them is a real finding rather than a redundancy.

And the --explain output says all of this in one screen, because a tool whose reasoning is invisible is a tool you have to take on faith — which is the thing this course exists to stop doing.

$ python3 harden.py --explain
  Each check names the SOURCE it read. A report that reads one byte twice
  and calls it two checks is worse than no report, so the sources here are
  deliberately different parts of the file:
    stack executable?   PT_GNU_STACK flags          (segment table)
    RELRO tier          PT_GNU_RELRO + DT_BIND_NOW  (segment + dynamic)
    RELRO covers GOT?   section table vs segment    (two different tables)
    TEXTREL             DT_TEXTREL                  (dynamic array)
    stack canary        %fs:0x28 in the bytes       (instruction stream)
    FORTIFY             DT_STRTAB                   (string table)
    CET                 PT_GNU_PROPERTY             (note)
    PIE                 ELF header e_type           (the header itself)
  The canary check is the one that surprises people: it is a byte pattern,
  not a flag, because the stack protector leaves no trace in any header. The
  same is true of FORTIFY -- its trace is an IMPORT, not a bit.

The last two lines are the ones to keep. The stack protector and FORTIFY both leave no header field at all, so a tool built on the assumption that hardening is a set of flags will report “unknown” for two of the most widely deployed features in the world. That is not a limitation of the tool; it is a fact about the features, and finding it out is the argument for reading the file rather than the documentation.

Running It On Things You Did Not Build

The real use is not your own binaries. It is everything else:

$ python3 harden.py /usr/bin/ls /usr/lib/x86_64-linux-gnu/libc.so.6
$ python3 harden.py --json mx_all | python3 -m json.tool
$ for f in build/*; do python3 harden.py "$f" | tail -1; done

Three uses, in increasing order of value. Auditing a dependency — you did not build it, so your build flags mean nothing to it, and the only evidence is the file. Comparing two builds — the H7 matrix in the build script is exactly this, and it is how the clang/gcc divergence was found. Failing a build — and this is the one that pays for itself, because hardening flags regress silently.

Nobody removes -z now on purpose. A link line gets rebuilt from variables, one flag falls out, and the binary still compiles, still runs, and is quietly weaker. The symptom of that change is not a build failure, so it needs a test rather than a review. The artifact exits non-zero, prints a WEAKNESSES line naming what is missing, and a CI job can gate on it.

One thing the report deliberately does not do, which is worth naming because every hardening tool eventually gets asked to. It does not produce a score. “7 out of 10 hardened” invites trading one feature for another, and the features are not commensurable — RELRO is nearly free, -z now costs startup time, CET is all-or-nothing across a whole process, and the canary catches a specific bug class. A list of named properties with their sources is honest. A number is a category error wearing a decimal point.

Try It Yourself

$ cd courses/sec/assets/samples
$ ./build_samples.sh
$ python3 harden.py --explain
$ python3 harden.py drv_gcc drv_clang mx_all mx_plain
$ python3 crosscheck.py 2>&1 | tail -14

Then do the thing that turns it into a habit:

  1. Run it on a binary you did not build and
     compare with what its documentation
     claims. Pick a system library:
       python3 harden.py /usr/lib/x86_64-linux-gnu/libc.so.6
     Does the distribution's hardening page
     agree? Any disagreement is either a
     documentation bug or a per-package
     exception, and both are worth knowing.

  2. Now prove the tool can fail. Break a
     claim it makes and check the crosscheck
     notices:
       python3 -c "
       import re
       s = open('harden.py').read()
       s = s.replace('(lo + sz) <= hi', 'True')
       open('/tmp/h2.py','w').write(s)"
       python3 /tmp/h2.py relro_partial
     (The coverage check now claims YES for a
     binary where it is NO, and nothing catches
     it -- because you changed the CHECK, not
     the data. A checker that has never failed
     is not known to work. Break the DATA
     instead and confirm the check fires.)

  3. Add a check the tool does not have. A
     good one: does every PT_LOAD that is
     writable have a corresponding page inside
     the RELRO range, and is every
     .data.rel.ro section inside it? That is
     the W^X concept's positive check, and it
     catches a linker that emits the section
     outside the sealed range.

  4. Make the report answer a question it
     currently cannot. "Is this binary at least
     as hardened as that one?" requires
     comparing two reports, and the naive
     version counts features. The honest
     version asks, per feature, whether the
     state changed -- and that is a PARTIAL
     ORDER, not a number, because a binary
     can be better in one row and worse in
     another. Try it. The absence of a total
     order is the finding.

  5. Finally, the exercise that is really the
     course: take a hardening CLAIM from a
     project you work on -- a README, a CI
     config, a wiki -- and check it against a
     binary that project produced. Write down
     every claim that does not hold, and for
     each one, name which of the three layers
     from sec-defaults it belongs to. (That
     last step is the whole course in one
     question: a false claim about hardening is
     almost never a mistake about security.
     It is a mistake about which tool decided
     what.)

Exercise 4 has the most interesting answer and it is a result rather than an exercise. Hardening properties are only partially ordered. A binary can be stronger than another in RELRO and weaker in FORTIFY, and neither dominates — so “is this at least as hardened” has no total-order answer, only a per-row comparison. That is the formal reason the tool refuses to emit a score, and it is a better reason than “scores are bad”. A number would have to invent a weighting the properties do not support, and the weighting would be the tool author’s opinion dressed as a measurement.

Exercise 5 is the one that makes the course pay off, and it is worth doing slowly. Every false hardening claim you find belongs to one of three layers — codegen, linker, or driver — and naming the layer tells you where to look and who can fix it. A claim about the canary is a codegen question. A claim about -z now is a link-line question. A claim that “our builds are hardened by default” is almost always a driver question, and it is the one nobody owns.

Connect

This concept is the payoff for the chain, and the connection runs the full length of it. The program-header concept taught the segment table. The section-header concept taught the section table and the string table index. The dynamic-section concept taught DT_* tags and the rule that the array is 16 bytes per entry. Every one of those is a source this artifact reads, and it reads them with struct.unpack_from rather than shelling out. That is the mission stated as a program: a format, parsed by hand, into a working executable, with no dependency on the tool that already understands it.

The connection to the image-loading course’s artifact is structural and it is the strongest argument for the whole collection. That artifact is a reader too — of a process image rather than of a file — and it made the same discipline its centre: every value checked against a source that did not produce it. Here the sources are the segment table, the section table, the dynamic array, the string table, a note, and the instruction stream. Two courses, two artifacts, one rule, and the rule is the thing worth carrying out of this collection rather than any individual finding.

Two connections that are about what the tool cannot see. The CET concept established that the shadow-stack half is configured by the loader through the thread control block, so reading the binary does not settle whether the property holds — and this report says so rather than printing a confident False. And the auxv concept established that the canary’s per-thread secret comes from kernel-supplied randomness, so %fs:0x28 is a count and not a check: this tool can say a binary has three tripwires and cannot say the value is unpredictable. Two features where the file is genuinely not the whole story, both stated as limits instead of guessed at.

And the connection forward, which is the next course in the list. Everything measured here is a mitigation, and mitigations are inputs to a policy. Knowing that clang defaults the stack protector off, that -z now is the difference between a sealed and an unsealed GOT, and that FORTIFY does not touch heap buffers is what makes a defensible hardening policy writable — because a policy that says “enable all the flags” is not a policy, it is a superstition with a build script. The next course is about turning these measurements into one, and it can only do that because this one established what each switch actually does.

render_lesson_js(&mut page)