# DF-2898 — unprivileged deterministic kernel panic via self-recursive
indirect-syscall dispatch (sysent[0]/sysent[198])

## One-paragraph summary

`sys/kern/init_sysent.c` maps syscall numbers 0 (`SYS_syscall`) and 198
(`SYS___syscall`) to the shared indirect-dispatch handler `sys_xsyscall`
(init_sysent.c:17 and :215).  That handler
(sys/platform/pc64/x86_64/trap.c:1386-1455, mirrored in
sys/platform/vkernel64/x86_64/trap.c) reads the *target* syscall number from
`frame->tf_rdi` and dispatches `sysent[code]` with **no guard against
`code` being 0 or 198 again**.  DragonFly's libc implements the generic
`syscall()`/`__syscall()` as `mov $198,%rax; syscall` with **no argument
shuffling**, so the caller's `number` arrives in RDI.  Any unprivileged user
who calls `syscall(SYS_syscall, ...)` or `syscall(SYS___syscall, ...)`
(i.e. passes the gateway number 0/198 *as the number*) makes the kernel
dispatch `sys_xsyscall` → `sys_xsyscall` → … forever.  Each level burns
kernel stack until the 16KB kernel stack guard page is hit:
`DOUBLE FAULT - KERNEL STACK GUARD HIT!` / `panic: double fault`.
Deterministic, one line of C, no privileges, default config.

## Build / run / expected

```
cc -O0 -o trigger trigger.c
./trigger            # any unprivileged user
```

