Back to course

Reading the Initial Stack by Hand

26 minadvancedModule 1: The HandoffContinues from img-auxv
26 min · Module 1: The Handoff · Advanced

Why This Matters

The previous concept said the auxv is an array. Arrays are easy. This one is about the two things that make it not easy, and both of them are traps that produce a crash rather than a wrong answer, which is the friendly kind of bug and also the reason people never find them.

Trap one: %rsp inside main is not the stack pointer the kernel set. Trap two: the auxv is not the array after envp — it is the array after the NULL that endsenvp, and the difference is one word.

Neither is a C language matter. Both are facts about a data structure the kernel built, and the only way to hold them is to go and look.

$ cd /tmp && cat > bad.c <<'EOF'
  int main(void) {
      unsigned long *sp;
      __asm__ volatile ("mov %%rsp, %0" : "=r"(sp));
      long argc = (long)sp[0];
      char **argv = (char **)&sp[1];
      char **envp = argv + argc + 1;
      unsigned long *aux = (unsigned long *)envp;   /* WRONG */
      while (*aux++) { }                             /* walk to find the end */
      aux--;
      printf("%lx\n", (unsigned long)aux);
  }
  EOF
$ clang -O1 -o bad bad.c && ./bad
  Segmentation fault (core dumped)
$ echo $?
  139

That is the first version of this course’s own code, and it is wrong in the most instructive way available. It reads argc and argv correctly — they really are at the bottom — and then it treats envp as if it were the auxv and scans forward looking for a zero. The scan runs off the end of the arrays and into memory that is not an array at all.

A Simple Model

The layout, from low addresses to high. Everything the kernel built is in this one contiguous run of stack, and the order is not a matter of taste:

LOW +--------------------------+ | argc | one word +--------------------------+ | argv[0] | pointer | argv[1] | pointer | | ... | | argv[argc-1] | | NULL | <-- the terminator +--------------------------+ | envp[0] | pointer | ... | | NULL | <-- the terminator +--------------------------+ | AT_PHDR, addr | a PAIR | AT_PHENT, 56 | | AT_PHNUM, 14 | | ... | | AT_NULL, 0 | <-- the terminator HIGH +--------------------------+ ... but the STRINGS are ABOVE all of this: | ".../imgdump" | <- argv[0] points here | "SHELL=/bin/bash" | | 16 random bytes | <- AT_RANDOM points here | "x86_64" | | ".../imgdump" | <- AT_EXECFN points here +--------------------------+ top of the stack mapping

Two features of that picture cause both bugs.

The pointers point up, not down. The arrays are at the bottom of the frame and the strings are at the top, so walking argv[i] means dereferencing a pointer to a higher address. This is why you cannot infer the array length from where the strings start, and why the frame is not self-delimiting from the bottom alone.

There are three terminators, and they mean different things.NULL after argv means “no more arguments”. NULL after envp means “no more environment strings”. AT_NULL means “no more auxv entries”, and unlike the other two it is a tag of zero, not a pointer. Skipping one NULL-terminated array instead of two is the bug from the previous section, and the failure mode is specific: you land inside envp, read an environment string’s pointer as an auxv tag, and since a stack address is not zero the scan never terminates. It does not stop in the wrong place. It stops when it hits a mapped page that is not mapped, which is why the symptom is a segfault and not a garbage number.

Finding the Bottom

Before any of that is usable you need the stack pointer the kernel set, and %rsp in main is not it. The kernel builds the frame, then jumps to the entry point; libc’s _start pushes a return address and aligns the stack, then calls __libc_start_main, which calls main, which allocates a frame. By the time your first line runs, %rsp is kilobytes below where the kernel put it, and the relationship is not constant.

The kernel keeps the answer. /proc/<pid>/stat field 28 is startstack — the value the kernel itself used — and it exists for precisely this purpose.

field 2 comm "(imgdump)" <- may CONTAIN SPACES field 3 state ... field 28 startstack <- the one you want so you cannot count fields from the start of the line: a process named "my prog" breaks every naive field counter. the comm field is parenthesised, so start from the LAST ')'.

That last point is a real trap and it is worth stating as a rule: find the last ) in the line, then count from there. A program whose name contains a space — which is entirely possible — desynchronises any counter that starts at the beginning of the line, and field 28 becomes some other field entirely, which is usually a small integer, which becomes a wild pointer.

