The user-access recovery
Status: FIXED (2026-09-11) - H-003 and H-010. The first attempt, and why it failed, is kept below: it is the reason the second one looks the way it does.
The defect
Validating a user pointer and dereferencing it are two instants, and nothing
makes the pair atomic: hw_user_range_ok walks the page tables, and a sibling
thread on another core can munmap the address before the read happens. A
ring-0 page fault panics, so an unprivileged multi-threaded process could stop
the machine by racing against its own memory.
H-003 found it on the futex path, where g_futex_lock can spin between the
check and the read. H-010 found the wider shape: a syscall that validates its
buffer and then blocks - pipe read and write, the console read, socket
receive - leaves the window open for as long as it sleeps.
The fix
kernel/arch/x86_64/uaccess.S holds vibeos_uaccess_copy(dst, src, len): one
rep movsb, bracketed by vibeos_uaccess_copy_begin and _end, and a
recovery point vibeos_uaccess_copy_fixup that returns -1. When the trap
handler takes a ring-0 page fault that copy-on-write did not resolve, at a rip
inside that range, on a user address, it resumes at the recovery point. The
stack is as the fault left it - inside a leaf, before any push - so ret
returns to the caller with the error. A kernel address faulting there still
panics.
Every site the two findings named goes through it, and a few they did not:
- futex: the word compared under
g_futex_lock; - exit: the join word written to
clear_child_tid; - pipes: read and write, in contiguous runs of the ring buffer, consuming or producing only what was actually copied;
- the console read, per character;
- sockets:
recv,recvfrom,send,sendto. The portable stack writes straight into whatever pointer it is given and cannot use an arch-layer routine, so data goes through a kernel bounce buffer underg_net_lock.
The syscall table's own write-out sites were swept in two batches. First
(6a94a32) clock_gettime, execve's argv vector walk and poison probe, time(),
and the crash-recorder stack read. Then the ones that batch's grep missed
because they are not a single *(T*) deref: pipe2's two-fd write (which now
rolls the pipe and both fds back on a faulting copy-out rather than leaking
them), hw_write_stat (assembled in a kernel buffer and copied out once instead
of filled field by field), getcwd, readlinkat, and prctl PR_SET_NAME (a
copy-in) / PR_GET_NAME. Each keeps hw_user_range_ok as the address-policy
check and adds the fault-safe copy for the race.
Other validate-then-use sites remain - there are dozens across the wider kernel - and each has the same theoretical window against a concurrent munmap. They do not block, so the window is a few instructions; they are the next place to apply the routine, not a claim that the class is gone.
How it is known to work
Bring-up forces a copy from the top of the high user window, which nothing
maps, before any task runs, and prints uaccess recovery ok if it came back as
an error. The boot gate asserts the line (uaccess_recovery_not_proven).
The first red was not a red. On the unfixed kernel the probe used a plain C copy into a local nothing read afterwards; the compiler removed it, and the probe reported a successful read of an unmapped address. The gate went red, for a reason that had nothing to do with the defect - exactly the failure the previous attempt's "three green boots" had been. The real red is a sabotage on the fixed kernel, the recovery branch switched off: the probe's copy takes a ring-0 page fault (vector 0xe, cs 0x8, rip 0x4000413 inside uaccess.S) and the gate reports guest_panicked (.boot-evidence/sabotage-uaccess.log).
Fixed: uaccess recovery ok, check.sh all green, twelve boots 12/12.
check-chokepoints.py moved hw_user_addr_ok from 2 to 4 call sites - the
handler asks it before resuming - and the count was updated with that reason.
It also showed that .f.sh printed that check without asserting it; it asserts
every check it prints now.
The first attempt, and why it did not work
An exception table with one entry per core: hw_user_read32 recorded a
recovery point with the GNU address-of-label extension, and the trap handler
rewrote frame->rip to it. The recovered rip was 0x000000aa020 with the
kernel linked at 0x4000000: under this freestanding link &&label produced a
section-relative offset, and the handler resumed at an unmapped low address.
Assembly symbols are ordinary relocations - the same kind every function
pointer in the kernel is - which is why the fix is assembly.
That attempt measured 3/3 green before a probe existed, because its only caller
always had the page mapped. A probe that runs from bring-up turned it into a
#UD in one run; the first version of the probe ran from vibeos_kmain, after
userland, with a CR3 that could belong to a torn-down address space, and the
machine went silent instead. Bring-up, in the kernel's own address space, is
where a probe like this belongs.