ng_fec_tick iterates port list with no list lock: UAF race vs addport/delport
Summary
ng_fec_tick(:579-612 from callout:535/616): TAILQ_FOREACH(p,&b->ng_fec_ports,fec_list) with NO lock on port list. Comment(:575-578) notes parent serializer not held. Concurrent NGM_FEC_DEL_IFACE TAILQ_REMOVE(:464)+kfree(p)(:465) under ifnet_lock only -> tick derefs freed list node -> UAF. Distinct from DF-0524(stored ifp UAF): this is list-node UAF not ifp UAF. Fix: acquire consistent lock across tick iteration AND addport/delport mutation.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0525 · 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | claim, source confirmation, repro status, fix | 3.4 KB | ↓ raw |
| VERDICT.md | verdict | full narrative: source-confirmed race, constructor-defect blocker, root-only reachability, fix | 4.8 KB | ↓ raw |
| trigger_df0525.sh | trigger-source | documented race setup (load, mkpeer, add ports, bring up, race delport) | 2.3 KB | view raw |
| build.sh | build-script | apply fix.diff + build ng_fec.ko | 469 B | view raw |
| run.sh | run-script | run the race trigger as root | 221 B | view raw |
| fix.diff | suggested-fix | ifnet_lock/unlock around ng_fec_tick TAILQ_FOREACH | 894 B | view raw |
| build.log | build-log | patched ng_fec.ko module build, rc=0 | 6.7 KB | view raw |
| panic.txt | panic-signature | separate constructor defect panic (blocks live trigger) | 1.4 KB | view raw |
| env.txt | environment | uname, cc, kldstat, module availability, privilege boundary | 1.5 KB | view 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-0525 — ng_fec_tick UAF race vs. NGM_FEC_DEL_IFACE
File: sys/netgraph/fec/ng_fec.c
Severity (filed): High
Class: Use-after-free (list-node), race condition
Module: ng_fec.ko (old netgraph v1) — shipped in /boot/kernel/, loadable.
The claim (source-level, CONFIRMED)
ng_fec_tick() (ng_fec.c:562) runs from a 1 Hz callout (callout_reset at
:535/:616) and walks the bundle's port list:
TAILQ_FOREACH(p, &b->ng_fec_ports, fec_list) { /* ng_fec.c:579 */
ifp = p->fec_if; /* :581 deref of list node */
...
}
The comment at :575-578 explicitly admits no lock is held:
Note: serializer for parent interface not held on entry, and cannot be held during the loop to avoid a deadlock.
Meanwhile ng_fec_delport() (:417), invoked from the NGM_FEC_DEL_IFACE
control message (:1176-1178), mutates the same list under the global
ifnet_lock():
ifnet_lock(); /* :430 */
...
TAILQ_REMOVE(&b->ng_fec_ports, p, fec_list); /* :464 */
kfree(p, M_NETGRAPH); /* :465 <-- node freed */
b->fec_ifcnt--;
ifnet_unlock(); /* :468 */
Because ng_fec_tick does not take ifnet_lock(), a concurrent delport
can TAILQ_REMOVE + kfree(p) the very node the tick is about to (or is)
dereferencing via TAILQ_FOREACH. The tick then dereferences freed memory
(p->fec_if, p->fec_ifstat) → UAF. ng_fec_addport() (:328, inserts
at :409 under ifnet_lock from the same call site) has the symmetric issue.
Reproduction status
- Source-level: CONFIRMED. The locking asymmetry is real and explicit in the comment; the race window is the whole tick loop body.
- Live trigger: NOT reproduced. The shipped
ng_fec.kodeterministically panics during node construction (kfree(NULL)reached fromng_fec_constructor,panic: trying to free NULL pointer) — a separate ng_fec defect — so no fec bundle / callout tick can ever be created on master DEV #0 to exercise the race. Seepanic.txt. - Privilege boundary: root-only. The netgraph control socket
(
ngc_attach,sys/netgraph/socket/ng_socket.c:172) requirescaps_priv_check(.., SYSCAP_RESTRICTEDROOT), so creating an fec node and issuingNGM_FEC_ADD_IFACE/NGM_FEC_DEL_IFACErequires root. There is no unprivileged path to the race.
Impact (realistic)
Root→kernel UAF race. Best-case observable effect is a self-inflicted kernel panic / DoS by a root context that manages fec bundles and races add/del port against the 1 Hz monitor tick. It is not an unprivileged→root escalation (root-only reachability; and the freed object is a list node, not a controllable credential/function-pointer victim). Treated as a reliability/hardening bug in shipped netgraph code.
Build / run
./build.sh # apply fix.diff, build ng_fec.ko (validates fix compiles) ./run.sh # attempt the race as root (panics at constructor on master DEV)
Full untrimmed logs: build.log, panic.txt, env.txt.
Fix
fix.diff — acquire the global ifnet_lock() across ng_fec_tick()'s
TAILQ_FOREACH (the same lock ng_fec_addport/ng_fec_delport already hold
when mutating the list). This is minimal and matches the existing mutation-side
locking. ifp->if_ioctl(SIOCGIFMEDIA) under ifnet_lock is safe — delport
already calls if_ioctl under ifnet_lock (:455).
DF-0525 — VERDICT
Verdict: NOT REPRODUCED at runtime (source-CONFIRMED real race; live trigger pre-empted by a separate ng_fec constructor defect; root-only reachability). Impact: none for an unprivileged user (root-only → at best self-inflicted DoS/panic). Confidence: likely (race confirmed by rigorous line-by-line source trace; not runtime-observed because the module cannot be instantiated on master DEV).
Mechanism (source-confirmed)
ng_fec_init()(sys/netgraph/fec/ng_fec.c:502) arms a 1 Hz callout:callout_reset(&priv->fec_timeout, hz, ng_fec_tick, priv);(:535).ng_fec_tick()(:562) iterates the port list without the list lock: -:575-578comment: "serializer for parent interface not held on entry, and cannot be held during the loop to avoid a deadlock." (This refers to the ifnet serializer, a different synchronization object from the global ifnet_lock that protects the port list.) -:579TAILQ_FOREACH(p, &b->ng_fec_ports, fec_list)— derefsp.ng_fec_delport()(:417), reached fromNGM_FEC_DEL_IFACE(:1176-1178), holds the globalifnet_lock()(:430) and: -:464TAILQ_REMOVE(&b->ng_fec_ports, p, fec_list);-:465kfree(p, M_NETGRAPH);← list node freed. -:468ifnet_unlock();- Because tick holds no lock on the list, a concurrent delport can free
the node tick is traversing →
p->fec_if/p->fec_ifstat(:581,:594,:602) dereference freed memory → UAF. Symmetric hazard withng_fec_addport()(:328,TAILQ_INSERT_TAILat:409).
The race is real and the lock asymmetry is explicit. This is not a false positive.
Why NOT reproduced at runtime
Two compounding facts:
(A) Separate constructor defect blocks node creation. On master DEV #0
(with-src), kldload ng_fec.ko succeeds, but creating any fec node panics
immediately:
ngctl mkpeer fec ether fec -> panic: trying to free NULL pointer ng_fec_constructor() at ng_fec_constructor+0x3ae
kern_slaballoc.c:1407 fires on kfree(NULL) reached from
ng_fec_constructor() (ng_fec.c:1071-1152). The constructor's error paths
also embed a latent double-free: at :1093/:1101 they call
kfree(ifp, M_NETGRAPH), but ifp = &priv->arpcom.ac_if (:1085) is an
embedded member of priv, not a separately allocated object. Because no
fec node can be created, no bundle/tick exists, so the DF-0525 race cannot be
exercised. (The constructor bug should be filed as a separate finding.)
(B) Root-only reachability (no privilege boundary). The netgraph control
socket requires root: ngc_attach() (sys/netgraph/socket/ng_socket.c:172)
does caps_priv_check(ai->p_ucred, SYSCAP_RESTRICTEDROOT | __SYSCAP_NULLCRED).
Creating an fec node and issuing NGM_FEC_ADD_IFACE/NGM_FEC_DEL_IFACE is
therefore root-only. Per the audit threat model, a root→kernel race is
game-over-by-definition on the privilege axis; the only realistic impact is a
root-triggered panic (DoS). The freed object is a list node (struct
ng_fec_portlist), not a credential/function-pointer victim, so even on a
non-INVARIANTS kernel there is no straightforward escalation primitive —
content control over the freed/reclaimed node would require heap grooming
into a same-bucket victim, and there is no unprivileged path to drive it.
Fix-validation (fix.diff)
fix.diff acquires ifnet_lock()/ifnet_unlock() around the
TAILQ_FOREACH in ng_fec_tick(), matching the lock that
ng_fec_addport/ng_fec_delport already hold when mutating the list. It is
git-apply-able (git apply --check clean) and compiles: a patched
ng_fec.ko module build completes with rc=0 (see build.log).
fix_status: not_testable (runtime). The specific tick/delport race cannot be exercised live because (a) the module deterministically panics on construction and (b) the path is root-only with no unprivileged driver. The fix was validated to apply + compile; it is the minimal correct change (it makes the iteration hold the exact lock the mutators hold).fix_baseline_reproduced: 0 (the race was not live-reproduced even on the unpatched baseline, for the reasons above).fix_patched_reproduced: 0.
PoC changes
Created the evidence pack from scratch (the folder was empty on handoff):
trigger_df0525.sh (documented race setup), build.sh, run.sh,
README.md, this VERDICT.md, panic.txt, env.txt, build.log,
fix.diff, manifest.json.
Recommended fix (summary)
In sys/netgraph/fec/ng_fec.c, wrap ng_fec_tick()'s TAILQ_FOREACH
(:579-:612) in ifnet_lock() / ifnet_unlock() so the port-list
iteration is serialized against ng_fec_addport/ng_fec_delport, which
already mutate that list under ifnet_lock(). (Supersedes the finding's
"acquire consistent lock" proposal by naming the concrete lock.)
Fix verification
not_testablenot_testable: compile validated, no runtime trigger.
git apply --check OK + module build rc=0.
Confirmed kernel references
—
Detail
Exploit chain
none
Evidence (decisive lines)
Source trace + compile validation. No live trigger.
PoC changes
fix.diff + VERDICT.md + manifest.json per finding.
Verified recommended fix
See individual fix.diff per finding.
Verdict
Source-confirmed real but not runtime-reproduced. See individual evidence packs.
No comments yet.