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

Unserialized ISR/netgraph concurrency over per-channel descriptor rings: UAF writes, hook write-after-free, descriptor double-free, and device DMA into freed mbufs (ignored channel-zap status)

Summary

mn_intr is registered MPSAFE with a NULL serializer and the netgraph core invokes rcvdata/disconnect synchronously with no lock - three unsynchronized contexts mutate the same schan rings. The guards are only TOCTOU state reads against an unordered state=DOWN followed by full ring teardown: a raced ISR writes through freed-and-reused trxd->m, executes hook->refs++ on a core-freed hook, and both contexts push the same descriptors onto the free list (double free / alias). Independently of any race, disconnect only WARNS when the channel-zap handshake failed and frees the RX mbufs anyway - a still-armed MUNICH32X keeps DMA-ing E1 frames into freed clusters. Root + mn hardware; deterministic zap-failure path needs no timing. Serializer + poll-zap fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4341 Β· 9 files
FileTypeDescriptionSize
README.md β€” 7.8 KB ↓ raw
VERDICT.md β€” 4.8 KB ↓ raw
trigger_race.sh β€” 1.5 KB view raw
run.sh β€” 244 B view raw
run.log β€” 296 B view raw
env_guest.txt β€” 569 B view raw
fix.diff β€” 4.0 KB view raw
verdict.json β€” 4.3 KB view raw
manifest.json β€” 1.1 KB view raw

DF-4341 β€” Unserialized ISR / netgraph concurrency over per-channel descriptor rings in if_mn.c β†’ UAF, double-free, and device write-after-free

  • File: sys/dev/netif/mn/if_mn.c
  • Lines: 1329-1330 (INTR_MPSAFE, NULL serializer), 1074-1091 (mn_tx_intr walk), 1116-1176 (mn_rx_intr walk), 744-797 (ngmn_disconnect teardown), 599-631 (ngmn_rcvdata append), 1125 (ng_queue_data on sch->hook)
  • Severity: Medium (hardware-gated: Siemens/Infineon "Easy321-R1" Munich32X+FALC54 PCI card 0x110a:2101; actor needs netgraph access, effectively root)
  • CVSS: CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H
  • CWE: CWE-416 (UAF) / CWE-362 (race) / CWE-415 (double-free)
  • Confidence: certain (statically provable absence of any synchronization; trigger needs hardware + timing)

Summary

All mutable per-channel state in the mn driver β€” the schan.state flag, the software descriptor rings r1/rl (RX) and x1/xl (TX), the mbufs hanging off trxd.m, and sch->hook β€” is touched concurrently by three unsynchronized contexts:

  1. the hardware interrupt mn_intr β†’ mn_rx_intr / mn_tx_intr (if_mn.c:1329 registers mn_intr with INTR_MPSAFE and a NULL serializer β€” no crit_enter, no lock, no token);
  2. arbitrary user threads sending data into the node: ng_send_data() (sys/netgraph/netgraph/ng_base.c:1678-1697) invokes the peer node's rcvdata method synchronously in the sender's thread with no lock β†’ ngmn_rcvdata (if_mn.c:573-645) mutates xl, dp->vnext, dp->flags, dp->m;
  3. control threads tearing a hook down: ng_disconnect_hook() (ng_base.c:839-858) invokes ngmn_disconnect (if_mn.c:743-797) with no lock; it frees every descriptor on both rings to the global mn_desc_free freelist and every dp->m mbuf via m_freem.

The only "protection" is a TOCTOU if (sch->state != UP) return; check at the top of mn_rx_intr/mn_tx_intr (if_mn.c:1109-1112, 1070-1073) β€” a plain memory read with no fence, no lock, and no ordering against ngmn_disconnect's sch->state = DOWN (if_mn.c:758), which executes before the frees.

Root cause / race windows (path:line)

Window A β€” ISR vs ngmn_disconnect: mn_rx_intr passes the state check at if_mn.c:1109 while state is still UP. A netgraph thread runs ngmn_disconnect: sets state = DOWN (:758), then frees the whole RX ring sc->ch[chan]->r1 … (:779-784, mn_free_desc to the global freelist :498-503, m_freem(dp->m)). The ISR resumes: - dp = sch->r1 (:1117) β€” freed descriptor, typically already reused by another channel's mn_alloc_desc (:486-496) since RX replenish runs in the same ISR; - m->m_pkthdr.len = m->m_len = (dp->status >> 16) & 0x1fff; (:1122) β€” write through dp->m read out of freed/reused descriptor memory (cross-channel mbuf corruption or wild pointer write β†’ panic); - ng_queue_data(sch->hook, m, NULL) (:1125) β€” sch->hook was freed by the netgraph core right after ngmn_disconnect returned (ng_base.c:854-858) β†’ ng_queue_data executes hook->refs++ (ng_base.c:1946), a write-after-free increment on freed M_NETGRAPH memory, and the raw pointer is later dequeued and dereferenced by the netgraph softintr. Identical exposure on TX: mn_tx_intr (:1074-1091) walks/frees x1 while ngmn_disconnect frees the same chain (:787-794) β†’ same descriptor freed twice onto mn_desc_free β†’ freelist cycle β†’ two channels alias one descriptor.

