Multiple ways to find the auxiliary vector (auxv)

I keep running into the auxiliary vector from different angles — a shellcode that needs the vDSO, a getauxval call that returns a value I didn’t expect, a stripped static binary where none of the usual tricks apply — and every time I end up re-deriving how to get at it from scratch. So this is me writing it all down once: what the auxv is, and every way I know to find it, from the one-liner you type at a shell prompt down to the “we have no libc and no envp, scan the stack and pray” method.

Everything here is x86-64 Linux with the System V AMD64 ABI.

What the auxv actually is

The man page for getauxval(3) calls it

a mechanism that the kernel’s ELF binary loader uses to pass certain information to user space when a program is executed.

That’s accurate but undersells why it exists. When the kernel’s ELF loader (fs/binfmt_elf.c) hands control to a new process, the dynamic linker hasn’t run yet — there is no libc, no TLS, no resolved symbols, nothing. But ld.so still needs to bootstrap itself, and to do that it needs facts that only the kernel knows: where did I map the program headers? Where’s the vDSO? What’s the page size? Is this process privileged? The auxv is the kernel’s answer to “how do I tell the not-yet-initialized C runtime everything it needs before it can ask anyone.” It’s a small array of typed key/value pairs pushed onto the initial stack, right next to argv and envp.

The entries are Elf64_auxv_tstruct:

A few of the ones worth knowing, with their numeric tags, because you’ll be matching on the raw numbers later:

Tag#Meaning
AT_PHDR3address of the program’s ELF program headers
AT_PHENT4size of one program header (0x38 = sizeof(Elf64_Phdr))
AT_PHNUM5number of program headers
AT_PAGESZ6page size (0x1000 on x86-64)
AT_BASE7base address of the interpreter (ld.so)
AT_ENTRY9entry point of the program
AT_UID / AT_EUID / AT_GID / AT_EGID11–14the four credential IDs
AT_HWCAP16CPU capability bitmask
AT_CLKTCK17times() tick frequency (100 on x86)
AT_SECURE23was this a “secure” (setuid-like) exec?
AT_RANDOM25pointer to 16 kernel-generated random bytes
AT_SYSINFO_EHDR33base of the vDSO’s ELF header
AT_EXECFN31pointer to the pathname used to exec
AT_NULL0terminator ({0, 0})

The full, canonical list lives in the kernel’s include/uapi/linux/auxvec.h, with the architecture-specific additions in arch/x86/include/uapi/asm/auxvec.h (x86 adds AT_SYSINFO_EHDR and, for 32-bit, AT_SYSINFO).

AT_RANDOM is the one people get excited about: those 16 bytes are where glibc draws the stack canary and the pointer guard from, so it’s a recurring target in exploitation writeups.

If you want to see exactly how the kernel builds the array, it’s create_elf_tables() in fs/binfmt_elf.c. The ordering is set there and it’s stable: ARCH_DLINFO first (on x86-64 that’s AT_SYSINFO_EHDR for the vDSO, plus AT_MINSIGSTKSZ), then AT_HWCAP, AT_PAGESZ, AT_CLKTCK, AT_PHDR, and so on. That ordering is going to matter when we get to the stack-scanning method, so hold onto it.

The layout at the entry point

This is the picture everything else hangs off of. When the kernel transfers control to _start, RSP points directly at argc, with argv, envp, and the auxv laid out above it toward higher addresses:

So the auxv sits immediately after envp‘s NULL terminator, which itself sits immediately after argv‘s NULL. Two consequences fall straight out of this and drive most of the methods below:

  1. If you have envp, the auxv is “walk to the NULL, step one past it.”
  2. The whole block only exists on the main thread’s initial stack. A thread created with clone(2) gets a fresh stack with none of this on it — which, as we’ll see, is exactly where the stack-scanning method falls off a cliff.

Now, the methods, roughly in order of “civilized” to “feral.”

Method 1: LD_SHOW_AUXV=1 (the shell one-liner)

glibc’s dynamic linker will dump the whole vector for you if you set an environment variable:

This is handled in ld.so: the env-var parsing lives in elf/rtld.c, which dispatches SHOW_AUXV to _dl_show_auxv() in sysdeps/unix/sysv/linux/dl-sysdep.c.

