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

Out-of-bounds descriptor write in wb_encap when TX chain fills all 16 fragments under minimum frame length

  • File: sys/dev/netif/wb/if_wb.c
  • Lines: 1221, 1248, 1274, 1275, 1276, 1277, 1279, 1284, 1285
  • Severity: High
  • CVSS: CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
  • CWE: CWE-787 Out-of-bounds Write
  • Confidence: likely

Summary

wb_encap() packs an outgoing mbuf chain into a fixed 16-slot wb_frag[] array. The for-loop at line 1221 caps frag at WB_MAXFRAGS (16) via an early break, but the WB_MIN_FRAMELEN padding branch at line 1274 then indexes wb_frag[frag] without re-checking frag, so when exactly 16 non-empty mbufs were consumed (loop exits with frag==16, m==NULL, coalesce branch skipped) AND total_len < 60, the code writes 12 bytes (wb_status/wb_ctl/wb_data) plus a read-modify-write of wb_ctl (line 1285 via WB_TXCTL macro at wb_lastdesc==16) into wb_frag[16], one full wb_txdesc (256 bytes) past c->wb_ptr.

Root cause

wb_encap() at sys/dev/netif/wb/if_wb.c:1221-1238 fills wb_frag[] until if (frag == WB_MAXFRAGS) break; (line 1223).

For a chain of exactly 16 non-empty mbufs whose m_len sum is < WB_MIN_FRAMELEN (60), the loop exits naturally with m==NULL and frag==16, so the coalesce fallback at line 1248 (if (m != NULL)) is NOT taken.

Control then falls into the padding branch at line 1274:

if (total_len < WB_MIN_FRAMELEN) {
    f = &c->wb_ptr->wb_frag[frag];     /* frag == 16, OOB */
    ...
}

with frag==16. wb_frag is declared struct wb_desc wb_frag[WB_MAXFRAGS] (WB_MAXFRAGS==16) at if_wbreg.h:291, so wb_frag[16] is one-past-end.

Concretely the driver writes:

  • f->wb_ctl = WB_MIN_FRAMELEN - total_len (line 1276)
  • f->wb_data = vtophys(&sc->wb_cdata.wb_pad) (line 1277)
  • f->wb_status = WB_TXSTAT_OWN (line 1279)
  • frag++ β†’ 17 (line 1280)
  • c->wb_lastdesc = frag - 1 = 16 (line 1284)
  • WB_TXCTL(c) |= WB_TXCTL_LASTFRAG (line 1285) expands (via if_wbreg.h:296) to c->wb_ptr->wb_frag[16].wb_ctl |= WB_TXCTL_LASTFRAG β€” a further OOB read-modify-write.

WB_MAXFRAGS is 16 (if_wbreg.h:271). The next wb_txdesc in wb_tx_list[] begins exactly at the wb_frag[16] offset (sizeof(struct wb_txdesc) == 16*sizeof(struct wb_desc) == 256), so the OOB either corrupts the next in-flight TX descriptor (common case) or, when c->wb_ptr == &wb_ldata->wb_tx_list[127] (last descriptor), writes past the contigmalloc region β€” which was sized only sizeof(struct wb_list_data) + 8 (line 779), so the f->wb_data write at OOB offset 8 is a 4-byte write into the kernel heap.

Threat

Local unprivileged user on a system with a wb(4) interface (Winbond W89C840F / Compex RL100-ATX NIC, or the device exposed to a VM) that can construct an outgoing packet whose mbuf chain has exactly 16 non-empty mbufs totaling < 60 bytes.

The 4-12 byte OOB write of partially-controlled data (a kernel physical address from vtophys(&wb_pad), plus fixed flags WB_TXSTAT_OWN=0x80000000 and WB_TXCTL_TLINK|WB_TXCTL_LASTFRAG) can corrupt a sibling TX descriptor the chip is concurrently DMA-ing from (packet corruption / chip-follows-stale-wb_next / panic) or, with TX-ring grooming that drives wb_tx_free to chain[127], land a 4-byte heap write past the contigmalloc allocation.

