Skip to content

examples: Containment tests for a protected build and a kernel build - #3721

Draft
casaroli wants to merge 4 commits into
apache:masterfrom
casaroli:fork-ostest-pffault
Draft

examples: Containment tests for a protected build and a kernel build#3721
casaroli wants to merge 4 commits into
apache:masterfrom
casaroli:fork-ostest-pffault

Conversation

@casaroli

Copy link
Copy Markdown
Contributor

Summary

Two test programs for a protected build and a kernel build.

examples/pffault touches one kernel address from a user process.
It is small and direct, and it shows the fault and the signal.

examples/sandbox is the general test.
Every target carries the outcome it expects, so the test fails a build that refuses everything as well as one that permits everything.

target address expected
self its own data must succeed
kernel kernel memory must fault
periph a peripheral register must fault
unmapped an address with no mapping must fault

The self target is the control.
Without it a build that refuses every access passes every other check.

The offending process allocates memory, writes to all of it, and opens a file before it makes the access.
It still holds both when it dies.
The test reads /proc/meminfo before, while the offender lives, and after it is reaped, and it reads /proc/<pid>/group/fd while the offender lives.
A count that never rises is reported as a failure, because "the same before and after" says nothing if the memory was never seen.

The offender is a process and not a task.
A kernel build does not give task_create() to user code, so posix_spawn() is used.

testing/ostest gets separate fork() and vfork() tests.

Impact

Three new example programs and one test change.
No existing configuration selects them.

Testing

Board: ESP32-S3-DevKitC with an ESP32-S3-WROOM-2 N32R8V, 32 MB octal flash and 8 MB PSRAM.

Configuration: esp32s3-devkit:kernel_oct, a BUILD_KERNEL image.

sandbox: /proc/meminfo reads
      total       used       free    maxused    maxfree  nused  nfree name
     378616      28336     350280      28704     345576    105      4 Kmem
    4194304    1441792    2752512               2752512               Page

sandbox: target self -- this process's own data, expecting success
sandbox: PASS - the allowed access completed
sandbox: memory 1441792 -> 2162688 -> 1441792
sandbox: PASS - 3 descriptor(s) open, none after

sandbox: target kernel -- kernel memory at 0x3fc98000, expecting a fault
pms_violation_isr: SIGSEGV (PMS) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: target periph -- a peripheral register at 0x600c5000, expecting a fault
pms_violation_isr: SIGSEGV (PMS) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: target unmapped -- an address with no mapping at 0x3d800000, expecting a fault
pms_violation_isr: SIGSEGV (MMU entry) task /system/bin/sandbox
sandbox: PASS - the offending process was terminated

sandbox: CONTAINED - 4 target(s), every check passed

The run above was made three times, which is twelve process deaths.
The memory returns to the same value each time, and the descriptors are closed each time.

ostest runs to the end on the same image.

vfork_test: Child 41 ran and exited before the parent resumed
fork_test: Child running independently (child)
fork_test: Parent and child had independent memory
ostest_main: Exiting with status 0

tools/checkpatch.sh -c -u -m -g reports no errors.

ostest's "vfork" test was never testing vfork().  It has the child write a
global and the parent observe the write -- the defining property of *sharing*,
not of vfork(), whose defining property is that the parent is suspended and
whose contract forbids the child to write anything at all.  It passed because
NuttX implemented fork() and vfork() as the same sharing primitive, which
apache/nuttx#19562 separates.

vfork.c is rewritten to test what vfork() promises.  The child does only what
POSIX permits -- it calls _exit(42) and nothing else, not even exit(), which
would run atexit handlers and flush stdio in the parent's address space.  Since
the child may not write memory and the parent cannot run while the child lives,
the observable is the child's exit status:  had the parent not been suspended,
it would have reached waitpid() while the child was still alive.  Where child
status is not retained -- ostest_main() sets SA_NOCLDWAIT for the whole run,
deliberately -- ECHILD is accepted as equally good evidence, since it says the
child was already gone when the parent asked.

fork.c is new and tests POSIX fork():  the child's writes to .data, .bss and
the heap are invisible to the parent and vice versa, a pointer to a stack local
taken before the fork names the same object in both, and the child does
everything a vfork() child may not -- calls malloc() and printf(), and returns
from the function that called fork().

Both run at the top of user_main().  They exercise the lowest-level machinery
in the suite -- address environments, stack setup, the architecture's register
context -- so a fault in one takes the process down instead of reporting a
failure.  Learning that in seconds rather than after everything else has passed
matters when a port is being brought up.

