syscall: three globals on the entry path, shared by every CPU
One to four processes died per boot with a fault they could not have caused:
`rip 0x0` -- an instruction fetch at zero, which a process does not branch to
-- or a dereference of `0xffffffffffffffea`, which is `-EINVAL`. It hit shells
and schinitd alike and had been carried for a long time as "zsh sometimes jumps
into .data".
`syscall_entry` has to swap FS_BASE from the user's TLS to the kernel's before
it can touch any `%fs:` data, and `wrmsr` destroys rax, rcx and rdx. Those have
to be parked somewhere first -- not on the user stack, which is untrusted and
has not been switched away from yet. They were parked in three globals:
// Single-CPU scratch for syscall_entry FS swap
// (safe while IF cleared by SYSCALL/SFMASK)
uint64 _sc_rax, _sc_rcx, _sc_rdx;
IF being clear answers for an interrupt arriving on *this* CPU. It says nothing
whatever about a second CPU executing the same three stores at the same
instant, and with more than one processor entering syscalls that is not a rare
interleaving -- it is the ordinary one.
**%rcx is the user return address.** SYSCALL puts the user's rip there and
SYSRET takes it back from there. So the CPU that lost the race returned its
process into *another process's* instruction stream, with %rax -- the syscall
number on the way in, the return value on the way out -- swapped alongside it.
Both symptoms fall straight out of that: a zero handed over and followed, and
an errno used as a pointer.
Per-CPU words at %gs:32..48 now. %gs is usable there, before the FS swap, for
exactly the reason RESTORE_KERNEL_FS can already read %gs:0: GS base is per-CPU
in hardware, seginit() sets it once, and userspace cannot change it --
sys_arch_prctl() implements ARCH_SET_FS and ARCH_GET_FS and rejects the rest.
Measured rather than argued. Both variants built once, `build/esp` saved for
each, the same pristine schfs.img restored before every boot, -smp 4:
three globals 10 boots 10 faults, in five of the ten
three per-CPU 18 boots 0 faults
A _Static_assert pins all seven per-CPU offsets, because trapasm.S spells them
as literals and cannot include a header -- a slot that moves would leave a
register quietly holding somebody else's value, which is the hardest possible
thing to find.
**This is the second bug of this exact shape in this file.** The first was
`kernel_fs_base`, a single global every CPU wrote during its own seginit(), so
the last writer won and every CPU restored one CPU's per-CPU block;
RESTORE_KERNEL_FS reads %gs:0 for that reason. The lesson generalises and is
now written down in both places: **on the entry path, a global is a bug.**
Closes the row added to Gate 0 four commits ago. It was the right row to put
first -- a compositor built on this would have failed in ways indistinguishable
from its own bugs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
374ed80096
commit
3185685997
6 changed files with 173 additions and 36 deletions
|
|
@ -157,15 +157,37 @@ trapret:
|
|||
# -----------------------------------------------------------------------
|
||||
.global syscall_entry
|
||||
syscall_entry:
|
||||
# Swap FS_BASE to kernel value BEFORE any %fs: access.
|
||||
# Save rax/rcx/rdx to single-CPU scratch globals (IF is off, no race).
|
||||
movq %rax, _sc_rax(%rip)
|
||||
movq %rcx, _sc_rcx(%rip) # user rip (SYSCALL stores it here)
|
||||
movq %rdx, _sc_rdx(%rip)
|
||||
# Swap FS_BASE to the kernel value BEFORE any %fs: access. wrmsr destroys
|
||||
# rax, rcx and rdx, so they have to be parked somewhere first -- not on the
|
||||
# user stack, which is untrusted and has not been switched away from yet.
|
||||
#
|
||||
# **Per CPU, at %gs:32..48.** These were three globals, `_sc_rax`,
|
||||
# `_sc_rcx` and `_sc_rdx`, under a comment reading "single-CPU scratch for
|
||||
# syscall_entry FS swap (safe while IF cleared by SYSCALL/SFMASK)". IF
|
||||
# being clear answers for an interrupt arriving on *this* CPU. It says
|
||||
# nothing whatever about a second CPU executing these same three stores at
|
||||
# the same moment -- and with two processors entering syscalls
|
||||
# concurrently, that is not a rare interleaving, it is the common one.
|
||||
#
|
||||
# %rcx is the user return address: SYSCALL puts the user's rip there and
|
||||
# SYSRET takes it back from there. So the CPU that lost the race returned
|
||||
# its process to *the other process's* instruction pointer. The symptom was
|
||||
# one to three processes per boot dying at `rip 0x0` or dereferencing
|
||||
# 0xffffffffffffffea (-EINVAL, a syscall return value used as a pointer,
|
||||
# because %rax was swapped too) -- never on -smp 1, one to three times on
|
||||
# -smp 2, and in any process rather than one.
|
||||
#
|
||||
# %gs is usable here, before the FS swap, for the same reason
|
||||
# RESTORE_KERNEL_FS can read %gs:0: GS base is per-CPU in hardware, is set
|
||||
# once by seginit(), and userspace cannot change it -- sys_arch_prctl()
|
||||
# implements ARCH_SET_FS and ARCH_GET_FS and rejects ARCH_SET_GS.
|
||||
movq %rax, %gs:32
|
||||
movq %rcx, %gs:40 # user rip (SYSCALL stores it here)
|
||||
movq %rdx, %gs:48
|
||||
RESTORE_KERNEL_FS # clobbers rax, rcx, rdx — that's OK
|
||||
movq _sc_rax(%rip), %rax
|
||||
movq _sc_rcx(%rip), %rcx
|
||||
movq _sc_rdx(%rip), %rdx
|
||||
movq %gs:32, %rax
|
||||
movq %gs:40, %rcx
|
||||
movq %gs:48, %rdx
|
||||
|
||||
# Switch to kernel stack: store user rsp at top of proc->kstack.
|
||||
#
|
||||
|
|
|
|||
|
|
@ -20,8 +20,16 @@ static pte_t * walkpgdir(pml4e_t *pml4, const void *va, int alloc);
|
|||
|
||||
// Kernel FS_BASE set by seginit(); read by trapasm.S to restore after user TLS
|
||||
uint64 kernel_fs_base;
|
||||
// Single-CPU scratch for syscall_entry FS swap (safe while IF cleared by SYSCALL/SFMASK)
|
||||
uint64 _sc_rax, _sc_rcx, _sc_rdx;
|
||||
|
||||
// The per-CPU block's layout is shared with trapasm.S, which spells the offsets
|
||||
// as literals because there is no way to include a C header from there. These
|
||||
// pin the C side: if a slot moves, the assembly keeps reading the old address
|
||||
// and the failure is a register quietly holding somebody else's value, which is
|
||||
// the hardest possible thing to find. Ask the question at compile time instead.
|
||||
_Static_assert(PERCPU_SELF == 0 && PERCPU_SCRATCH == 8 && PERCPU_CPU == 16 &&
|
||||
PERCPU_PROC == 24 && PERCPU_SC_RAX == 32 &&
|
||||
PERCPU_SC_RCX == 40 && PERCPU_SC_RDX == 48,
|
||||
"per-CPU block layout changed -- update kernel/arch/x86_64/trapasm.S");
|
||||
|
||||
static pml4e_t *kpml4;
|
||||
static pdpe_t *kpdpt;
|
||||
|
|
@ -116,11 +124,15 @@ seginit(void)
|
|||
// particular compiler's TLS optimisation.
|
||||
*(uint64 *) kernel_fs_base = kernel_fs_base;
|
||||
|
||||
// Clear the two per-CPU slots before anything can read them. Their offsets
|
||||
// are PERCPU_CPU and PERCPU_PROC in include/proc.h and are shared with
|
||||
// trapasm.S, which reads %gs:16 for proc on the way out to user mode.
|
||||
// Clear the per-CPU slots before anything can read them. Their offsets are
|
||||
// the PERCPU_* constants in include/proc.h and are shared with trapasm.S,
|
||||
// which reads %gs:24 for proc on the way out to user mode and parks three
|
||||
// registers at %gs:32..48 across the FS_BASE swap on the way in.
|
||||
((uint64 *) kernel_fs_base)[2] = 0; // struct cpu * (PERCPU_CPU)
|
||||
((uint64 *) kernel_fs_base)[3] = 0; // struct proc * (PERCPU_PROC)
|
||||
((uint64 *) kernel_fs_base)[4] = 0; // syscall rax (PERCPU_SC_RAX)
|
||||
((uint64 *) kernel_fs_base)[5] = 0; // syscall rcx (PERCPU_SC_RCX)
|
||||
((uint64 *) kernel_fs_base)[6] = 0; // syscall rdx (PERCPU_SC_RDX)
|
||||
|
||||
// The same address in GS base, per CPU.
|
||||
//
|
||||
|
|
|
|||
Loading…
Reference in a new issue