Now the second measurement problem, which is the one that cost this course the most time. startstack is the low end of the frame the kernel built, and the strings are above it, up near the top of the 8 MB stack mapping. How far up? It depends on your environment, and it is not something you can guess:

$ printf '\n' | ./imgdump | head -1
  STACK 7ffe5b141170 11920

11920 bytes, and that is not a number anybody chose. It is high_end_of_stack_mapping - startstack, which is what the dump has to be, because the strings are at the top of that mapping and reading past the high end is a fault. Two guesses both fail, and both were tried here:

So the only correct size comes from the kernel: read /proc/self/maps, find the mapping containing startstack, and dump exactly to its high end. That is 15 lines in imgdump.c and it is the difference between a parser that works and one that works until the environment is large.

Doing It For Real

Three steps, each of which the artifact performs on every run. The whole program is stackwalk.py; this is the part that matters.

One: get the bytes.imgdump reads its own startstack, copies the frame out, prints it as hex, and then blocks on stdin. That block is not decoration. It means the parent can read /proc/<pid>/maps and /proc/<pid>/cmdline of a process that has printed bytes and executed nothing else — so all three observations describe the same instant, and comparing them is sound.

/* imgdump.c, the only part that is not parsing */ unsigned long hi = stack_end_for(read_startstack()); size_t N = hi - ss; if (N > 65536) N = 65536; memcpy(raw, (void *)ss, N); printf("STACK %lx %zu\n", ss, N); ... hex ... fgets(line, sizeof line, stdin); /* hold still */

Two: parse with no help.argc is one signed word. Then argc pointers. Then a word that must be zero. Then pointers until a zero. Then pairs of words until a zero tag. Every step is a check, because each one is a place to be wrong:

argc, = u64(raw, 0) if argc < 0 or argc > 4096: raise "implausible argc" # 3. the array count, and the layout, are # the kernel's. An argc of 3000000 means # you started in the wrong place, and the # only symptom would be a segfault. off = 8 for _ in range(argc): argv.append(cstr(u64(raw, off))); off += 8 assert u64(raw, off) == 0; off += 8 # the first terminator while u64(raw, off): envp.append(cstr(u64(raw, off))); off += 8 off += 8 # the SECOND terminator # ^ THIS is the line the first version # did not have, and it is the whole bug. while True: tag, val = u64(raw, off), u64(raw, off + 8); off += 16 if tag == 0: break # 4. step 16, not 8. step 8 and every value # becomes a tag.

Three: check it against something independent. This is the step that makes the parser trustworthy rather than merely plausible. The auxv is data handed to you by the kernel; the ELF file is data on disk. If the kernel is describing a real load of a real file, the two must agree, and if they do not you have found something.

$ python3 stackwalk.py 2>&1 | sed -n '/KERNEL DESC/,$p'
  [ok  ] AT_PHENT == e_phentsize in the file  -- auxv 56, file 56
  [ok  ] AT_PHNUM == e_phnum in the file  -- auxv 14, file 14
  [ok  ] AT_ENTRY - AT_PHDR == e_entry - e_phoff  -- 0x10f0 == 0x10f0
  [ok  ] AT_ENTRY lands in an r-x region  -- 0x5a9b10f0f130 in .../imgdump
  [ok  ] AT_ENTRY maps to the file offset of e_entry  -- 0x1130, 0x1130
  [ok  ] AT_PHDR maps back to e_phoff  -- 0x40 vs 0x40
  [ok  ] AT_BASE lands in the dynamic linker, not in us
  [ok  ] AT_EXECFN names the file the kernel actually ran
  [ok  ] AT_SYSINFO_EHDR == the base of the [vdso] mapping
  [ok  ] AT_RANDOM points into the initial stack we hold
  18/18 checks passed

Read the third line again, because it is the one that makes this a parser rather than a dump. AT_ENTRY - AT_PHDR == e_entry - e_phoff, both 0x10f0 — the left side from the kernel’s stack, the right side from 24 bytes of an ELF header this script parsed itself. And the two checks below it close the loop the other way: it maps AT_ENTRY through /proc/<pid>/maps to a file offset, and that offset is 0x1130, which is e_entry. Kernel → address → mapping → file offset → file. Four hops, and it lands on the number in the file.

