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

ngmn_newhook channel aliasing: DOWN-state reuse admits two live hooks on one schan β€” silent second connect, blackholed peer hook, permanent nhooks drift blocking line reconfig, and a concurrent dual-teardown double-free enabler

Summary

newhook only returns EBUSY when the state==UP; while a channel is DOWN a second hook that maps to the same chan silently aliases it (ts/ts-range names collide on the same ffs-derived chan; every E1U spec hardcodes chan 1). The first disconnect executes full teardown under the still-connected second hook; that hook's own disconnect early-returns BEFORE the nhooks--, so nhooks is permanently positive and the line can never be reconfigured while the second hook's traffic is silently dropped. With DF-4341's missing serialization, two concurrent rmhooks of the aliased pair double-free both rings. Fully deterministic via plain ngctl. Hook-ownership + clear-at-teardown fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4342 Β· 9 files
FileTypeDescriptionSize
README.md β€” 5.2 KB ↓ raw
VERDICT.md β€” 3.2 KB ↓ raw
trigger_alias.sh β€” 1.4 KB view raw
run.sh β€” 195 B view raw
run.log β€” 328 B view raw
env_guest.txt β€” 569 B view raw
fix.diff β€” 622 B view raw
verdict.json β€” 3.5 KB view raw
manifest.json β€” 892 B view raw

DF-4342 β€” ngmn_newhook channel aliasing: state-DOWN reuse lets two live hooks share one schan (nhooks imbalance, silent channel teardown, double-free enabler)

  • File: sys/dev/netif/mn/if_mn.c
  • Lines: 470-479 (newhook EBUSY condition), 474-475 (ts/hook overwrite), 664-666 (connect silent-success), 756-758 (disconnect early-return), 795 (nhooks decrement), 308-310 (ngmn_config line-change guard)
  • Severity: Low (hardware-gated + root netgraph; deterministic logic corruption, concurrency follow-on)
  • CVSS: CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:L/A:L
  • CWE: CWE-667 (improper locking β€” here: absent serialization of channel ownership) / CWE-415 as follow-on
  • Confidence: certain (deterministic from code)

Summary

ngmn_newhook only refuses a second hook on a channel when that channel is UP:

if (!sc->ch[chan])
    mn_create_channel(sc, chan);
else if (sc->ch[chan]->state == UP)
    return (EBUSY);         /* if_mn.c:470-473 */
sc->ch[chan]->ts = ts;          /* overwrites */
sc->ch[chan]->hook = hook;      /* overwrites β€” old hook unrecorded */
hook->private = sc->ch[chan];
sc->nhooks++;               /* counted again */

Two distinct hook names map to the same channel β€” e.g. in E1 framing ts2 and ts2-3 (both ffs(ts)-1 == 2, if_mn.c:466-467), or in E1U any two valid 32-bit timeslot specs (both hardcode chan = 1, if_mn.c:468-469). When the first hook is still DOWN, the second newhook silently aliases the same schan: sch->hook is overwritten while the first hook's hook->private still points at the same sch, and ts is replaced.

Both hooks can then reach UP: ngmn_connect(hook2) arms the channel; ngmn_connect(hook1) sees sch->state == UP and returns 0 silently (if_mn.c:664-665) β€” the caller believes it is connected. Both hooks' rcvdata interleave onto the same wire channel.

Tearing hook1 down executes the full teardown (ngmn_disconnect, if_mn.c:756-794) while hook2 is still core-connected: rings freed, state DOWN. From then on:

  • hook2's data is silently blackholed (ngmn_rcvdata:586-589 drops on state != UP);
  • hook2's own disconnect early-returns at if_mn.c:756-758 before sc->nhooks-- (if_mn.c:795), so nhooks was incremented twice and decremented once β€” permanently positive;
  • ngmn_config refuses any line reconfiguration forever (if_mn.c:308-310: "Cannot change line when %d hooks open").

Because nothing serializes the two ngmn_disconnect calls (see DF-4341: no locks anywhere), two threads disconnecting the two aliased hooks concurrently can both pass the sch->state == DOWN check (if_mn.c:756) before either sets DOWN (:758) β€” both then execute the full teardown and double-free every descriptor on both rings (mn_free_desc, if_mn.c:779-794). Worse, mn_free_desc clobbers dp->vnext with the freelist head (if_mn.c:498-503), so the second thread's walk (dp2 = dp->vnext, if_mn.c:782/792) follows the freelist pointer and frees arbitrary pooled descriptors β€” M_MN pool-wide corruption without needing any ISR timing.

Threat

Actor: root with netgraph control (same bar as DF-1539/1540/1541); hardware: the mn card. The aliasing itself is deterministic β€” a plain sequence of ngctl mkpeer/connect/rmhook commands (below) with no racing required produces the blackhole + permanent nhooks drift (local DoS of line administration). The double-free variant needs two concurrent rmhook syscalls (trivially scripted).

Exploit / PoC

trigger_alias.sh (run as root on hardware, node mn0, E1 framing):

ngctl mkpeer mn0: echo ts2 downstream     # hook A on chan 2 (DOWN)
ngctl mkpeer mn0: echo ts2-3 downstream2  # hook B: EBUSY check skipped (DOWN), aliases chan 2
ngctl show mn0:                           # both hooks listed; sch->hook == B
ngctl rmhook mn0: ts2                     # full teardown while B connected
ngctl send mn0: ...                       # B's data silently dropped forever
ngctl msg mn0: textconfig "line e1u"      # refused forever: nhooks stuck > 0

Success criterion (deterministic): ngmn_config error "Cannot change line when 1 hooks open" while ngctl show mn0: lists zero connected hooks; B's tx counters frozen at 0 despite sends.

Double-free variant: ngctl rmhook mn0: ts2 & ngctl rmhook mn0: ts2-3 & concurrently β†’ watch for mn_alloc_desc handing the same trxd to two channels / pool corruption panic.

Not executable on the audit guest β€” no Munich32X hardware, driver not built (see env_guest.txt / run.log; VERDICT.md).

Track channel ownership by the hook pointer, not by the UP/DOWN state: clear sch->hook in ngmn_disconnect (which also closes DF-1540's dangling read) and refuse a newhook whenever sc->ch[chan]->hook != NULL. See fix.diff:

@@ ngmn_newhook(...)
-   if (!sc->ch[chan])
-       mn_create_channel(sc, chan);
-   else if (sc->ch[chan]->state == UP)
-       return (EBUSY);
+   if (!sc->ch[chan])
+       mn_create_channel(sc, chan);
+   else if (sc->ch[chan]->hook != NULL)
+       return (EBUSY);     /* a hook already owns this channel */
@@ ngmn_disconnect(...)
    if (sch->state == DOWN)
        return (0);
    sch->state = DOWN;
+   sch->hook = NULL;   /* one owner at a time; also closes DF-1540 */
VERDICT.md
↓ download raw

DF-4342 VERDICT

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

What was verified statically (deterministic, no hardware semantics assumed)

  1. Two distinct hook names alias one channel: - E1: chan = ffs(ts) - 1 (if_mn.c:466-467) β€” "ts2" and "ts2-3" both give ffs(...)-1 == 2 (verified against mn_parse_ts, if_mn.c:505-540: "2-3" sets bits 2 and 3). - E1U: every valid hook hardcodes chan = 1 (if_mn.c:468-469). - The netgraph core does not filter this: hooks are keyed by name only (ng_add_hook / ng_findhook, sys/netgraph/netgraph/ng_base.c:697-763).
  2. The second newhook is admitted while the first is DOWN: the EBUSY branch tests sc->ch[chan]->state == UP (if_mn.c:470-473) β€” state DOWN falls through to sc->ch[chan]->hook = hook; (:475) overwriting the first hook's back-pointer while hook1->private still references the same schan, and sc->nhooks++ (:478) counts a second owner.
  3. Second connect is a silent success: ngmn_connect returns 0 early on sch->state == UP (if_mn.c:664-665) with no error to the caller.
  4. First disconnect executes the full teardown regardless of the second hook (if_mn.c:756-794); the second disconnect early-returns BEFORE sc->nhooks-- (:756-758 vs :795) β†’ permanent nhooks surplus.
  5. Permanent admin DoS: ngmn_config gates every line change on sc->nhooks > 0 (if_mn.c:308-310).
  6. Double-free enabler: nothing serializes two concurrent ngmn_disconnect calls (DF-4341 evidence: ng_disconnect_hook ng_base.c:839-858 takes no lock); both pass the state == DOWN? check at if_mn.c:756 before either stores DOWN at :758; both then run the free loops :779-794. mn_free_desc overwrites dp->vnext with the freelist head (:498-503), so the loser's dp2 = dp->vnext (:782, :792) walks onto the global freelist and frees live descriptors belonging to other channels.

Guest verification attempt

Same environment as DF-4341 (see env_guest.txt / run.log): no 0x110a PCI device, if_mn.c is optional mn netgraph (not in GENERIC), no if_mn.ko, kldload ENOENT β€” the mn0 node cannot exist, so trigger_alias.sh cannot run. Nothing was refuted; classification is inconclusive for the on-guest reproduction while the logic defect itself is certain from source.

Fix

fix.diff in this pack (ownership by sch->hook != NULL + clearing the pointer at disconnect). git apply --check clean against the read-only tree. Also closes the DF-1540 dangling-sch->hook read at the writer side. Not buildable/testable on this guest (no hardware).

Files

README.md, VERDICT.md, trigger_alias.sh, run.sh, run.log, env_guest.txt, fix.diff, 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 on this guest (hardware absent). fix.diff is line-accurate (git apply --check clean) and closes both the aliasing admission and the dangling sch->hook left behind (DF-1540 writer side).

findings/poc/DF-4342/fix.diff
↓ fix.diffper-fix-DF-4342

Confirmed kernel references

Detail

Exploit chain

root ngctl: mkpeer ts2; mkpeer ts2-3 (aliases chan 2, accepted while DOWN); connect both (second silent); rmhook ts2 -> full teardown under live ts2-3 hook; ts2-3 blackholed; 'textconfig line e1u' refused forever (nhooks stuck). Concurrent variant: rmhook ts2 & rmhook ts2-3 & -> both teardowns pass the DOWN check -> double-free of every trxd + m_freem of every mbuf -> M_MN pool corruption. Not runnable on this guest (hardware absent).

Evidence (decisive lines)

VERDICT.md (7-step deterministic trace); README.md (code excerpts); trigger_alias.sh; run.log + env_guest.txt (surface absent); fix.diff (hook-ownership guard, git apply --check OK)

PoC changes

trigger written fresh (no seed)

Verified recommended fix

Gate ngmn_newhook on sc->ch[chan]->hook != NULL instead of state==UP, and clear sch->hook in ngmn_disconnect (fix.diff; also closes DF-1540 at the writer side)

Verdict

Deterministic logic defect, verified line-by-line against the source; not exercisable on the audit guest because the mn netgraph surface cannot exist there (no 0x110a PCI device, if_mn 'optional mn netgraph' absent from GENERIC, kldload ENOENT). ngmn_newhook admits a second hook on an existing channel whenever its state is DOWN (if_mn.c:470-473), silently overwriting sc->ch[chan]->hook/ts (474-475) and double-incrementing nhooks (478); ngmn_connect then reports silent success for the second hook (664-665); the first ngmn_disconnect tears the shared channel down under the still-connected second hook (756-794) whose own disconnect early-returns before the nhooks decrement (756-758 vs 795), permanently wedging ngmn_config line changes (308-310) and blackholing the second hook's traffic. Two distinct hook names provably alias one channel (E1: ffs(ts)-1 for 'ts2' vs 'ts2-3'; E1U: hardcoded chan=1) and the netgraph core keys hooks by name only (ng_base.c:697-763). Concurrent rmhook of the two aliased hooks double-frees both descriptor rings (no serialization; mn_free_desc clobbers dp->vnext with the freelist head at if_mn.c:498-503, so the losing walker frees live descriptors of other channels).