It’s commonly said this “doesn’t work for setuid/setgid and statically linked binaries,” and both halves of that are true, but for two completely different reasons, and the distinction is worth having straight:

  • Static binaries: there is no ld.so in the process at all, so nobody parses LD_SHOW_AUXV. The variable is simply inert, so a statically linked binary run with LD_SHOW_AUXV=1 prints nothing.
  • “Secure-exec” binaries:ld.sois present, but it refuses to act on LD_SHOW_AUXV. The gate isn’t the setuid bit as such — it’s the kernel’s AT_SECURE flag, which the security subsystem raises whenever an execvegains privilege relative to its caller. The capability LSM sets it when a setuid/setgid actually changes euid/egid, or for file-capability (setcap) binaries; SELinux adds it on domain transitions (gated by the process:noatsecure permission); other stacked LSMs can pile on too. glibc copies AT_SECURE into __libc_enable_secure, and when it’s 1 the loader runs process_envvars_secure() instead of the normal path and unsetenv()s a whole blacklist of dangerous variables — LD_SHOW_AUXV among them, listed in sysdeps/generic/unsecvars.h. So the variable is stripped before it can do anything. The important part is “privilege relative to the caller”: a setuid binary is not automatically secure. Run a setuid-root program as an unprivileged user and euid jumps 1000 → 0, so AT_SECURE=1 and LD_SHOW_AUXV is dropped. Run the same setuid-root program as root and nothing is gained — euid stays 0, equal to your real uid — so AT_SECURE=0 and it dumps the vector just fine.

That second mechanism (glibc rewriting environ for a secure exec) is going to come back and wreck one of the later methods, so flag it mentally.

It’s also glibc-specific. musl has no LD_SHOW_AUXV — its dynamic linker honors only LD_LIBRARY_PATH and LD_PRELOAD and never dumps the vector — so there you’d reach for one of the methods below. getauxval is one of them: musl has provided it since 1.1.0 (and, per the note in Method 3, without glibc’s AT_HWCAP rewrite).

Method 2: read /proc/self/auxv directly

The kernel exposes the raw vector as a file. It’s a flat array of Elf64_auxv_t structs, i.e. pairs of 8-byte little-endian words, terminated by the {0, 0} pair. A hexdump incantation does the job:

The left column is a_type, the right is a_val. If you don’t have hexdump handy, od does the same job:

A couple of things to know about this file. The backing store is mm->saved_auxv — the kernel keeps its own copy of the vector hanging off mm_struct, and fs/proc/base.c (auxv_read) just serves that copy. That has a subtle implication: it’s the snapshot the kernel took at execve, not the vector the process is running with. If something in the process patches the on-stack auxv after the fact (and ld.so does exactly that — more below), /proc/self/auxv still shows the pristine kernel values. The two can legitimately disagree.

/proc/<pid>/auxv for another process is mode S_IRUSR and gated by PTRACE_MODE_READ_FSCREDS, i.e. the same “am I allowed to ptrace this task?” check, run in the FSCREDS variant because the access comes through a filesystem open(), so it compares your fsuid (your filesystem identity, which is what file operations use) against the target rather than your real uid. In practice, that means you can read it for your own processes but need ptrace-level access (same UID, or CAP_SYS_PTRACE) for someone else’s. /proc/self/auxv is always readable by the process itself, which is what makes this method so reliable: no libc, no special privileges, just an open/read.

Method 3: getauxval(3)

If you’re already linked against glibc (≥ 2.16) or musl, this is the civilized API:

It returns 0 and sets errno = ENOENT if the tag isn’t present, so if 0 is a legitimate value for your tag (it is for several), clear errno first and check it after.

Here’s the gotcha that cost me an afternoon once, and that I’d never seen written down until I read the source. getauxval does not always return what the kernel put in the auxv. For AT_HWCAP and AT_HWCAP2 it short-circuits: instead of scanning the vector it returns glibc’s own internal GLRO(dl_hwcap) / GLRO(dl_hwcap2) — see misc/getauxval.c.

