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

ng_hole getstats/clrstats/getclrstats pass msg->data to ng_findhook strcmp without validating the advertised length against what was sent β€” heap OOB read, deterministic heap-residue match oracle (DF-0610/DF-2600/DF-3496 class on the DF-0534 enabler)

Summary

ngh_rcvmsg checks only msg->header.arglen == NG_HOOKSIZ (32) then calls ng_findhook(node, msg->data). ngc_send kmallocs len+1 bytes from the actual mbuf-chain length with no arglen-vs-len validation and ng_package_msg validates nothing, so an under-sent control message leaves msg->data[] as stale heap and the strcmp walks past the allocation until a NUL. VERIFIED on the stock INVARIANTS guest: a getstats supplying ZERO hook-name bytes returned the real hook's stats (frames=1 octets=5) in 256/256 rounds per run, 3 runs - the name was completed entirely from the groomed freed 64-byte slab chunk. Negative control (full-length unterminated) -> ENOENT, proving the OOB needs the under-sent variant. Root-gated (uid 1001 EPERM); module buildable/loadable but not in the default kernel or netgraph7 SUBDIR. Fix validated: socket-side length check + hole-side NUL check -> 0/256, EINVAL, no regression. FLOW-cookie relay (DF-3557 class) ABSENT - ngh_rcvmsg handles only its own cookie.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3558 Β· 16 files
FileTypeDescriptionSize
hole_oob.c β€” 9.5 KB view raw
echo_neg.c β€” 7.6 KB view raw
build.sh β€” 139 B view raw
run.sh β€” 145 B view raw
build.log β€” 843 B view raw
run.log β€” 798 B view raw
run.2.log β€” 378 B view raw
run.3.log β€” 245 B view raw
echo_neg.log β€” 1.0 KB view raw
fix_run.log β€” 1.5 KB view raw
env.txt β€” 1.2 KB view raw
fix.diff β€” 1.3 KB view raw
README.md β€” 4.4 KB ↓ raw
VERDICT.md β€” 6.4 KB ↓ raw
manifest.json β€” 986 B view raw
verdict.json β€” 3.9 KB view raw

DF-3558 -- netgraph7 ng_hole: unterminated/under-sent hook name in getstats/clrstats/getclrstats -> ng_findhook strcmp walks past the kmalloc'd ng_mesg buffer (heap OOB read, heap-residue match oracle) =========================================================================

Root cause

ngh_rcvmsg() (sys/netgraph7/hole/ng_hole.c:163-168) validates ONLY that msg->header.arglen == NG_HOOKSIZ (32) and then hands (char *)msg->data to ng_findhook() (ng_base.c:1095-1107), which strcmp's it against every stored hook name. Neither ngc_send() (socket/ng_socket.c:261-262: the message buffer is kmalloc'd as len+1 with zero validation that header.arglen fits in what was actually sent) nor ng_package_msg() (ng_base.c:3007-3027: no validation at all) checks the claimed length, so an under-sent control message makes msg->data[] (and everything strcmp walks past it) stale heap residue. Same consumer-side pattern as DF-0610 (ng_nat), DF-2600 (ng_source), DF-3496 (ng_fec); the framework enabler is DF-0534.

Note the honest-length unterminated case ("abcde1xxx...x" for all 32 bytes, no NUL) is provably SAFE: stored hook names are NUL-terminated within 31 characters, so strcmp always stops at or before data[31]. The OOB requires the under-sent message (arglen lie) -- the PoC demonstrates exactly that distinction with a negative control.

Trigger

hole_oob.c (AF_NETGRAPH control socket + data socket):

  1. hole node with hook "abcde1"; one 5-byte packet pushed through it (frames=1, octets=5).
  2. GROOM: getstats with arglen=32 but only 7 real data bytes "abcde1\0" (kmalloc(56+7+1)=64 -> 64-byte slab zone).
  3. ATTACK: getstats with arglen=32 and ZERO data bytes (kmalloc(56+0+1)=57 -> same 64-byte zone). The allocator hands back the just-freed groom chunk, strcmp completes "abcde1\0" ENTIRELY from bytes past the sent payload, and the stats response (frames=1 octets=5) comes back. Correct behavior: ENOENT (no hook name supplied at all).