Window B β€” ngmn_rcvdata vs mn_tx_intr / ngmn_disconnect: ngmn_rcvdata (sender thread, :599-631) links new descriptors at xl, clears dp2->flags &= ~0x40000000 on the old tail (:626), while the ISR's mn_tx_intr concurrently frees from the x1 head (:1089-1090). A descriptor freed by the ISR between dp2 = sc->ch[chan]->xl (:597) and the link writes (:620-622) is written after free and later freed a second time by ngmn_disconnect (:787-794) β†’ double free / ring corruption. Also tx_pending -= m->m_pkthdr.len (:1085) double-decrements on raced processing β†’ u_long underflow, permanently wedging the tx_pending > tx_limit * mn_maxlatency gate (:590).

Window C β€” device write-after-free (no concurrency needed, failure-ignored handshake): ngmn_disconnect zaps the channel by issuing sc->m32x->cmd = 0x1 and waiting DELAY(30) (:770-772); if the device did NOT complete (!(u & 1)) it only prints a warning and frees the RX ring anyway (:773-784). A MUNICH32X still armed on that channel keeps DMA-ing received E1 frames (1600-byte bursts) into the freed mbuf clusters and writing completion status into freed descriptors β€” hardware write-after-free.

Threat

Preconditions: the Easy321-R1 card present and driver attached (node mn0), live E1 traffic, and an actor that can create/destroy hooks on mn0 (PF_NETGRAPH control socket β€” DF-1539 established the privilege bar is SYSCAP_RESTRICTEDROOT). Under continuous RX/TX interrupts (E1 line carrying frames), repeated ngctl connect … ; ngctl rmhook … cycles hit Window A/B with realistic probability; Window C fires whenever the 30 Β΅s zap deadline is missed. Impact: kernel heap corruption (mbuf zone, M_MN descriptor pool, netgraph hook/node structs) β†’ panic or, with the freelist-alias primitive, potential arbitrary write; plus E1-payload writes into freed kernel pages.

Exploit / PoC

trigger_race.sh β€” the exact userland sequence (run as root on real hardware with an active E1 line):

# start line traffic (any HDLC peer) then:
while :; do
  ngctl mkpeer mn0: echo ts2 downstream      # ngmn_newhook + connect
  dd if=/dev/zero bs=2048 count=64 | ngctl sendfn ...   # pump TX
  ngctl rmhook mn0: ts2                      # races mn_intr / ngmn_rcvdata
done

Success criterion: Fatal trap 12/10 in mn_rx_intr/mn_tx_intr (panic.txt), or freelist-alias corruption (duplicate descriptors observed via NGM_TEXT_STATUS channel counters running together).

This could not be executed on the audit guest β€” no Munich32X PCI device (pciconf -l | grep 110a empty) and if_mn is optional mn netgraph, absent from GENERIC and not built as a module (kldload if_mn β†’ ENOENT). See VERDICT.md.

Serialize all driver contexts against the interrupt: take a serializer at bus_setup_intr time and enter it in every netgraph method, or convert to a lock + explicitly quiesce the channel (with a real timeout + error return) before freeing rings. Minimum defensible patch (see fix.diff): pass a serializer to bus_setup_intr and lwkt_serialize_enter/exit it in ngmn_connect, ngmn_disconnect, ngmn_rcvdata, ngmn_rcvmsg, plus bail out of the zap path when the device refuses.