Those internal fields start as exact copies of the kernel’s values. Early in startup _dl_parse_auxv seeds every dl_hwcapN straight from the auxv — GLRO(dl_hwcap) = auxv_values[AT_HWCAP], GLRO(dl_hwcap2) = auxv_values[AT_HWCAP2], and so on. The divergence happens afterwards, and only on one field: on x86 cpu-features.c overwrites dl_hwcap with a tiny homegrown bitmask (defined in sysdeps/x86/dl-hwcap.h: bit 0 = SSE2, bit 1 = “x86-64”, bit 2 = AVX512) rather than leaving the kernel’s CPUID feature bits in place. dl_hwcap2 is never touched, so it still holds the kernel value. The result is stark:

So on x86-64, if you want the kernel’s actual AT_HWCAP bitmask, getauxval(AT_HWCAP) is the wrong tool — go to /proc/self/auxv or walk the stack vector yourself. Every other tag is a straight linear scan of GLRO(dl_auxv), so those are faithful. And this is a glibc quirk specifically: musl’s getauxval special-cases only AT_SECURE and scans the vector for everything else, so on musl getauxval(AT_HWCAP) returns the raw kernel bitmask — the same value /proc/self/auxv shows.

Method 4: prctl(PR_GET_AUXV)

Newer and underused: since Linux 6.4 you can ask the kernel for the vector directly, no /proc required (handy in a seccomp sandbox that blocks openat but allows prctl):

It copies min(sizeof(mm->saved_auxv), your_len) bytes into your buffer and returns the full size of the kernel’s saved vector, so you can detect truncation. On my box it returned 416, i.e. 26 elements (416 / sizeof(Elf64_auxv_t)), which is the same as AT_VECTOR_SIZE on my platform. Like /proc/self/auxv, it serves mm->saved_auxv, so it gives you the same execve-time snapshot. Implementation is prctl_get_auxv() in kernel/sys.c — it’s a dozen lines.

While we’re here: the write-side counterpart, prctl(PR_SET_MM, PR_SET_MM_AUXV, ...), lets a process with CAP_SYS_RESOURCE overwrite mm->saved_auxv. Note what that does and doesn’t touch — the kernel comment is explicit that it “doesn’t move the auxiliary vector itself since it’s pinned to mm_struct“. It changes only the saved copy that /proc and PR_GET_AUXV report; the array already on the stack, and glibc’s cached dl_auxv pointer, are untouched. So this is a way to make the introspection interfaces lie while the real vector the process bootstrapped from stays put — a fun detail for anyone doing forensics against a /proc/<pid>/auxv they assumed was ground truth.

Method 5: from the entry point, in asm

Now we leave the libc behind. This is the shellcode case: you’re running at (or near) _start, RSP points at argc, and you want auxv with no imports. Walk past argc, past argv and its NULL, then scan envp to its NULL; auxv begins one word later.

The reflex is to end it with a ret, and then you spend a few minutes figuring out why it segfaults. As genuine entry-point shellcode nothing called you, so there’s no return address on the stack — [rsp] is argc — and that ret pops the small integer argc as RIP. Drop it: you were jumped to, not called, so just fall through with the result live in RAX.

The arithmetic: with RSP → argc, argv starts at rsp+8, there are argc pointers plus a NULL ((argc+1)*8 bytes), so envp[0] lands at rsp + 8 + (argc+1)*8 = rsp + argc*8 + 16.

Method 6: from envp or environ, in C

Same idea as Method 5, but you already have envp from main (or __libc_start_main handed it to you):

There’s a tempting shortcut: if envp isn’t around, glibc exports the global environ (man 7 environ), and the code is the same. That’s mostly true, and it’s where a loud warning belongs, because the trap is easy to hit and the mechanism is right there in the source.

environ is only equal to the kernel’s on-stack envp until the first time you add a variable. setenv/putenv (stdlib/setenv.c) can’t grow the array the kernel left on the stack, so the first add copies the entries into a heap array glibc manages and repoints environ at it. (A plain overwrite of an existing variable doesn’t grow the array and leaves environ put; and once it’s on the heap glibc over-allocates and reuses that array, so it needn’t move again — but it never returns to the stack.) The moment that happens, environ no longer sits on the initial stack, the auxv is nowhere near it, and get_auxv(environ) walks off into the heap and returns garbage:

So: use the envp argument that came in from main/_start, which keeps pointing at the original stack array. Only fall back to environ if you must, and only before anyone has touched the environment.

