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

encap4_input's ipip_input hook: unlocked double-read test-then-call races ip_mroute KLD unload (which performs no netisr drain) β€” NULL-call and execute-after-unmap panics

Summary

encap4_input reads the global function pointer ipip_input twice - 'if (proto == IPPROTO_IPV4 && ipip_input)' then 'ipip_input(mp, offp, proto)' - with no lock. ip_mroute's MOD_UNLOAD NULLs it under mroute_token (which the netisr0 reader never takes) and performs no netisr drain before the linker unmaps the module text. Two fatal interleavings, both reproduced: (1) the store lands between the two reads -> call to 0x0; (2) the call executes the valid old pointer but the text is unmapped mid-execution. VERIFIED 2/2 on 6-CPU INVARIANTS guest (23s and <100s; RIP=0x0 and RIP=module_base+0x275 inside unmapped text). Distinct from DF-0689 (encaptab list) and not covered by the validated DF-3425/3433 mp_token patch. Racing packets from any local user (raw socket) or on-link peer; root-gated unload caps impact at panic/DoS (KLD VA reuse = chosen module code execution post-root). Fix validation itself two-step evidence: single-sided fix (unload-side sync only) STILL panicked at 89s RIP=0x0 (reader double-read independently fatal); two-sided fix (reader snapshot + unload netmsg_service_sync, rebuilt kernel + module) survived 480s/115,650 load-unload cycles/16.8M packets.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3450 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.0 KB ↓ raw
VERDICT.md β€” 4.2 KB ↓ raw
sprayer.c β€” 2.9 KB view raw
racer.sh β€” 553 B view raw
panic.txt β€” 580 B view raw
run.2.log β€” 2.0 KB view raw
console_full.txt β€” 2.0 KB view raw
console_run2.txt β€” 2.0 KB view raw
fixattempt1_single_sided.log β€” 2.0 KB view raw
console_fixrun.txt β€” 2.0 KB view raw
env.txt β€” 434 B view raw
fix.diff β€” 1.5 KB view raw
code_hash.txt β€” 193 B view raw
verdict.json β€” 5.1 KB view raw
manifest.json β€” 1.1 KB view raw

DF-3450 β€” encap4_input's ipip_input hook: unsynchronized test-then-call races ip_mroute KLD unload β†’ NULL-call / execute-after-unmap

