Back to course

The vDSO: An ELF File With No File

23 minadvancedModule 2: Reading the ValuesContinues from img-entries
23 min · Module 2: Reading the Values · Advanced

Why This Matters

Every dynamic process on Linux maps a shared library that has no file on disk. It appears in /proc/<pid>/maps under the name [vdso] — square brackets, which is the kernel’s way of saying there is no path here. It has an address, given to the process in the auxv as AT_SYSINFO_EHDR. And it has, if you read it, a perfectly ordinary ELF header.

Here is that header, read out of the running process’s own memory:

$ cd courses/img/assets/samples && ./build_samples.sh 2>&1 | sed -n '/I4/,/I5/p'
     size, e_ident magic, e_type, e_machine, e_phentsize, e_phnum, e_phoff:
      vdso_size=8192
      magic=7f454c46
      e_type=3 e_machine=62 e_phentsize=56 e_phnum=6 e_phoff=64
      AT_SYSINFO_EHDR=136793659318272
      EHDREQ_VDSO=1

7f 45 4c 46 is \x7fELF. e_type = 3 is ET_DYN. e_machine = 62 is EM_X86_64. Six program headers, 56 bytes each, at offset 64. That is a well-formed 64-bit shared object, and there is no file anywhere that contains it.

Why should a course about the kernel’s handoff care? Because it is the cleanest available proof of what the previous five courses described. Every one of them taught you to read an ELF file that a compiler produced. This is an ELF file the kernel produced, at run time, for every process it creates — and the same header fields, in the same order, at the same offsets, mean exactly what they meant in readelf output.

A Simple Model

Why a kernel builds a library at all. The motivation is a performance argument, and it is worth stating because it explains the shape of the solution:

gettimeofday() is very common. time(2) is cheap to COMPUTE. but a syscall is expensive to ENTER: the mode switch, the register save, the trap. ~1000 cycles, versus ~20 for the addition that does the actual work. so on x86 the CPU has an instruction: RDTSC read the timestamp counter that instruction is fast, and it lets you compute a high-resolution time with no syscall at all. but the counter's frequency is per-CPU and needs calibrating against a reference -- which needs a syscall. so: the kernel does the calibration ONCE, writes the result into a small code+data image, and gives every process a pointer to its own copy. that image is the vDSO. it is a shared object, so each process gets a private copy and the calibration can differ per CPU.

So the vDSO is not a weird exception. It is the ordinary machinery of the chain, with the linker step removed. It is ET_DYN, so the kernel maps it at a base and reports that base in the auxv — the same AT_BASE mechanism as the dynamic linker, and the reason AT_SYSINFO_EHDR exists as a separate entry at all.

What makes it a teaching object is that you can read it. There is no readelf that will open it, because there is no file. But the header is in your address space, so you can open /proc/self/mem, seek to the address /proc/self/maps gave you, and read sixty-four bytes. That is the whole technique, and it is the same technique the artifact uses for every other check in this course.

1. read /proc/self/maps, find the line whose last field is "[vdso]" -> lo, hi 2. open /proc/self/mem 3. seek to lo, read 64 bytes 4. that is an Elf64_Ehdr. parse it with the same code you already wrote for a file. there is no step 5. it is not a special case. it is an ELF file, obtained unusually.

What Is Measured, and What Is Not

The header, exactly:

$ python3 crosscheck.py 2>&1 | sed -n '/G4/,$p' | head -9
  G4    7 passed   0 failed

Each of those seven is a real check, run on every invocation:

The last item deserves its own explanation, because the mistake is a good one to have made. The natural way to check “does AT_SYSINFO_EHDR point at the vDSO?” is to run the auxv dumper in one process and the /proc/maps reader in another, then compare. That cannot work, because they are two different processes and ASLR puts the vDSO somewhere different in each. The first version of this script did exactly that and printed two unrelated addresses, which looked like a bug in the kernel.

The fix is to compare within one process: have the measuring program read its own auxv and its own map, and print the two side by side. It does, and they are equal — EHDREQ_VDSO=1. Any time you verify something a randomiser perturbs, both observations must come from the same process, or you are comparing two samples of a distribution and calling it a match. That is a general rule about experiments, and this course hit it hard enough to be worth stating as a rule rather than a footnote.

$ python3 stackwalk.py 2>&1 | grep vdso
  [ok  ] AT_SYSINFO_EHDR == the base of the [vdso] mapping
         -- 0x7f92255a0000 vs 0x7f92255a0000

And the square brackets, which are the detail that makes the whole thing click: [vdso], [stack], [heap]. The kernel uses brackets for a region it made itself and has no file for. Every one of those is the kernel writing into an address space, and the vDSO is the one where it also wrote a file’s worth of structure into it.

Reading It Yourself

The complete program, and it is short. Everything here has appeared in this course already; that is the point.

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

int main(void) {
    unsigned long lo = 0, hi = 0;
    FILE *f = fopen("/proc/self/maps", "r");
    char line[512];
    while (fgets(line, sizeof line, f)) {
        unsigned long a, b; char p[8], d[64];
        if (sscanf(line, "%lx-%lx %7s %*s %*s %*s %63s",
                   &a, &b, p, d) >= 4 && strstr(d, "vdso")) {
            lo = a; hi = b;
        }
    }
    fclose(f);

    /* the kernel's own view of its own memory */
    FILE *m = fopen("/proc/self/mem", "rb");
    unsigned char h[64];                 /* exactly one Elf64_Ehdr */
    fseek(m, lo, SEEK_SET);
    if (fread(h, 1, 64, m) != 64) return 1;
    fclose(m);

    printf("magic %02x%02x%02x%02x\n", h[0],h[1],h[2],h[3]);
    printf("e_type %d e_machine %d\n",
           h[16] | h[17]<<8, h[18] | h[19]<<8);
    return 0;
}