Try It Yourself

$ cd courses/img/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/I8/,$p'
$ python3 stackwalk.py --dump 2>&1 | head -30
$ python3 crosscheck.py 2>&1 | tail -13

Then break it deliberately, which is the only way to learn where the edges are:

  1. Change the envp skip from "e + 1" to
     "e" in parse_image. Run it. It does not
     fail cleanly -- it walks off the end. Read
     the first tag it prints and work out which
     array it came from. (It is an environment
     string's address, read as a tag. Every
     such tag is huge and unaligned, and that
     pattern in the output is the fingerprint of
     this bug.)

  2. Change the auxv loop from "a += 2" to
     "a += 1". Run it. Every VALUE is now read
     as a tag. Count how many nonsense tags you
     get before the loop stops, and check: is
     AT_ENTRY one of them? (It is. That is the
     bug that makes a working parser look like it
     has a memory problem.)

  3. Count the envp in stackwalk.py's output,
     then run the same program with a 200-entry
     environment. Re-run ./imgdump and look at
     the byte count in the STACK header. Then
     put a 4 KB window back into imgdump.c and
     try it with that environment. (It breaks.
     The window must come from the mapping.)

  4. /proc/self/stat: rename the test binary to
     one with a SPACE in its name and run it.
     Then un-harden the field counter to start at
     the beginning of the line and run it again.
     (Field 28 becomes something else, and the
     resulting pointer is small. This is why the
     rule is "start from the last ')'".)

  5. Skip the stdin block in imgdump and try to
     read /proc/<pid>/maps from the parent while
     the child is running. (It works -- but only
     because the child is short. The block is what
     makes the two observations simultaneous
     rather than nearly simultaneous, and
     "nearly" is not a property you want to rely
     on when ASLR is moving things.)

Exercise 4 is the one that generalises past this course. A process name containing a space is not exotic — it is one execve call with a different string — and any tool that parses /proc/<pid>/stat by counting whitespace-separated fields from column one is wrong for it. This is a bug class, not a curiosity, and it shows up in profilers, container runtimes, and anything that reads process metadata.

Exercise 3 connects to the previous concept. The frame size is a function of your environment, and it is measured, not assumed — 11920 bytes here with 64 environment entries. That is the kernel doing string work proportional to what you passed it, and it is a nice illustration that the initial stack is a real allocation, not a fixed structure. A shell with a huge environment makes every process it starts measurably slower to create, and this is why that is true.

Connect

Within module 1 this is the other half. The Auxiliary Vector established what the third array is and why no C function returns it. This concept establishes where it is and how to walk it, which is the difference between knowing a thing exists and being able to read it — and in this chain, the second is the point. The course mission is parsing to a working executable, and this is the first concept where the thing being parsed is produced by something that is not a compiler.

The connection to the ELF course’s memory-layout concept is the sharpest correction in the course. That concept showed a diagram of a process address space with the stack at the top, the heap below it, and a gap between. That diagram is right and it is static, and this is where it becomes real: the top of the stack is not a fixed address, the frame at the bottom of it is not a fixed size, and both are chosen at run time.Where the Kernel Put Everything takes the diagram apart and measures it.

The connection to the dynamic linking course’s breadth-first concept is a mechanism the reader has already used without seeing. That course measured the loader’s load order and found it breadth-first, and the loader got its starting point from AT_PHDR. So the loader does exactly what stackwalk.py does: it reads a data structure the kernel built, by hand, with no API, at addresses it did not choose. Everything the loader does for the first few microseconds of a process’s life is this concept’s content, running in C, one layer down. That is worth saying plainly, because it collapses the mental distance between “a Python script that reads a stack” and “the thing that starts every program on Linux”.

Two traps from this concept reappear later as the same trap in a different costume. Stepping through pairs one word at a time is the auxv loop’s bug; it is also what happens if you assume DT_* entries in the dynamic section are an array of 16-byte records without checking the size field — which is exactly what Rebuilding the Scope Yourself had to get right. And finding the last ) before counting fields is the same instinct as reading e_shstrndx before indexing section names: the container tells you where the variable-length part starts. Two different formats, one rule, and the rule is worth having as a rule.

render_lesson_js(&mut page)