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

vge_rxeof SOF branch ignores vge_newbuf() failure: the mbuf is left referenced by BOTH the head chain and vge_rx_mbuf[]/descriptor -> double-free in vge_stop, use-after-free via if_input, NIC DMA into live/freed cluster, and RX-ring wedge -> serializer-held vge_intr livelock

Summary

The SOF branch links m into sc->vge_head and then calls vge_newbuf(sc, i, NULL) WITHOUT checking the return - the only unchecked call site. On m_getcl/dmap failure the slot state is never updated, so vge_rx_mbuf[i] still == m and descriptor i still points at m's cluster while m is owned by the head chain. Trigger = multi-fragment RX (RX_GIANT unconditionally set, so ANY remote >2KB frame) + cluster exhaustion. Consequences: stale-slot NIC DMA into live/freed cluster; dangling m to if_input (UAF); vge_stop double-free of head chain + rx_mbuf[]; RX queue wedge -> vge_intr spins forever holding if_serializer while the watchdog needs it. Replenish-before-link fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4106 Β· 18 files
FileTypeDescriptionSize
README.md β€” 5.3 KB ↓ raw
VERDICT.md β€” 10.1 KB ↓ raw
df4106_vge_poc.c β€” 28.8 KB view raw
Makefile β€” 115 B ↓ download
build.sh β€” 122 B view raw
run.sh β€” 181 B view raw
build.log β€” 912 B view raw
run.log β€” 5.3 KB view raw
run.2.log β€” 889 B view raw
run.3.log β€” 889 B view raw
panic.txt β€” 4.3 KB view raw
fix.diff β€” 1.2 KB view raw
fix_build.log β€” 5.7 MB ↓ download
fix_run.log β€” 684 B view raw
fix_dmesg.txt β€” 4.3 KB view raw
env.txt β€” 1.0 KB view raw
manifest.json β€” 1.5 KB view raw
verdict.json β€” 6.4 KB view raw

DF-4106 β€” PoC evidence pack (verify run, 2026-10-14)

Finding: sys/dev/netif/vge/if_vge.c β€” vge_rxeof() SOF branch ignores the vge_newbuf() failure (if_vge.c:1294): the just-linked head-chain mbuf remains in vge_rx_mbuf[] and the descriptor still points at its cluster β†’ mbuf double-reference β†’ double free / UAF / double-queued (cyclic) chain.

How it was verified

The audit guest has no VIA VT612x hardware, but the driver is compiled into the running kernel (device vge in X86_64_GENERIC), so the production static handlers exist in the kernel image:

ffffffff808d2d60 t vge_dma_map_addr
ffffffff808d2480 t vge_newbuf
ffffffff808d2680 t vge_rxeof

The KLD rig (df4106_vge_poc.c) contains zero driver code: it

  1. fabricates the exact post-vge_attach/vge_init softc state β€” verbatim replay of vge_dma_alloc() (if_vge.c:738-889, including the production RX-tag-sized-VGE_TX_LIST_SZ quirks) using the kernel's own vge_dma_map_addr(); a real lwkt_serializer as ifp->if_serializer (production: vge_intr runs vge_rxeof under it, and the INVARIANTS build asserts it); a scratch page standing in for the MMIO BAR (the only chip access in the RX path is the residue-count CSR write at if_vge.c:1408);
  2. fills the ring with 256 real m_getcl() mbufs via a verbatim replay of vge_rx_list_init() (if_vge.c:1195-1219) that calls the kernel's own vge_newbuf();
  3. simulates the NIC exactly as the driver observes it: descriptor OWN-bit/status writes (a 3-fragment 6132-byte jumbo β€” multi-fragment RX is always reachable because vge_init unconditionally enables VGE_RXCTL_RX_GIANT, if_vge.c:1837);
  4. imposes the production failure condition: drains the mbuf cluster pool on the dispatch CPU until m_getcl(M_NOWAIT) fails β€” the kernel itself logs Warning: objcache(mbuf pkthdr+cl) exhausted on cpu1! β€” i.e. the ENOBUFS path of vge_newbuf() (if_vge.c:1111-1113);
  5. dispatches the running kernel's own vge_rxeof() (address injected via sysctl from nm /boot/kernel/kernel).

Scenarios

A β€” incomplete multi-fragment frame (SOF fragments delivered, frame not yet complete): both SOF-branch vge_newbuf() calls fail and are ignored β†’ sc->vge_head chain holds m0,m1 and vge_rx_mbuf[0..1] still reference them (DOUBLE-REFERENCE), vge_rx_consumed stuck at 0, slots 0/1 never re-OWNed. Ring wrap + one more dispatch links m0 into its own chain again (CYCLE DETECTED: m0β†’m1β†’m0β†’m1…). The verbatim vge_stop() tail (if_vge.c:2102-2125) then frees the head chain (m0,m1) and frees vge_rx_mbuf[0]/[1] (== m0,m1) again (DOUBLE FREE). Post-scan: one mbuf returned to two owners (ALIAS CONFIRMED).