There’s a nastier cousin of this for setuid binaries, and it ties back to Method 1. When ld.so strips the LD_* variables from a secure exec, it does so with unsetenv, which in glibc compacts the array in place — it shifts the surviving pointers down and writes an earlier NULL terminator, leaving stale slots between that new NULL and where the auxv actually starts. After that, “walk envp to the first NULL and step one past” lands you on a stale slot, not on auxv[0]. I ran a root-owned setuid binary with LD_SHOW_AUXV and LD_LIBRARY_PATH in its environment and watched get_auxv(envp) return an auxv whose first entry was AT_NULL — i.e. an apparently empty vector — while /proc/self/auxv, getauxval, and the stack scanner all still found the real thing 16 bytes higher up. If you’re reversing something that runs setuid, don’t trust the envp-walk; it can silently hand you an empty vector.

The reason this method matters: you can run it before main. get_auxv only needs an envp, and a function in the executable’s .init_array is handed one before main ever runs. glibc calls every constructor with the process’s (int argc, char **argv, char **envp) — see call_init in csu/libc-start.c, where the array is typed void (*[])(int, char **, char **). So a constructor is the earliest point in an ordinary (non-shellcode) program where you can read the auxv:

.preinit_array runs even earlier — ahead of the ordinary constructors, same signature — but only for the main executable, never a shared library.

Both beat main, and both get the still-on-stack envp (glibc hands constructors the global __environ). That dodges the setenv-relocation trap, since no user code has run yet to move environ to the heap. It does not dodge the setuid case above: ld.so strips LD_* during its own startup (dl_main → process_envvars_secure → unsetenv, all before your _start), and unsetenv compacts the array in place rather than reallocating, so on an AT_SECURE binary a constructor’s envp-walk hits the same stale NULL and fails exactly as in main. For those, use /proc, getauxval, prctl, or the stack scanner.

The (argc, argv, envp) handoff is glibc’s doing, not the C standard’s — worth remembering if you care about musl. musl calls .init_array entries with no arguments (same for static binaries), so a three-argument constructor reads garbage there. On musl, use environ inside the constructor instead — it’s valid that early — or just call getauxval.

Method 7: scan the stack for known invariants

Last resort. No argc, no envp, no libc, no /proc, no prctl — you just have a pointer somewhere on the main thread’s stack and you need the auxv. It sits near the top of that stack, so scan upward from wherever you are and recognize it by content.

What makes it recognizable: the kernel’s entry ordering is hard-coded in create_elf_tables() and has been stable for years, so a couple of entries are environment-invariant — AT_PAGESZ is always 0x1000 (4 KiB pages on x86-64) and AT_CLKTCK is always 100 (USER_HZ). Those two values plus a little structure are a fingerprint that effectively never occurs by accident.

Call it with the address of any local — int x; find_auxv_by_scan(&x); — and it hands back &auxv[0].

The pieces that make it correct:

  • Scan upward. The stack grows down, so your frame is below the auxv the kernel built near the top; you walk toward higher addresses.
  • 8-byte alignment. auxv[0] is only guaranteed 8-byte aligned, not 16. (The 16-aligned landmark on the stack is argc; the auxv sits a variable number of 8-byte words above it, so it’s 16-aligned only about half the time.) Round the start down to 8 and step by 8 — a 16-byte stride would step clean over auxv[0] on the runs where it isn’t 16-aligned.
  • The false-positive guards. Scanning up, you cross C stack frames and the argv/envp pointer arrays, so some positions will look auxv-ish. A spot is accepted as auxv[0] only if the word just below it is 0 (envp’s terminating NULL) and walking forward from it reaches a clean AT_NULL with sane tags (<= AT_MINSIGSTKSZ) that include AT_PAGESZ and AT_CLKTCK at their known values. Only the real vector clears all of it. That first check also handles a classic trap: a program that copied the auxv onto its stack (say, read from /proc/self/auxv) leaves a copy lower down that the scan meets first — but a copy isn’t preceded by envp’s NULL, so it’s skipped and the scan moves on to the real one.
  • A ceiling. The scan stops at STACK_TOP — 0x00007ffffffff000 on x86-64. It’s not an arbitrary constant: that’s the kernel’s own STACK_TOP macro (= DEFAULT_MAP_WINDOW = (1<<47) - PAGE_SIZE), the value fs/binfmt_elf.c feeds to randomize_stack_top() when it lays out the stack. With ASLR on, that function subtracts a random page-aligned offset (up to 16 GiB on x86-64, where STACK_RND_MASK is 22 bits of pages), and the [stack] VMA ends at that randomized top, not at STACK_TOP. Only with ASLR off (setarch -R, or under gdb, which disables it by default) do the two coincide — that’s the familiar …-7ffffffff000 [stack] line in a gdb session. So STACK_TOP is an upper bound on where the auxv can be, not the end of the mapping. What it isn’t is a safety net: past the real top of [stack] is unmapped memory, so a scan that misses faults or wanders into unrelated pages long before it reaches STACK_TOP. On the main thread that never comes up, because a correct scan finds the real auxv inside the VMA first; the ceiling only keeps the loop bounded on paper. If you need a precise bound, read the real [stack] end from /proc/self/maps.