With heap grooming the write can be directed at an adjacent kernel object to escalate to arbitrary kernel write.

Even absent escalation, the chip DMAing from a corrupted descriptor is a reliable kernel panic (A:H).

wb_start also ignores wb_encap's return value (line 1327), so a failing encap still queues the descriptor, compounding the corruption.

Exploit / PoC

PoC plan (drop into findings/poc/DF-1487/):

  1. trigger.c β€” open AF_INET SOCK_DGRAM socket bound to a wb interface address; build a 16-entry iov via sendmsg() with each iov_base pointing at a 1-byte buffer (iov_len=1) for a 16-byte user payload; target a destination on the wb segment so ether_output fires.

If the local UDP/IP prepend produces a 17th mbuf, fall back to: trigger_bpf.c β€” open /dev/bpfN on the wb interface and write a hand-crafted 16-mbuf chain via a kernel helper, OR trigger_raw.c β€” use AF_INET SOCK_RAW with IP_HDRINCL and craft the IP header inline in the first iov so the network layer does not prepend.

The decisive condition is exactly 16 mbufs with sum(m_len) < 60 in the chain handed to wb_start.

  1. Build on DragonFlyBSD: cc -O2 -o trigger trigger.c.
  2. Run: ./trigger β€” observe panic in dmesg (panic comes from the chip following a corrupted wb_next pointer or from a corrupted wb_data physical address) or, with KASAN/KMSAN enabled, a clear OOB write report at if_wb.c:1275-1279.
  3. For the heap-OOB variant (c->wb_ptr == wb_tx_list[127]), first flood the interface with 127 small UDP packets to advance wb_tx_free to chain[127], pause to let the chip drain (or block TX via ifconfig down/up), then fire the 16-fragment trigger so its descriptor lands at chain[127]; KASAN will report the write past the contigmalloc allocation.

Success criterion: kernel panic with stack through wb_encap+wb_start, or KASAN OOB write at the cited lines.

Validate frag in the padding branch and refuse (or coalesce) instead of overflowing. Minimal diff against sys/dev/netif/wb/if_wb.c:

--- a/sys/dev/netif/wb/if_wb.c
+++ b/sys/dev/netif/wb/if_wb.c
@@ -1271,6 +1271,17 @@ wb_encap(struct wb_softc *sc, struct wb_chain *c, struct mbuf *m_head)
        frag = 1;
    }