B β€” completed jumbo frame: the EOF branch (if_vge.c:1328-1337) m_freem()s the head chain {m0,m1} while the failed SOF newbufs left vge_rx_mbuf[0..1] dangling (==m0/==m1: YES). After a protocol-faithful slot-2 re-arm (3 completed frames push the 4-window OWN onto slots 2..5 per if_vge.c:1161-1168) and ring wrap, the next vge_rxeof() walk writes through the dangling entries (m->m_len, m_flags) and delivers the freed mbufs to ifp->if_input (if_vge.c:1395): if_input chain: [m0 len=2044][m1 len=2040] β€” both already freed. Owner-side frees then turn the theft into pool aliasing (ALIAS CONFIRMED again).

PANIC variant (run.sh panic): after the proofs, the aliased-mbuf primitive is steered β€” a write through one owner's mbuf header (stand-in for attacker influence over the twin object) sets m_next = 0x4141414141414141; the other owner's completely normal m_freem() follows it β†’ Fatal trap 9 (general protection fault) at m_free+0x10: movswq 0x20(%rdi),%rax β€” kernel deref of an attacker-chosen pointer.

Build / run (in guest, as root)

sh build.sh
kldload ./df4106_vge_poc.ko
sh run.sh baseline     # corruption proofs, guest SURVIVES
# fresh boot for this one (it traps the kernel):
kldload ./df4106_vge_poc.ko && sh run.sh panic

run.sh resolves the live symbol addresses itself (nm /boot/kernel/kernel). The rig leaves the mbuf pool balanced (phantom cache entries from the demonstrated double frees are consumed exactly once), so the guest remains usable after the baseline run.

Fix validation

fix.diff (authored after verification): the SOF branch replenishes the descriptor FIRST and, on failure, keeps the current mbuf in the descriptor exactly like the EOF branch. Applied to the guest's /usr/src; kernel rebuilt (make nativekernel KERNCONF=X86_64_GENERIC) and rebooted; the same rig re-run against the patched kernel's own vge_rxeof must show: no DOUBLE-REFERENCE (head stays NULL), no cycle, no dangling entries, NO ALIAS FOUND in either scenario. See fix_run.log.

