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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3539 Β· 11 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download raw

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's rcvmsg synchronously β€” 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's rcvdata synchronously β€” 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; β€” the kfree can 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) reads cfg.amru and sc->sbuf/sc->slen without any consistency guarantee; once the race corrupts sc->cfg.amru (or sc->slen) the guard no longer bounds the write and the loop sc->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_async decode loop writing sc->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" executes kfree(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)

  1. 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)).
  2. 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 exactly nga_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-byte sbuf allocation (guard limit is amru+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 address 0xffffffff826671d5 lies 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

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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
↓ fix.diffper-fix-DF-3539

Confirmed kernel references

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.