207 lines
11 KiB
Markdown
207 lines
11 KiB
Markdown
# The vDSO — the public system-call boundary
|
|
|
|
> **Status:** design note, not built. The runtime today issues raw `syscall`
|
|
> instructions from `library/runtime/system-call.zig` using the numbers in
|
|
> `system/abi.zig`. This note designs the layer that replaces that arrangement:
|
|
> a **kernel-supplied, C-ABI entry library** mapped into every process — the
|
|
> only supported way into the kernel — so the raw numbers can stay private,
|
|
> be renumbered at will, and eventually be randomised per boot.
|
|
|
|
## Why: the ABI danos promises, and the one it doesn't
|
|
|
|
`system/abi.zig` is the **private** kernel ↔ runtime contract. Its header says
|
|
so: the numbers are an implementation detail the runtime hides and may
|
|
renumber, the same split as libSystem over the XNU syscalls on macOS or win32
|
|
over the NT syscalls on Windows. Linux — with its world-visible, frozen
|
|
syscall table — is the outlier, not the norm.
|
|
|
|
That stance has consequences the moment binaries exist that we don't rebuild
|
|
ourselves:
|
|
|
|
1. **Third-party binaries** (docs/zig-self-hosting.md) must keep working across
|
|
kernel updates. If they contain raw `syscall` instructions with today's
|
|
numbers baked in, every renumbering breaks the world — the ABI would be
|
|
*de facto* public no matter what the header says. Go on macOS made exactly
|
|
this mistake: it issued XNU syscalls directly instead of going through
|
|
libSystem, and macOS updates repeatedly broke every Go binary until Go
|
|
switched to the library like everyone else.
|
|
2. **Not everything is Zig.** A Rust or C program can't import the `runtime`
|
|
module. The public boundary has to be expressible in the one calling
|
|
convention every language speaks: the C ABI.
|
|
3. **Randomised syscall numbers** — a hardening option we want open — only
|
|
work if no user binary anywhere knows a number at build time. The binding
|
|
must happen at *load time*, from something the kernel controls.
|
|
|
|
All three point at the same well-known shape: a **vDSO** (virtual dynamic
|
|
shared object). The kernel carries a small blob of user-mode code, maps it
|
|
into every process at spawn, and that blob — not the application — contains
|
|
the `syscall` instructions. Fuchsia works exactly this way: its vDSO is the
|
|
*only* kernel entry, version-matched by construction because the kernel itself
|
|
injects it. Because the kernel and the blob ship as one artifact, there is
|
|
**no version skew, no loader, no search path, and no shared file on disk** —
|
|
which is what makes this the resilient way to have a private ABI
|
|
(docs/resilience.md), where a conventional `ld.so` + `/lib/libdanos.so`
|
|
arrangement would add a loader to every spawn and a single shared point of
|
|
failure.
|
|
|
|
The public danos ABI then has exactly two layers, neither of which is
|
|
`abi.zig`:
|
|
|
|
| Layer | Contract | Spoken by |
|
|
|-------|----------|-----------|
|
|
| **vDSO** | C-ABI functions, this note | every language's thin shim (`runtime.system` for Zig, a `-sys` crate for Rust, a header for C) |
|
|
| **IPC wire protocols** | byte layouts over `ipc_call` ([vfs-protocol.md](vfs-protocol.md) is the first one documented) | any client that can lay out bytes |
|
|
|
|
Everything above those — the heap, `runtime.fs`, the service harness — is
|
|
per-language convenience, compiled into each binary from source, exactly as
|
|
today. Nothing about the Zig runtime's shape changes; it just stops being the
|
|
*only* door.
|
|
|
|
## The blob
|
|
|
|
A single copy of the vDSO code lives in the kernel image (built by
|
|
`build.zig` as a tiny freestanding object, embedded like the AP trampoline).
|
|
At boot the kernel finalises it once — this is where randomised numbers would
|
|
be patched in — and thereafter maps the **same physical pages** read-execute
|
|
into every process's address space. The blob is:
|
|
|
|
- **Position-independent.** It is mapped at a per-process randomised base, so
|
|
it must be PIC (rip-relative addressing only — no relocations to process).
|
|
- **Stateless and re-entrant.** No writable data. Anything stateful belongs to
|
|
the process, not the vDSO.
|
|
- **Architecture-specific.** The x86-64 blob wraps `syscall`; an aarch64 blob
|
|
wraps `svc #0`. It lives beside the other per-architecture kernel sources
|
|
(`system/kernel/architecture/<arch>/`), selected the same way the
|
|
`architecture` module is (docs/arch.md).
|
|
|
|
### Shape: a function table, not an ELF
|
|
|
|
A real `.so` with a dynamic symbol table is the conventional vDSO shape, but
|
|
linking against one at load time needs a dynamic linker in every binary —
|
|
machinery danos deliberately doesn't have. Instead the v1 shape is the
|
|
simplest thing that is still a stable contract — a **function-pointer table**
|
|
at the vDSO base:
|
|
|
|
```
|
|
offset 0 u64 magic 'danosVDS' — a mapped-the-wrong-thing guard
|
|
offset 8 u64 api_level incremented when the table grows
|
|
offset 16 u64 count number of table entries that follow
|
|
offset 24 u64 table[count] function pointers into the vDSO's own code
|
|
```
|
|
|
|
Table *indices* are the public constants (published in a C header,
|
|
`danos.h`), assigned once and append-only — the same discipline the IPC
|
|
protocols use for operation values. The pointers point at stubs inside the
|
|
blob; what those stubs put in `rax` is nobody's business but the kernel's.
|
|
A language shim binds in one step: read the base from the init block, check
|
|
the magic, keep the table pointer. Feature detection for a binary built
|
|
against older headers is `count`/`api_level` — a kernel never removes or
|
|
reorders entries.
|
|
|
|
(If danos ever grows a real dynamic linker, the same blob can additionally
|
|
present an ELF `dynsym` without breaking the table — Fuchsia's vDSO is
|
|
likewise both a mappable blob and a linkable `.so`. That is a later
|
|
convenience, not a requirement.)
|
|
|
|
### Delivery: the auxiliary vector
|
|
|
|
The kernel already builds a System V entry block — argc, argv, envp
|
|
terminator, **auxiliary vector** — on every new process's stack
|
|
(`buildEntryStack`, read by `runtime.start`). The vDSO base rides in a new
|
|
auxv entry, exactly Linux's `AT_SYSINFO_EHDR` move. No new syscall, no magic
|
|
address, and a language shim finds it the same portable way on every
|
|
architecture.
|
|
|
|
## The function surface
|
|
|
|
One table entry per kernel call, C ABI (System V AMD64), names prefixed
|
|
`danos_`. The current `SystemCall` set maps directly; integer arguments and
|
|
returns are `u64`, errors return as negative values exactly as today.
|
|
|
|
The calls that return two values in `rax:rdx` today — `dma_alloc`
|
|
(virtual_address + physical_address), `msi_bind` (address + data), `shm_create` (virtual_address + handle) —
|
|
become functions returning a two-`u64` struct. The System V ABI returns a
|
|
16-byte struct in `rax:rdx`, so the stub is a plain `syscall; ret` — the
|
|
C-ABI spelling of the existing convention, at zero cost.
|
|
|
|
Grouped as `abi.zig` groups them:
|
|
|
|
| Group | Functions |
|
|
|-------|-----------|
|
|
| process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` |
|
|
| memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shm_create`, `danos_shm_map`, `danos_shm_physical` |
|
|
| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` |
|
|
| devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` |
|
|
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
|
|
| diagnostics | `danos_debug_write`, `danos_klog_read` |
|
|
|
|
The constants that ride alongside the calls — mmap protection bits, DMA
|
|
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
|
|
ids, `page_size`, the IPC message maximum — move to the public header too:
|
|
they are wire values a Rust program needs verbatim. What stays private in
|
|
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
|
|
numbers and the trap convention.
|
|
|
|
## Enforcement, and an honest threat model
|
|
|
|
Renumbering only has teeth if the kernel **refuses syscalls that don't come
|
|
from the vDSO**. The check is cheap: on kernel entry, the saved user `rip`
|
|
must lie inside the calling process's vDSO mapping; otherwise the process is
|
|
killed with a fault-class exit reason (its supervisor restarts or gives up,
|
|
docs/process-lifecycle.md — a foreign-syscall attempt is a bug or an attack,
|
|
never something to limp past). Fuchsia enforces exactly this.
|
|
|
|
What this buys, precisely:
|
|
|
|
- **ABI freedom** — the real prize. The numbers can change per release or per
|
|
boot and nothing outside the kernel image cares. The private ABI stays
|
|
actually private, permanently.
|
|
- **A single audited chokepoint** for kernel entry, per process, at a
|
|
randomised address.
|
|
- **Raised bar for exploits**: shellcode can't issue a hard-coded `syscall`;
|
|
it must first discover the per-process vDSO base (ASLR) and call through
|
|
it.
|
|
|
|
What it does *not* buy: an attacker with arbitrary code execution in a
|
|
process can still *call* the vDSO functions — they are mapped executable in
|
|
that process, and return-oriented chains reach them. Syscall randomisation is
|
|
hardening, not a security boundary; the security boundary remains the
|
|
capability model (what the process's endpoints and device claims let it do).
|
|
It is worth building anyway — for the ABI freedom first and the hardening
|
|
second — but the design should never be sold as more than that.
|
|
|
|
## Migration
|
|
|
|
Phased so every step ships alone (the M-milestone discipline):
|
|
|
|
1. **The blob + the table.** Build the vDSO, map it at spawn, deliver the
|
|
base via auxv. `runtime.system-call.zig` binds through the table when the
|
|
auxv entry is present, falls back to raw `syscall` when absent — the whole
|
|
tree keeps booting during the transition.
|
|
2. **Cut the runtime over.** Delete the raw stubs; `runtime` no longer
|
|
imports the `SystemCall` numbers at all (`abi.zig`'s enum becomes
|
|
kernel-internal). The QEMU suite passing proves the table carries the
|
|
whole system.
|
|
3. **Enforce + randomise.** Add the `rip`-range check, then per-boot number
|
|
randomisation patched into the blob at kernel init. A test boots with
|
|
randomisation on and runs the full suite.
|
|
4. **The other languages.** Publish `danos.h`; a Rust `danos-sys` crate wraps
|
|
the table. This is also the seam `std.os.danos` calls through when the Zig
|
|
self-hosting fork lands (docs/zig-self-hosting.md) — the vDSO is what
|
|
makes that seam stable across kernel versions.
|
|
|
|
## What deliberately stays out
|
|
|
|
- **No dynamic linker, no `/lib/*.so`.** The vDSO is kernel-injected precisely
|
|
so danos binaries can stay fully static above it. Sharing *library code*
|
|
across processes stays what it is today: a service behind IPC, or source
|
|
compiled into each binary.
|
|
- **No file/device I/O in the vDSO.** The microkernel line doesn't move: the
|
|
vDSO wraps the same deliberately tiny table (docs/syscall.md); files are
|
|
still the VFS server's business over IPC.
|
|
- **No fast-path user-mode implementations yet.** Linux's vDSO exists mostly
|
|
to answer `gettimeofday` without a kernel entry. `danos_clock` could one
|
|
day read the calibrated TSC in user mode the same way — the blob is where
|
|
such an optimisation would live — but that is an optimisation, not part of
|
|
this design's contract.
|