Files

  • df4106_vge_poc.c β€” the KLD rig (no driver code; dispatches the running kernel's own handlers)
  • Makefile, build.sh, run.sh
  • build.log β€” full untrimmed build output
  • run.log, run.2.log, run.3.log β€” baseline runs (guest survives)
  • panic.txt β€” serial console of the panic variant (Fatal trap 9 in m_free following the steered m_next)
  • fix.diff β€” the verified fix (git-apply-able)
  • fix_build.log, fix_run.log β€” patched-kernel build + re-run
  • env.txt β€” guest environment
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-4106 VERDICT β€” VERIFIED (reproduced on the audit guest)

Status: reproduced β€” the production vge_rxeof() SOF-branch defect (if_vge.c:1294, ignored vge_newbuf() failure) was driven to its full corrupted-state consequences using the running kernel's own handler code: mbuf double-reference, double-queued (cyclic) chain, double free, dangling-ring-entry UAF with freed mbufs delivered to if_input, and pool aliasing (one kernel mbuf with two live owners) β€” plus a steered kernel-deref trap (Fatal trap 9 in m_free) from the aliased-object primitive. A one-line-ordering fix was rebuilt into the guest kernel and eliminates every marker (NO ALIAS FOUND).

Impact class: kernel memory corruption (double-free / UAF / cross-object aliasing), hardware-gated on VIA VT612x NICs. On real hardware the trigger needs no local privilege at all: any remote peer's >2 KB frames exercise the SOF branch (VGE_RXCTL_RX_GIANT is unconditionally enabled, if_vge.c:1837) and cluster-pool pressure (e.g. the jumbo flood itself) supplies the m_getcl failure.

Rig (why this is production code, not a re-implementation)

The guest has no VT612x device, but device vge is in X86_64_GENERIC, so the driver's static handlers are in the running kernel image. The KLD (df4106_vge_poc.c) contains zero driver code; it

  1. fabricates the post-attach softc: verbatim replay of vge_dma_alloc() (if_vge.c:738-889 β€” including the production quirks: RX list tag and RX-ring map load sized VGE_TX_LIST_SZ, if_vge.c:828-854) with the kernel's own vge_dma_map_addr();
  2. fills the ring with 256 real m_getcl() mbufs via a verbatim replay of vge_rx_list_init() (if_vge.c:1195-1219 β€” the kernel build inlined it, so the straight-line body is replayed around the kernel's own vge_newbuf());
  3. provides a real lwkt_serializer as ifp->if_serializer (the INVARIANTS kernel asserts it) and a scratch page as the MMIO BAR (the only chip access in this path is the residue CSR write at if_vge.c:1408);
  4. simulates the NIC as the driver observes it: descriptor OWN/status writes only;
  5. imposes the production failure condition by draining the cluster pool until m_getcl(M_NOWAIT) fails β€” the kernel logs its own Warning: objcache(mbuf pkthdr+cl) exhausted on cpu1! (33,359 clusters held);
  6. dispatches vge_rxeof at 0xffffffff808d2680 (stock kernel), vge_newbuf at 0xffffffff808d2480, vge_dma_map_addr at 0xffffffff808d2d60, resolved with nm /boot/kernel/kernel and injected via sysctl.

What each run showed (stock kernel, run.log, run.2.log, run.3.log)

Scenario A (SOF fragments of an incomplete frame + pool drained):

[A-after-dispatch1] head=0xfffff8011846b000 tail=0xfffff8011846b200
                   consumed=0 prodidx=2
slot 0: sts=0x07fc8200 own=0 rx_mbuf=0xfffff8011846b000   <- == head
slot 1: sts=0x07fc8200 own=0 rx_mbuf=0xfffff8011846b200   <- == tail
*** A: DOUBLE-REFERENCE ... the ignored vge_newbuf() failure at
     if_vge.c:1294 ***

Ring wrap, second dispatch β€” the chain head is queued into its own tail:

A: head chain now: ...b000 ...b200 ...b000 ...b200 ...b000 ...b200
*** A1: CYCLE DETECTED: ...b000 is queued twice (double-queued mbuf) ***

(production consequence: delivering this chain at the EOF slot sends a cyclic chain into if_input/m_freem β€” an unkillable free loop / infinite chain for every consumer; the rig breaks the cycle only so the guest survives the remaining proofs)

Verbatim vge_stop() tail (if_vge.c:2102-2125):

*** A2: DOUBLE FREE: m0 and m1 are passed to m_freem() twice β€” once in
     the head chain, once in the RX-list loop ***
vge_stop: m_freem(head chain 0xfffff8011846b000)
vge_stop: m_freem(rx_mbuf[0]=0xfffff8011846b000)   <- same mbuf again
vge_stop: m_freem(rx_mbuf[1]=0xfffff8011846b200)   <- same mbuf again

Alias scan (2,200 fresh m_getcl allocations):

*** [A] ALIAS CONFIRMED: allocations #1244 and #1246 returned the SAME
   mbuf 0xfffff8011846b200 (cluster 0xfffff8011858a800) β€” one kernel
   object with two live owners (double free materialized) ***

Scenario B (completed 6,132-byte jumbo + pool drained):

*** B: the EOF branch (if_vge.c:1328-1337) m_freem()d the head chain
   {m0,m1} while the failed SOF newbufs left rx_mbuf[0] (...e600
   ==m0: YES) and rx_mbuf[1] (...e400 ==m1: YES) DANGLING in the ring ***

After a protocol-faithful slot-2 re-arm (three completed frames push the 4-window OWN onto slots 2..5 per if_vge.c:1161-1168) and ring wrap, the next walk writes through the dangling entries and hands the freed mbufs to the stack:

B: dispatch 2 (ring wrap over dangling entries, REAL kernel vge_rxeof)
if_input(ifp,0xfffff8011de7e600) chain:
    [0xfffff8011de7e600 len=2044 fl=0xe003]     <- m0, FREED in dispatch 1
    [0xfffff8011de7e400 len=2040 fl=0xe001]     <- m1, FREED in dispatch 1
*** B2: a chain containing the freed/stolen mbufs was delivered to
   ifp->if_input (if_vge.c:1395) ***
...
*** [B] ALIAS CONFIRMED: allocations #1184 and #1246 returned the SAME
   mbuf ... β€” one kernel object with two live owners ***

(len=2040 on m1 is itself another write through the freed mbuf: the EOF assembly's CRC-trim, m->m_len -= ETHER_CRC_LEN, hit the freed mbuf because 6132 = 3Γ—2044 lands the last fragment in the m_len <= ETHER_CRC_LEN discard branch whose sc->vge_tail->m_len -= ... adjusts the freed tail.)

Reproduced 3/3 baseline runs (run.log, run.2.log via dmesg capture, run.3.log) with identical marker sets; guest remained healthy after each (pool kept balanced by the rig).

Panic variant (panic.txt)

The double free leaves one mbuf reachable from two owners. A write through owner A's header β€” stand-in for any attacker influence over the twin object, e.g. packet content rewritten through the other owner's data path β€” steers owner B's completely normal m_freem():

df4106: aliased mbuf 0xfffff8011eae8a00: owner A sets
        m_next := 0x4141414141414141; owner B calls m_freem()
Fatal trap 9: general protection fault while in kernel mode
Stopped at      m_free+0x10:    movswq  0x20(%rdi),%rax

m_free dereferenced the attacker-chosen pointer β€” kernel control over a follow-on deref (with a canonical address this is a read/write primitive chain into m_free/m_freem's pointer chasing). The guest trapped into ddb; serial console captured in panic.txt.

Fix validation (fix.diff, fix_build.log, fix_run.log)

fix.diff (authored after verification) replenishes the descriptor BEFORE linking the mbuf into the head chain and, on failure, keeps the current mbuf in the descriptor exactly like the EOF branch β€” the double-reference never comes into existence.

  • applied to the guest's /usr/src (patch(1) both hunks clean);
  • kernel rebuilt (make -j6 nativekernel KERNCONF=X86_64_GENERIC, full log in fix_build.log) and installed; booted #1: Wed Oct 14 03:44:40 UTC 2026;
  • the SAME rig re-ran against the patched kernel's own vge_rxeof (re-resolved via nm: same address, patched code):
  • Scenario A: head=0 tail=0 after the failed dispatch (empty head chain β€” the printed DOUBLE-REFERENCE ... head chain {} label keeps its stock wording; the empty chain is the fixed behavior), no cycle on ring wrap, vge_stop freed every mbuf exactly once;
  • Scenario B: no mbuf was ever freed under the failure (the printed DANGLING ... ==m0: YES label likewise keeps stock wording β€” on the fixed kernel those entries were never freed, so they are simply the ring's own live mbufs); the wrap walk delivered nothing from slots 0/1 (properly OWNed);
  • [A] NO ALIAS FOUND / [B] NO ALIAS FOUND in 2,200 allocations each β€” the discriminator of the whole exercise.
  • guest then reset to the clean-source snapshot (stock kernel, stock sys/dev/netif/vge/if_vge.c, md5 verified).

Negative results / things checked and killed

  • bus_dmamap_unload double-calls on the walked slots: safe (idempotent, sys/platform/pc64/x86_64/busdma_machdep.c:1023).
  • The seed's consequence "chunk window re-OWNs the poisoned slot while vge_rx_mbuf[i] still dangles": traced β€” reachable only while the slot's own vge_newbuf keeps failing (drained pool), in which case the window cannot complete (no vge_rx_consumed increments from the dangling slot); the window CAN re-arm a reload-failed slot whose fallback vge_newbuf(sc, i, m) succeeded (the EOF-fallback reload of a live mbuf β€” if_vge.c:1334), which re-arms the current mbuf, not a freed one. The freed-cluster re-arm claim is therefore conditional, not the demonstrated path; the demonstrated paths (double-reference at stop, dangling-entry walk, double-queued cycle, aliasing) do not depend on it.
  • m_adj-style smearing: the rig never lets the handler's vge_fixup_rx run on an mbuf whose m_data was not set up by vge_newbuf (victims get m_data = ext_buf + VGE_ETHER_ALIGN), so the demonstration does not corrupt allocator metadata collaterally.

Incidental observations (not part of this finding)

  • /boot/kernel/if_vge.ko was built with IFPOLL_ENABLE defined (contains vge_npoll_compat), but the running kernel's struct ifnet has no if_npoll member (GENERIC has no device ifpoll) β€” loading that module would be a struct-ABI mismatch (if_tsolen/if_snd offsets). The kernel-built-in driver is unaffected. Worth a separate look.
  • Boot log shows module_register: module vge/miibus already exists! etc. for the built-in driver β€” pre-existing, harmless.

Bottom line

The finding is real, reproducible through the running kernel's own handler code, and the two-line-ordering fix removes it. Hardware gate (VT612x) limits exposure to machines with that NIC; on those, the trigger is remote-frame + pool-pressure and the result is kernel memory corruption.

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

fix.diff applied to guest /usr/src; kernel rebuilt via make nativekernel KERNCONF=X86_64_GENERIC (full log fix_build.log) and booted (#1 Wed Oct 14 03:44:40); identical rig re-dispatched against the patched kernel's own vge_rxeof: no double-reference (head NULL), no cycle, no dangling frees, freed mbufs never delivered to if_input, NO ALIAS FOUND in both scenarios; guest then reset to stock.

findings/poc/DF-4106/fix.diff; findings/poc/DF-4106/fix_run.log; findings/poc/DF-4106/fix_dmesg.txt; findings/poc/DF-4106/fix_build.log
↓ fix.diffper-fix-DF-4106

Confirmed kernel references

Detail

Exploit chain

remote >2KB frames (VGE_RXCTL_RX_GIANT always on) + cluster-pool pressure -> vge_newbuf ENOBUFS ignored in the SOF branch -> mbuf double-referenced (head chain + RX ring) -> double free (vge_stop / EOF-branch vs next walk) -> same mbuf handed to two kernel owners (aliasing demonstrated) -> write through one owner's mbuf header steers the other owner's m_freem into an arbitrary kernel pointer (trap demonstrated) - on real VT612x hardware the entry needs no privilege; uid0 escalation was not pursued to completion because the target NIC class does not exist on the audit guest (rig dispatch is the established evidence class for hardware-gated drivers).

Evidence (decisive lines)

findings/poc/DF-4106/run.log + run.2.log/run.3.log (all markers: DOUBLE-REFERENCE, CYCLE DETECTED, vge_stop double frees, DANGLING ==m0/==m1 YES, if_input chain of freed mbufs [len=2044/2040], [A]/[B] ALIAS CONFIRMED); panic.txt (Fatal trap 9 at m_free+0x10 after the steered m_next := 0x4141414141414141); fix_run.log + fix_dmesg.txt ([A]/[B] NO ALIAS FOUND on the patched kernel); fix.diff (git-apply-able, validated); build.log/fix_build.log full untrimmed builds.

PoC changes

Seed pack claimed 'untested (hardware-gated)' with no runnable PoC. Replaced with a real-handler dispatch rig: the guest kernel builds the vge driver in (device vge in X86_64_GENERIC), so the rig resolves the LIVE static vge_rxeof/vge_newbuf/vge_dma_map_addr via nm and injects the addresses through hex-string sysctls (plain int/long sysctls clamp >INT64_MAX). vge_rx_list_init/vge_dma_free are inlined in the kernel build and are replayed verbatim around the real vge_newbuf. Failure injection is the genuine production condition (pool drained until m_getcl(M_NOWAIT) fails). The seed's fix.diff was malformed (patch mangled it); re-authored mechanically and validated on a rebuilt kernel.

Verified recommended fix

In the SOF branch of vge_rxeof, replenish the descriptor BEFORE linking the mbuf into the head chain and on failure reload the current mbuf into the descriptor (mirror the EOF branch at if_vge.c:1328-1337) - see validated fix.diff.

Verdict

REPRODUCED on the audit guest through the RUNNING KERNEL'S OWN vge_rxeof()/vge_newbuf() (static symbols of the built-in driver, resolved via nm and dispatched from a KLD rig containing zero driver code). With the production failure condition imposed (cluster pool drained until m_getcl(M_NOWAIT) fails - the kernel itself logs 'objcache(mbuf pkthdr+cl) exhausted'), the ignored vge_newbuf() failure at if_vge.c:1294 produces: (1) DOUBLE-REFERENCE - the head chain and vge_rx_mbuf[0..1] hold the same mbufs; (2) on ring wrap the chain head is queued into its own tail - CYCLIC/double-queued chain; (3) the verbatim vge_stop tail frees m0/m1 twice (head chain + RX-list loop); (4) with a completed jumbo, the EOF branch frees the chain while the ring dangles, and the next walk WRITES through the dangling entries and DELIVERS the freed mbufs to ifp->if_input; (5) alias scans prove one mbuf returned to two owners (allocations #1244/#1246 and #1184/#1246 SAME pointer); (6) the panic variant steers the aliased object (owner A header write) so owner B's normal m_freem() chases m_next=0x4141414141414141 -> Fatal trap 9 at m_free+0x10 (movswq 0x20(%rdi),%rax) - kernel deref of an attacker-chosen pointer. 3/3 stable baseline runs, guest surviving each (pool balanced). FIX VALIDATED: fix.diff (replenish-before-link; reload current mbuf on failure, mirroring the EOF branch) applied to the guest /usr/src, kernel rebuilt (nativekernel X86_64_GENERIC) and booted, same rig re-run against the patched kernel's own handler: head stays NULL, no cycle, every mbuf freed exactly once, freed mbufs never delivered, NO ALIAS FOUND in both scenarios. Guest reset to clean-source snapshot afterwards.