Files

  • sprayer.c β€” floods proto-4 (IPIP) packets to 127.0.0.1 (all non-TCP/UDP hashes are 0 β†’ every packet is dispatched to netisr0 where encap4_input's IN_NETISR(0) path runs); the 96 extra raw IPPROTO_IPV4 sockets both prove X_ipip_inputβ†’rip_input delivery and widen the in-module window.
  • racer.sh β€” tight kldload/kldunload ip_mroute loop.
  • panic.txt β€” run 1 fault (RIP = 0x0, call-NULL variant).
  • run.2.log / console_run2.txt β€” run 2 fault (RIP = 0xffffffff82600275, execute-in-unmapped-module-text variant; module base 0xffffffff82600000).
  • fixattempt1_single_sided.log β€” single-sided fix attempt (unload-side netmsg_service_sync only): STILL panics (RIP=0x0) β€” proves the reader's double-read is independently fatal.
  • fix.diff β€” two-sided fix: single-read snapshot in encap4_input + netmsg_service_sync in ip_mroute MOD_UNLOAD.
  • console_fixrun.txt-era validation: 480 s race with the two-sided fix, 115,650 load/unload cycles, 16.8 M sprayed packets, no crash.

Build (guest, root)

(cd /usr/src/sys/net/ip_mroute && make obj && make)   # ip_mroute.ko
cc -O2 -o /root/sprayer /root/sprayer.c

Run (baseline)

(/root/sprayer 96 > /root/sprayer.log 2>&1 &)
(sh /root/racer.sh   > /root/racer.log 2>&1 &)
# within ~25-100 s:
#   Fatal trap 12: page fault while in kernel mode
#   fault virtual address = 0x0 | 0xffffffff82600xxx
#   fault code = supervisor read instruction, page not present
#   instruction pointer = 0x8:0x0 | 0x8:0xffffffff82600275
#   current process = Idle (netisr0)

Fix validation

Apply fix.diff in /usr/src; make -j24 nativekernel KERNCONF=X86_64_GENERIC && make installkernel; reboot; rebuild ip_mroute.ko; rerun the same race: no crash after 480 s (baseline crashed in 23–89 s), guest alive, racer logged 115,650 load/unload cycles.

VERDICT.md
↓ download raw

DF-3450 VERDICT

Status: reproduced (race-condition kernel panic, 2/2 baseline runs, both fault variants). Fix: validated (two-sided; single-sided attempt failed and is itself documented). Impact: panic (root-gated kldunload racing unprivileged/remote-triggered proto-4 packet dispatch; control-flow divert β€” NULL-call or jump into unmapped module text).

Root cause (source)

  • sys/netinet/ip_encap.c:231-235 β€” encap4_input's backward-compat path: c if (proto == IPPROTO_IPV4 && ipip_input) { /* read #1 */ *mp = m; ipip_input(mp, offp, proto); /* read #2 + call */ No lock/token around the read-test-call, and the global is read TWICE. ipip_input storage lives in this (always-compiled) file; the value points into the ip_mroute KLD.
  • sys/net/ip_mroute/ip_mroute.c:3266/3285 β€” MOD_LOAD/MOD_UNLOAD swap the hook under mroute_token β€” a token the netisr-side reader never takes, so it provides no mutual exclusion; MOD_UNLOAD additionally performs no netisr drain before returning, after which the linker unmaps the module text (vm_map_remove-style teardown β‡’ execute faults, not just stale heap reads).

Two fatal interleavings, both observed: 1. call-NULL: reader passes test on the old value, unload stores NULL between read #1 and read #2, reader calls 0x0. 2. execute-after-unmap: reader calls the (valid) old pointer and is executing X_ipip_input/rip_input when the unload completes and the text is unmapped mid-execution.

Distinct from DF-0689 (the encaptab list walk vs LIST mutation β€” different data structure and different fix) and not covered by the DF-3425/DF-3433 mp_token fix (that patch touches encap_attach/attach_func/detach only). This is the ipip_input function-pointer hook read at ip_encap.c:231.

Reproduction (guest)

Harness: sprayer.c floods crafted proto-4 packets to 127.0.0.1 (correct IP checksum; IP_HDRINCL raw socket). Non-TCP/UDP protocols hash to 0 (ip_demux.c:315-318) so every packet is processed on netisr0 β€” the exact context the IN_NETISR(0) path requires. 96 raw IPPROTO_IPV4 sockets fan out per packet through rip_input (delivery counter proves X_ipip_input ran: 201 M receives over the run) and widen the in-module execution window. racer.sh loops kldload/kldunload ip_mrace (mrouting never configured, so ip_mrouter == NULL and unload is permitted, ip_mroute.c:3281).

  • Run 1: panic; fault virtual address = 0x0, fault code = supervisor read instruction, instruction pointer = 0x8:0x0, current process = Idle (netisr0). Variant (1).
  • Run 2 (fresh reset, same harness): panic after 23 s; instruction pointer = 0x8:0xffffffff82600275 β€” inside the freshly-unloaded module's text (kldstat showed ip_mrace.ko at 0xffffffff82600000+0x12000). Variant (2).

Setup is root-gated (kldunload); the racing packet stream can be produced by any local user or an on-link remote peer (proto 4 to a local address).

Fix

fix.diff (two sides, both required):

  1. Reader (sys/netinet/ip_encap.c): snapshot the hook once into a local and call the local β€” eliminates the double-read call-NULL window.
  2. Unloader (sys/net/ip_mroute.c): after NULLing the hooks, drain netisr (netmsg_service_sync()) before returning so the module text cannot be unmapped under a live reader β€” the same discipline the dummynet module unload already uses.

Validation history (honest): - Attempt 1 (unload-side sync only): still panicked at 89 s with RIP=0x0 (fixattempt1_single_sided.log) β€” proving the reader's double-read is independently fatal and the reader-side hunk is required. - Attempt 2 (two-sided, kernel rebuilt: X86_64_GENERIC #1 Sat Sep 12 23:27:53 UTC 2026, module rebuilt): 480 s continuous race, 115,650 load/unload cycles, 16.8 M sprayed packets, zero crashes, guest alive (baseline died in 23–89 s).

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline: panic in 23-89 s (RIP=0x0 and RIP=unmapped-module-text variants). Single-sided fix (unload drain only): still panicked at 89 s, RIP=0x0 - the reader's double read is independently fatal, both hunks required. Two-sided fix on rebuilt kernel #1 + rebuilt module: 480 s continuous race, 115,650 kldload/kldunload cycles, 16.8 M sprayed proto-4 packets, zero panics, guest alive and responsive.

['fix.diff (ip_encap.c + ip_mroute.c hunks)', 'fixattempt1_single_sided.log (failed single-sided attempt, RIP=0x0)', 'VERDICT.md validation section (480 s / 115,650 cycles / no crash)', 'panic.txt, run.2.log (baseline faults)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Sep 12 23:27:53 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (reader hunk compiled in; unloader hunk in rebuilt ip_mroute.ko)

Confirmed kernel references

Detail

Exploit chain

root: sh racer.sh (kldload/kldunload ip_mroute loop); unpriv/remote: proto-4 packet stream to a local address (sprayer). netisr0 encap4_input reads ipip_input non-NULL, unload NULLs+unmaps concurrently -> indirect call to 0x0 (observed) or into unmapped-but-predictable module text (observed at module_base+0x275). Escalation ceiling: with a KLD reloaded into the freed VA before a late reader's call, the diverted call executes chosen module code - root-gated setup caps honest impact at panic/DoS.

Evidence (decisive lines)

['panic.txt: run 1 - fault virtual address 0x0, instruction pointer 0x8:0x0, supervisor read instruction page not present, current process Idle', 'run.2.log: run 2 - instruction pointer 0x8:0xffffffff82600275 (former ip_mroute.ko text, base 0xffffffff82600000), current process Idle', 'fixattempt1_single_sided.log: single-sided fix still panics RIP=0x0 at 89s (proves reader double-read independently fatal)', 'fix.diff: two-sided fix (ip_encap.c snapshot + ip_mroute.c netmsg_service_sync)', 'env.txt + validation narrative in VERDICT.md: 480s/115650 cycles/16.8M packets no-crash on rebuilt kernel #1']

PoC changes

Harness written from scratch (no runnable seed existed): raw-socket proto-4 sprayer with hand-built IP header + checksum (non-TCP/UDP hashes to netisr0 per ip_demux.c), 96 fan-out raw sockets to prove X_ipip_input delivery and widen the in-module window, kldload/kldunload racer loop. First single-sided fix attempt failed; fix.diff extended to the two-sided version with a kernel rebuild.

Verified recommended fix

Two-sided: snapshot ipip_input once into a local in encap4_input before the test+call, and make ip_mroute MOD_UNLOAD call netmsg_service_sync() after clearing the hooks so netisr drains before the module text is unmapped.

Verdict

Race reproduced 2/2 baseline runs on the stock INVARIANTS guest with both fault variants: encap4_input (ip_encap.c:231-235) reads the ipip_input hook unlocked and twice (test, then call); ip_mroute's MOD_UNLOAD NULLs it under mroute_token (which the netisr reader never takes) and performs no netisr drain before the linker unmaps the module text. Run 1: RIP=0x0 (call-NULL variant). Run 2 (23 s): RIP=0xffffffff82600275 inside the unloaded module text (execute-after-unmap variant); both in netisr0/Idle context while a root kldload/kldunload loop raced a proto-4 packet spray (any local user or on-link peer can be the packet source). Root-gated setup, race winnable in seconds. Two-sided fix validated: reader-side single-read snapshot (kernel rebuilt, #1 Sep 12 23:27:53) + unload-side netmsg_service_sync (module rebuilt) survived 480 s / 115,650 load-unload cycles / 16.8 M packets with no crash; a single-sided fix attempt (unload drain only) still panicked at 89 s with RIP=0x0, documenting that both hunks are required.