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)
PoC verification
Evidence pack
findings/poc/DF-4106 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
- fabricates the exact post-
vge_attach/vge_initsoftc state β verbatim replay ofvge_dma_alloc()(if_vge.c:738-889, including the production RX-tag-sized-VGE_TX_LIST_SZ quirks) using the kernel's ownvge_dma_map_addr(); a reallwkt_serializerasifp->if_serializer(production:vge_intrrunsvge_rxeofunder 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); - fills the ring with 256 real
m_getcl()mbufs via a verbatim replay ofvge_rx_list_init()(if_vge.c:1195-1219) that calls the kernel's ownvge_newbuf(); - 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_initunconditionally enablesVGE_RXCTL_RX_GIANT, if_vge.c:1837); - imposes the production failure condition: drains the mbuf
cluster pool on the dispatch CPU until
m_getcl(M_NOWAIT)fails β the kernel itself logsWarning: objcache(mbuf pkthdr+cl) exhausted on cpu1!β i.e. theENOBUFSpath ofvge_newbuf()(if_vge.c:1111-1113); - dispatches the running kernel's own
vge_rxeof()(address injected via sysctl fromnm /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.shbuild.logβ full untrimmed build outputrun.log,run.2.log,run.3.logβ baseline runs (guest survives)panic.txtβ serial console of the panic variant (Fatal trap 9 inm_freefollowing the steeredm_next)fix.diffβ the verified fix (git-apply-able)fix_build.log,fix_run.logβ patched-kernel build + re-runenv.txtβ guest environmentVERDICT.md,manifest.json,verdict.json
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
- 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 sizedVGE_TX_LIST_SZ, if_vge.c:828-854) with the kernel's ownvge_dma_map_addr(); - fills the ring with 256 real
m_getcl()mbufs via a verbatim replay ofvge_rx_list_init()(if_vge.c:1195-1219 β the kernel build inlined it, so the straight-line body is replayed around the kernel's ownvge_newbuf()); - provides a real
lwkt_serializerasifp->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); - simulates the NIC as the driver observes it: descriptor OWN/status writes only;
- imposes the production failure condition by draining the
cluster pool until
m_getcl(M_NOWAIT)fails β the kernel logs its ownWarning: objcache(mbuf pkthdr+cl) exhausted on cpu1!(33,359 clusters held); - dispatches
vge_rxeofat0xffffffff808d2680(stock kernel),vge_newbufat0xffffffff808d2480,vge_dma_map_addrat0xffffffff808d2d60, resolved withnm /boot/kernel/kerneland 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 infix_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=0after the failed dispatch (empty head chain β the printedDOUBLE-REFERENCE ... head chain {}label keeps its stock wording; the empty chain is the fixed behavior), no cycle on ring wrap,vge_stopfreed every mbuf exactly once; - Scenario B: no mbuf was ever freed under the failure (the
printed
DANGLING ... ==m0: YESlabel 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 FOUNDin 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_unloaddouble-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 ownvge_newbufkeeps failing (drained pool), in which case the window cannot complete (novge_rx_consumedincrements from the dangling slot); the window CAN re-arm a reload-failed slot whose fallbackvge_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'svge_fixup_rxrun on an mbuf whosem_datawas not set up byvge_newbuf(victims getm_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.kowas built withIFPOLL_ENABLEdefined (containsvge_npoll_compat), but the running kernel'sstruct ifnethas noif_npollmember (GENERIC has nodevice 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
fixedfix.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
Confirmed kernel references
- sys/dev/netif/vge/if_vge.c:1294
- sys/dev/netif/vge/if_vge.c:1285
- sys/dev/netif/vge/if_vge.c:1328
- sys/dev/netif/vge/if_vge.c:1334
- sys/dev/netif/vge/if_vge.c:1161
- sys/dev/netif/vge/if_vge.c:2102
- sys/dev/netif/vge/if_vge.c:2118
- sys/dev/netif/vge/if_vge.c:1395
- sys/dev/netif/vge/if_vge.c:1837
- sys/kern/uipc_mbuf.c:1377
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.
No comments yet.