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

Unbounded recursion in kqsort causes kernel stack overflow on adversarial input

Summary

kqsort/kqsort_r always recurses on left partition and iterates on right via goto loop (lines 173-184) without comparing partition sizes to recurse on smaller. Adversarial input consistently yielding large left partition (median-of-3 killer where pivot always 2nd-largest) drives recursion depth to O(n). swap_cnt==0 insertion-sort fallback only fires when pivot is unique global max/min (zero swaps) does NOT fire when pivot near-max because true maximum gets swapped during partitioning (swap_cnt=1) defeating fallback while still producing left partition of n-2 elements. On amd64 16KB kernel stack ~100-128 bytes/frame ~150-200 levels overflow stack panic. Reachable from mounted ext2 image via ext2_htree_split_dirblock (ext2_htree.c:587) which calls kqsort on directory-entry hashes fully attacker-controlled (names->hashes directory order->initial array order). ext2 directory block holds 300-1300+ entries well above ~400 needed for stack overflow. Attack scenario: craft ext2 image with directory whose entries hashes form median-of-3 killer sequence mount (root or vfs.usermount) any file creation in target directory triggers split->kqsort->recursive stack overflow->kernel panic. Impact: DoS system crash. No privilege escalation if stack guard page present adjacent kernel memory corruption potential if absent.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2245 Β· 8 files
FileTypeDescriptionSize
kqsort_depth_harness.c trigger-source replicates kqsort (VULN exact-copy + FIXED recurse-smaller) with depth counters and a median-of-3 killer adversary 8.8 KB view raw
build.sh build-script cc -O2 the harness 341 B view raw
run.sh run-script run the depth harness 866 B view raw
run.log run-log depth-vs-N table; VULN killer=883@4096, FIXED=7@4096 2.2 KB view raw
fix.diff suggested-fix recurse on smaller partition, iterate on larger -> O(log n) bound 1.5 KB view raw
fix_build.log build-log full nativekernel build of the fix, rc=0 5.6 MB ↓ download
env.txt environment uname, cc version, ext2fs module state 317 B view raw
VERDICT.md verdict full narrative: defect proven, reachability analysis, dead-code ext2 path, fix validation 5.7 KB ↓ raw
VERDICT.md verdict full narrative: defect proven, reachability analysis, dead-code ext2 path, fix validation
↓ download raw

DF-2245 β€” Unbounded recursion in kqsort

Verdict: NOT REPRODUCED (latent code defect). The algorithmic defect is REAL and PROVEN, but the kernel stack-overflow panic is not reachable on current master. The fix is a hardening fix: compile + boot validated, and its O(log n) recursion bound is proven by the standalone harness.

What the bug is (confirmed)

sys/libkern/qsort.c:173-184 always recurses on the < pivot partition and iterates (goto loop) on the > pivot partition, without comparing the two partition sizes:

if ((r = pb - pa) > es)          /* recurse on LEFT ("<") */
    kqsort(a, r / es, es, cmp);
if ((r = pd - pc) > es) {        /* iterate on RIGHT (">") */
    a = pn - r; n = r / es; goto loop;
}

If an input consistently makes the < pivot partition large (pivot β‰ˆ max each time), the recursion depth is O(n), not O(log n). On amd64's 16 KB kernel stack (~120 B/frame) that overflows the stack at ~120-160 levels.

Proof of the primitive (harness)

kqsort_depth_harness.c replicates the exact kqsort algorithm twice β€” the vulnerable in-kernel form and a fixed "recurse on the smaller half" form β€” both instrumented with a max-recursion-depth counter, and feeds each several input distributions including a McIlroy-style adaptive median-of-3 killer.

Decisive output (run.log):

N input VULN_depth FIXED_depth log2(N)
512 killer 109 5 9
1024 killer 225 5 10
2048 killer 453 6 11
4096 killer 883 7 12

The vulnerable variant's depth grows linearly with N (each doubling β‰ˆ doubles depth β†’ O(n)); the fixed variant stays at log2(N)+a few. This definitively proves the unbounded-recursion defect. (Random/ascending/ descending/org-pipe inputs all give small depth because med3 picks a true median.)

Why it is NOT triggerable on this kernel (reachability analysis)

All five kqsort/kqsort_r callers were traced:

Caller Array Max size Attacker-controlled?
sys/vfs/ext2fs/ext2_htree.c:587 directory-entry hashes ~340 / 4 KB block only weakly (filename→half_md4 hash, one-way); AND the call site is #if 0
sys/netgraph/ppp/ng_ppp.c:1747 sortByLatency NG_PPP_MAX_LINKS = 16 no (link latencies)
sys/dev/raid/vinum/vinumio.c:760 drivelist vinum drive count (root-set) no
sys/dev/drm/linux_sort.c:36 DRM objects hw-bound (planes/modes) no

The ext2 htree path β€” the one cited in the finding β€” is dead code. Both ext2_htree_split_dirblock callers live inside ext2_htree_add_entry, whose only call site (ext2_lookup.c:922) is wrapped in:

#if 0                          /* ext2_lookup.c:920-941 */
    if (ext2_htree_has_idx(dp)) {
        error = ext2_htree_add_entry(dvp, &newdir, cnp);   /* -> kqsort */
        ...
    }
    ...
    return ext2_htree_create_index(...);
#endif

The htree index-creation/write path was disabled in DragonFly's ext2fs (a known-buggy feature; the #if 0 even carries a comment about lost dirents). So ext2_htree_split_dirblock β†’ kqsort is never invoked at runtime.