--- a/sys/dev/netif/mn/if_mn.c
+++ b/sys/dev/netif/mn/if_mn.c
@@ static int
 ngmn_disconnect(hook_p hook)
 {
    int chan, i;
    struct softc *sc;
    struct schan *sch;
    struct trxd *dp, *dp2;
    u_int32_t u;

    sch = hook->private;
    chan = sch->chan;
    sc = sch->sc;

+   lwkt_serialize_enter(sc->slicer);   /* block mn_intr */
    if (sch->state == DOWN)
-       return (0);
+       goto out;
    sch->state = DOWN;
    ...
    sc->m32x->cmd = 0x1;
-   DELAY(30);
-   u = sc->m32x->stat;
-   if (!(u & 1))
-       kprintf("%s: zap chan %d stat %08x\n", sc->name, chan, u);
+   for (i = 0; i < 1000; i++) {        /* poll, don't guess */
+       DELAY(30);
+       u = sc->m32x->stat;
+       if (u & 1)
+           break;
+   }
+   if (!(u & 1)) {
+       kprintf("%s: zap chan %d FAILED stat %08x\n", sc->name, chan, u);
+       sch->state = UP;            /* keep DMA targets alive */
+       goto out;               /* DO NOT free rings */
+   }
    sc->m32x->stat = 1;
    ... free rings ...
+out:
+   lwkt_serialize_exit(sc->slicer);
    sc->nhooks--;
    return(0);
 }

(with error = bus_setup_intr(self, sc->irq, INTR_MPSAFE, mn_intr, sc, &sc->intrhand, sc->slicer) and matching enter/exit in ngmn_rcvdata, ngmn_connect, ngmn_newhook, ngmn_rcvmsg.)

VERDICT.md
↓ download raw

DF-4341 VERDICT

Status: inconclusive on this guest (finding stands on static proof) reproduced: 0 Β· impact on this guest: none Β· confidence in the finding: certain

What the finding claims

Three unsynchronized execution contexts mutate the same per-channel state in sys/dev/netif/mn/if_mn.c:

Context Entry Evidence of no synchronization
HW interrupt bus_setup_intr(self, sc->irq, INTR_MPSAFE, mn_intr, sc, &sc->intrhand, NULL) β€” if_mn.c:1329-1330 7th arg (serializer) is NULL; mn_rx_intr/mn_tx_intr take no lock, no crit section (if_mn.c:1061-1092, 1097-1177)
Data sender thread ng_send_data() calls rcvdata synchronously in the caller's thread β€” sys/netgraph/netgraph/ng_base.c:1678-1697 ngmn_rcvdata (if_mn.c:573-645) mutates xl/dp->vnext/dp->m lock-free
Hook-teardown thread ng_disconnect_hook() β†’ type->disconnect(hook) lock-free β€” ng_base.c:839-858 ngmn_disconnect (if_mn.c:743-797) frees both rings + mbufs, then the core frees the hook itself (ng_base.c:854-858)

The only guard is the TOCTOU sch->state != UP read (if_mn.c:1070, :1109) against sch->state = DOWN at if_mn.c:758 β€” unordered, unlocked.

Consequences (each traced to a sink): - UAF write: m->m_pkthdr.len = m->m_len = (dp->status >> 16) & 0x1fff (if_mn.c:1122) writes through dp->m read from a freed-and-reused descriptor (the global freelist mn_desc_free, if_mn.c:483-503, recycles them across channels inside the same ISR). - UAF write on freed hook: ng_queue_data(sch->hook, m, NULL) (if_mn.c:1125) after the core freed the hook. - Double free / freelist alias: mn_tx_intr (if_mn.c:1089-1090) and ngmn_disconnect (if_mn.c:787-794) both push the same trxd onto mn_desc_free; ngmn_rcvdata (if_mn.c:620-626) links/writes descriptors the ISR may already have freed. - HW write-after-free without any race: ngmn_disconnect only warns when the 30 Β΅s channel-zap did not complete (if_mn.c:773-776) and then frees the RX mbufs anyway (:779-784) β€” an still-armed MUNICH32X keeps DMA-ing E1 frames into freed clusters. - NULL-deref amplification: once the ring pointers are corrupted, dp = sc->ch[chan]->x1 can be NULL in the drain loop and vtophys(dp)==ctxd fails, so m = dp->m (if_mn.c:1078) faults on NULL.

Verification attempt on the guest

Guest: DragonFly 6.5-DEVELOPMENT #0 ... X86_64_GENERIC (see env_guest.txt).

  1. pciconf -l | grep 110a β†’ no Munich32X device present.
  2. /boot/kernel/if_mn.ko β†’ not built; kldload if_mn β†’ ENOENT.
  3. grep dev/netif/mn /usr/src/sys/conf/files β†’ dev/netif/mn/if_mn.c optional mn netgraph β€” the driver is not in GENERIC (device mn absent), so the audit kernel cannot instantiate it.
  4. Even with a hand-built module, mn_probe (if_mn.c:1252-1275) matches only PCI ID 0x2101110a and returns ENXIO otherwise, so no softc, no ng_newtype (if_mn.c:1287-1291), and no mn0 node can ever exist on this guest.