Expected output (stock kernel): STEP sanity getstats: token=... frames=1 octets=5 STEP negative control: unterminated full-length -> sendto errno=2 (ENOENT) RESULT oob_matches=256/256 enoent=1 rejected=0 other=0 RESULT OOB-READ PROVEN: ...

Deterministic: 256/256 in the decisive run and in 3 stress runs.

echo_neg.c (negative evidence for the ng_echo pass-2 lead, no finding): builds the closed echo<->echo ring exactly as DF-3557's dance, then tries both injection channels: A. data into a third hook on a ring member -> reflected straight back to the sender socket ("PING" readable on ds), ring untouched; B. FLOW-cookie control messages hook-addressed INTO ring hooks ("e1:q", "e2:n") -> bounced to the ORIGINAL sender as an NGF_RESP response (token-matched), never relayed into the ring. Guest survives: a bare echo pair is inert; ng_echo has no DF-3557-style relay (its NG_RESPOND_MSG -> ng_address_ID(NGI_RETADDR) terminates at the original sender, netgraph.h:998-1013).

Reachability: root-only (ngc_attach caps_priv_check; uid 1001 gets EPERM at socket() -- unpriv_check section of run.log). Module is not shipped in the default kernel or netgraph7 SUBDIR; it is buildable (hole/ has a Makefile) and loadable as ng_hole.ko.

Fix (fix.diff, validated)

  • socket/ng_socket.c (the effective fix): reject control messages whose mbuf chain is shorter than sizeof(struct ng_mesg) + header.arglen (EINVAL).
  • hole/ng_hole.c (defense-in-depth): require msg->data[NG_HOOKSIZ-1] == '\0'.

Validated on the guest: fresh vm.sh reset with-src, patch applied to /usr/src, modules rebuilt (-Werror clean), exact same PoC re-run: oob_matches=0/256, rejected=256 (EINVAL at the socket), sanity well-formed getstats still returns frames=1/octets=5 (no regression), echo_neg still passes. See fix_run.log.

Files

hole_oob.c / echo_neg.c trigger sources (cc -O2) build.sh / run.sh exact in-guest build & run build.log module + PoC build output run.log decisive baseline run (256/256) run.2.log, run.3.log stress runs (determinism) echo_neg.log ng_echo negative evidence (stock kernel) fix_run.log patched-kernel run (0/256, rejected=256) env.txt guest uname, modules loaded fix.diff git-apply-able unified diff manifest.json / verdict.json

VERDICT.md
↓ download raw

DF-3558 VERDICT

Status: reproduced (deterministic, 256/256 per run, 3 stress runs) Impact: heap OOB read (CWE-125) exposed as a heap-residue match oracle; root-gated; module buildable+loadable but not shipped by default. Fix: validated (fixed).

What was found

ngh_rcvmsg() β€” sys/netgraph7/hole/ng_hole.c:163-168 β€” checks only msg->header.arglen == NG_HOOKSIZ (32) before passing (char *)msg->data to ng_findhook() (sys/netgraph7/netgraph/ng_base.c:1095-1107, a plain strcmp against each hook name). The length of what the user ACTUALLY sent is never validated anywhere on the path:

So a control message that DECLARES arglen=32 but SENDS zero data bytes gives msg->data[] = 100% stale heap, and the strcmp reads it (and everything up to the first NUL beyond it) out of bounds of the 57-byte kmalloc. This is the consumer-side instance of the class already filed as DF-0610 (ng_nat), DF-2600 (ng_source), DF-3496 (ng_fec), on top of the framework enabler DF-0534.

Why the honest-length unterminated case is NOT the bug