Confirmed empirically: a 64 MB ext2 image (dir_index feature on, half_md4 hash) was created on the host, mounted on the guest, and a directory was flooded with 4000 entries (forcing many directory-block splits). The guest did not panic (debugfs confirmed the directory grew to 37 blocks but its htree-index inode flag stayed 0x0 β€” i.e. it grew linearly, never entering the #if 0 htree path). This both proves the live path is closed and shows that even if the index path were re-enabled, attacker-chosen filenames hash to values that do not form a median-of-3 killer, so recursion stays O(log n).

Every remaining live caller sorts an array far too small to overflow 16 KB even at O(n) depth (max 16 for ng_ppp; hardware-bound for drm), and none lets the attacker control comparison outcomes.

Conclusion: real algorithmic defect, latent β€” not triggerable on current master. Impact ceiling = a kernel stack-overflow DoS that would become reachable again only if the ext2 htree write path were re-enabled and an attacker could solve a multi-entry hash-preimage problem (infeasible).

The fix (fix.diff)

Recurse on the smaller partition, iterate on the larger one, so recursion depth is bounded to ⌈log2 nβŒ‰ regardless of input. This is the standard remedy for this exact (FreeBSD-derived) quicksort and is what the harness's FIXED variant implements and verifies.

Fix validation

  • fix.diff applies cleanly (patch -p1 β†’ "Hunk #1 succeeded at 170").
  • make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0 (fix_build.log).
  • Installed kernel.stripped β†’ /boot/kernel/kernel, rebooted β†’ kern.version = DragonFly 6.5-DEVELOPMENT #1 ... (boots clean, system healthy, ext2 mount + dir read still work).
  • Because no kernel panic is reachable (dead-code path + bounded live callers), there is no "before panic / after no-panic" to show; the fix is validated as compiles + boots + algorithmically correct (harness FIXED variant = O(log n)). fix_status: not_testable for the (unreachable) panic; the hardening fix is sound.

Files

  • kqsort_depth_harness.c β€” standalone proof-of-defect (VULN vs FIXED, depth counters, killer adversary).
  • build.sh / run.sh β€” exact build/run of the harness.
  • run.log β€” harness output table (depth vs N for all input kinds).
  • fix.diff β€” minimal git apply-able recursion-bounding fix.
  • fix_build.log β€” full nativekernel build log (rc=0).
  • env.txt β€” guest environment.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable for the panic (kernel stack-overflow panic NOT reachable on current master -- cited ext2 kqsort path is #if 0 dead code and all other callers sort small bounded arrays -- so no 'before panic' to compare against). fix.diff otherwise fully validated: applies cleanly (Hunk #1 succeeded at 170); make -j6 nativekernel KERNCONF=X86_64_GENERIC -> rc=0; installed kernel.stripped -> /boot/kernel/kernel, rebooted to kern.version #1 which BOOTS CLEAN and system healthy (ext2 mount + 4000-entry dir read still work). Algorithmic correctness of fix independently PROVEN by harness: FIXED variant stays at O(log n) depth (7 at N=4096) on same killer input that drives vulnerable variant to depth 883. So: compiles, boots, algorithmically correct; panic itself untestable because unreachable on this kernel.

fix.diff applies: 'Hunk #1 succeeded at 170'. nativekernel: '=== NK_DONE_2245 rc=0 ==='. Fixed kernel boots: kern.version = 'DragonFly 6.5-DEVELOPMENT #1: Sun Aug  9 01:53:33 UTC 2026' (was #0). Post-fix health: ext2 mount OK, 4000 dir entries readable, system up. Harness FIXED-variant depth on killer input: 64->2, 256->4, 1024->5, 4096->7 (O(log n), vs VULN 64->9 ... 4096->883).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Aug 9 01:53:33 UTC 2026 (single-fix kernel, fix.diff applied to sys/libkern/qsort.c)

Confirmed kernel references

Detail

Exploit chain

none -- not memory corruption; an algorithmic unbounded-recursion / potential stack-overflow DoS that is UNREACHABLE on current master (cited ext2_htree_split_dirblock kqsort caller is #if 0 dead code; all other live callers sort <=16-element bounded arrays). Primitive (O(n) recursion on adversarial input) proven by harness but no kernel path can deliver it. No escalation chain applies (no write primitive).

Evidence (decisive lines)

Harness depth table (VULN=exact kqsort copy, FIXED=recurse-smaller): 4096 killer -> VULN_depth=883 FIXED_depth=7 (log2=12); 2048 killer -> VULN=453 FIXED=6; 1024 killer -> VULN=225 FIXED=5; 512 killer -> VULN=109 FIXED=5. Random/asc/desc/organ inputs all give depth 1-12 on both (med3 picks true median). Live-path empirical test: ext2 image flooded with 4000 entries -> guest STAYED UP (no panic); debugfs showed bigdir grew to 37 blocks with Flags: 0x0 (NO htree index -> #if 0 kqsort path never entered).

PoC changes

Authored findings/poc/DF-2245/ from scratch (no scaffolding): kqsort_depth_harness.c (replicates in-kernel kqsort twice -- VULN exact-copy and FIXED recurse-smaller -- both instrumented with max-recursion-depth counters, fed random/asc/desc/organ-pipe and McIlroy-style adaptive median-of-3 killer adversary); build.sh; run.sh; fix.diff (recurse on smaller partition -> O(log n) bound); plus ext2 image reachability test. Fixed several harness bugs during iteration (es-aware swaps: n<7 insertion sort and pivot swap incorrectly hardcoded to long/8-byte on 4-byte int elements).

Verified recommended fix

In sys/libkern/qsort.c, after the two vecswap() calls, recurse on the SMALLER of the two partitions and iterate (goto loop) on the larger one, instead of always recursing on the '<' (left) partition. Bounds recursion depth to ceil(log2(n)) regardless of input, eliminating O(n) worst case. Full minimal git-apply-able diff in findings/poc/DF-2245/fix.diff. Supersedes finding proposal (suggested iterative introsort; recurse-on-smaller-half is standard minimal remedy for this exact FreeBSD-derived quicksort).

Verdict

NOT REPRODUCED -- LATENT code defect, not a triggerable kernel panic. The algorithmic bug is REAL and PROVEN: sys/libkern/qsort.c:173-184 always recurses on the '< pivot' partition and iterates on the other without comparing sizes, so a median-of-3 killer input drives recursion to O(n). A standalone harness replicating the EXACT in-kernel kqsort reaches depth 883 at N=4096 on the killer input (linear: 52->109->225->453->883 as N doubles), while the fixed 'recurse on the smaller half' variant stays at depth 7 (O(log n)). HOWEVER the kernel stack-overflow panic is NOT reachable on current master: the only attacker-influenced caller cited, ext2_htree_split_dirblock->kqsort (ext2_htree.c:587), is DEAD CODE -- its only call site (ext2_lookup.c:922, ext2_htree_add_entry) and ext2_htree_create_index are both inside a #if 0 block (ext2_lookup.c:920-941). Confirmed empirically: a 64MB ext2 image mounted on guest and flooded with 4000 entries grew LINEARLY to 37 blocks with htree-index inode flag 0x0 (never entered the #if 0 path) and did NOT panic. Other live callers all sort small bounded arrays (ng_ppc NG_PPP_MAX_LINKS=16, vinum drivelist, drm_blend hw plane count). Classification: real algorithmic defect, latent -- hardening fix warranted, panic not triggerable today.