β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-2898

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2898 Β· 17 files
FileTypeDescriptionSize
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

  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
VERDICT.md
↓ download raw

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; syscall with 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)

  1. 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.
  2. 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.
  3. 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.
  4. ps at 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Rebuilt 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']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Sep 3 03:11:47 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (pristine + fix.diff only)

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.