I initially suspected the classic "32 bytes, no NUL" case. It is provably safe here: stored hook names are NUL-terminated within 31 characters, so strcmp(hookname, data) stops at the first difference β€” at worst index 31, the last in-bounds byte. There is no way to read data[32] with a full-length message. The PoC carries this as an explicit negative control (full-length unterminated "abcde1xxxx..." β†’ ENOENT).

Reproduction (run.log, run.2.log, run.3.log)

  1. hole node, hook "abcde1", one 5-byte packet (frames=1, octets=5).
  2. GROOM: getstats, arglen=32, 7 real bytes "abcde1\0" β€” kmalloc(64), same 64-byte slab zone as the attack; when freed the chunk retains the name at data offset.
  3. ATTACK: getstats, arglen=32, ZERO real bytes β€” kmalloc(57β†’64 zone). The allocator returns the groom chunk; strcmp completes the name from bytes past the sent payload; stats come back.
RESULT oob_matches=256/256 enoent=1 rejected=0 other=0
RESULT OOB-READ PROVEN: stats returned for a request that supplied ZERO
hook-name bytes (kernel strcmp completed the name from stale heap past
the sent payload)

Deterministic because DF's slab allocator zones are keyed by size class (8-byte granularity below 128: kern_slaballoc.c zoneindex()), and the per-CPU objcache hands the just-freed 64-class chunk straight back. ng7 also returns the node handler's error synchronously through sendto() (ENOENT when no hook matches), which makes the match/mismatch oracle trivially observable.