Three things to notice. It is 64 bytes and no more — an Elf64_Ehdr is a fixed-size structure, which is the only reason this is possible. Every field is a hand-decoded little-endian byte pair, because there is no readelf and there is no file, so the struct layout has to be written out. And the sscanf has a %*s in it for the file offset — which is skipped, not parsed, because this region has no file and therefore no offset worth having.

That last detail is the real signature of the vDSO. Compare it to the same maps line for an ordinary library:

$ ./maps | grep -E 'vdso|ld-linux' | head -3
  71e4c0d45000-71e4c0d47000 r-xp 00000000                  [vdso]
  7881a3b73000-7881a3b74000 r--p 00000000 103:02 8678400  /usr/lib/.../ld-linux-x86-64.so.2
  7881a3b74000-7881a3b81000 r-xp 00001000 103:02 8678400  /usr/lib/.../ld-linux-x86-64.so.2

Look at the vDSO line: no device, no inode, no path. The rwxp permissions and the address range are there, because those are the kernel’s own bookkeeping. Everything that would identify a backing file is blank, because there is none. That single line is the entire concept: a mapped, executable, ELF-shaped thing with nothing behind it.

Try It Yourself

$ cd courses/img/assets/samples
$ ./build_samples.sh 2>&1 | sed -n '/I4/,/I5/p'
$ python3 crosscheck.py 2>&1 | sed -n '/G4/,/G5/p'
$ ./maps | grep vdso

Then go past what was measured, in a controlled way:

  1. Read the vDSO's PROGRAM HEADERS, not just its
     header. Six of them at offset 64. Print
     p_type, p_flags, p_offset, p_vaddr, p_filesz,
     p_memsz for each. (There will be two PT_LOADs
     and a PT_GNU_RELRO among others, and the PT_LOAD
     vaddrs will be RELATIVE TO ZERO because
     e_type is ET_DYN. This is the same program
     header table format from
     /courses/elf/lessons/program-header-table,
     in a file nobody compiled.)

  2. Use AT_SYSINFO_EHDR as the base and add each
     PT_LOAD's p_vaddr. Where do they land
     relative to the 8192 bytes you measured? Do
     they fit? (They must. That is checkable
     arithmetic and it is a real check that the
     header you read belongs to the mapping you
     read it from.)

  3. Look for the SYMBOL TABLE. The vDSO's
     section headers are all zero on most kernels
     (it is built for the loader, which wants
     program headers, not sections). Confirm that,
     and then explain what that implies: the
     vDSO cannot be read with readelf-style
     tooling even in principle, because it has no
     section header table to walk.

  4. Compare the vDSO across two processes, properly.
     Run ./maps | grep vdso twice. The ADDRESSES
     differ (ASLR). The SIZE does not. Then run
     it twice in ONE process and confirm the
     address is stable. (The stability is the
     point: the vDSO is mapped once, at process
     creation, and never moves.)

  5. strace -e trace=clock_gettime a program
     that calls it, and one that does not, and
     count the syscalls. (Zero for a program
     using the vDSO path. This is the entire
     reason the vDSO exists, and you can watch
     it not happen.)

Exercise 1 is the one that ties this course to the whole chain, because it makes the vDSO into a program header table you have to parse. Same structure, same field order, same 56-byte record — in a file that no toolchain produced. And the p_vaddr values are relative to zero precisely because e_type is ET_DYN, which is the rule the relocations course established for shared objects and the linker-script course found written into the default script.

Exercise 3 has the most interesting answer. The vDSO has no section header table, so the family of tools that navigate ELF by section simply cannot read it. That is not a defect — sections exist for linkers, and the vDSO is never linked, only mapped. It is a clean demonstration that the two halves of ELF serve different consumers, and that a format can be completely valid while being unreadable by the tools you already know. The program header table is the kernel’s half; the section header table is the toolchain’s; the vDSO is a file that only has the first one.

Connect

The most direct connection in the course is to the dynamic linking course’s dlopen concept, and it is a genuine contrast rather than a repetition. That concept covered loading a library at run time from a path, and the loader’s search rules, and the mode bits. Every one of those concerns evaporates for the vDSO, because the kernel is the loader and there is no path to search. No search path, no DT_NEEDED, no scope insertion, no symbol resolution — just an address in the auxv. It is the dlopen mechanism with the entire search problem designed out, which is a useful thing to have seen precisely because the search problem is invisible in the normal case.

The connection to Where the Thread Blocks Come From is about who allocates. That concept measured the loader allocating TLS storage a linker could not. The vDSO is the same idea taken further: the kernel allocating an entire ELF image that no linker, compiler, or human wrote. Both exist because a per-process value cannot be baked into a file. The difference is that the TLS block is data the linker described and the kernel supplied, while the vDSO is structure the kernel both described and supplied.

And the connection to the beginning of the chain is the one that makes the course’s title true. The object-file course began with “an ELF file is a header, a program header table, a section header table, and sections.” The vDSO is the case that proves which of those the kernel cares about: header, program headers, sections, and no section header table at all. The kernel built it, so the kernel needed only the parts the kernel reads. Everything the previous five courses taught you about the section header table, the string table, and the symbol table is real and important and absent here — and its absence is the cleanest available statement of what the two halves of the format are for.

One connection forward, which is the next concept. The vDSO is mapped at a chosen address like everything else, and it is not the only thing. Where the Kernel Put Everything puts the whole address space on the page, with the floor it will not go below and the reason a hint is not a request.

render_lesson_js(&mut page)