Therefore the trigger (trigger_race.sh) cannot be executed: the target device/feature is absent. The honest classification for this run is inconclusive (not not_reproduced β€” nothing was refuted; the code-level absence of synchronization is a static fact, and every sink above is cited).

Why the finding is still real

  • The netgraph core provably calls driver methods with no lock (ng_base.c:1678-1697, :839-858).
  • The driver provably registers its ISR with no serializer (if_mn.c:1329).
  • The driver provably frees the exact objects the ISR walks (if_mn.c:779-794 vs :1075-1090, :1117-1150).
  • Sibling findings DF-1539/1540/1541 already established this node type's surface is reachable by a SYSCAP_RESTRICTEDROOT actor when the hardware exists.

Fix validation

Not performable on this guest (no hardware, driver not in the build). The fix.diff in this pack is the reviewed, line-accurate patch: serializer wiring + polled zap with refusal path. It applies cleanly to the read-only sys/ tree (git apply --check verified on the host clone).

Files

  • README.md β€” full finding write-up
  • trigger_race.sh β€” the userland trigger (needs real hardware + E1 traffic)
  • run.sh β€” what would be run, and what was actually run on the guest
  • env_guest.txt / run.log β€” guest environment + verification-session log
  • fix.diff β€” git-apply-able fix (not applied to sys/)
  • verdict.json / manifest.json

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

not_testable
baseline no→ patch + rebuild →patched clean

No baseline can be established on a guest without the Munich32X hardware; fix.diff is line-accurate against sys/dev/netif/mn/if_mn.c (git apply --check clean) and covers every cited sink: serializer shared by ISR and all ngmn_* methods; polled zap with leak-on-failure instead of free-under-armed-device.

findings/poc/DF-4341/fix.diff (git apply --check OK on the read-only tree)
↓ fix.diffper-fix-DF-4341

Confirmed kernel references

Detail

Exploit chain

on real hardware: root ngctl loop { mkpeer mn0 ksocket ts2 ...; rmhook mn0 ts2 } while E1 traffic drives RX/TX interrupts -> mn_rx_intr/tx_intr race ngmn_disconnect ring teardown -> freed trxd reused via mn_desc_free freelist -> cross-channel write through stale dp->m (if_mn.c:1122) / double-free -> M_MN pool corruption -> panic or freelist-alias arbitrary-write primitive. Not executable on this guest (no device).

Evidence (decisive lines)

VERDICT.md (race-window table + sinks); env_guest.txt + run.log (guest: no 110a PCI device, no if_mn.ko, optional-not-GENERIC); fix.diff (serializer wiring + polled zap, git apply --check OK); README.md full write-up

PoC changes

trigger is an ngctl connect/rmhook loop written fresh for this pack (no seed); guest-mode runner documents surface absence

Verified recommended fix

Register a lwkt serializer with bus_setup_intr and hold it across all ngmn_* methods; poll the channel-zap handshake in ngmn_disconnect and refuse to free rings on failure (fix.diff in this pack)

Verdict

Could not be exercised on the audit guest: the mn(4) driver requires the Siemens Easy321-R1 Munich32X+FALC54 PCI card (0x110a:2101), which QEMU does not emulate; if_mn is 'optional mn netgraph', absent from GENERIC, and if_mn.ko is not built (kldload -> ENOENT), so the mn netgraph node type can never register (ng_newtype runs only in mn_attach). The finding itself is statically certain: mn_intr is registered INTR_MPSAFE with a NULL serializer (if_mn.c:1329), the netgraph core invokes ngmn_rcvdata synchronously in sender threads with no lock (ng_base.c:1678-1697) and ngmn_disconnect in teardown threads with no lock (ng_base.c:839-858), and ngmn_disconnect (if_mn.c:779-794) frees the exact descriptor rings and mbufs that mn_rx_intr/mn_tx_intr walk (if_mn.c:1075-1090, 1117-1150) behind a bare TOCTOU state check -- plus it frees RX rings even when the 30us channel-zap handshake failed (if_mn.c:773-784), leaving the device DMA-ing into freed memory with no race required. Net effect on real hardware: UAF writes (if_mn.c:1122 via reused trxd->m), UAF on the core-freed hook (if_mn.c:1125), descriptor double-free onto mn_desc_free, and E1-payload hardware writes into freed mbuf clusters. Classification 'inconclusive' (not 'not_reproduced') because nothing was refuted -- the surface simply cannot exist on this guest.