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:
1 2 3 4 5 6 | typedef struct { uint64_t a_type; /* AT_* tag */ union { uint64_t a_val; /* integer value, or a pointer */ } a_un; } Elf64_auxv_t; |
A few of the ones worth knowing, with their numeric tags, because you’ll be matching on the raw numbers later:
| Tag | # | Meaning |
|---|---|---|
AT_PHDR | 3 | address of the program’s ELF program headers |
AT_PHENT | 4 | size of one program header (0x38 = sizeof(Elf64_Phdr)) |
AT_PHNUM | 5 | number of program headers |
AT_PAGESZ | 6 | page size (0x1000 on x86-64) |
AT_BASE | 7 | base address of the interpreter (ld.so) |
AT_ENTRY | 9 | entry point of the program |
AT_UID / AT_EUID / AT_GID / AT_EGID | 11–14 | the four credential IDs |
AT_HWCAP | 16 | CPU capability bitmask |
AT_CLKTCK | 17 | times() tick frequency (100 on x86) |
AT_SECURE | 23 | was this a “secure” (setuid-like) exec? |
AT_RANDOM | 25 | pointer to 16 kernel-generated random bytes |
AT_SYSINFO_EHDR | 33 | base of the vDSO’s ELF header |
AT_EXECFN | 31 | pointer to the pathname used to exec |
AT_NULL | 0 | terminator ({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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | Higher addresses ... Elf64_auxv_t { AT_NULL, 0 } <- auxv terminator ... Elf64_auxv_t { AT_SYSINFO_EHDR, ... } <- auxv[0] NULL <- envp terminator envp[n-1] ... envp[0] NULL <- argv terminator argv[argc-1] ... argv[0] argc <- RSP points here at _start Lower 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:
- If you have
envp, the auxv is “walk to the NULL, step one past it.” - 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | $ LD_SHOW_AUXV=1 /bin/true AT_SYSINFO_EHDR: 0x7f67fbff3000 AT_MINSIGSTKSZ: 1776 AT_HWCAP: f8bfbff AT_PAGESZ: 4096 AT_CLKTCK: 100 AT_PHDR: 0x555645065040 AT_PHENT: 56 AT_PHNUM: 14 AT_BASE: 0x7f67fbff5000 AT_FLAGS: 0x0 AT_ENTRY: 0x5556450677e0 AT_UID: 1000 AT_EUID: 1000 AT_GID: 1000 AT_EGID: 1000 AT_SECURE: 0 AT_RANDOM: 0x7ffe1906fab9 AT_HWCAP2: 0x2 AT_EXECFN: /bin/true AT_PLATFORM: x86_64 AT_RSEQ_FEATURE_SIZE: 28 AT_RSEQ_ALIGN: 32 |
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.soin the process at all, so nobody parsesLD_SHOW_AUXV. The variable is simply inert, so a statically linked binary run withLD_SHOW_AUXV=1prints nothing. - “Secure-exec” binaries:
ld.sois present, but it refuses to act onLD_SHOW_AUXV. The gate isn’t the setuid bit as such — it’s the kernel’sAT_SECUREflag, which the security subsystem raises whenever anexecvegains 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 theprocess:noatsecurepermission); other stacked LSMs can pile on too. glibc copiesAT_SECUREinto__libc_enable_secure, and when it’s 1 the loader runsprocess_envvars_secure()instead of the normal path andunsetenv()s a whole blacklist of dangerous variables —LD_SHOW_AUXVamong them, listed insysdeps/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 jumps1000 → 0, soAT_SECURE=1andLD_SHOW_AUXVis dropped. Run the same setuid-root program as root and nothing is gained — euid stays0, equal to your real uid — soAT_SECURE=0and 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:
1 2 3 4 5 6 7 8 | $ hexdump -v -e '1/8 "0x%016x: " 1/8 "0x%016x\n"' /proc/self/auxv 0x0000000000000021: 0x00007fa646de1000 <- 0x21 = 33 = AT_SYSINFO_EHDR 0x0000000000000033: 0x0000000000002eb0 <- 0x33 = 51 = AT_MINSIGSTKSZ 0x0000000000000010: 0x000000001f8bfbff <- 0x10 = 16 = AT_HWCAP 0x0000000000000006: 0x0000000000001000 <- 0x06 = 6 = AT_PAGESZ 0x0000000000000011: 0x0000000000000064 <- 0x11 = 17 = AT_CLKTCK ... 0x0000000000000000: 0x0000000000000000 <- AT_NULL |
The left column is a_type, the right is a_val. If you don’t have hexdump handy, od does the same job:
1 | $ od -A n -t x8 -w16 -v /proc/self/auxv |
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:
1 2 3 4 | #include <sys/auxv.h> unsigned long pagesz = getauxval(AT_PAGESZ); /* 4096 */ void *random = (void *) getauxval(AT_RANDOM); /* the 16 random bytes */ |
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:
1 2 3 4 | # raw kernel value from /proc/self/auxv: AT_HWCAP = 0x000000000f8bfbff # what getauxval hands you on the same machine: getauxval(AT_HWCAP) = 0x0000000000000002 |
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):
1 2 3 4 5 6 | #include <linux/prctl.h> #include <sys/prctl.h> #include <elf.h> Elf64_auxv_t buf[64] = {0}; long full = prctl(PR_GET_AUXV, buf, sizeof buf, 0, 0); |
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.
1 2 3 4 5 6 7 8 9 | ; RSP -> argc. Falls through with RAX = &auxv[0] mov rcx, [rsp] ; argc lea rsi, [rsp + rcx*8 + 16] ; &envp[0]: skip argc + argv[argc] + argv NULL .skip_env: lodsq ; DF is clear at process entry (SysV ABI) test rax, rax jnz .skip_env mov rax, rsi ; &auxv[0] ; ... keep going, RAX is live |
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):
1 2 3 4 5 | Elf64_auxv_t *get_auxv(char **envp) { while (*envp != NULL) envp++; return (Elf64_auxv_t *)(envp + 1); } |
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:
1 2 3 | at start: environ=0x7ffc4adb3cf8 (stack) get_auxv(environ) AT_PAGESZ=4096 [truth=4096] after overwrite: environ=0x7ffc4adb3cf8 (stack) get_auxv(environ) AT_PAGESZ=4096 [truth=4096] after add-new: environ=0x55666d24c6f0 (HEAP) get_auxv(environ) AT_PAGESZ=0 [truth=4096] |
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:
1 2 3 4 5 | __attribute__((constructor)) static void peek_auxv(int argc, char **argv, char **envp) { Elf64_auxv_t *av = get_auxv(envp); // the walker above // inspect av before main(): AT_SECURE, AT_HWCAP, AT_RANDOM, ... } |
.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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | #define _GNU_SOURCE #include <stdint.h> #include <unistd.h> #include <elf.h> #define STACK_TOP 0x00007ffffffff000UL /* DEFAULT_MAP_WINDOW = (1<<47) - PAGE_SIZE */ /* Is p the real &auxv[0]? An auxv entry is just two 64-bit words. */ static int is_auxv(const uint64_t *p, uint64_t pagesz, uint64_t clktck) { if (p[-1] != 0) /* envp's NULL sits just below auxv[0] */ return 0; int anchors = 0; for (int i = 0; i < 64; i++, p += 2) { /* each entry: { a_type, a_val } */ uint64_t type = p[0], val = p[1]; if (type == AT_NULL) /* clean terminator + both anchors */ return anchors == 2; if (type > AT_MINSIGSTKSZ) /* 51 = largest real tag */ return 0; anchors += (type == AT_PAGESZ && val == pagesz); anchors += (type == AT_CLKTCK && val == clktck); } return 0; } /* Scan the main thread's stack upward from any address below the auxv. */ Elf64_auxv_t *find_auxv_by_scan(void *start) { uint64_t pagesz = sysconf(_SC_PAGESIZE), clktck = sysconf(_SC_CLK_TCK); /* auxv[0] is 8-byte aligned (not necessarily 16): round down, step one word. */ for (uint64_t *p = (uint64_t *)((uintptr_t)start & ~(uintptr_t)7); (uintptr_t)p < STACK_TOP; p++) if (is_auxv(p, pagesz, clktck)) return (Elf64_auxv_t *)p; return NULL; } |
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 isargc; 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 overauxv[0]on the runs where it isn’t 16-aligned. - The false-positive guards. Scanning up, you cross C stack frames and the
argv/envppointer arrays, so some positions will look auxv-ish. A spot is accepted asauxv[0]only if the word just below it is0(envp’s terminating NULL) and walking forward from it reaches a cleanAT_NULLwith sane tags (<= AT_MINSIGSTKSZ) that includeAT_PAGESZandAT_CLKTCKat 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,readfrom/proc/self/auxv) leaves a copy lower down that the scan meets first — but a copy isn’t preceded byenvp’s NULL, so it’s skipped and the scan moves on to the real one. - A ceiling. The scan stops at
STACK_TOP—0x00007ffffffff000on x86-64. It’s not an arbitrary constant: that’s the kernel’s ownSTACK_TOPmacro (= DEFAULT_MAP_WINDOW = (1<<47) - PAGE_SIZE), the valuefs/binfmt_elf.cfeeds torandomize_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, whereSTACK_RND_MASKis 22 bits of pages), and the[stack]VMA ends at that randomized top, not atSTACK_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. SoSTACK_TOPis 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 reachesSTACK_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=1for glibc dynamic binaries, otherwisehexdump/odon/proc/self/auxv. - In your own program?
getauxval— but on x86-64 go to/proc/self/auxvorprctl(PR_GET_AUXV)if you specifically want the kernel’s realAT_HWCAP. - Sandboxed,
/procblocked?prctl(PR_GET_AUXV)on 6.4+. - Shellcode at the entry point? The
envp-walk fromRSP, falling through with noret(Method 5). - Have
envpbut no libc? The Cget_auxv— and use theenvpfrommain, neverenvironafter asetenv, 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:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | $ /lib64/ld-linux-x86-64.so.2 ./ldso_auxv argv[0] = ./ldso_auxv tag /proc/self/auxv (kernel) on-stack (ld.so's copy) AT_SYSINFO_EHDR 33 0x7f7310c2f000 0x7f7310c2f000 AT_MINSIGSTKSZ 51 1776 1776 AT_HWCAP 16 0xf8bfbff 0xf8bfbff AT_PAGESZ 6 4096 4096 AT_CLKTCK 17 100 100 AT_PHDR 3 0x7f7310c31040 0x7f7310c26040 <- differs AT_PHENT 4 56 56 AT_PHNUM 5 9 14 <- differs AT_BASE 7 0 0 AT_FLAGS 8 0 0 AT_ENTRY 9 0x7f7310c4e2c0 0x7f7310c27100 <- differs AT_UID 11 1000 1000 AT_EUID 12 1000 1000 AT_GID 13 1000 1000 AT_EGID 14 1000 1000 AT_SECURE 23 0 0 AT_RANDOM 25 0x7fffc881c749 0x7fffc881c749 AT_HWCAP2 26 2 2 AT_EXECFN 31 /lib64/ld-linux-x86-64.so.2 ./ldso_auxv <- differs AT_PLATFORM 15 0x7fffc881c759 0x7fffc881c759 AT_RSEQ_FEATURE_SIZE 27 28 28 AT_RSEQ_ALIGN 28 32 32 4 of 22 tags differ |
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!