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)
PoC verification
Evidence pack
findings/poc/DF-3450 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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'sIN_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β tightkldload/kldunload ip_mrouteloop.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.
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_inputstorage 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 undermroute_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 (kldstatshowed 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):
- Reader (sys/netinet/ip_encap.c): snapshot the hook once into a local and call the local β eliminates the double-read call-NULL window.
- 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
fixedBaseline: 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)']
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.
No comments yet.