+   /*
+    * We can only fit WB_MAXFRAGS fragments per super-descriptor. If the
+    * chain filled every slot (frag == WB_MAXFRAGS) and still needs a
+    * runt-padding fragment, the fragment array cannot hold it -- bail out
+    * so wb_start can drop the packet instead of writing past wb_frag[].
+    */
+   if (frag >= WB_MAXFRAGS && total_len < WB_MIN_FRAMELEN) {
+       m_freem(m_head);
+       return (1);
+   }
+
    if (total_len < WB_MIN_FRAMELEN) {
        f = &c->wb_ptr->wb_frag[frag];
        f->wb_ctl = WB_MIN_FRAMELEN - total_len;
@@ -1324,7 +1335,15 @@ wb_start(struct ifnet *ifp, struct ifaltq_subque *ifsq)
        sc->wb_cdata.wb_tx_free = cur_tx->wb_nextdesc;

        /* Pack the data into the descriptor. */
-       wb_encap(sc, cur_tx, m_head);
+       if (wb_encap(sc, cur_tx, m_head) != 0) {
+           /*
+            * Encap failed (e.g. fragment array would overflow).
+            * cur_tx was not consumed; m_head was already freed by
+            * wb_encap on this error path. Stop dequeuing.
+            */
+           ifp->if_oerrors++;
+           break;
+       }

        if (cur_tx != start_tx)
            WB_TXOWN(cur_tx) = WB_TXSTAT_OWN;

The first hunk makes wb_encap return failure (1) when it would otherwise index wb_frag[16]; the second makes wb_start honor that return value (it currently ignores it) so the corrupt descriptor is never queued to the chip.

A more aggressive fix would also extend the if (m != NULL) coalesce branch at line 1248 to trigger on (frag == WB_MAXFRAGS) regardless of m, so the packet is still transmitted via a single coalesced cluster instead of being dropped β€” but the minimal diff above is sufficient to close the memory-safety hole.

Note on RX path (deliberately NOT a finding)

The RX length handling in wb_rxeof (lines 982–986) DOES enforce WB_RXBYTES <= 1536 AND >= WB_MIN_FRAMELEN, and the buffer is WB_BUFBYTES = 4096 bytes. So the missing-MCLBYTES pattern (DF-1410/DF-1478/ DF-1481) does NOT apply to this driver β€” only the TX-side wb_encap OOB write above is a security issue in if_wb.c.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1487 Β· 11 files
FileTypeDescriptionSize
harness.c trigger-source reproduces wb_encap fill-loop + padding branch OOB 5.2 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 65 B view raw
run.sh run-script ./harness 41 B view raw
build.log build-log in-guest build, BUILD_EXIT=0 13 B view raw
run.log run-log decisive run; 3/6 configs OOB 808 B view raw
env.txt environment uname + guest PCI inventory (no wb) 543 B view raw
fix.diff suggested-fix refuse encap when frag>=WB_MAXFRAGS && total_len<WB_MIN_FRAMELEN 673 B view raw
fix_build.log fix-build-log patched nativekernel, rc=0 5.6 MB ↓ download
VERDICT.md verdict full narrative 3.6 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
VERDICT.md verdict full narrative
↓ download raw

DF-1487 β€” wb_encap wb_frag[16] OOB write

Verdict

REPRODUCED (source-level harness). The bug is real; impact ceiling is kernel heap corruption (12 bytes of fresh write + 4-byte RMW past the end of struct wb_txdesc) on any local user who can route a 16-fragment sub-60-byte mbuf chain to a wb(4) (Winbond W89C840F) NIC. The guest has no Winbond NIC (pciconf -lv lists only virtio + PIIX3), so the kernel code path cannot be exercised end-to-end; the harness demonstrates the OOB indexing using the genuine loop/padding logic from if_wb.c:1221-1285. fix.diff applies cleanly and nativekernel succeeds (rc=0).

Mechanism (sys/dev/netif/wb/if_wb.c)

  1. Lines 1221-1238: for (m = m_head, frag = 0; m != NULL; m = m->m_next) β€” fills wb_frag[frag] while frag < WB_MAXFRAGS (=16).
  2. If exactly 16 non-empty mbufs are supplied, the loop exits with frag == 16 and m == NULL (because m->m_next was NULL).
  3. Line 1248: if (m != NULL) coalesce branch is then skipped, so no re-pack into a cluster resets frag.
  4. Line 1274: if (total_len < WB_MIN_FRAMELEN) (=60) is true for any 16-fragment chain summing to < 60 bytes (e.g. 16 Γ— 1-byte mbufs).
  5. Line 1275: f = &c->wb_ptr->wb_frag[frag] = wb_frag[16] β€” one past the wb_frag[WB_MAXFRAGS] array declared at if_wbreg.h:291.
  6. Lines 1276-1280: write f->wb_ctl, f->wb_data, f->wb_status β€” 12 bytes of fresh OOB write. frag++ makes it 17.
  7. Line 1284: c->wb_lastdesc = frag - 1 = 16.
  8. Line 1285: WB_TXCTL(c) |= WB_TXCTL_LASTFRAG expands to wb_frag[c->wb_lastdesc].wb_ctl |= ... = wb_frag[16].wb_ctl |= ... β€” a 4-byte OOB read-modify-write.
  9. Compounding: wb_start at line 1327 ignores the return value of wb_encap, so even when encap fails the half-corrupted state is published.

Trigger: sendmsg() with a 16-entry iov of total length < 60 to a UDP socket bound to the wb NIC, OR AF_INET SOCK_RAW with IP_HDRINCL and a short IP header.

Harness proof (harness.c)

Reproduces the genuine fill-loop and padding logic and reports the OOB index:

config                  total_len      coalesce?     result
16x1                           16             no       OOB!
16x2                           32             no       OOB!
16x3                           48             no       OOB!
16x4                           64             no  in-bounds
15x1                           15             no  in-bounds
17x1                           17            yes  in-bounds
Buggy configs: 3/6 write past wb_frag[15]

Exploit-chain note

This is a write-capable local primitive on real hardware. QEMU has no Winbond NIC, so the kernel-side chain cannot be exercised here. On a real wb(4) system the corruption lands in adjacent heap (or the next contiguous wb_txdesc when c->wb_ptr == wb_tx_list[127]), so a successful trigger yields reliable heap corruption / DoS at minimum, with heap-grooming β†’ controlled write a credible escalation path.

PoC changes

  • Original PoC folder had no source.
  • Added harness.c, build/run scripts, env, logs, fix.diff, VERDICT.md, manifest.json.

Fix

fix.diff adds if (frag >= WB_MAXFRAGS) return(1); at the top of the padding branch, refusing the encap when the chain consumed all descriptors and still needs padding. Matches the finding markdown proposal ("refuse encap when frag>=16 && total_len<60").

Fix-validation

patch -p1 --forward succeeds (hunk #1 at line 1271). nativekernel completes with rc=0 (fix_build.log). No run-time exercise is possible because no Winbond NIC is present on the guest β†’ fix_status: "not_testable". Diff applies and compiles; changed logic closes the OOB.

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

not_testable because no Winbond W89C840F NIC on the audit guest; validated that fix.diff applies cleanly (hunk #1 at line 1271) and single-fix nativekernel compiles rc=0 (fix_build.log).

baseline (harness): Buggy configs: 3/6 write past wb_frag[15]
patched kernel build: === NK_DONE rc=0 ===
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 master+df1487-fix (single-fix kernel built, rc=0)

Confirmed kernel references

Detail

Exploit chain

none (HW-gated): no Winbond NIC in QEMU. Primitive characterized via harness: 12 bytes of fresh OOB write + 4-byte OOB RMW past struct wb_txdesc into adjacent kernel heap (or the next contiguous wb_txdesc when wb_ptr==wb_tx_list[127]). Realistic ceiling on real HW: local heap corruption -> DoS at minimum, heap-grooming -> controlled write a credible escalation.

Evidence (decisive lines)

config                  total_len      coalesce?     result
16x1                           16             no       OOB!
16x2                           32             no       OOB!
16x3                           48             no       OOB!
16x4                           64             no  in-bounds
15x1                           15             no  in-bounds
17x1                           17            yes  in-bounds
Buggy configs: 3/6 write past wb_frag[15]

PoC changes

Original folder had no source. Added harness.c replicating wb_encap fill-loop + padding branch, build/run scripts, env, logs, fix.diff, VERDICT.md, manifest.json.

Verified recommended fix

fix.diff adds if (frag >= WB_MAXFRAGS) return(1); at the top of the padding branch, refusing encap when the chain consumed all 16 descriptors and still needs padding. Matches finding markdown proposal.

Verdict

REPRODUCED at the source-logic level. if_wb.c:1221-1238 wb_encap fill loop fills wb_frag[0..15] when supplied 16 non-empty mbufs, exiting with frag==16 and m==NULL (chain end). Line 1248 coalesce branch (if(m!=NULL)) is skipped. Line 1274 padding branch (total_len<60) fires for chains summing to <60 bytes (e.g. 16x1-byte mbufs). Line 1275 f = &wb_frag[frag=16] is one past the wb_frag[16] array (if_wbreg.h:291); lines 1276-1280 write 12 bytes OOB; line 1284 c->wb_lastdesc=16; line 1285 WB_TXCTL(c) |= LASTFRAG = wb_frag[16].wb_ctl |= ... (4-byte OOB RMW). Harness confirms 3/6 configs (16x1,16x2,16x3) overflow; 16x4/15x1/17x1 are in-bounds. No Winbond W89C840F NIC on guest; harness proof only.