Expected on a stock kernel (verified on the audit guest's stock
INVARIANTS `X86_64_GENERIC` #0, Thu Jul 2 06:02:54 UTC 2026):

```
DOUBLE FAULT - KERNEL STACK GUARD HIT!

Fatal double fault
rip = sys_xsyscall+0x84        (the retpoline-thunk `call` that performs
rsp = <kstack guard page>       (*callp->sy_call)(sysmsg, argp))
rbp = rsp+0x48
panic: double fault
cpuid = N
dblfault_handler() at dblfault_handler+0x10c
Debugger("panic")
db>
```

The box is dead (sits in DDB; ssh stops answering).

## Mechanism, traced end to end

1. libc (x86_64) `syscall()`/`__syscall()` stubs are
   `mov $0,%rax` / `mov $198,%rax`; `mov %rcx,%r10; syscall`
   (verified by disassembling `syscall.o`/`__syscall.o` out of
   `/usr/lib/libc.a` in the guest, and by kernel-side instrumentation
   observing tf_rax=198 for every libc-originated syscall).
   The C caller's first argument (`number`) is left in RDI, args ride
   RSI..R9 and [RSP+8].. — exactly the layout `sys_xsyscall` expects.
2. `syscall(SYS_syscall=0, 0)` → rax=198, rdi=0.
3. `syscall2` (trap.c:1219-1245) → `sysent[198].sy_call` = `sys_xsyscall`.
4. `sys_xsyscall` (trap.c:1402-1406): `code = tf_rdi = 0`;
   `callp = &sysent[0]` → `sy_call = sys_xsyscall` again.
5. trap.c:1445 `(*callp->sy_call)(sysmsg, argp)` — re-enters
   `sys_xsyscall`, which re-reads the *unchanged* `tf_rdi` = 0 …
   unbounded recursion, ~70 bytes/level, 16KB kstack ⇒ ~150 frames.
6. First push into the guard page faults; the fault push in the
   double-fault handler faults again → `panic: double fault`.
   The faulting instruction is the `-mindirect-branch=thunk-inline`
   (retpoline) call sequence that implements the indirect dispatch at
   `sys_xsyscall+0x84`.

Note `sys_nosys`-style entries never recurse because `sys_xsyscall` is the
only self-referential target in the whole `sysent[]` table.

## Why the fault always lands at sys_xsyscall+0x84

The recursion adds one `sys_xsyscall` frame per level (~0x70 bytes plus the
8-byte retpoline thunk bookkeeping).  The deepest push executed before the
guard is hit is the retpoline dispatch `call` — so the panic RIP is always
`sys_xsyscall+0x84` (offset shifts with rebuilds; verified identical in
stock #0 and instrumented #2 builds).

## Instrumented-kernel proof (no leak, pure recursion)

A kernel rebuilt with a `kprintf` at `syscall2` entry printing
rsp/td_pcb/td_kstack for code∈{0,198,20} showed, for the crashing thread:

```
SYSE code=198 pid=892 tid=1 rsp=0xfffff8011804c968 pcb=0xfffff8011804cac0 kstack=0xfffff80118049000+16384
<interleaved with> panic: double fault ... rsp=0xfffff8011804b000
```

- entry geometry healthy on EVERY indirect syscall (rsp ≈ pcb ≈ kstack top):
  **there is no stack-pointer leak** — earlier "leak" hypotheses are refuted;
- fault rsp sits ~10.5KB below the healthy frame ⇒ the whole distance was
  consumed *inside* the single dispatch ⇒ recursion;
- `ps` at the DDB prompt names the faulting thread: the unprivileged
  user process itself (`comm sysent_stage` / pid of `probe`).

## Reproduction history (baseline, stock kernel #0)

| # | call | source | result |
|---|------|--------|--------|
| 1 | `syscall(SYS_syscall,197,0,4096,3,0x1002,-1,0)` | sysent_edge stage 5 | panic (rip sys_xsyscall+0x84) |
| 2 | same, fresh boot | sysent_edge stage 5 | panic, identical signature |
| 3 | raw rax=0/rdi=0-equivalent (compiler aliasing artifact, same kernel path) | sysent_var d | panic |
| 4 | `syscall(SYS_syscall,20)` (pid 874) | libcind p | panic |
| 5 | `syscall(SYS_syscall,20)` (pid 892) | probe 4 | panic |
| 6 | `syscall(SYS___syscall,198)` class (probe 3/6/7) | probe | panic (same class) |

Direct-control loops (5000× direct getpid, 3000× direct 7-arg mmap,
200× direct mmap on instrumented kernel) never crash — only the
indirect-gateway-number-in-rdi path does.

## Fix validation (single-fix kernel)

`fix.diff` adds a two-line guard to `sys_xsyscall` (both pc64 and
vkernel64): if the gateway number resolves to 0 or 198, remap to
`SYS___nosys` exactly like the existing out-of-range remap.

Guest rebuilt pristine+fix (`make nativekernel`, kernel #1
Thu Sep 3 03:11:47 UTC 2026) and the full probe matrix was re-run:

```
P2 raw rax=0,rdi=0          → "Bad system call (core dumped)"  (SIGSYS, no panic)
P3 raw rax=198,rdi=198      → SIGSYS, no panic
P4 libc syscall(SYS_syscall,20)      → SIGSYS, no panic
P5 libc syscall(SYS_syscall,197,...) → SIGSYS, no panic   (original crasher)
P6 libc syscall(0,0)        → SIGSYS, no panic
P7 libc syscall(198,198)    → SIGSYS, no panic
P8 libc syscall(SYS___syscall,20)    → SIGSYS, no panic
P1 raw rax=0,rdi=20 (valid indirect getpid) → returns pid (846) — gateway still works
guest: up
```

Baseline: same calls panic the stock kernel (rows above).
Patched: clean SIGSYS rejection, legitimate indirect dispatch intact.

## Impact

Unprivileged local kernel panic (availability).  The recursion pushes only
return addresses and is stopped by the kernel-stack guard page — no data
corruption beyond the guard is achievable; this is a DoS, not a corruption
primitive.  Every DragonFly x86_64 system (pc64 and vkernel64 dispatchers)
is affected on default config; nothing in normal operation calls
`syscall(0,…)`/`syscall(198,…)`, which is why it survived in the wild.

## Files in this pack

```
trigger.c        minimal 1-line trigger (canonical PoC)
probe.c          8-case probe matrix (raw asm + libc), used for fix validation
sysent_edge.c    original edge-case harness (stage 5 = first crasher)
sysent_stage.c   stage-driven bisect harness
sysent_var.c     variant bisect harness (raw asm + libc)
leaktest.c       stack-leak hypothesis loops (controls + indirect loops)
libcind.c        libc indirect matrix (proved libc rax=198 no-shift)
sysent_xcheck.py pass-2 machine cross-check of sysent[] vs sysproto/sysunion
                   (556 rows, nargs≤union capacity, no gaps, names aligned)
instrument-snippet.c  kprintf instrumentation used in the diagnostic kernel
fix.diff         authoritative 2-line fix (pc64 + vkernel64), git-apply-able
panic-baseline.txt    stock-kernel panic transcripts (2 independent boots + probe run)
run-patched.txt       full probe matrix output on the fixed kernel
build.log (excerpt)   fixed-kernel build completion
env.txt          guest/kernel environment
manifest.json / verdict.json
```
