This is an automated email from the ASF dual-hosted git repository.
acassis pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/nuttx.git
The following commit(s) were added to refs/heads/master by this push:
new a652708efad arch/x86_64: Kill only the offending task on a user-space
fault.
a652708efad is described below
commit a652708efad6bc043352f25f507e92d3c7e8e4a8
Author: Marco Casaroli <[email protected]>
AuthorDate: Wed Jul 29 09:30:04 2026 +0200
arch/x86_64: Kill only the offending task on a user-space fault.
An unprivileged task that touched memory it does not own took the whole
system down. The MMU refused the access, as it should, and then
x86_64_fault_panic_isr() panicked -- so a contained application bug
became a system-wide outage. x86_64 had no user-fault recovery at all,
while arm64, RISC-V and esp32s3 each have one, and ISR13 and ISR14 here
went straight to a handler whose own comment says "Don't even brother
to recover, just dump the regs and PANIC."
The decision has to be made in the handler. The user-task check in
_assert() looks like it already covers this, but a fault arrives as an
exception, so up_interrupt_context() is already true by the time it is
reached and the panic branch is taken no matter who faulted.
The CPL in the saved CS is the whole test, and on this architecture it
is exact: everything that runs on behalf of a user task inside the
kernel -- a system call body, an interrupt handler, a kernel thread --
runs in ring 0, so a fault there is correctly refused recovery. That is
what RISC-V reads out of STATUS_PPP and arm64 out of SPSR_MODE_EL0T.
x86_64 cannot use TCB_FLAG_SYSCALL the way those two do: it has never
set it, and making it do so is a separate change with its own hazards
(see the note in x86_64_syscall()). The task type is checked as well,
as RISC-V does, so that a frame that cannot be trusted -- an early-boot
fault, before any user task exists -- cannot talk its way in with a
stale selector.
Recovery is the same shape as the other architectures -- RIP to _exit,
first argument SIGSEGV, TCB_FLAG_FORCED_CANCEL raised -- plus the three
x86_64 specifics:
* CS and SS move to the kernel selectors together with RIP. The frame
being rewritten is the one x86_64_fullcontextrestore() is about to
iretq from, and iretq takes the target privilege level from the CS
it pops; in long mode it pops SS with it even when the level does
not change.
* RSP moves to the top of the task's own kernel stack. _exit() must
not run on the user stack the fault came from, because the address
environment that stack belongs to is torn down while _exit() is
still running. The kernel stack is free -- the fault was taken in
user mode, so no system call of this task is in flight. The -8
reproduces the offset a call would have left, which is what the SysV
ABI states its 16-byte rule against and what up_initial_state() sets
up for the same reason.
* RFLAGS is reset to the value up_initial_state() gives a new thread
rather than carried over. The faulting task's flags are its own to
set, and DF in particular must be clear on entry to any C function.
ISR6 is routed through the same handler. An invalid opcode is
attributable to the instruction that raised it, so a user task running
garbage should die on its own rather than take the system with it --
the same conclusion esp32s3 reached for EXCCAUSE_ILLEGAL. ISR8 keeps
panicking unconditionally: a double fault says an exception could not be
delivered at all, and there is nothing left to trust.
The message gives the fault address (CR2) only for a page fault, because
the other exceptions do not set CR2.
X86_GDT_PL_MASK and X86_GDT_RPL_USER now live in intel64/arch.h beside
the selectors they mask; x86_64_fork.c had a private copy of the latter.
Verified on qemu-intel64:knsh_romfs under QEMU TCG with
apps/examples/sandbox. The probe targets kernel .text at _stext
(0x100909000). This config sets CONFIG_RAM_START to 0x0, a Kconfig
default, so the address is given on the command line. The "x" probes
call the address; that mode was added to the sandbox locally for the
test.
sandbox r 0x100909000 -> Exception 14, error code 5, 2816
sandbox w 0x100909000 -> Exception 14, error code 7, 2816
sandbox x 0x100909000 -> Exception 14 at RIP=100909000, 2816
sandbox r 0x8000000000000000 -> Exception 13 (non-canonical), 2816
sandbox x 0x80000000d -> Exception 6 at RIP=80000000d, 2816
All five probes run in one boot and report CONTAINED. The offender
dies with SIGSEGV, the parent survives, a canary thread keeps running,
and the memory and descriptors of the offender come back. The shell
answers after the last probe. Without this change each probe panics
the system.
ostest exits with status 0 on knsh_romfs, fork and vfork included. It
also exits with status 0 on the flat qemu-intel64:nsh with
CONFIG_SCHED_THREAD_LOCAL disabled. With it enabled, ostest faults in
sched_thread_local_test() with and without this change.
Each vector is reached deliberately. A non-canonical address is a #GP
rather than a #PF, which the read and call probes never produce on their
own. The #UD needs no corrupt binary either: the crt0 stub this
architecture links into every user ELF already ends in a ud2 at
_stext+0xd, and with CONFIG_ARCH_TEXT_VBASE at 0x800000000 the call
probe can simply be aimed at it, executing an invalid opcode in ring 3
out of the process's own text.
The negative direction was checked too, on the flat build, where the
same non-canonical read is taken at CPL 0: it reaches
x86_64_fault_panic_isr() exactly as before and panics with a full
register dump reporting CPL 0.
Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Marco Casaroli <[email protected]>
---
arch/x86_64/include/intel64/arch.h | 8 +++
arch/x86_64/src/common/x86_64_fork.c | 16 -----
arch/x86_64/src/intel64/intel64_irq.c | 128 ++++++++++++++++++++++++++++++++--
3 files changed, 132 insertions(+), 20 deletions(-)
diff --git a/arch/x86_64/include/intel64/arch.h
b/arch/x86_64/include/intel64/arch.h
index e249ecba813..4eddfc26190 100644
--- a/arch/x86_64/include/intel64/arch.h
+++ b/arch/x86_64/include/intel64/arch.h
@@ -106,6 +106,14 @@
#define X86_GDT_USERCODE_SEL_NUM 7
# define X86_GDT_USERCODE_SEL (X86_GDT_USERCODE_SEL_NUM *
X86_GDT_ENTRY_SIZE)
+/* Privilege level carried in the low two bits of a segment selector. In the
+ * CS of a saved exception frame this is the CPL the fault was taken at, and
+ * so tells kernel code from user code.
+ */
+
+#define X86_GDT_PL_MASK 3
+#define X86_GDT_RPL_USER 3
+
/* The first TSS entry */
#define X86_GDT_ISTL_SEL_NUM 8
diff --git a/arch/x86_64/src/common/x86_64_fork.c
b/arch/x86_64/src/common/x86_64_fork.c
index 88d22099be6..fd8fd9061c4 100644
--- a/arch/x86_64/src/common/x86_64_fork.c
+++ b/arch/x86_64/src/common/x86_64_fork.c
@@ -42,22 +42,6 @@
#include "x86_64_internal.h"
#include "sched/sched.h"
-/****************************************************************************
- * Pre-processor Definitions
- ****************************************************************************/
-
-#ifdef CONFIG_LIB_SYSCALL
-
-/* Requested privilege level 3 in a segment selector. A caller that reached
- * here through the `syscall' instruction was in user mode, and SYSRETQ is
- * going to put it back there with the selectors IA32_STAR describes -- see
- * x86_64_fork_syscall().
- */
-
-# define X86_GDT_RPL_USER 3
-
-#endif
-
/****************************************************************************
* Private Functions
****************************************************************************/
diff --git a/arch/x86_64/src/intel64/intel64_irq.c
b/arch/x86_64/src/intel64/intel64_irq.c
index 7bf1d4163b3..aa02ce48d47 100644
--- a/arch/x86_64/src/intel64/intel64_irq.c
+++ b/arch/x86_64/src/intel64/intel64_irq.c
@@ -27,8 +27,11 @@
#include <nuttx/config.h>
#include <nuttx/compiler.h>
+#include <inttypes.h>
+#include <signal.h>
#include <stdint.h>
#include <string.h>
+#include <unistd.h>
#include <nuttx/debug.h>
#include <nuttx/irq.h>
@@ -504,6 +507,117 @@ static int x86_64_fault_panic_isr(int irq, void *c, void
*arg)
return 0;
}
+/****************************************************************************
+ * Name: x86_64_fault_user_isr
+ *
+ * Description:
+ * A fault that an unprivileged task raised is that task's own doing -- a
+ * bad pointer, a jump into nothing, or a deliberate reach across the
+ * kernel/user boundary. Killing just that task is the whole point of a
+ * kernel build, so the system carries on and only the offender dies.
+ * Taken from ring 0 the same fault is the kernel's own and still panics.
+ *
+ * The CPL in the saved CS is the whole test. Everything that runs on
+ * behalf of a user task inside the kernel -- a system call body, an
+ * interrupt handler, a kernel thread -- runs in ring 0, so a fault there
+ * is correctly refused recovery. That is the same discriminator RISC-V
+ * uses in STATUS_PPP and arm64 in SPSR_MODE_EL0T; x86_64 cannot use
+ * TCB_FLAG_SYSCALL, which it has never set (see x86_64_syscall()).
+ *
+ ****************************************************************************/
+
+static int x86_64_fault_user_isr(int irq, void *c, void *arg)
+{
+ /* The frame irq_common() saved for this exception, which is also what
+ * x86_64_fullcontextrestore() is going to iretq from. It arrives as the
+ * dispatch context rather than through running_regs() so that the frame
+ * being tested is certainly the one being rewritten.
+ */
+
+ uint64_t *regs = (uint64_t *)c;
+ struct tcb_s *tcb = this_task();
+ struct tcb_s *ptcb;
+ uint64_t cr2;
+
+ /* The CPL check is the one that decides this. The task type is checked
+ * as well, as RISC-V does, so that a frame that cannot be trusted -- an
+ * early-boot fault, before any user task exists -- cannot talk its way
+ * into the recovery path with a stale selector.
+ */
+
+ if (((tcb->flags & TCB_FLAG_TTYPE_MASK) == TCB_FLAG_TTYPE_KERNEL) ||
+ ((regs[REG_CS] & X86_GDT_PL_MASK) != X86_GDT_RPL_USER))
+ {
+ return x86_64_fault_panic_isr(irq, c, arg);
+ }
+
+ ptcb = nxsched_get_tcb(tcb->group->tg_pid);
+
+ _alert("Segmentation fault in %s (PID %d: %s)\n",
+ ptcb != NULL ? get_task_name(ptcb) : "<gone>",
+ tcb->pid, get_task_name(tcb));
+
+ /* Only a page fault sets CR2; for the others it is stale */
+
+ if (irq == ISR14)
+ {
+ __asm__ volatile ("mov %%cr2, %%rax; mov %%rax, %0"
+ : "=m"(cr2) :: "memory", "rax");
+
+ _alert("Exception %d at RIP=%016" PRIx64 " accessing %016" PRIx64
+ ", error code %" PRId64 "\n",
+ irq, regs[REG_RIP], cr2, regs[REG_ERRCODE]);
+ }
+ else
+ {
+ _alert("Exception %d at RIP=%016" PRIx64 ", error code %" PRId64 "\n",
+ irq, regs[REG_RIP], regs[REG_ERRCODE]);
+ }
+
+#ifdef CONFIG_SCHED_BACKTRACE
+ sched_dumpstack(tcb->pid);
+#endif
+
+ tcb->flags |= TCB_FLAG_FORCED_CANCEL;
+
+ /* Return to _exit(SIGSEGV) in ring 0 instead of to the instruction that
+ * faulted, which would only fault again. The frame this rewrites is the
+ * one x86_64_fullcontextrestore() is about to iretq from, so CS and SS
+ * have to move to the kernel selectors together with RIP: iretq takes
+ * the target privilege level from the CS it pops and, in long mode, pops
+ * SS with it even when the level does not change.
+ */
+
+ regs[REG_RIP] = (uint64_t)_exit;
+ regs[REG_RDI] = SIGSEGV;
+ regs[REG_CS] = X86_GDT_CODE_SEL;
+ regs[REG_SS] = X86_GDT_DATA_SEL;
+ regs[REG_DS] = X86_GDT_DATA_SEL;
+ regs[REG_ES] = X86_GDT_DATA_SEL;
+
+ /* RFLAGS is reset rather than carried over. The faulting task's flags are
+ * its own to set, and DF in particular must be clear on entry to any C
+ * function. This is the value up_initial_state() gives a new thread.
+ */
+
+ regs[REG_RFLAGS] = X86_64_RFLAGS_IF;
+
+#ifdef CONFIG_ARCH_KERNEL_STACK
+ /* _exit() must not run on the user stack the fault came from: the address
+ * environment it belongs to is torn down while _exit() is still running on
+ * it. The task's kernel stack is unused -- the fault was taken in user
+ * mode, so no system call of this task is in flight -- so start from its
+ * top. The -8 reproduces the offset a call instruction would have left,
+ * which is what the SysV ABI's 16-byte alignment rule is stated against
+ * and what up_initial_state() sets up for the same reason.
+ */
+
+ regs[REG_RSP] = (uint64_t)tcb->xcp.ktopstk - 8;
+#endif
+
+ return 0;
+}
+
/****************************************************************************
* Name: x86_64_fault_kill_isr
****************************************************************************/
@@ -565,13 +679,19 @@ void up_irqinitialize(void)
up_irq_restore(X86_64_RFLAGS_IF);
#endif
- /* Attach default handlers for faults */
+ /* Attach default handlers for faults.
+ *
+ * ISR6, ISR13 and ISR14 are attributable to the instruction that raised
+ * them, so an unprivileged task that raises one is killed on its own and
+ * the system runs on. The double fault is not: it says an exception
+ * could not be delivered at all, and there is nothing left to trust.
+ */
irq_attach(ISR0, x86_64_fault_kill_isr, NULL);
- irq_attach(ISR6, x86_64_fault_panic_isr, NULL);
+ irq_attach(ISR6, x86_64_fault_user_isr, NULL);
irq_attach(ISR8, x86_64_fault_panic_isr, NULL);
- irq_attach(ISR13, x86_64_fault_panic_isr, NULL);
- irq_attach(ISR14, x86_64_fault_panic_isr, NULL);
+ irq_attach(ISR13, x86_64_fault_user_isr, NULL);
+ irq_attach(ISR14, x86_64_fault_user_isr, NULL);
irq_attach(ISR16, x86_64_fault_kill_isr, NULL);
#ifdef CONFIG_SMP