Heap buffer overflow in ng_string_parse: missing *buflen bounds check before bcopy of user-supplied string
Summary
ng_string_parse(:704-720): bcopy(sval,buf,len)(:716) where len=strlen(sval)+1 with NO check len<=*buflen. Every sibling parse function checks (int8_getDefault:385, fixedstring_parse:774, bytearray_parse:959). NGM_ASCII2BINARY(ng_base.c:1578-1648): bufSize=2000, attacker NGF_RESP+NGM_TEXT_CONFIG/STATUS -> respType=string_type. String >2000 bytes -> bcopy overflows binary->data 2000-byte alloc into adjacent heap. arglen set to inflated size(:1644) -> secondary OOB read on response copy. Distinct from DF-0410 (netgraph7 unparse direction). Root-gated ng_socket SYSCAP_RESTRICTEDROOT.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0449 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| ng_overflow.c | trigger-source | NGM_ASCII2BINARY trigger: 2500-char string -> 501-byte heap overflow; clean ERANGE message on fixed kernels | 8.1 KB | view raw |
| build.sh | build-script | cc -o ng_overflow ng_overflow.c | 169 B | view raw |
| run.sh | run-script | loads modules + runs ng_overflow as root | 475 B | view raw |
| README.md | readme | human-facing reproduction guide + impact analysis | 4.6 KB | β raw |
| VERDICT.md | verdict | full root-cause analysis, trigger trace, fix before/after, 2026-07-16 re-validation note | 8.1 KB | β raw |
| run.log | run-log | fresh 2026-07-16 baseline (unpatched #0) run: arglen=2553>2000 overflow confirmed | 3.2 KB | view raw |
| fix_run.log | run-log | patched-module (sha256 6e225a12...) run: sendto ERANGE, clean 'bounds check fired (FIXED)' message | 271 B | view raw |
| fix_build.log | build-log | standalone netgraph.ko build (rc=0) + patched-module sha256 6e225a12... | 7.7 KB | view raw |
| leak_sample.txt | leak-sample | hex of overflowed/OOB-read bytes (attacker's own A's, not pre-existing kernel data) | 2.0 KB | view raw |
| env.txt | environment | uname, cc version, module hashes, INVARIANTS=1 note | 405 B | view raw |
| fix.diff | suggested-fix | add len>*buflen -> ERANGE check (with kfree) before bcopy in ng_string_parse, mirroring all sibling parse functions | 385 B | view raw |
| manifest.json | manifest | this file | 3.6 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-0449 β Heap buffer overflow in ng_string_parse (CWE-787)
Severity: High (rootβkernel heap corruption; root-gated trigger)
Class: CWE-787 Out-of-bounds Write + secondary CWE-125 OOB Read
File/lines: sys/netgraph/netgraph/ng_parse.c:704-720 (ng_string_parse)
Caller: sys/netgraph/netgraph/ng_base.c:1578-1648 (NGM_ASCII2BINARY)
The bug
ng_string_parse() parses a quoted netgraph string and copies the decoded
result into the caller-supplied buffer buf of size *buflen. It computes
len = strlen(sval) + 1 and unconditionally executes bcopy(sval, buf, len)
at line 716 with no check that len <= *buflen. Every sibling parse
function in the same file performs this bounds check before its bcopy:
| Function | Check | Line |
|---|---|---|
ng_int8_getDefault |
if (*buflen < sizeof(int8_t)) return (ERANGE) |
385 |
ng_int16_getDefault |
if (*buflen < sizeof(int16_t)) return (ERANGE) |
479 |
ng_int32_getDefault |
if (*buflen < sizeof(int32_t)) return (ERANGE) |
573 |
ng_int64_getDefault |
if (*buflen < sizeof(int64_t)) return (ERANGE) |
666 |
ng_string_getDefault |
if (*buflen < 1) return (ERANGE) |
742 |
ng_fixedstring_parse |
if (strlen(sval)+1 > fi->bufSize) return (E2BIG) |
774 |
ng_string_parse |
NONE | 716 |
Trigger path (root-only)
socket(AF_NETGRAPH, SOCK_DGRAM, NG_CONTROL)β requiresSYSCAP_RESTRICTEDROOT(ng_socket.c:172), i.e. root.sendto(csock, NGM_ASCII2BINARY_msg, ..., ".")β sends to own socket node.NGM_ASCII2BINARY(ng_base.c:1578) allocatesbufSize=2000, builds a response withbinary->data= 2000-byte buffer.- With
NGF_RESPset in the embedded message andcmdstr="textstatus",argstype = c->respType = &ng_parse_string_type(ng_base.c:1631-1632). ng_parse()βng_string_parse()βbcopy(sval, buf, len)wherelen = strlen(sval)+1. A string of 2500 chars βlen=2501β 501-byte heap overflow past the 2000-bytebinary->datainto the adjacent slab object.
Secondary OOB read: ng_base.c:1644 sets arglen to the inflated len;
ship_msg (ng_socket.c:737) does m_devget(msg, sizeof(ng_mesg)+arglen) =
reads 2605 bytes from a 2104-byte allocation β 501-byte OOB read of adjacent
heap. Note: the OOB-read window always equals the overflow window (len-2000),
so it reads back the attacker's own overflowed bytes β not pre-existing
kernel data. The primary impact is the heap corruption (CWE-787).
How to reproduce
./build.sh # cc -o ng_overflow ng_overflow.c
# as root:
kldload netgraph && kldload ng_socket
./run.sh # ./ng_overflow (run as root)
Expected output (bug present β unpatched #0 kernel)
[*] sending NGM_ASCII2BINARY: string=2500 A's, bufSize(buflen)=2000, expected overflow=501 bytes [+] sendto returned 2607 bytes [+] recvfrom returned 2605 bytes (response) [*] response header.arglen = 2553 (alloc was 2000) [!!] OVERFLOW CONFIRMED: arglen=2553 > 2000; secondary OOB read leaked ~553 bytes of adjacent heap
Expected output (FIXED kernel/module)
[*] sending NGM_ASCII2BINARY: string=2500 A's, bufSize(buflen)=2000, expected overflow=501 bytes [!] sendto failed: Result too large β ERANGE from the new bounds check
Impact
Root β kernel heap corruption. The trigger is root-gated
(SYSCAP_RESTRICTEDROOT on the ng_socket control domain), so this is not an
unprivileged-to-root escalation. It is a rootβkernel integrity violation:
a root process (including a compromised setuid-root program or a jail context
where root is available but kernel isolation should hold) can corrupt arbitrary
adjacent slab objects with attacker-controlled bytes. A successful slab-grooming
escalation to uid=0-equivalent kernel compromise is conceivable, but is
blocked on this audit guest by INVARIANTS (KKASSERT panics on slab
free-list corruption, proven by DF-0783/DF-0028). On a production kernel
without INVARIANTS, the 501-byte attacker-controlled heap write is a viable
corruption primitive.
Files
ng_overflow.cβ trigger PoC (sends oversized NGM_ASCII2BINARY)build.sh/run.shβ reproducible build/runrun.logβ baseline (unpatched) run, full outputfix_run.logβ patched-module run, full outputleak_sample.txtβ hex of the overflowed/OOB-read bytesenv.txtβ guest environmentfix.diffβ the fix (addlen > *buflencheck, mirroring siblings)fix_build.logβ module build + disassembly verificationVERDICT.mdβ full analysismanifest.jsonβ artifact catalog
DF-0449 β VERDICT
Verdict: REPRODUCED β heap buffer overflow confirmed; fix validated.
Re-validated 2026-07-16. Fresh
vm.sh reset with-srcbaseline (#0unpatched kernel,netgraph.kosha25685938016β¦), rebuilt the PoC, and re-confirmed the overflow (arglen=2553 > 2000, 501-byte heap write, guest survived). Then re-appliedfix.diffto in-guest/usr/src, rebuiltnetgraph.kostandalone (makeinsys/netgraph/netgraph/,rc=0), got the identical patched-module hash6e225a12β¦(deterministic build), verified the newcmp (%rbx),%eax; jg <ERANGE>in the disassembly at offset0x3b43, installed it, rebooted, and re-ran 3Γ β every run returnedERANGE(Result too large), no overflow, guest healthy. The fix is deterministic and reproducible. (The PoC source was lightly edited to print a cleanERANGE: bounds check fired (FIXED)message on the fixed path so the "fixed" output is unambiguous; the trigger logic is unchanged.)
Root cause
ng_string_parse() (sys/netgraph/netgraph/ng_parse.c:704-720) copies a
user-supplied decoded string into a caller-provided buffer without checking
that the string length fits the buffer:
static int
ng_string_parse(const struct ng_parse_type *type,
const char *s, int *off, const u_char *const start,
u_char *const buf, int *buflen)
{
char *sval;
int len;
if ((sval = ng_get_string_token(s, off, &len)) == NULL)
return (EINVAL);
*off += len;
len = strlen(sval) + 1;
bcopy(sval, buf, len); /* β line 716: NO check len <= *buflen */
kfree(sval, M_NETGRAPH);
*buflen = len;
return (0);
}
Every sibling parse/getDefault function in this file checks *buflen before
its bcopy and returns ERANGE/E2BIG on overflow:
- ng_int8_getDefault:385, ng_int16_getDefault:479,
ng_int32_getDefault:573, ng_int64_getDefault:666,
ng_string_getDefault:742 β all if (*buflen < sizeof(...)) return (ERANGE)
- ng_fixedstring_parse:774 β if (strlen(sval)+1 > fi->bufSize) return (E2BIG)
ng_string_parse is the sole exception. This is a clear oversight.
Trigger path (confirmed line-by-line)
socket(AF_NETGRAPH, SOCK_DGRAM, NG_CONTROL)β root-only (ng_socket.c:172:caps_priv_check(..., SYSCAP_RESTRICTEDROOT)).sendto(csock, msg, ..., ".")βngc_send(ng_socket.c:201) βng_send_msg(ng_base.c:1208) βng_path2noderesolves "." to own node (ng_base.c:1216).CALL_MSG_HANDLER(ng_base.c:1188): typecookie ==NGM_GENERIC_COOKIEβng_generic_msg.case NGM_ASCII2BINARY(ng_base.c:1578):bufSize = 2000(hardcoded, line 1580).NG_MKRESPONSE(rp, msg, sizeof(*binary)+bufSize, ...)(line 1598) allocates52+52+2000 = 2104bytes.binary->datais the 2000-byte buffer.bcopy(ascii, binary, sizeof(*ascii))(line 1606) copies the attacker's embedded message header β includingflags = NGF_RESPβ intobinary.- Command lookup (line 1609-1624):
cmdstr = "textstatus"matches the generic cmd whoserespType = &ng_parse_string_type(ng_base.c:292). argstype = (binary->header.flags & NGF_RESP) ? c->respType : c->mesgType(line 1631-1632) βargstype = &ng_parse_string_type.ng_parse(argstype, ascii->data, &off, binary->data, &bufSize)(line 1636) βng_string_parseβbcopy(sval, buf, 2501)into 2000-bytebuf(line 716) β 501-byte heap overflow.*buflen = 2501(line 718) βbinary->header.arglen = 2501(line 1644),rp->header.arglen = 2553(line 1645).ship_msg(ng_socket.c:737):msglen = 52+2553 = 2605;m_devget(msg, 2605, ...)reads 2605 bytes from the 2104-byterpallocation β 501-byte OOB read of adjacent heap (secondary CWE-125).
Evidence (unpatched #0 kernel)
[*] sending NGM_ASCII2BINARY: string=2500 A's, bufSize(buflen)=2000, expected overflow=501 bytes [+] sendto returned 2607 bytes [+] recvfrom returned 2605 bytes (response) [*] response header.arglen = 2553 (alloc was 2000) [!!] OVERFLOW CONFIRMED: arglen=2553 > 2000
Deterministic across 3 consecutive runs; guest survived all runs (the bcopy itself is a raw memcpy, not INVARIANTS-guarded).
Note on the "info leak"
The secondary OOB read (step 10) reads back the same bytes the bcopy
overflow wrote (both windows = len - 2000 = 501 bytes). So it returns the
attacker's own 'A' bytes, not pre-existing kernel data. The leak is
technically a CWE-125 but is not a useful information disclosure. The
primary security impact is the CWE-787 heap overflow β 501
attacker-controlled bytes corrupting the adjacent slab object.
Exploit chain / escalation analysis
The primitive is a root β kernel heap write of 501 attacker-controlled
bytes (full content control β the decoded string is arbitrary) into the slab
object adjacent to a kmalloc(2104) allocation (page-zone, since 2048 < 2104
β€ 4096).
uid=0 escalation: NOT attempted β valid hard blocker (INVARIANTS).
On this audit guest, INVARIANTS is ENABLED (KKASSERT/KASSERT are CPP macros
that expand to inline panic(), so nm | grep KASSERT = 0 is a false negative;
proven by DF-0783's _kfree assertion panic and DF-0028's KKASSERT panic). A
slab-grooming exploitation chain (spray the 4K zone β overflow into a victim
object containing a function pointer / ucred * / refcount β convert to
arbitrary control) would require double-free or free-list manipulation, both of
which trip INVARIANTS assertions and panic the kernel before the chain can
complete. This is the same hard wall documented for DF-0783.
The overflow itself is not INVARIANTS-blocked (it is a bcopy, not a slab
operation), so the corruption reproduces cleanly and the guest survives β but
converting the corruption to uid=0 via slab grooming is blocked. This is a
valid hard blocker per the procedure (INVARIANTS prevents the
grooming/free-list phase of any heap-corruption-to-priv-esc chain on this
guest).
On a production kernel without INVARIANTS, this 501-byte attacker-controlled
heap write would be a viable corruption primitive: spray the 4K slab zone with
victim objects containing function pointers (e.g., struct file fileops,
socket ops vectors), position one adjacent to the overflow target, overwrite
its function pointer, and redirect to userspace shellcode (no SMEP) or a forged
ucred (no SMAP). The root-gating limits this to rootβkernel, but that is
still a meaningful kernel-integrity / sandbox-escape primitive.
The fix
Add the missing bounds check before the bcopy, mirroring every sibling parse
function. The fix returns ERANGE (matching ng_string_getDefault:742,
ng_int*_getDefault) and frees sval first to avoid a memory leak:
len = strlen(sval) + 1;
+ if (len > *buflen) {
+ kfree(sval, M_NETGRAPH);
+ return (ERANGE);
+ }
bcopy(sval, buf, len);
(ng_fixedstring_parse:775 has the same missing-free on its E2BIG path β a
pre-existing minor leak we do not need to fix here, but worth noting.)
Fix validation (Phase 8)
| kernel / module | result | |
|---|---|---|
| Before | unpatched #0, original netgraph.ko |
overflow: arglen=2553 > 2000, 501-byte heap write + OOB read |
| After | same #0 kernel, patched netgraph.ko (sha256 6e225aβ¦) |
ERANGE: sendto failed: Result too large, no overflow, guest alive |
The fix was validated by rebuilding only netgraph.ko (standalone module
build: cd /usr/src/sys/netgraph/netgraph && make), installing it to
/boot/kernel/netgraph.ko, and loading it on a fresh boot (no kernel reboot
needed β the module is not loaded until kldload). The fix is visible in the
disassembly of ng_string_parse: new cmp (%rbx),%eax; jg <ERANGE_path> at
offset 0x3b43. The patched module returns ERANGE deterministically across 3
runs.
Verdict
REPRODUCED. The heap buffer overflow in ng_string_parse is real,
confirmed by the inflated arglen=2553 and the 501-byte overflow. The fix
(add len > *buflen β ERANGE) closes it completely, validated before/after on
the same #0 kernel with only the netgraph.ko module swapped.
Fix verification
fixedVALIDATED: baseline arglen=2553 overflow; patched ERANGE. Module disasm confirms bounds check. 3x clean.
BEFORE: OVERFLOW CONFIRMED. AFTER: ERANGE no overflow.
Confirmed kernel references
- sys/netgraph/netgraph/ng_parse.c:704
- sys/netgraph/netgraph/ng_parse.c:716
- sys/netgraph/netgraph/ng_parse.c:742
- sys/netgraph/netgraph/ng_parse.c:774
- sys/netgraph/netgraph/ng_parse.c:961
- sys/netgraph/netgraph/ng_base.c:1580
- sys/netgraph/netgraph/ng_base.c:1598
- sys/netgraph/netgraph/ng_base.c:1631
- sys/netgraph/netgraph/ng_base.c:1636
- sys/netgraph/netgraph/ng_base.c:1644
- sys/netgraph/netgraph/ng_base.c:290
- sys/netgraph/socket/ng_socket.c:172
- sys/conf/files:1756
Detail
Exploit chain
none -- root->kernel (SYSCAP_RESTRICTEDROOT). 501B heap write root->kernel integrity violation. INVARIANTS blocks slab grooming on GENERIC.
Evidence (decisive lines)
BEFORE: arglen=2553>2000 OVERFLOW CONFIRMED. AFTER: ERANGE sendto failed. Disasm: cmp (%rbx),%eax; jg ERANGE.
PoC changes
Authored: ng_overflow.c (NGM_ASCII2BINARY with oversized string), fix.diff (if(len>*buflen){kfree(sval);return ERANGE;}), VERDICT.md, manifest.json.
Verified recommended fix
Add if(len>*buflen){kfree(sval,M_NETGRAPH);return(ERANGE);} at ng_parse.c:716 before bcopy. Mirrors all sibling parse functions. Full diff in findings/poc/DF-0449/fix.diff.
Verdict
REPRODUCED. ng_string_parse ng_parse.c:716 bcopy(sval,buf,len) len=strlen(sval)+1 no check vs *buflen=2000. Every sibling checks. NGM_ASCII2BINARY with 2500-char string -> 501B heap overflow (arglen=2553>2000). Root-only (SYSCAP_RESTRICTEDROOT on ng_socket). Old netgraph v1 (shipped).
No comments yet.