v1 ng_async: unserialized SET_CONFIG realloc races rcvdata decode/encode - write-after-free cascades into runaway unbounded kernel heap overflow (silent hard lockup)
Summary
v1 netgraph dispatches control messages and data directly in the senders' contexts with no per-node serialization. ng_async's SET_CONFIG executes kmalloc/kfree(sc->sbuf)/kfree(sc->abuf) plus sc->cfg = *cfg on cpu0's netisr while nga_rcv_async/nga_rcv_sync run in ng_ksocket-upcall ip-input context on other CPUs writing sc->sbuf[sc->slen++]/sc->abuf with no lock. The ng7 twin added NG_HOOK_FORCE_WRITER plus the cpu0 funnel for exactly this hazard. VERIFIED on the stock guest: flood+config storm hard-locked the machine twice in <20s and once wedged ~8min in with QEMU-monitor register proof of CPU0 spinning inside nga_rcv_async's write loop with sc->slen=0x5655 (22101) against an <=8202-byte allocation - a self-amplifying unbounded heap overflow walking attacker bytes up the kernel heap (silent, banner-less death). Root-gated trigger (v1 control socket SYSCAP_RESTRICTEDROOT); control runs (flood-only 60s, config-only 70k msg/s) stable. fix.diff (per-byte spinlock consistency for the (cfg,buf,slen) tuple, serialized SET_CONFIG, retired-buffer frees after unlock) validated: 10-minute identical soak + create/destroy cycle storm survived. Encode/decode bounds proven sound in isolation (2*smru+6 <= 2*smru+10).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3539 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| race2.c | β | 6.0 KB | view raw | |
| build.sh | β | 96 B | view raw | |
| run.sh | β | 190 B | view raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| README.md | β | 1.7 KB | β raw | |
| df3539_regs.txt | β | 14.9 KB | view raw | |
| df3539_cpu0.txt | β | 4.1 KB | view raw | |
| console_run2.log | β | 13.2 KB | view raw | |
| fix.diff | β | 4.0 KB | view raw | |
| env.txt | β | 681 B | view raw | |
| verdict.json | β | 4.7 KB | view raw |
DF-3539 evidence pack
Reproduces: v1 netgraph ng_async SET_CONFIG-vs-rcvdata race β write-after-free
into sc->sbuf β corrupted cfg.amru/slen β runaway unbounded kernel
heap overflow β hard machine lockup (silent, no panic banner).
Build (on the DragonFly guest, as root)
cc -O2 -Wall -o /root/race2 /root/race2.c
Run
kldload -n netgraph; kldload -n ng_socket; kldload -n ng_async; kldload -n ng_ksocket /root/race2 all config # 3 UDP flooders + SET_CONFIG realloc storm
Expected (unpatched): guest wedges/deadlocks within seconds to minutes
(two observed hard locks < 20 s); serial console ends at
Warning: objcache(mbuf cluster) exhausted on cpuN and goes silent.
Control runs: /root/race2 ctl config alone (70k msg/s, stable) and
flood alone (stable).
Proof of the corruption primitive
df3539_regs.txt / df3539_cpu0.txt β QEMU-monitor register dump of the
wedged machine: CPU0 spinning (CPL=0) at 0xffffffff82656737 inside
ng_async.ko; RDX = sc->slen = 22101 while the allocation is at most
8202 bytes; CPUs 1β5 halted.
console_run2.log β serial console of crash #2 (silent lockup after mbuf
exhaustion warnings).
fix.diff β validated fix (per-byte spinlock consistency on the data
path; serialized SET_CONFIG mutation; retired-buffer free after unlock).
Patched soak: 10 min alive + cycle-mode storm survived (see VERDICT.md).
Files
race2.c PoC (fork-based harness: control driver + UDP flooders) build.sh / run.sh exact commands env.txt guest, kernel, modules, context fix.diff git-apply-able fix against sys/netgraph/async/ng_async.c verdict.json machine verdict manifest.json artifact catalog
DF-3539 β v1 netgraph ng_async: control/data-path race β kernel heap corruption (unbounded write past sc->sbuf)
File
sys/netgraph/async/ng_async.c (v1 netgraph, 607 LOC)
Root cause
v1 netgraph dispatches control messages and data directly in the sender's context with no per-node serialization:
ng_send_msg()(sys/netgraph/netgraph/ng_base.c:1226) calls the target node'srcvmsgsynchronously β for a control socket this runs in cpu0's netisr thread (so->so_port = netisr_cpuport(0), sys/kern/uipc_socket.c:259).ng_send_data()(ng_base.c:1687) calls the peer node'srcvdatasynchronously β for the async hook this runs wherever the data arrived, e.g. an ng_ksocket upcall (so_upcall = ng_ksocket_incoming, ng_ksocket.c:605) executing inside udp/ip input on any CPU's netisr.
ng_async keeps mutable per-node state β sc->abuf, sc->sbuf, sc->slen,
sc->fcs, sc->amode, sc->cfg β with zero locking:
NGM_ASYNC_CMD_SET_CONFIG(ng_async.c:303-341) reallocates both buffers:kmalloc(new); kfree(sc->sbuf); sc->sbuf = buf; β¦ sc->cfg = *cfg;β thekfreecan execute while a decoder/encoder on another CPU is still writing through the old pointer.- The decode guard
if (sc->slen >= SYNC_BUF_SIZE(sc->cfg.amru))(ng_async.c:595) readscfg.amruandsc->sbuf/sc->slenwithout any consistency guarantee; once the race corruptssc->cfg.amru(orsc->slen) the guard no longer bounds the write and the loopsc->sbuf[sc->slen++] = ch(ng_async.c:600) walks past the allocation without limit.
The ng7 twin kills exactly this with NG_HOOK_FORCE_WRITER() on both hooks
(sys/netgraph7/async/ng_async.c:257-275, comment: "We use a static buffer
here so only one packet at a time can be allowed to travel in this
direction. Force Writer semantics.") plus the cpu0 netgraph-thread funnel
(ng_snd_item β lwkt_sendmsg(ng_cpuport(0)), ng7 ng_base.c:1953). v1 has
neither.
Reproduction (all as root on the stock guest)
Graph: [ng control socket S] βsyncβ [async node A] βasyncβ [ksocket K,
udp/127.0.0.1:<port>].
- 3 flood processes sendto() 8000-byte UDP datagrams of
[0x7e]['A'Γ63]β¦β ksocket upcall βnga_rcvdata(async hook)βnga_rcv_asyncdecode loop writingsc->sbuf[sc->slen++]continuously in ip-input context on CPUs 1β5. - The control process loops
SET_CONFIG amru 8191 β 8192(~70k msg/s measured); every "grow" executeskfree(sc->sbuf)on cpu0 while the decoders run.
cc -O2 -Wall -o /root/race2 /root/race2.c /root/race2 all config # flood + SET_CONFIG realloc storm
Observed (unpatched)
- Run 1 & 2: guest hard-locked < 20 s β serial console shows only
Warning: objcache(mbuf cluster) exhausted on cpuN, then silence; no panic banner, no dump; guest unresponsive; vm.sh's DDB heuristic force-killed QEMU (guest not answering (likely DDB on panic)). - Run 3: guest degraded over ~8 min; captured via the QEMU monitor with
the vCPUs frozen:
- CPU0 RIP
0xffffffff82656737β inside ng_async.ko text, not halted, CPL=0; CPUs 1β5 idle-halted. - Disassembly at the RIP is exactlynga_rcv_async's byte loop:movq 0x28(%rbx),%rcx(=sc->sbuf),movl %esi,0x30(%rbx)(=sc->slen++),movb %al,(%rcx,%rdx)(= the write),movzwl 0x1a(%rbx)(=sc->fcs, PPP_FCS) β struct-offset-proven. - RDX = 0x5655 = 22101 =sc->slenβ 14 KB past the 8202-bytesbufallocation (guard limit isamru+10 β€ 8202), i.e. a runaway, self-amplifying kernel heap overflow writing attacker bytes at monotonically increasing offsets. This flattens the kernel heap, which is why the machine dies silently instead of panicking. - Stack return address0xffffffff826671d5lies in ng_ksocket.ko (ng_ksocket_incomingβNG_SEND_DATAβnga_rcvdata) β the exact analyzed delivery chain.
Control runs (no race): flood-only 60 s β stable; SET_CONFIG-only at 70k msg/s β stable, zero mbuf growth. The crash requires the concurrency of the two paths.
Fix (validated)
fix.diff: per-node struct spinlock sc_lock;
- decode guard+write and every abuf write take the lock (the guard and
the write observe a consistent (cfg.amru, sbuf, slen) tuple),
- SET_CONFIG performs the realloc/swap/cfg copy under the lock and
frees the retired buffers only after unlocking,
- alloc-failure paths keep the old ERROUT semantics (cfg is never applied
against a smaller buffer).
Baseline vs patched, same trigger (race2 all config):
- unpatched: dead < 20 s (twice), wedge ~8 min (third run, register-dumped).
- patched: alive & ssh-responsive at 2/4/6/8/10 min soak; mbufs stable;
cycle mode (graph create/destroy storm) also survived. No wedge, no
crash, no corruption.
Threat model / reachability
The trigger needs a v1 netgraph control socket, which requires
caps_priv_check(SYSCAP_RESTRICTEDROOT) (ng_socket.c:172) β i.e. root (or
a root-built PPP-era graph plus local root for the config side). The
decode side alone is remotely reachable in a PPP deployment, but the
reconfiguring side is local-root, hence Medium, not High. The primitive
itself is unbounded kernel heap corruption with attacker-influenced data.
Residual issues (documented, not fixed here)
nga_shutdown (ng_async.c:368-382) frees sc and the node while an
in-flight rcvdata may still hold sc β the same architectural v1
netgraph absence of node-lifetime serialization; a per-node lock cannot
fix a free of the lock itself. That needs reference counting in the v1
core and affects every v1 node type, not just ng_async.
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 unpatched: 2x silent hard lockup <20s + 1x captured wedge with register proof of the unbounded overflow. Patched (module rebuilt from fix.diff, /boot/kernel/ng_async.ko swapped): identical trigger survived a 10-minute soak (alive at 2/4/6/8/10 min checks, ssh responsive, mbufs stable) plus a create/destroy cycle-mode storm; bad behavior gone.
fix.diff (git-apply-clean against sys/netgraph/async/ng_async.c); patched module build log in session; soak timings in VERDICT.md
Confirmed kernel references
- sys/netgraph/async/ng_async.c:317
- sys/netgraph/async/ng_async.c:325
- sys/netgraph/async/ng_async.c:339
- sys/netgraph/async/ng_async.c:595
- sys/netgraph/async/ng_async.c:600
- sys/netgraph/netgraph/ng_base.c:1226
- sys/netgraph/netgraph/ng_base.c:1687
- sys/netgraph/socket/ng_socket.c:257
- sys/netgraph/socket/ng_socket.c:400
- sys/netgraph/ksocket/ng_ksocket.c:605
Detail
Exploit chain
root -> ng control socket -> graph [S]-sync-[async]-async-[ksocket udp] -> 3x UDP flooders drive the decode loop in ip-input context on CPUs 1-5 -> concurrent SET_CONFIG amru 8191<->8192 storm executes kfree(sc->sbuf) mid-decode -> write-after-free corrupts ng_async_private (cfg.amru/slen) -> guard passes with bogus limit -> sc->sbuf[sc->slen++] walks 14KB+ past the heap chunk writing 0x41/'A' pattern -> kernel heap flattened -> silent hard lockup (no panic banner, no dump; vm.sh DDB-heuristic kill). Escalation to uid0 not pursued: trigger itself requires root (SYSCAP_RESTRICTEDROOT on v1 ng control sockets), so there is no unprivileged entry point on a stock system.
Evidence (decisive lines)
df3539_regs.txt + df3539_cpu0.txt (frozen-vCPU registers: RIP in ng_async.ko, RDX=sc->slen=22101); VERDICT.md embeds the disassembly match of the spin site to nga_rcv_async (movq 0x28(%rbx)=sc->sbuf, movl 0x30(%rbx)=sc->slen, movzwl 0x1a(%rbx)=sc->fcs/PPP_FCS); console_run2.log (silent lockup #2); fix.diff + 10-min patched soak
PoC changes
Rewrote the seed as a fork()-based harness (race2.c): control driver in one process at 70k msg/s + separate UDP flood processes (the pthread prototype crawled at ~45 msg/s due to a userspace pathology); per-run unique node names and UDP ports (stale graphs from earlier runs otherwise collide); v1 quirks handled: NG_CONTROL protocol number is 2 (inverted vs ng7), absolute node paths need a trailing colon, sockaddr sa_len must be set, ksocket address is encoded in the hook name ('inet/dgram/udp'), ng_ksocket delivers via socket upcall so rcvdata runs in ip-input context rather than cpu0 netisr.
Verified recommended fix
Serialize ng_async control-path mutation against the data path: per-node spinlock making the decode guard+write and every abuf write atomic vs SET_CONFIG's realloc/cfg swap, with retired buffers freed only after unlock (see fix.diff).
Verdict
Unpatched: the SET_CONFIG realloc (cpu0 netisr, ngc_send->ng_send_msg direct dispatch) frees sc->sbuf/sc->abuf while nga_rcv_async/nga_rcv_sync run unlocked in ksocket-upcall ip-input context on other CPUs; the corrupted state then defeats the SYNC_BUF_SIZE(cfg.amru) guard and the decode loop writes attacker bytes without bound past the 8KB sbuf (QEMU-monitor proof: CPU0 spinning in nga_rcv_async with sc->slen=0x5655=22101 vs allocation <=8202; two additional silent hard lockups <20s each). Patched with fix.diff (per-byte spinlock consistency for (cfg,buf,slen), serialized SET_CONFIG, retired frees after unlock): 10-minute soak of the identical trigger plus cycle-mode storm survived with the guest alive and responsive. Root-gated trigger (v1 control socket requires SYSCAP_RESTRICTEDROOT) - Medium severity, memcorrupt bucket.
No comments yet.