The Syscall Boundary
July 16, 2026 · View on GitHub
A syscall crosses from a capsule in ring 3 into the kernel in ring 0, is decoded to a
typed number, passes the capability contract, and dispatches to a handler. What makes
the NØNOS boundary notable is that the capability check is not merely a runtime gate that
a handler could forget to call; it is a type-enforced precondition, so a handler cannot
run without proof that the check happened. This page documents the entry, the register
ABI, the number decode, the contract gate, and the witness type that enforces the check.
The code is under src/arch/x86_64/syscall/ and src/syscall/.
The instruction and the stub
On x86_64 the boundary is the SYSCALL instruction. LSTAR is programmed during core
init to point at an assembly entry stub, which saves the user state, switches to the
kernel GS and stack, translates the user register layout into the System V calling
convention, and calls syscall_handler (src/arch/x86_64/syscall/manager/entry.rs:22):
#[no_mangle]
syscall_handler(number, arg1..arg6) -> u64:
sc = SyscallNumber::from_u64(number), else return -ENOSYS
result = contract_dispatch(sc, SyscallArgs::new([arg1..arg6]))
result.value as u64
It is extern "C" and #[no_mangle] because the assembly stub calls it by name. It is
the arch-specific bridge; everything past contract_dispatch is arch-neutral.
The register ABI
Arguments follow the System V register order, with one substitution the SYSCALL
instruction forces:
a0 -> RDI a1 -> RSI a2 -> RDX
a3 -> R10 a4 -> R8 a5 -> R9
syscall number -> RAX return value -> RAX
R10 stands in for RCX because SYSCALL clobbers RCX with the return address, so a
capsule places the fourth argument in R10 and the stub moves it into place. The number
travels in RAX and the return value comes back in RAX, with errors returned as a
negative errno, which is why an unknown number returns -ENOSYS cast to u64.
Number decode
The raw u64 is decoded to a SyscallNumber (src/syscall/numbers/) before anything
else, and a value that does not correspond to a known syscall is rejected immediately
with ENOSYS rather than reaching the contract. The number scheme itself, four-character
ASCII tags packed into a word so they read as mnemonics in a trace, is documented on the
numbers page. Only a valid, typed SyscallNumber proceeds.
The contract gate
contract_dispatch (src/syscall/contract/dispatch.rs:31) is the single gate every
syscall passes, and its doc comment states that there is no other path that runs the
capability resolution:
dispatch(number, args):
cap = Capability::resolve(number, args)
if cap is None:
log the denial
return EPERM
invoke(number, args) the router
It resolves the calling thread's capability against the requested syscall and its
arguments. If resolution fails, it logs a CAP-DENY with the pid and syscall and returns
EPERM, and the handler never runs. If it succeeds, it invokes the router with the raw
arguments. This is the one place Capability::resolve is called from, so every syscall in
the system goes through exactly this check.
The capability witness
The mechanism that makes the check unforgettable is the Capability type
(src/syscall/contract/capability.rs:30):
pub struct Capability { token: CapabilityToken } the field is private
Capability::resolve(number, args) -> Option<Capability>:
proc = current process, else None
ctx = ResolveContext { current_asid, boot_session_nonce, capsule_revocation_epoch }
resolver_resolve(proc.token, number, args, ctx).ok()?
Some(Capability { token })
The struct wraps a capability token behind a private field, and its only constructor is
resolve. User-space cannot build one, and in-kernel code outside the contract module
cannot either, because the field is private. As the source puts it, a handler that takes a
Capability therefore has executable proof that the check ran: the check is encoded in
the type, not left to a convention a caller might skip. resolve builds the resolve
context, the address-space id, the boot-session nonce, and the capsule's revocation
epoch, and runs the resolver chain over the process's token; only if that chain passes
does a Capability come into existence.
The resolver chain
The five checks resolve runs, that the token's MAC verifies and it is unexpired and
unrevoked, that it is bound to this boot and this address space, that its revocation epoch
is current, and that the held bits permit this specific syscall, are the ordered resolve
chain documented in full on the capabilities page.
The boundary here is where that chain is invoked; the capability model is where it is
defined. On success the router runs; the router page covers the dispatch to
the per-family handlers.
Security analysis
The syscall entry is the one place ring 3 turns into ring 0, so it is the trust boundary for the whole capsule model. Everything a capsule passes in registers is untrusted, and the boundary is built so that untrusted input cannot reach a handler without first clearing a decode, a capability check, and (for any pointer) a page walk. Three properties draw that line.
A handler cannot run without proof of the capability check. The mechanism is the Capability type
(src/syscall/contract/capability.rs:30), a struct that wraps a CapabilityToken behind a private field
whose only constructor is Capability::resolve (capability.rs:35). User-space cannot build one, and
neither can in-kernel code outside the contract module, because the field is private. dispatch
(dispatch.rs:31) is the sole caller of resolve, and its doc comment states there is no other path that
runs it. If resolve returns None the dispatch logs a CAP-DENY and returns EPERM before invoke is
ever reached (dispatch.rs:34). So the check is not a convention a handler might forget; it is encoded in the control
flow, and a handler that needs a capability takes the witness as evidence the five-step resolver chain ran.
The number is decoded before anything else touches it. syscall_handler
(src/arch/x86_64/syscall/manager/entry.rs:22) turns the raw RAX value into a typed SyscallNumber with
from_u64, and an unrecognised tag returns -ENOSYS immediately (entry.rs:31) without building
SyscallArgs or entering the contract. A malformed number therefore cannot select a handler by index or
walk off the end of a table, because there is no table index in play: the decode is a lookup that yields
either a known enum variant or nothing.
No user pointer is dereferenced on the strength of the register value alone. The boundary passes the
six argument registers through untouched as a SyscallArgs array; it does not itself interpret any of them
as a pointer. When a handler needs to read or write a user buffer, it goes through the usercopy layer
(src/usercopy/), where check_range (policy.rs:35) rejects a null pointer, a length over the 64 MiB
MAX_COPY_SIZE cap, an address that overflows on addr + len, or a range crossing the canonical user
limit 0x0000_7FFF_FFFF_FFFF, and then validate (validate.rs:34) walks every page in the range to
confirm it is present, user-accessible, and (for a write) writable before a single byte moves. The copy
itself runs through the direct map (copy.rs), so the kernel never dereferences the user virtual address
directly. A capsule passing a kernel pointer, an unmapped pointer, or a read-only pointer to a write gets
EFAULT, not a kernel fault.
The honest limit is that the boundary itself trusts the assembly stub to have switched GS and the stack
and to have saved user state correctly; that stub (entry.rs in the arch tree, set up by init.rs) is the
small amount of hand-written code the whole model rests on, and it is arch-specific rather than covered by
the type-level guarantees above.
Debugging the boundary
Every rejection at or just past the boundary comes back to the capsule as a negative errno in RAX, and
the errno alone tells you which stage refused the call:
-ENOSYS (38) the number did not decode entry.rs, before the contract
-EPERM (1) the capability resolve failed dispatch.rs, CAP-DENY logged
-EFAULT (14) a user pointer failed validation usercopy, inside a handler
-EINVAL (22) a handler rejected the arguments inside a handler
-ENOMEM (12) a handler could not allocate inside a handler
The first distinction to draw is ENOSYS versus EPERM. ENOSYS means the tag in RAX is not a
registered syscall at all, so the fix is in the caller's number, not its capabilities. EPERM means the
number decoded fine but the resolver chain rejected the token, and this one leaves a trace: dispatch
calls log_deny (dispatch.rs:43), which prints [CAP-DENY] pid=… syscall=… with the pid and the typed
syscall name. So an EPERM is diagnosed by reading that line, then checking which resolver step failed:
the chain is token MAC, then boot-session binding, then address-space binding, then revocation epoch, then
the per-syscall bit (resolver/resolve.rs:31), and the corresponding ResolverError variant
(resolver/error.rs) names the reason (TokenRevoked, BootSessionMismatch, AsidMismatch,
RevocationEpochStale, or SyscallNotPermitted).
The second distinction is EFAULT versus EPERM, which are the two failures most easily confused because
both look like "the kernel refused me." EPERM is authority: the capsule does not hold the capability the
syscall requires, and it is caught at the contract before the handler runs, so no CAP-DENY-free path
reaches it. EFAULT is memory: the capability passed, the handler ran, and a user pointer argument failed
validate_user_read or validate_user_write. So the trace is different in kind. An EPERM with no
CAP-DENY line is impossible; an EFAULT never prints CAP-DENY because it happens after the gate. To
narrow an EFAULT further, the underlying UsercopyError (src/usercopy/error.rs) distinguishes
NullPointer, PageNotMapped, PageNotUser, and PageNotWritable, all of which map to errno 14, so the
question "is the buffer unmapped, kernel-owned, or read-only for a write" is answerable from the variant
even though the errno collapses them to one number.
Source map
src/arch/x86_64/syscall/manager/entry.rs syscall_handler, the arch bridge and ENOSYS decode
src/arch/x86_64/syscall/manager/init.rs LSTAR/STAR/SFMASK setup
src/syscall/contract/dispatch.rs the contract gate, log_deny, invoke
src/syscall/contract/capability.rs the Capability witness and resolve
src/syscall/contract/resolver/ the ordered five-step resolve chain and ResolverError
src/syscall/numbers/ SyscallNumber and from_u64
src/usercopy/policy.rs check_range, the null/overflow/limit/size rules
src/usercopy/validate.rs validate_user_read / validate_user_write, the page walk
src/usercopy/copy.rs copy_from_user / copy_to_user through the direct map
src/usercopy/error.rs UsercopyError and its i32 errno mapping
Every reference above is verified against those trees. The ordered resolve chain and the per-syscall capability table are on the capabilities page, the tag encoding is on the numbers page, and the family dispatch past the gate is on the router page.