You can now read the auxv. You cannot yet use it, because knowing that AT_PHNUM holds 0xe and knowing what to do with 0xe are different skills, and the second one is where programs go wrong.
Here is the trap, stated as a bug you can write today:
$ cd courses/img/assets/samples
$ cat > trap.c <<'EOF'
#include <stdio.h>
int main(void) {
/* found AT_PAGESZ, value 0x1000, and helpfully
printed it as a pointer */
unsigned long v = 0x1000;
printf("%s\n", *(char **)v);
}
EOF
$ clang -O1 -o trap trap.c && ./trap
Segmentation fault (core dumped)
AT_PAGESZ is 4096, not an address. It is a size. And the auxv gives you no way to tell, from the tag alone, which kind of value you are holding. This is not a subtlety in the documentation — it is a property of the data structure, and it is the single most common way an auxv reader produces garbage.
Twenty-two entries fall into four groups, and the groups differ in what you may do with the value. This is the classification that makes the auxv usable:
The reason this is dangerous is worth stating precisely, because it is a property of C and not of the kernel: every value in the auxv is one unsigned long. There is no type information, no length, and no discriminant. The kernel could not have made it otherwise — the array is (tag, value) pairs of machine words, and that is the ABI. So a reader that assumes “the value is an address because the auxv is addresses” is not making a small error. It is making an error that a compiler cannot warn about and a type system cannot prevent.
The practical consequence: you must know which tag you asked for. There is no way to ask the array “is this a pointer?” You write AT_PHDR and you know it is a pointer, because that is what AT_PHDR means. Every reference implementation keeps a static table saying so, and the table is the interface.
Twenty-two entries, and three of them do the work the previous five courses cared about. Everything else is context.
The load-bearing arithmetic. All three move on every run, because the kernel randomises where it puts things. That is fine — and it is checkable, because their differences are not supposed to move. They are pure file quantities:
$ python3 stackwalk.py 2>&1 | grep -E 'AT_ENTRY - AT_PHDR|AT_PHDR maps back' [ok ] AT_ENTRY - AT_PHDR == e_entry - e_phoff -- 0x10f0 == 0x10f0 [ok ] AT_PHDR maps back to e_phoff -- 0x40 vs 0x40
Three of the moving values, then, and one relationship that holds across every run. That relationship is the proof that the kernel translated the file rather than recomputed it, and it is the most useful single check in this course. Note the arithmetic, because it is not arbitrary:
AT_BASE is a different object, and conflating it with the others is the second big trap. It is not a field about your program at all. It is where the kernel put the dynamic linker — a separate file, /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2, that your program never names. The artifact checks that AT_BASE lands in a mapping whose path contains ld-linux and not in your own executable:
$ python3 stackwalk.py 2>&1 | grep -E 'AT_BASE|ET_DYN'
[ok ] AT_BASE lands in the dynamic linker, not in us
-- /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
[ok ] ld.so is ET_DYN, so it must be given a base -- e_type 3
And the reason it has to be passed separately is in that second line. Both your program and the loader are ET_DYN — the relocations course measured that a PIE is a shared object with a preferred base of zero — so neither has an entry point the CPU can use directly. The kernel loads each at a base of its choosing and has to tell someone. AT_BASE is how it tells the loader where the loader is.
That closes a loop the last two courses left open. The relocations course said a PIE cannot have absolute addresses in its data. This is why the kernel is the one who has to tell the program anything at all — and it is why a statically linked, non-PIE binary needs none of AT_BASE, AT_PHDR, or AT_ENTRY to be meaningful. The auxv is not overhead. For a position-dependent executable it is close to empty, and the kernel still builds it, because the kernel does not know what the program linked against.
The reference pattern, which is what stackwalk.py does and what any correct reader should look like. The important part is the table, not the loop:
That last line is not decoration, and it is the difference between a reader that fails and a reader that fails informatively. AT_PHDR being unmapped is a real failure mode — a statically linked binary on a system with no loader will have AT_BASE == 0, and a program that unconditionally dereferences it gets a fault at address zero. Checking before dereferencing converts a crash into a message, and for the auxv specifically it is nearly free because you can validate against /proc/self/maps, which the artifact already does for AT_ENTRY and AT_SYSINFO_EHDR.
Now the measurement, and it is the one that ties the kernel to the file with no slack in it at all. Read AT_ENTRY out of the auxv. Find which mapping contains it. That mapping has a file offset. The address’s offset within the mapping is its offset within the file. It must equal e_entry.
$ python3 stackwalk.py 2>&1 | grep -E 'AT_ENTRY' [ok ] AT_ENTRY lands in an r-x region -- 0x5a9b10f0f130 in .../imgdump [ok ] AT_ENTRY maps to the file offset of e_entry -- offset 0x1130, e_entry 0x1130
Four independent hops and it lands exactly on the number in the file. Kernel → auxv → address → mapping → file offset. Nothing in that chain trusts anything else, and every step is a place the previous five courses taught you to read: the r-xp permissions are the PT_LOAD flags from the segment-types concept, the file offset is the program header field from the program-header concept, and e_entry is the header field from the header-fields concept. This course adds no new file format. It supplies the one thing all of them were missing: the addresses.
$ cd courses/img/assets/samples $ python3 stackwalk.py 2>&1 | sed -n '/KERNEL DESC/,$p' $ python3 crosscheck.py 2>&1 | sed -n '/G2/,/G3/p' $ clang -O1 -o trap trap.c && ./trap; echo $?
Then find the boundaries, which is the better exercise:
1. Build the same program -static -no-pie and
run stackwalk.py's auxv dump against it.
Which of AT_PHDR, AT_ENTRY, AT_BASE still
work? (PHDR and ENTRY do -- they are about
YOUR file. AT_BASE should be 0, because
there is no loader. Now read that 0. This
is the concrete shape of "the auxv is a
kernel structure, not a linker one".)
2. Set the IS_POINTER table in stackwalk.py
to empty and run it. Count the crashes.
Then add ONLY tag 6 (AT_PAGESZ) and run
again. (This is the trap.c bug inside the
artifact, and seeing which check catches it
is the point.)
3. AT_ENTRY - AT_PHDR == e_entry - e_phoff is
the course's central check. Break it
deliberately: subtract AT_BASE instead of
AT_PHDR and see how far off you are. Then
compute what the correct base actually is:
base = AT_PHDR - e_phoff
and confirm AT_ENTRY == base + e_entry.
(You have just recovered the load base from
two auxv entries and a file header, with no
/proc involved at all. That is the kernel's
address-decision, solved.)
4. Print AT_HWCAP and look up which bits are
which. Then compare against /proc/cpuinfo's
flags. (They are the same feature set in
different vocabularies. AT_HWCAP is what a
program should use, because it is a machine
word and cpuinfo is text. This is a small
lesson that carries: use the interface that
was designed for the consumer.)
5. Run a setuid-root binary and diff the whole
auxv against a normal run. AT_SECURE and the
credential entries are the interesting ones.
(This is what AT_SECURE exists for, and it
is the only entry in the array that is a
WARNING rather than a fact.)
Exercise 3 is the best exercise in the course and it is worth doing slowly. Two auxv entries and 24 bytes of an ELF header are enough to recover the address the kernel chose — no /proc, no debugger, no readelf. That is the kernel’s entire ASLR decision, extracted arithmetically. And it reframes what ASLR is: not a mystery, but one number that a program can compute for itself and that an attacker cannot predict, which is exactly the property that makes it worth having and exactly why the relocations course treated it as a cost to be paid rather than a feature.
Exercise 1 is the one that closes the chain. A static binary needs no dynamic linker and still gets a full auxv. The kernel builds it unconditionally because the kernel is not a linker and does not know what the program will need. So the correct statement of the boundary is not “static binaries skip loading” — it is “static binaries skip the loader, and the kernel’s handoff is not the loader’s work.”
This is where module 1’s two concepts become usable. The Auxiliary Vector said the array exists and that it is unordered. Reading the Initial Stack showed how to walk it. This concept says what the words mean, and the answer — four groups, three of which you may not dereference — is why a correct parser needs a table rather than a loop. The three concepts together are what “parse a process image” actually means, and a reader who has all three can do it from nothing.
The connection to Rebuilding the Scope Yourself is a direct dependency, and the dependency runs in the direction people expect. That exercise asks the reader to parse DT_NEEDED out of a live process, and the only way to know where the program headers are is AT_PHDR. So the artifact in this course is a prerequisite for that one, not a repeat of it — it stops at the program header table, and the dyn course starts there and goes on to the dynamic section, the symbol tables, and the scope. Two artifacts, two layers, one program. That is the chain working the way the mission describes.
The connection to the PIE concepts is the deepest one in the course, and it corrects something. Both the program and the loader are ET_DYN, which the relocations course measured and this course explains the consequence of: a shared object has no entry point until someone gives it a base, and the kernel is the only one who can. The reloc course showed what that costs the compiler (one relocation per datum on x86-64, two on AArch64). This concept shows what it costs the kernel: it must communicate the base, and the only channel is an array on the stack.
And the security thread gets its sharpest statement here. AT_SECURE sits in group 3 — identity, not addresses — and it is the only entry in the array that is a warning. The kernel randomises the base and then tells the program whether the environment can be trusted. Those are the two halves of the same defence, and putting them in one array is a reminder that ASLR is a property of a process image, not of an instruction stream: the same binary gets a different layout every run, and the program is told enough to know whether to believe the strings it was handed.