Each test gates on the one primitive it tests, ARCH_HAVE_VFORK and
ARCH_HAVE_FORK respectively.  There is no compatibility layer and no mapping
between symbols.  vfork.c no longer requires SCHED_WAITPID:  the suspension is
in the kernel primitive now, so the test's core assertion holds without it and
only the status check is conditional.

The other in-tree callers are audited for which primitive they actually meant:

* interpreters/python's _posixsubprocess and netutils/libwebsockets'
  LWS_HAVE_WORKING_VFORK want the fork-then-exec path -- ARCH_HAVE_VFORK.
* python's os.fork() and libwebsockets' LWS_HAVE_FORK mean real fork() and stay
  on ARCH_HAVE_FORK, so they become *absent* rather than silently wrong.
* testing/fs/fdsantest's vfork case follows ARCH_HAVE_VFORK.

interpreters/bas is deliberately left alone.  Its SHELL and EDIT statements
reach for vfork() under an ARCH_HAVE_FORK guard and want the same treatment,
but checkpatch.sh checks the whole of any file a patch touches and
bas_statement.c produces 1681 pre-existing findings against master, so a
one-line change there fails CI on its own.  The consequence is small:
EXAMPLES_BAS_SHELL is EXPERIMENTAL and already depends on ARCH_HAVE_FORK, so it
becomes unselectable rather than misbehaving.

Assisted-by: Claude Code:claude-opus-5
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
A small user-space task that reads or writes an arbitrary address, used to
exercise the ESP32-S3 recoverable-fault path on target.  With
CONFIG_ESP32S3_PAGEFAULT[_SELFTEST] enabled on the kernel side,
"pffault r 0x80000000" drives the precise-fault -> RFE-restart proof; a bad
address such as "pffault r 0x0" exercises the dispatcher's report/decline path.

Assisted-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit bad8c4f)
(cherry picked from commit 4e93ce3)

Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
examples/pffault already probes the kernel/user boundary, but only on one
chip:  it hard-codes 0x3fc98000, the base of the ESP32-S3 kernel DRAM
region, and is written in PMS/WORLD1 terms.  It says nothing on any other
architecture.

This is the architecture-neutral version.  The address it touches is
derived rather than hard-coded:  every BUILD_PROTECTED configuration in
the tree places the kernel blob below the user blob, with the boundary at
CONFIG_NUTTX_USERSPACE, so the word just below that belongs to the kernel
on all of them -- 0x00200000 on qemu-armv7a:pnsh, 0x41000000 on
qemu-armv8a:pnsh, 0x80040000 on rv-virt:pnsh[64], 0x10200000 on
mps2-an521:knsh, 0x10100000 on pimoroni-pico-2-plus:pnsh.  Whether that
address holds kernel code, kernel data or nothing mapped at all does not
matter; an unprivileged task must not be able to read it either way.

The test is self-checking rather than a bare crash.  It starts a canary
task, spawns a second task to make the forbidden access, waits for that
task, and then asserts three separate things:  the offender died, the
caller is still running, and the canary is still counting.  That last one
is what distinguishes "the offender was contained" from "the whole system
stopped", which a test that only observes its own survival cannot tell
apart.

A flat build has no boundary to escape from, and no CONFIG_NUTTX_USERSPACE
either; there the test says there is nothing to contain rather than
reporting a pass it did not earn.

`sandbox escape [r|w] [addr]` is the one-shot form, which faults in the
calling task, for use under a debugger.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
(cherry picked from commit e8f4272)
The test spawned the offender with task_create(), which a kernel build does
not give user code, so it did not link there.  Spawn a process instead, which
works in a protected build and in a kernel build.

Give every target the outcome it expects.  A build that refuses everything is
as wrong as one that permits everything, and only a target that must succeed
can tell them apart, so "self" touches memory the process owns.

Take the addresses from Kconfig.  A user process cannot see kernel symbols,
because that is the boundary under test, so a board supplies them.  A
protected build still derives the kernel target from CONFIG_NUTTX_USERSPACE.
A kernel build has no such address, because each process has its own address
environment;  saying "this is a flat build" there was wrong.

Add the peripheral and unmapped targets.  An MMU keeps processes apart but
does not stop one reaching a peripheral, and an unmapped access is refused by
a different mechanism again, so neither is covered by the kernel target.

Assisted-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Marco Casaroli <marco.casaroli@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant