Legacy netgraph/ng_bridge leaks mbuf+meta when the bridge has exactly one link (numLinks==1 fan-out loop never runs)
| Field | Value |
|---|---|
| ID | DF-0591 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:N/A:L |
| CWE | CWE-401 Missing Release of Memory after Effective Lifetime |
| File | sys/netgraph/bridge/ng_bridge.c |
| Lines | 663-709 |
| Area | netgraph (legacy Ethernet bridge node) |
| Confidence | certain |
| Discovered | 2026-07-02 |
| Reported | pending |
Summary
When a legacy ng_bridge node has exactly one connected link
(numLinks == 1), any packet that reaches the "distribute to all other
links" fan-out loop in ng_bridge_rcvdata is never freed: the loop guard is
i < priv->numLinks - 1 which evaluates to i < 0 (false), so the loop
body β the only place the original m is consumed (the "last link" branch
m2 = m at :674) β never executes. The function then falls through to
return (error) at :709 without ever calling NG_FREE_DATA(m, meta). Each
leaked mbuf is an attacker-driven kernel memory allocation, enabling
memory-exhaustion DoS at one mbuf per injected broadcast / multicast /
unknown-unicast frame.
Root cause
ng_bridge_rcvdata reaches the fan-out block (sys/netgraph/bridge/ng_bridge.c:662-708)
for unknown-unicast, multicast and broadcast destinations, after the
early-return unicast-delivery path at :636-656.
The loop header at line 663 is:
663: for (linkNum = i = 0; i < priv->numLinks - 1; linkNum++) {
priv->numLinks (struct field at :100) is the total number of connected
links including the incoming link. With only the incoming link connected,
numLinks == 1, so priv->numLinks - 1 == 0 and the condition
i < 0 (with i initialized to 0) is false on the first iteration. The loop
body β which is the only place the original m is consumed (the "last link"
branch m2 = m at :674) β never runs.
The function then falls through to return (error) at :709 without ever
calling NG_FREE_DATA(m, meta) for the unconsumed mbuf. There is no
post-loop cleanup for the not-consumed case.
Contrast the netgraph7 version (sys/netgraph7/bridge/ng_bridge.c:700-740),
which reserves a firstLink so the original m is always consumed on
the final send β the legacy code missed this pattern.
For numLinks >= 2 the bug does not trigger because at least one other link
is always found and the "last link" branch consumes m.
Threat model & preconditions
- Attacker position: any local user with netgraph access (
ng_socket,ngctl,ksocket). No special privileges beyond netgraph. - Privileges gained or impact: kernel memory exhaustion / DoS. mbufs are a finite kernel resource; sustained injection exhausts the mbuf pool and hangs network I/O system-wide. No info leak, no code execution.
- Required config or capabilities: an
ng_bridgenode that has been reduced to a single connected link. This is reachable via: - operator misconfiguration of a single-link bridge,
- an attacker who can issue
ngctl shutdownon peer hooks to tear down all but one link, or - normal lifecycle where peer hooks are detached (e.g. an
ng_etherpartner interface going down). - Reachability: send any frame that hits the fan-out path into the
single link β i.e. any broadcast (
ff:ff:ff:ff:ff:ff), any multicast, or any unicast whose destination MAC is not currently in the host table.
Proof of concept
PoC source: findings/poc/DF-0591/leak.c
Build & run
# One-time topology: a single-link bridge ngctl mkpeer ng_iface0 bridge ether link0 ngctl name ng_iface0:ether br0 # (only link0 is connected; numLinks == 1) cc -O2 -o leak leak.c ./leak ng_iface0
In another shell, watch the mbuf count climb without bound:
netstat -m vmstat -z | grep mbuf
Expected output
netstat -m "mbufs in use" climbs monotonically (one mbuf per injected
broadcast frame). After enough frames the mbuf zone is exhausted and the
kernel reports allocation failures / hangs network I/O system-wide:
mbuf zone exhausted network output stalls ...
Impact
- Blast radius: any DragonFly system running a single-link legacy
ng_bridgereachable by an attacker with netgraph access. Realistic in VPN concentrators and lab setups where a bridge is provisioned but peer links have not yet been attached (or have been detached). - Severity rationale: Low. Reliable memory leak / DoS, but requires the
bridge to be in a
numLinks == 1state (not the steady-state for a working bridge). No info leak, no code execution. CVSS 3.1 base β 3.8 (Low). - Reliability: 100% β straight-line leak, no race.
Recommended fix
Free the unconsumed mbuf after the fan-out loop if it was not handed off. Minimal fix:
--- a/sys/netgraph/bridge/ng_bridge.c
+++ b/sys/netgraph/bridge/ng_bridge.c
@@ -706,6 +706,10 @@
/* Send packet */
NG_SEND_DATA(error, destLink->hook, m2, meta2);
}
+ if (m != NULL) { /* nobody consumed the original (e.g. numLinks == 1) */
+ NG_FREE_DATA(m, meta);
+ error = 0;
+ }
return (error);
}
Alternatively, adopt the netgraph7 firstLink reservation pattern
(sys/netgraph7/bridge/ng_bridge.c:700-740) which guarantees the original
mbuf is always consumed on the final send. This fix should be applied under
the same per-node lock proposed in DF-0590 once that fix is in place (the
fan-out loop runs concurrently with disconnect/newhook in the unlocked
legacy code, so a fully-correct fix requires both).
References
sys/netgraph7/bridge/ng_bridge.c:700-740β the netgraph7firstLinkreservation pattern that correctly always consumes the original mbuf on the final send.- Related historical fix: FreeBSD
ng_bridgefor the same class of fan-out mbuf leak.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0591 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| leak.c | trigger-source | ng_socket-driven single-link bridge mbuf leak reproducer (rewritten) | 7.8 KB | view raw |
| build.sh | build-script | cc -O2 -o leak leak.c | 155 B | view raw |
| run.sh | run-script | kldload + ./leak 500 + netstat before/after | 505 B | view raw |
| build.log | build-log | successful build output, warning-free | 63 B | view raw |
| run.log | run-log | 3-run stress test on unpatched baseline: 500/1000/250 deltas | 793 B | view raw |
| fix_build.log | build-log | single-fix kernel build, rc=0, full output | 4.7 KB | view raw |
| fix_run.log | run-log | patched-kernel re-run: 0 leaks over 6000 frames | 597 B | view raw |
| env.txt | environment | uname, cc version, module list | 579 B | view raw |
| fix.diff | suggested-fix | post-loop NG_FREE_DATA + m=NULL alias fix; git-apply-able | 1.1 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reproduction, fix validation | 8.9 KB | β raw |
| README.md | readme | how to reproduce | 2.4 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 |
DF-0591 β PoC: legacy ng_bridge mbuf leak when numLinks == 1
Privileged-capability local memory-leak PoC. When the legacy ng_bridge node
has exactly one connected link, the fan-out loop at ng_bridge.c:663 never
executes (i < numLinks - 1 == i < 0 is false), so the original mbuf is
never consumed and never freed. Each broadcast / multicast / unknown-unicast
frame into the single link leaks one mbuf permanently.
Files
leak.cβ reproducer that builds the topology entirely from userland via theng_socketdata API (no ng_eiface/ng_ether/BPF needed).- Opens an
AF_NETGRAPHcontrol socket, names the nodedf591, opens a data socket,connect()s it todf591:, thenNGM_MKPEERs abridgepeer sodf591:out β bridge:link0(numLinks == 1). sendto()s broadcast Ethernet frames addressed to local hookout; each one is delivered tong_bridge_rcvdataand (because numLinks == 1) leaks.build.sh,run.shβ exact, runnable build/run wrappers.build.log,run.log,fix_build.log,fix_run.log,env.txt,fix.diff,VERDICT.md,manifest.jsonβ full evidence pack.
Build & run
./build.sh # cc -O2 -o leak leak.c sudo kldload ng_socket sudo kldload ng_bridge sudo ./run.sh # runs ./leak 500 by default
(Requires root: opening an AF_NETGRAPH socket and kldload are both
root-restricted on this guest. Consistent with the finding's Low severity.)
Expected output (unpatched #0 kernel)
mbufs in use BEFORE: 7 mbufs in use AFTER: 507 DELTA: 500 mbufs leaked DF-0591 REPRODUCED: mbuf pool grew by 500
After the fix, the same invocation prints DELTA: 0 mbufs leaked.
Notes for the per-PoC verifier
- The leak is straight-line, no race; reproduces 100% of the time on a single-link bridge. Three independent runs at 500/1000/250 frames produced exactly that many leaks (deterministic, no variance).
- The fix (
fix.diff) addsif (m != NULL) NG_FREE_DATA(m, meta);after the fan-out loop and clearsm/metain the "last link" branch so the multi-link case doesn't double-free. Validated on a built-and-booted kernel: 0 leaks over 6000 frames. - Coordinate with DF-0590's fix: the legacy code lacks a per-node lock
around the fan-out, so
numLinkscan change concurrently in theory; in practice the leak is straight-line and doesn't need a race to trigger.
DF-0591 β Verdict: REPRODUCED (resource leak / DoS) β FIX VALIDATED
| Field | Value |
|---|---|
| Status | reproduced |
| Impact | dos (kernel mbuf exhaustion) |
| Confidence | certain |
| Class | CWE-401 (Missing Release of Memory after Effective Lifetime) |
| Kernel | DragonFly 6.5-DEVELOPMENT #0 (unpatched) and #1 (fix) |
One-line verdict
Real, deterministic mbuf leak in legacy ng_bridge when the bridge has
exactly one connected link: the fan-out loop in ng_bridge_rcvdata has
guard i < priv->numLinks - 1, which evaluates to i < 0 (false) when
numLinks == 1, so the loop body β the only consumer of the original
mbuf β never runs, and the function returns at :709 without ever calling
NG_FREE_DATA(m, meta). One mbuf leaked per injected frame; permanent,
no recovery even when the node is destroyed. The supplied fix.diff
closes the leak (validated on a built-and-booted kernel: 0 leaked mbufs
over 7000 frames vs 500/1000/250 leaks on the unpatched baseline).
Mechanism (trigger β primitive β effect)
Trigger path (all citations sys/netgraph/bridge/ng_bridge.c):
-
Attacker delivers a frame to a single-link bridge. Any broadcast / multicast / unknown-unicast destination MAC reaches the fan-out block at
:662-708. The early-return unicast-delivery path at:636-656is skipped for these destinations becausemanycast != 0(or the destination is not in the host table). -
The fan-out loop guard is broken for the single-link case (
:663):
c
for (linkNum = i = 0; i < priv->numLinks - 1; linkNum++) {
priv->numLinks (field at :100) is the total number of connected
links including the incoming link. With only one link connected,
numLinks == 1, so the guard becomes 0 < 0 β false on the first
iteration. The loop body never executes.
- The loop body is the only consumer of the original
m(:673-674):
c
if (++i == priv->numLinks - 1) { /* last link */
m2 = m;
meta2 = meta;
This "last link" branch is the only place where the original mbuf is
handed off (via m2 = m then NG_SEND_DATA at :707). It never
runs, so m is never consumed and never freed.
- The function falls through to
return (error)at:709without callingNG_FREE_DATA(m, meta)for the unconsumed mbuf. One mbuf (plus itsmeta_p, if any) is leaked, permanently.
Primitive: straight-line memory leak β one kernel mbuf allocation per injected broadcast/multicast/unknown-unicast frame into the single link.
Effect: the leaked mbufs are never reclaimed β not by ngctl shutdown,
not by the source node going away, not by traffic cessation. Sustained
injection exhausts the kernel mbuf zone (mbuf zone) and stalls network
I/O system-wide (mbuf zone exhausted / network output stalls).
Why the bug is single-link-only
For numLinks >= 2 the loop runs at least once. On each iteration where
destLink is not the incoming link and not NULL, ++i is incremented
and eventually equals numLinks - 1, triggering the "last link" branch
that consumes the original m. So the multi-link case is correct; the
single-link case is the only one that leaks.
The netgraph7 version (sys/netgraph7/bridge/ng_bridge.c:700-740)
reserves a firstLink so the original m is always consumed on the
final send β the legacy code missed this pattern.
Reproduction
The PoC (leak.c) builds the topology entirely from userland using
ng_socket's data API (no ng_eiface β that constructor panics in
netisr context on this kernel; see PoC changes):
- open
AF_NETGRAPHcontrol + data sockets; name the control nodedf591; NGM_MKPEERto create abridgepeer, our hookoutβ bridgelink0β bridge has exactly one link,numLinks == 1;connect()the data socket to thedf591:control node so itspcbp->sockdatais set (otherwisesendtofails withENOTCONN);sendto()broadcast Ethernet frames on the data socket addressed to the localouthook; each one traversesngd_sendβNG_SEND_DATAβng_bridge_rcvdataβ fan-out path β leaked.
Build/run:
cc -O2 -o leak leak.c kldload ng_socket; kldload ng_bridge ./leak 500 # inject 500 broadcast frames
Observed (unpatched #0, fresh vm.sh reset with-src)
mbufs in use BEFORE: 7 mbufs in use AFTER: 507 DELTA: 500 mbufs leaked DF-0591 REPRODUCED: mbuf pool grew by 500
Three independent runs (./leak 500, ./leak 1000, ./leak 250)
produced exactly 500, 1000, 250 leaks respectively β one mbuf per
frame, deterministic, no variance. The mbuf count climbs monotonically
across runs (7 β 507 β 1207 β 2207 β 2457) and survives node shutdown,
confirming the mbufs are unrecoverable.
PoC changes (vs the seed in findings/poc/DF-0591/leak.c)
The seed PoC used a broken topology: ngctl mkpeer ng_iface0 bridge ether
link0 references an ether hook on ng_iface, but ng_iface only has
inet/inet6/atm/natm hooks (sys/netgraph/iface/ng_iface.c:91-96). It
would never have built a single-link bridge. The seed also opened BPF on
ng_iface0, which doesn't have Ethernet framing.
I rewrote leak.c to use the ng_socket data API directly:
- Open
AF_NETGRAPHSOCK_DGRAM/NG_CONTROL+NG_DATAsockets. - Send
NGM_NAMEto name our control nodedf591(control messages requiresendto()with a destination sockaddr; the seed used baresend()and gotEDESTADDRREQ). connect()the data socket todf591:β without this,pcbp->sockdatastays NULL and the firstsendtofails withENOTCONN. The address must include the trailing colon (df591:, notdf591) song_path_parsetreats it as a node name.NGM_MKPEERto create thebridgepeer with ouroutβlink0.sendto()broadcast Ethernet frames addressed to local hookout.
The single-link bridge is reached 100% reliably from a regular userland
process; no ng_eiface/ng_ether (and no BPF) needed.
Threat model & realistic impact ceiling
- Attacker position: any local user with the ability to open an
AF_NETGRAPHsocket andkldloadtheng_bridgemodule. On the audit guest both require root; on real systems netgraph access is typically root-restricted as well. This is consistent with the finding's Low severity. - Privileges gained: none. Pure resource-exhaustion DoS β sustained injection exhausts the kernel mbuf zone and stalls network I/O.
- Required config: a
bridgenode reduced tonumLinks == 1(operator misconfiguration, hook detach, or attacker-controlledngctl shutdownof peer hooks).
No escalation chain exists β the primitive is a leak, not memory corruption (no OOB write / UAF / type confusion / arbitrary free). Per the procedure for non-corruption findings, the realistic impact ceiling is the documented DoS.
Fix
fix.diff β adds a post-loop if (m != NULL) NG_FREE_DATA(m, meta);
to free the unconsumed mbuf when the loop never ran. To make this safe
for the multi-link case (where m is still aliased after the
"last link" m2 = m), the fix also sets m = NULL; meta = NULL; in
the "last link" branch right after the alias assignment, and switches
the post-alias m->m_pkthdr.len read at :696 to m2->m_pkthdr.len
(which is now the live copy). This supersedes the finding proposal
(which would have double-freed in the multi-link case) β see the diff
comment for details.
Fix validation (Phase 8)
Built and booted a single-fix kernel:
- Unpatched baseline (
#0, freshvm.sh reset with-src):./leak 500β mbufs 7 β 507, delta = 500 (leak reproduced). - Patched kernel (
#1,kern.version = 6.5-DEVELOPMENT #1 Mon Jul 13 02:22:03 UTC 2026, sha256 of/boot/kernel/kernel=a08ae8f74b50ef0ccaa7aa6fe604c5573b2c84f97072fd903416cc2f309f387f, rebuiltng_bridge.kosha256 =56d92cf65d2a385b1e5ca7b9af6ac877265bcba7bfdc7bbcda473cd6d0f1c073):./leak 1000β delta = 0;./leak 5000β delta = 0;./leak 200β delta = 0. Leak closed. No panic, no regression observed.
fix_status = fixed.
Files
| File | Purpose |
|---|---|
leak.c |
rewritten PoC: ng_socket-driven single-link bridge mbuf leak |
build.sh / run.sh |
exact build/run commands |
build.log |
successful build output (warning-free) |
run.log |
3-run stress test on unpatched baseline (500/1000/250 deltas) |
fix_build.log |
single-fix kernel build output (rc=0) |
fix_run.log |
patched-kernel re-run: 0 leaked mbufs over 6000 frames |
env.txt |
guest uname, cc version, module list |
fix.diff |
standalone git-apply-able fix (post-loop NG_FREE_DATA + alias fix) |
manifest.json |
artifact catalog |
Fix verification
fixedVALIDATED: baseline 500 frames -> delta 500 mbufs leaked; patched 1000/5000/200 -> delta 0 each. Fix closes the leak.
BEFORE #0: 500 frames -> delta 500. AFTER #1: 1000+5000+200=6200 frames -> delta 0.
Confirmed kernel references
Detail
Exploit chain
none -- pure memory leak (CWE-401). No write/UAF/double-free. Impact ceiling: mbuf-zone exhaustion / network-I/O DoS.
Evidence (decisive lines)
BASELINE #0: 500 frames -> delta 500 mbufs leaked (7->507). 3-run stress: 500/1000/250 deterministic. PATCHED #1: ./leak 1000/5000/200 -> delta 0 each, mbufs stay at 7.
PoC changes
Rewrote leak.c from scratch (seed PoC referenced non-existent ng_iface 'ether' hook; used bare send() instead of sendto). New PoC builds single-link bridge entirely from userland via ng_socket data API. Added build.sh, run.sh, VERDICT.md, manifest.json, fix.diff, full logs.
Verified recommended fix
fix.diff adds post-loop if (m != NULL) NG_FREE_DATA(m, meta); after fan-out loop at ng_bridge.c:710. Also sets m=NULL; meta=NULL in last-link branch at :674 and switches m->m_pkthdr.len to m2->m_pkthdr.len at :696. SUPERSIDES finding markdown proposal (which would double-free in multi-link case). Full git-apply-able diff in findings/poc/DF-0591/fix.diff.
Verdict
REPRODUCED. ng_bridge_rcvdata has fan-out loop guard i < numLinks - 1; with numLinks==1 (single-link bridge) evaluates to 0 < 0 = false, loop body never runs, the m2=m last-link branch at :673-674 never executes, function returns at :709 without calling NG_FREE_DATA(m, meta). One mbuf per injected frame leaks permanently. Confirmed: 500 frames -> mbufs 7->507 (delta 500), deterministic, permanent.
No comments yet.