sysent[0]/sysent[198] indirect-syscall gateway dispatches itself: unprivileged one-line syscall(SYS_syscall,0) deterministically panics the kernel (double fault on the kernel-stack guard)
| Field | Value |
|---|---|
| ID | DF-2898 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:N/I:N/A:H (6.5) |
| CWE | CWE-674 Uncontrolled Recursion |
| File | sys/kern/init_sysent.c |
| Lines | 17, 215 (dispatch: trap.c:1386-1455 pc64; vkernel64:1216) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | base:kern |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
init_sysent.c maps both gateway entries β {0,4,sys_xsyscall} (line 17)
and {0,4,sys_xsyscall} (line 215) β to the same indirect-dispatch
handler. sys_xsyscall reads the target syscall number from frame->tf_rdi
and calls sysent[code].sy_call with no rejection of code==0/198.
Because tf_rdi never changes between hops, any syscall arriving with
raxβ{0,198} and rdiβ{0,198} re-dispatches sys_xsyscall into itself
unbounded: ~150 nested frames exhaust the 16KB kernel stack, the guard
page faults, and the double-fault handler panics. DragonFly's libc
makes this trivially reachable: the generic syscall()/__syscall()
stubs pass number in RDI with no shuffle, so a plain
syscall(SYS_syscall, 0) from any unprivileged user panics the machine
β verified 5Γ on the stock INVARIANTS guest across 3 fresh boots
(DOUBLE FAULT - KERNEL STACK GUARD HIT! / rip = sys_xsyscall+0x84),
libc-free raw-asm variants reproduce it, and an instrumented kernel
proved the recursion is strictly intra-dispatch (no stack-pointer leak).
sysent[0]/[198] are the only self-referential entries in the table.
Impact ceiling = panic (local DoS); no corruption escapes the guard
page, no uid=0 path exists for this primitive. Negative sweep in the
pack also machine-verified the whole table: nargs β€ 7 == union capacity
(no extargs overflow), bounds == SYS_MAXSYSCALL, no NULL sy_call, no
numbering gaps, rsize fields correct, no unloadable KLD targets.
Proof of contest
VERIFIED (findings/poc/DF-2898/): trigger.c syscall(SYS_syscall, 0)
as any user β deterministic double-fault panic, 5/5 runs. Fix (guard
code β {SYS_syscall, SYSsyscall} β SYSnosys in both pc64 and
vkernel64 trap.c) validated on a guest-rebuilt kernel: all trigger
variants get SIGSYS, valid indirect dispatch still works, guest stays
up.
Recommended fix
Validated 2-line guard in sys_xsyscall β fix.diff in findings/poc/DF-2898/ (trap.c pc64 + vkernel64).
Timeline
- 2026-09-02 Discovered during pass-2 audit of init_sysent.c (GLM 5.3); unpriv panic reproduced 5Γ + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2898 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | β | 989 B | view raw | |
| probe.c | β | 1.9 KB | view raw | |
| sysent_edge.c | β | 3.2 KB | view raw | |
| sysent_stage.c | β | 2.2 KB | view raw | |
| sysent_var.c | β | 3.8 KB | view raw | |
| leaktest.c | β | 3.8 KB | view raw | |
| libcind.c | β | 980 B | view raw | |
| sysent_xcheck.py | β | 6.0 KB | view raw | |
| instrument-snippet.c | β | 353 B | view raw | |
| fix.diff | β | 1.2 KB | view raw | |
| panic-baseline.txt | β | 1.8 KB | view raw | |
| run-patched.txt | β | 1.2 KB | view raw | |
| build.log | β | 542 B | view raw | |
| env.txt | β | 967 B | view raw | |
| README.md | β | 7.5 KB | β raw | |
| VERDICT.md | β | 5.4 KB | β raw | |
| verdict.json | β | 4.9 KB | view raw |
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
- libc (x86_64)
syscall()/__syscall()stubs aremov $0,%rax/mov $198,%rax;mov %rcx,%r10; syscall(verified by disassemblingsyscall.o/__syscall.oout of/usr/lib/libc.ain 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 layoutsys_xsyscallexpects. syscall(SYS_syscall=0, 0)β rax=198, rdi=0.syscall2(trap.c:1219-1245) βsysent[198].sy_call=sys_xsyscall.sys_xsyscall(trap.c:1402-1406):code = tf_rdi = 0;callp = &sysent[0]βsy_call = sys_xsyscallagain.- trap.c:1445
(*callp->sy_call)(sysmsg, argp)β re-enterssys_xsyscall, which re-reads the unchangedtf_rdi= 0 β¦ unbounded recursion, ~70 bytes/level, 16KB kstack β ~150 frames. - 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 atsys_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;
psat the DDB prompt names the faulting thread: the unprivileged user process itself (comm sysent_stage/ pid ofprobe).
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
VERDICT β DF-2898 (sys/kern/init_sysent.c pass 2)
status: reproduced impact: panic (unprivileged, deterministic, local DoS) confidence: certain guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 X86_64_GENERIC (stock INVARIANTS) source tree bit-identical to the audited repo (md5 trap.c fa54899450dc815d52a503319315154e, init_sysent.c a58e1fe6a06d2acce88097d691da272d)
WHAT REPRODUCED
syscall(SYS_syscall, 0) β a single unprivileged libc call β panics the
stock kernel with a double fault on the kernel-stack guard page. Observed
five times on three fresh boots (sysent_edge stage 5 twice, sysent_var d,
libcind p, probe 4), always with identical signature:
DOUBLE FAULT - KERNEL STACK GUARD HIT! rip = sys_xsyscall+0x84 rsp = <guard page> rbp = rsp+0x48 panic: double fault
ROOT CAUSE (path:line)
- sys/kern/init_sysent.c:17 β sysent[0] (
syscall) β sys_xsyscall - sys/kern/init_sysent.c:215 β sysent[198] (
__syscall) β sys_xsyscall - sys/platform/pc64/x86_64/trap.c:1402-1406 β code = (u_int)frame->tf_rdi; if (code >= p->p_sysent->sv_size) code = SYSnosys; callp = &p->psysent->sv_table[code]; β¦ no rejection of code β {SYS_syscall, SYS_syscall}; trap.c:1445 then calls callp->sy_call == sys_xsyscall again with the same trapframe. Identical code exists in sys/platform/vkernel64/x86_64/trap.c (sys_xsyscall, same shape).
- libc contract that feeds it: lib/libc/x86_64/SYS.h KERNCALL stubs β
mov $198,%rax; syscallwith the number left in RDI (disassembled from /usr/lib/libc.a syscall.o/__syscall.o in the guest; kernel-side instrumentation independently observed tf_rax=198 with the caller's number in tf_rdi).
sysent[0]/[198] are the only self-referential entries in the entire table, so the recursion is exclusive to the indirect gateway this file defines.
INVESTIGATION PATH (why this took several rounds)
- First crash: edge-case harness (OOR numbers, obsolete slots, 7-arg mmap, indirect mmap). Suspected: nargs/extargs copyin. Refuted by machine cross-check (sysent_xcheck.py): every AS(x_args) struct exists, is a sysunion member, max narg (7, mmap) == union capacity (7) β the extargs copyin cannot overflow; 556 rows == SYS_MAXSYSCALL == syscallnames[]; no NULL sy_call; all literal-0 entries have dummy-only args structs.
- Suspected kernel-stack-pointer leak (delayed crashes). Refuted by the instrumented kernel: entry rsp/td_pcb/td_kstack identical and healthy on every indirect syscall; ~10.5KB consumed strictly inside one dispatch β recursion.
- Raw-asm variants: two of my own stubs had a register-allocation bug
(input allocated to RAX, clobbered by
mov $0,%eaxβ rdi=0) which accidentally took the same kernel path β recognized and replaced with explicit-register asm (probe.c). The libc-based crashers (stage 5, probe 4/5/6/7) never involved my asm. psat DDB named the faulting thread = the unprivileged user process.
EXPLOIT CEILING
Stack-exhaustion recursion writing only return addresses into the guard page. No controlled data reaches past the guard; nothing corruptible escapes. Ceiling = kernel panic (all-user availability loss). No uid=0 path exists for this primitive.
FIX VALIDATION
fix.diff (2 lines, pc64 + vkernel64): remap codeβ{0,198} to SYS___nosys, mirroring the adjacent out-of-range remap. Rebuilt in-guest (make nativekernel, kernel #1 03:11:47 UTC 2026): - baseline (stock): P2-P8 all panic (see panic-baseline.txt) - patched: P2-P8 β SIGSYS "Bad system call", guest up; P1 (valid indirect getpid, rax=0/rdi=20) still returns the pid β no regression of the indirect gateway. fix_status: fixed.
PASS-2 NEGATIVE RESULT (machine-verified, gives the clean parts weight)
- nargs vs args-struct words: 556/556 consistent (AS() computed from the same struct at compile time; every referenced struct exists in sysproto.h and as a union sysunion member; max narg 7 == union capacity 7 β trap.c:1415-1437 copyin can never overflow sysmsg.extargs).
- bounds: SYS_MAXSYSCALL 556 == sysent rows == syscallnames[] rows;
dispatch clamps
code >= sv_size(trap.c:1220,1403); sv_size wired from SYS_MAXSYSCALL (sys/kern/init_main.c:341). - no entry has NULL sy_call; obsolete/nosys/lkm placeholders all route to sys_nosys (SIGSYS+EINVAL) or sys_lkmnosys.
- all literal-0-narg entries correspond to dummy-only args structs (fork/vfork/getpid/.../lwp_gettid) β no uninitialized-args reads.
- sy_rsize β {4,8} only; rsize=8 impls verified to store 64-bit results (read/write/extpread/v/iommap/lseek/shmat/vmspace_pread/write/getrandom/ __realpath/sbrk β sysmsg_szresult/offset/resultp; mq_receive stores 32-bit iresult under rsize=8 β cosmetic, dispatch pre-zeroes the full 8-byte result word, msg sizes bounded Β« 2^31; sys_mqueue.c nit, not a table defect).
- no native-table entry points into an unloadable KLD (all sy_call targets are built-in; the lkmnosys slots 210-219 mutate only via root-only kldload/ syscall_register, covered by DF-2739/2740).
- new-in-pass-1 syscalls present: __realpath 551, getrandom 550, futimesat 555 β¦ table complete through 555.
Fix verification
fixedRebuilt in-guest from pristine source plus the 2-line guard; the exact baseline trigger set (probe 2-8, incl. the original stage-5 crasher) now yields SIGSYS with the guest healthy, while a valid indirect getpid via rax=0/rdi=20 still returns the pid β bug eliminated, no regression.
['findings/poc/DF-2898/run-patched.txt', 'findings/poc/DF-2898/build.log', 'findings/poc/DF-2898/fix.diff']
Confirmed kernel references
Detail
Exploit chain
unpriv user -> libc syscall(SYS_syscall,0) (rax=198 stub, number left in rdi) -> syscall2 -> sysent[198].sy_call = sys_xsyscall -> code = tf_rdi = 0 -> sysent[0].sy_call = sys_xsyscall -> identical re-entry forever -> kernel stack guard page -> double fault -> panic (machine dead in ddb). No escalation path: the recursion writes only return addresses downward into the guard page; it is a pure local DoS.
Evidence (decisive lines)
['findings/poc/DF-2898/panic-baseline.txt β stock-kernel panic transcripts (2 independent boots + instrumented probe run), rip=sys_xsyscall+0x84, rsp page-aligned on the kstack guard', 'findings/poc/DF-2898/run-patched.txt β fixed kernel: all 7 trigger variants -> SIGSYS, no panic, P1 valid-indirect still returns pid, guest up', 'findings/poc/DF-2898/trigger.c β one-line canonical PoC', 'findings/poc/DF-2898/README.md β full mechanism incl. libc rax=198 no-shift stub disassembly and SYSE instrumentation data (healthy entry rsp, ~10.5KB consumed inside one dispatch)', 'findings/poc/DF-2898/fix.diff β validated 2-line guard (pc64 + vkernel64)']
PoC changes
Original harness (sysent_edge.c stage 5) crashed via libc syscall(SYS_syscall,197,...); distilled to the minimal one-line trigger syscall(SYS_syscall,0); intermediate raw-asm stubs had a compiler register-allocation artifact (input bound to RAX, clobbered by movl $0,%eax -> rdi=0) that took the same kernel path β replaced with explicit-register asm in probe.c; added libc/no-libc matrix to prove independence from my asm.
Verified recommended fix
In sys_xsyscall (pc64 and vkernel64 trap.c), after the sv_size bounds remap add: else if (code == SYS_syscall || code == SYSsyscall) code = SYSnosys; β mirrors FreeBSD's indirect-handler guard, validated to fully stop the panic while preserving valid indirect dispatch.
Verdict
A single unprivileged libc call syscall(SYS_syscall, 0) deterministically panics the stock DragonFly x86_64 kernel: sysent[0] and sysent[198] (init_sysent.c:17,215) both dispatch sys_xsyscall, which re-reads the target number from the never-changing tf_rdi (trap.c:1402-1406) and re-dispatches itself without a recursion guard until the 16KB kernel stack guard page is hit (DOUBLE FAULT, rip=sys_xsyscall+0x84, verified 5x across 3 boots incl. twice via plain libc). Instrumented-kernel tracing proved entry stack geometry is healthy on every indirect syscall (no stack leak) and the ~10.5KB is consumed strictly inside the one dispatch, i.e. pure recursion. Ceiling is availability loss (return addresses only, stopped by the guard); no corruption beyond the guard, no uid=0 path. Two-line guard remapping code in {0,198} to SYS___nosys validated on a rebuilt guest kernel: all trigger variants now get SIGSYS, valid indirect dispatch (rax=0/rdi=20 -> getpid) still returns correct results, guest stays up.
No comments yet.