One hard limitation: this only works on the main thread. A clone-spawned thread runs on a fresh stack with no argc/argv/envp/auxv on it, far below the 0x7fff... ceiling, so the scan would run off the end of that mapping into the guard page long before STACK_TOP. If you must do this off the main thread, bound the scan to that thread’s own stack region first (from /proc/self/maps) instead of trusting the ceiling.

So: a genuine last resort. The fingerprint is strong and the envp-NULL check kills the usual copy problem, but it’s still a heuristic over raw memory, and a sufficiently adversarial or unlucky layout can fool it. If any of Methods 1–6 are available, use them.

Which one to actually reach for

  • Just looking? LD_SHOW_AUXV=1 for glibc dynamic binaries, otherwise hexdump/od on /proc/self/auxv.
  • In your own program? getauxval — but on x86-64 go to /proc/self/auxv or prctl(PR_GET_AUXV) if you specifically want the kernel’s real AT_HWCAP.
  • Sandboxed, /proc blocked? prctl(PR_GET_AUXV) on 6.4+.
  • Shellcode at the entry point? The envp-walk from RSP, falling through with no ret (Method 5).
  • Have envp but no libc? The C get_auxv — and use the envp from main, never environ after a setenv, and never trust the envp-walk inside a setuid binary.
  • Nothing but a stack pointer, on the main thread? The scanner, eyes open about its limits.

The recurring theme, and the reason I bothered writing this up: /proc/self/auxv and prctl(PR_GET_AUXV) give you the kernel’s original snapshot from mm->saved_auxv, while the on-stack vector — what getauxval ultimately reads through glibc’s dl_auxv — is a live copy that ld.so can rewrite after the kernel builds it. Most of the time the two agree; when they don’t, knowing which copy each method reads is the whole game.

The cleanest case where they diverge is invoking the dynamic loader explicitly. Normally you run ./prog: the kernel sees the PT_INTERP entry, maps ld.so for you, and fills the auxv to describe ./prog — AT_PHDR/AT_PHNUM/AT_ENTRY point at the program and AT_EXECFN is its path. But you can also run ld.so ./prog, running the loader itself as the program with ./prog as its argument. Now the exec’d file is ld.so, so the kernel fills the auxv to describe ld.so: AT_PHDR/AT_PHNUM/AT_ENTRY point at the loader and AT_EXECFN is /lib64/ld-linux-x86-64.so.2. ld.so then loads ./prog itself and, to make the process look as though ./prog had been exec’d directly, walks the on-stack auxv and rewrites exactly those four tags — AT_PHDR, AT_PHNUM, AT_ENTRY, AT_EXECFN — to point at the real program. That patch loop lives in dl_main in elf/rtld.c.

The catch is that it patches only the stack copy. mm->saved_auxv is never touched, so /proc/self/auxv and PR_GET_AUXV still describe ld.so while getauxval — reading the patched stack vector — describes ./prog. Same process, same tags, two different answers:

Run the same binary the normal way (./prog) and the two match to the byte. So the auxv isn’t really one object — it’s a kernel snapshot plus a user-space-visible copy the loader is allowed to edit, and the method you reach for decides which one you see.

Happy hacking!