Why this is not worse than Low

  • Root-gated: AF_NETGRAPH control sockets require privilege (ngc_attach caps_priv_check); uid 1001 gets EPERM at socket() (run.log unpriv section, mirroring DF-3557's unpriv_check).
  • The OOB read's observable is a match oracle (did a stale-heap-completed name match a hook), not a direct byte disclosure; response content is the hook's own stats.
  • Not shipped: no NETGRAPH7_HOLE in the default kernel, not in netgraph7/Makefile SUBDIR β€” but hole/ has a working module Makefile (build.log) so it is reachable for anyone building/loading it.

Fix validation (fix_run.log)

fix.diff = socket-side length validation (effective) + hole-side NUL check (defense-in-depth):

  • socket/ng_socket.c: after the version check, reject when len < sizeof(struct ng_mesg) || len < sizeof(struct ng_mesg) + header.arglen (EINVAL).
  • hole/ng_hole.c: additionally require msg->data[NG_HOOKSIZ-1]=='\0'.

Fresh vm.sh reset with-src, patch applied to the guest's /usr/src, ng_socket.ko + ng_hole.ko rebuilt (-Werror clean), exact PoC re-run:

STEP sanity getstats: token=22531 frames=1 octets=5     <- no regression
STEP negative control: unterminated full-length -> sendto errno=22
RESULT oob_matches=0/256 enoent=1 rejected=256 other=0
RESULT fixed behavior: under-sent requests rejected with EINVAL at the
socket (no OOB possible)

echo_neg (ng_echo smoke) still passes on the patched kernel. The previously-observed bad behavior is gone; well-formed messages still work.

The other three files in this batch (negative results, live where noted)

  • ng_split.c β€” pass 2 found nothing beyond known DF-0759 (out-hook double NG_FREE_ITEM, already verified reproduced/panic). Hook-private pointers into priv are safe because ng_rmnode destroys all hooks (ng_base.c:715-716) before the type shutdown frees priv; in/mixed fallthrough paths free the item exactly once; newhook dedups (EISCONN); no rcvmsg surface.
  • ng_ip_input.c β€” the DF-0735 lead is REFUTED by code: ng_ip_input hands the mbuf to netisr_queue(NETISR_IP, m) (ng_ip_input.c:125), which only characterizes the hash and lwkt_sendmsgs it to the proper per-CPU netisr port (net/netisr.c:394-442) β€” ip_input runs in correct NETISR context, unlike ng_ipfw's direct ip_input(m) (ng_ipfw.c:248), which anyway is unbuildable dead code (DF-3556). M_PKTHDR is enforced on every data hop by CHECK_DATA_MBUF (ng_base.c:1942/361). File is also unwired (no module Makefile, not in SUBDIR, no default-kernel option).
  • ng_echo.c β€” clean, with LIVE negative evidence (echo_neg.log): the closed echo<->echo ring is inert. Data sent into any hook of a ring member reflects straight back to the sender (ng_echo.c:106 NG_FWD_ITEM_HOOK on the arrival hook only), and control messages are responded to the ORIGINAL sender via NG_RESPOND_MSG β†’ ng_address_ID(NGI_RETADDR) (netgraph.h:998-1013) β€” the exact relay channel that made ng_UI exploitable (DF-3557) does not exist in ng_echo. Echo-containing rings that ARE injectable (e.g. via ng_tee's left2right hook) are instances of the already-filed DF-3540/DF-3553 class whose root cause (unbounded synchronous forwarding, no cycle detection) lives outside ng_echo.c.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fixed

fix_run.log: after vm.sh reset with-src + patch(1) of fix.diff into /usr/src + -Werror-clean rebuild of ng_socket.ko/ng_hole.ko, the exact PoC shows oob_matches=0/256 with rejected=256 (EINVAL at the socket), sanity well-formed getstats still returns frames=1/octets=5 (no regression), and echo_neg still passes.
↓ fix.diffper-fix-DF-3558

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log: 'RESULT oob_matches=256/256 ... stats returned for a request that supplied ZERO hook-name bytes'; negative control ENOENT on the same run; run.2.log/run.3.log determinism; unpriv EPERM in run.log; fix_run.log: oob_matches=0/256 rejected=256 (EINVAL), sanity stats still frames=1 octets=5; echo_neg.log: live negative proof that a closed echo<->echo ring is inert (separate ng_echo pass-2 lead).

PoC changes

Seed harness was written fresh for this batch (DF-3557's ui_cycle.c pattern). Iterations: (1) initial version treated sendto failure as fatal -- ng7 returns the node handler's error synchronously via sendto errno (ENOENT = no hook match), so error accounting was reworked around that; (2) response wait switched from SO_RCVTIMEO to poll(); (3) fix-run accounting added (rejected/EINVAL).

Verified recommended fix

ng_socket.c ngc_send: reject messages whose mbuf chain is shorter than sizeof(struct ng_mesg)+header.arglen (EINVAL); ng_hole.c: also require msg->data[NG_HOOKSIZ-1]=='\0'.

Verdict

REPRODUCED (deterministic). ngh_rcvmsg (sys/netgraph7/hole/ng_hole.c:163-168) validates only header.arglen==NG_HOOKSIZ(32) and passes (char*)msg->data to ng_findhook's strcmp (ng_base.c:1095-1107); nothing on the path checks that the arglen claim fits what was actually sent (ngc_send kmallocs len+1: ng_socket.c:261-262; ng_package_msg validates nothing: ng_base.c:3007-3027), so an under-sent control message makes msg->data stale heap and the strcmp reads out of bounds of the kmalloc'd buffer. Demonstrated as a heap-residue match oracle: a getstats that supplies ZERO hook-name bytes returns the real hook's stats (frames=1 octets=5) in 256/256 rounds per run across 3 runs, because the allocator hands back the groomed 64-byte slab chunk (zoneindex() 8-byte granularity, kern_slaballoc.c). The honest-length unterminated case is provably safe (hook names are NUL-terminated within 31 chars, so strcmp stops by data[31]) -- carried as the PoC's negative control (ENOENT). Root-gated (uid 1001: EPERM at socket(), caps_priv_check); module buildable/loadable but not shipped in the default kernel or netgraph7 SUBDIR. Same consumer-side pattern as DF-0610/DF-2600/DF-3496 on the DF-0534 enabler. Fix validated: socket-side arglen-vs-len check + hole-side NUL check -> 0/256, all EINVAL, well-formed stats unregressed.