Missing IP-length / ip_hl validation in ng_nat_rcvdata allows OOB access of mbuf trailing area
| Field | Value |
|---|---|
| ID | DF-0611 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:H |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/netgraph7/ng_nat.c |
| Lines | 690-763 |
| Area | netgraph7 (NAT node data path) |
| Confidence | speculative |
| Discovered | 2026-07-02 |
| Reported | pending |
BUILD CAVEAT (read first). This finding is latent: in the current DragonFlyBSD source tree
sys/netgraph7/ng_nat.cis conditionally compiled (optional netgraph7_natinsys/conf/files:1718) but the two dependencies it requires to link are absent: -sys/netinet/libalias/β the directory does not exist on disk (the module callsLibAliasInit/LibAliasProxyRule/LibAliasIn/β¦). -m_megapullup()β referenced atng_nat.c:692, defined nowhere in the tree.So
netgraph7_natas committed cannot be compiled or loaded, and this defect is not reachable on any currently-buildable kernel. It is recorded because (a) it is a real code-level bug that becomes live the moment libalias /m_megapullupare reintroduced or the module is fixed, and (b) the missing-validation pattern is worth fixing alongside any such reintroduction.
Summary
ng_nat_rcvdata() m_megapullup's the inbound mbuf, then dereferences
struct ip fields and (in the TCP-fixup block) constructs a tcphdr
pointer purely from the attacker-controlled ip_hl field, with no
minimum-packet-length check and no ip_hl/ip_len sanity check. m_len
is then reassigned directly from ip->ip_len (line 722) without re-checking
that the resulting mbuf actually contains that much valid data. A crafted
frame that libalias does not reject causes the TCP-fixup block to read and
write bytes lying past the real packet data, and then in_delayed_cksum()
to read past the mbuf's actual contents.
Root cause
sys/netgraph7/ng_nat.c:690-763. After m_megapullup the only validation is
a debug-only KASSERT (compiled out on production kernels):
690: m = NGI_M(item);
692: if ((m = m_megapullup(m, m->m_pkthdr.len)) == NULL) { ... }
698: NGI_M(item) = m;
700: c = mtod(m, char *);
701: ip = mtod(m, struct ip *);
703: KASSERT(m->m_pkthdr.len == ntohs(ip->ip_len),
704: ("ng_nat: ip_len != m_pkthdr.len"));
No m_pkthdr.len >= sizeof(struct ip) check is performed, so for any
sub-20-byte frame the read of ip->ip_len at line 703 already samples
uninitialized trailing mbuf data, and on production kernels the KASSERT
is compiled out. Then at line 722 m_len is assigned directly from
ip->ip_len with no upper-bound check against the pullup'd buffer:
722: m->m_pkthdr.len = m->m_len = ntohs(ip->ip_len);
At lines 724-727 the TCP-fixup block computes the tcphdr pointer purely
from ip_hl, with no check that the offset fits the packet:
724: if ((ip->ip_off & htons(IP_OFFMASK)) == 0 &&
725: ip->ip_p == IPPROTO_TCP) {
726: struct tcphdr *th = (struct tcphdr *)((caddr_t)ip +
727: (ip->ip_hl << 2));
...
751: if (th->th_x2) { /* READ past real data */
752: th->th_x2 = 0; /* WRITE */
753: th->th_sum = in_pseudo(...); /* WRITE */
757: if ((m->m_pkthdr.csum_flags & CSUM_TCP) == 0) {
758: m->m_pkthdr.csum_data =
759: offsetof(struct tcphdr, th_sum);
760: in_delayed_cksum(m); /* reads m_len bytes */
761: }
762: }
763: }
ip->ip_hl is a 4-bit attacker-controlled field (0..15). With ip_hl=15
the th pointer lands at ip+60; the subsequent reads/writes of th_x2
(+12) and th_sum (+16..17) therefore touch offsets 72..77 of the mbuf
data area regardless of the actual packet length.
in_delayed_cksum() (sys/netinet/ip_output.c:928-952) then iterates
in_cksum_skip(m, ntohs(ip->ip_len), offset) over m_len bytes; if line
722 has grown m_len beyond the actual data (which a crafted ip_len
allows), it walks heap it does not own, and its inner m_pullup() failure
path at ip_output.c:949-951 dereferences m unconditionally after a
possible NULL return (*(u_short *)(m->m_data + offset) = csum;),
panicking the kernel.
Threat model & preconditions
- Attacker position: remote adjacent-network. Anyone who can put a frame
on the nat node's
inorouthook β which is precisely the node's normal operating mode (packets traversing the NAT are by definition untrusted network traffic). - Required config: a netgraph graph like
iface: -> nat:in,nat:out -> iface:where the attacker controls packets on the wire, plus a NAT alias address set viaNGM_NAT_SET_IPADDR. - The speculative element: whether libalias (
LibAliasIn/LibAliasOutat lines 707/714) rejects every malformed frame before ng_nat reaches the TCP-fixup block. The libalias sources are not present in this tree (sys/netinet/libalias/is absent), so it cannot be proven that libalias rejects every malformed frame. Even if libalias today happens to reject every such frame, ng_nat provides no defense of its own and any future change to libalias, or any mode the user can select viaNGM_NAT_SET_MODE(lines 355-372 βPKT_ALIAS_PROXY_ONLY/PKT_ALIAS_REVERSE), could re-expose it. - Impact: info leak of mbuf trailing data (read of
th_x2from uninitialized content), corruption of mbuf trailing data (writes toth_x2/th_sum), or kernel panic (NULL-deref inin_delayed_cksum'sm_pullupfailure path, or a page fault while walking a craftedip_len). - Real-world exposure today: NONE β module does not build (see BUILD CAVEAT above).
Proof of concept
PoC source: findings/poc/DF-0611/README.md (craft-and-send recipe, since
the trigger depends on libalias acceptance which cannot be verified in-tree).
Trigger frame (modulo libalias acceptance)
ip_v = 4,ip_hl = 0xF(claims 60-byte IP header)ip_p = IPPROTO_TCP(6)ip_off = 0(so(ip_off & IP_OFFMASK) == 0)ip_len = htons(80)β 60 IP header + 20 TCP header, satisfies KASSERTm_pkthdr.len == 80β matchesip_lenso the production KASSERT is a no-op- body bytes 12..17 of the claimed TCP region set to non-zero so
th_x2reads non-zero, forcing the write branch
Cycle NGM_NAT_SET_MODE through flags=0, mask=0xffffffff (lines 355-372)
and toggle NGM_NAT_REVERSE to maximise the chance libalias passes the
frame through unchanged.
Expected output
Kernel panic with a faulting RIP inside in_delayed_cksum() or
in_cksum_skip(); OR, on a debug kernel, the dmesg line delayed m_pullup,
m->len: .. off: .. p: 6 from sys/netinet/ip_output.c:941 followed by a
NULL-deref panic at the *(u_short *)(m->m_data + offset) = csum; write.
Impact
- Blast radius: any host with a loadable
netgraph7_natmodule doing NAT on untrusted traffic. Today: none, because the module cannot build. - Severity rationale: Medium. Remote trigger if the module builds and
libalias passes the frame; the
AC:Hreflects the unverified libalias acceptance gate. Deterministic panic once the OOB path is entered. - Confidence: speculative β depends on libalias acceptance, which cannot be verified without the libalias sources.
Recommended fix
Validate the packet before dereferencing any header field, and only enter the TCP-fixup block when the TCP header actually fits inside the (post-libalias) packet.
--- a/sys/netgraph7/ng_nat.c
+++ b/sys/netgraph7/ng_nat.c
@@ -688,6 +688,12 @@ ng_nat_rcvdata(hook_p hook, item_p item )
m = NGI_M(item);
+ /* Reject anything too short to be an IP packet. */
+ if (m->m_pkthdr.len < sizeof(struct ip)) {
+ NG_FREE_ITEM(item);
+ return (EINVAL);
+ }
+
if ((m = m_megapullup(m, m->m_pkthdr.len)) == NULL) {
NGI_M(item) = NULL; /* avoid double free */
NG_FREE_ITEM(item);
@@ -701,8 +707,18 @@ ng_nat_rcvdata(hook_p hook, item_p item )
ip = mtod(m, struct ip *);
+ /*
+ * Validate ip_hl and ip_len against the actual packet before we trust
+ * any field of the header.
+ */
+ if (ip->ip_hl < 5 ||
+ ntohs(ip->ip_len) < (ip->ip_hl << 2) ||
+ ntohs(ip->ip_len) > m->m_pkthdr.len) {
+ NG_FREE_ITEM(item);
+ return (EINVAL);
+ }
+
KASSERT(m->m_pkthdr.len == ntohs(ip->ip_len),
("ng_nat: ip_len != m_pkthdr.len"));
if (hook == priv->in) {
@@ -721,8 +737,10 @@ ng_nat_rcvdata(hook_p hook, item_p item )
m->m_pkthdr.len = m->m_len = ntohs(ip->ip_len);
- if ((ip->ip_off & htons(IP_OFFMASK)) == 0 &&
- ip->ip_p == IPPROTO_TCP) {
+ if ((ip->ip_off & htons(IP_OFFMASK)) == 0 &&
+ ip->ip_p == IPPROTO_TCP &&
+ ntohs(ip->ip_len) >= (u_int)(ip->ip_hl << 2) + sizeof(struct tcphdr)) {
struct tcphdr *th = (struct tcphdr *)((caddr_t)ip +
(ip->ip_hl << 2));
References
sys/netgraph7/ng_nat.c:690-704βm_megapullup+ debug-only KASSERT, no real validation.sys/netgraph7/ng_nat.c:722βm_lenreassigned fromip->ip_lenwithout bounds check.sys/netgraph7/ng_nat.c:724-763β TCP-fixup block dereferencingthcomputed from attacker-controlledip_hl.sys/netinet/ip_output.c:928-952βin_delayed_cksum, which walksm_lenbytes and NULL-derefsmonm_pullupfailure.
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-0611 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | original craft-and-send recipe (retained; in-kernel repro for when the module is buildable) | 2.5 KB | β raw |
| VERDICT.md | verdict | full source-level trace, unreachability proof, harness explanation, fix analysis | 12.7 KB | β raw |
| df0611_harness.c | trigger-source | deterministic userspace harness: ports buggy + fixed ng_nat_rcvdata logic; proves OOB and that fix closes it | 12.8 KB | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff: 3 guards in sys/netgraph7/ng_nat.c (min-IP-len, ip_hl/ip_len sanity, TCP-header-fits) | 1.2 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -Wextra -o df0611_harness df0611_harness.c | 262 B | view raw |
| run.sh | run-script | runs harness 3x for variance | 197 B | view raw |
| build.log | build-log | final successful build, full output | 339 B | view raw |
| run.log | run-log | 3 decisive runs, full output | 4.5 KB | view raw |
| env.txt | environment | uname, cc version, kldload failure, /boot/kernel/ng_nat* missing, standalone kmod build failure on netinet/libalias/alias.h | 1.0 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-0611 β PoC: ng_nat_rcvdata missing IP validation β OOB access
Remote adjacent-network DoS / OOB-read PoC (speculative on libalias acceptance).
Status
LATENT β module does not build in the current tree.
sys/netinet/libalias/ is absent and m_megapullup() (referenced at
ng_nat.c:692) is defined nowhere, so netgraph7_nat cannot be compiled
or loaded. This PoC is a craft-and-send recipe, source-only, until
libalias / m_megapullup are reintroduced.
Trigger frame (modulo libalias acceptance)
ip_v = 4,ip_hl = 0xF(claims 60-byte IP header)ip_p = IPPROTO_TCP(6)ip_off = 0(so(ip_off & IP_OFFMASK) == 0)ip_len = htons(80)β 60 IP header + 20 TCP header, satisfies KASSERTm_pkthdr.len == 80β matchesip_lenso the production KASSERT is a no-op- body bytes 12..17 of the claimed TCP region set to non-zero so
th_x2reads non-zero, forcing the write branch
Cycle NGM_NAT_SET_MODE through flags=0, mask=0xffffffff (lines
355-372) and toggle NGM_NAT_REVERSE to maximise the chance libalias
passes the frame through unchanged.
Preconditions
- A configured
ng_natnode with both hooks connected (in,out) and an alias address set viaNGM_NAT_SET_IPADDR. - The attacker delivers IP frames into
nat:invia whatever peer node is connected (ng_ifaceover a tunneled interface, anethernode, etc.). - The frame must pass libalias (
LibAliasIn/LibAliasOutat lines 707/714) β this is the speculative element; libalias sources are not in the tree so acceptance cannot be verified.
Expected outcome
Kernel panic with a faulting RIP inside in_delayed_cksum() or
in_cksum_skip(); OR, on a debug kernel, the dmesg line
delayed m_pullup, m->len: .. off: .. p: 6 from
sys/netinet/ip_output.c:941 followed by a NULL-deref panic at the
*(u_short *)(m->m_data + offset) = csum; write.
Notes for the per-PoC verifier
- Module must be buildable first β libalias +
m_megapullupmust exist. - Primary verification task: determine whether libalias actually passes a
malformed frame (large
ip_hl, craftedip_len) through to ng_nat's TCP-fixup block. If libalias always rejects, this is a pure latent bug and the finding should be downgraded to Info. - The fix adds three guards:
m_pkthdr.len >= sizeof(struct ip),ip_hl >= 5 && ip_len >= ip_hl<<2 && ip_len <= m_pkthdr.len, and a TCP-header-fits check before the fixup block. Verify withgit apply findings/poc/DF-0611/fix.diff.
DF-0611 β Verification verdict
Verdict: CONFIRMED-LATENT (source-level) β the missing-validation
logic in sys/netgraph7/ng_nat.c:690β763 is a real, code-confirmed bug,
but the module is unreachable on the running guest (it cannot be
compiled or loaded). Deterministic userspace harness reproduces the
missing-validation logic and proves the proposed fix closes it.
Reproduction status: not reproduced on the running kernel (impossible β the module does not build); reproduced deterministically at the code-logic level via the harness in this folder.
Impact: unreachable on this guest; would be oob-read/oob-write/panic
on a build of the module. Severity Medium holds as a latent code-level
finding; real-world exposure today is NONE.
Confidence: certain (on the source-level claim and the unreachability); speculative on libalias acceptance (libalias sources absent from the tree).
Why the kernel sink cannot be reached
The vulnerable node is netgraph7_nat, conditionally compiled by
sys/conf/files:1718:
netgraph7/ng_nat.c optional netgraph7_nat
with five further optional netgraph7_nat translation units in
sys/conf/files:1735β1739:
netinet/libalias/alias.c optional netgraph7_nat netinet/libalias/alias_db.c optional netgraph7_nat netinet/libalias/alias_mod.c optional netgraph7_nat netinet/libalias/alias_proxy.c optional netgraph7_nat netinet/libalias/alias_util.c optional netgraph7_nat
Two of these dependencies are absent from the current DragonFlyBSD master
tree and from the audit guest's /usr/src:
- The entire
sys/netinet/libalias/directory is missing. Confirmed on the host (find sys -name libaliasreturns onlydfbsd-upstream/lib/libalias, a userspace library β not the kernel sources the module expects) and on the guest (ls /usr/src/sys/netinet/libaliasβ No such file or directory). m_megapullup()is undefined.grep -rn 'm_megapullup' sys/finds exactly one occurrence β the call site atng_nat.c:692. There is no definition anywhere insys/.
Empirical confirmation on the running guest (saved in env.txt):
$ kldload ng_nat kldload: can't load ng_nat: No such file or directory $ ls /boot/kernel/ng_nat* /boot/kernel/*libalias* ls: No such file or directory $ cd /tmp/ngnat_mod && make # KMOD=ng_nat, bsd.kmod.mk ng_nat.c:45:10: fatal error: netinet/libalias/alias.h: No such file or directory *** Error code 1
ng_nat.ko is not shipped, not enabled in X86_64_GENERIC, cannot be
kldloaded, and cannot be built from /usr/src because its
#include <netinet/libalias/alias.h> (ng_nat.c:45) is unsatisfiable.
Net reachability: the sink is dead on this kernel β neither an
unprivileged user nor root can drive a packet through ng_nat_rcvdata(),
because the node type cannot exist. The bug is therefore recorded as
latent: real code, no runtime exposure today.
Source-level line-by-line trace (the bug IS real)
sys/netgraph7/ng_nat.c:690β763. After m_megapullup collapses the mbuf
into a single contiguous buffer:
690: m = NGI_M(item);
692: if ((m = m_megapullup(m, m->m_pkthdr.len)) == NULL) { ... }
698: NGI_M(item) = m;
700: c = mtod(m, char *);
701: ip = mtod(m, struct ip *);
703: KASSERT(m->m_pkthdr.len == ntohs(ip->ip_len),
704: ("ng_nat: ip_len != m_pkthdr.len"));
The only validation here is the debug-only KASSERT at 703-704
(sys/sys/param.h β KASSERT expands to a no-op when INVARIANTS is off).
There is no check that m->m_pkthdr.len >= sizeof(struct ip), so a
frame shorter than 20 bytes already samples uninitialized trailing data
when ip->ip_len is read.
722: m->m_pkthdr.len = m->m_len = ntohs(ip->ip_len);
m_len is reassigned directly from the attacker-controlled ip->ip_len
with no upper-bound check against the pullup'd buffer. A lying
ip_len (e.g. 200 when the frame is 24 bytes) inflates m_len, which is
then consumed by in_delayed_cksum() (sys/netinet/ip_output.c:928β952,
called at ng_nat.c:760) to walk in_cksum_skip(m, ntohs(ip->ip_len), offset)
β i.e. heap it does not own.
724: if ((ip->ip_off & htons(IP_OFFMASK)) == 0 &&
725: ip->ip_p == IPPROTO_TCP) {
726: struct tcphdr *th = (struct tcphdr *)((caddr_t)ip +
727: (ip->ip_hl << 2));
...
751: if (th->th_x2) { /* READ past real data */
752: th->th_x2 = 0; /* WRITE */
753: th->th_sum = in_pseudo(...); /* WRITE */
...
760: in_delayed_cksum(m); /* walks m_len bytes */
761: }
762: }
763: }
ip->ip_hl is a 4-bit attacker-controlled field (0..15). The th pointer
at 726-727 is derived purely from it, with no check the offset fits inside
the packet. With ip_hl=15, th lands at ip+60; subsequent reads/writes
of th_x2 (+12) and th_sum (+16..17) touch offsets 72..77 of the mbuf
data area regardless of actual packet length.
The downstream sink in in_delayed_cksum() (sys/netinet/ip_output.c:928-952)
has its own latent NULL-deref:
949: m = m_pullup(m, offset + sizeof(u_short));
950: }
952: *(u_short *)(m->m_data + offset) = csum; /* unconditional β NULL-deref
* if m_pullup returned NULL */
So the documented impact chain is real:
- OOB read of mbuf trailing data (th_x2 and in_cksum_skip walk);
- OOB write to mbuf trailing data (th_x2=0, th_sum=in_pseudo(...));
- kernel panic (NULL-deref at ip_output.c:952 after m_pullup failure,
or a page fault while walking a lying ip_len).
The speculative element the finding flags β whether libalias would itself
reject every malformed frame first β cannot be resolved in-tree because the
libalias sources are gone. Even if libalias today happens to reject every
malformed frame, ng_nat provides no defense of its own and any future
change to libalias, or any mode the user can select via
NGM_NAT_SET_MODE (lines 355-372 β PKT_ALIAS_PROXY_ONLY /
PKT_ALIAS_REVERSE), could re-expose it. The missing-validation pattern is
a real defect that should be fixed alongside any reintroduction of
libalias / m_megapullup.
Exploit chain / escalation
Not applicable β the sink is unreachable on the running guest (module
cannot be built or loaded), so there is no primitive to convert. Per
AGENT.md's bright-line rule this is a valid hard blocker: "The
vulnerable code path is dead/unreachable at runtime on this guest AND no
harness can exercise it" β provably the case here, since
netinet/libalias/ and m_megapullup() are absent from the source tree
itself. The harness below proves the primitive at the object/logic level,
which is the AGENT.md-endorsed fallback for latent findings.
If netgraph7_nat were ever built (libalias + m_megapullup reintroduced),
the same harness ported into a kernel module, plus a ng_socket-driven
graph with nat:in connected to an ng_iface and a crafted raw frame
injected, would convert the OOB-write of th_sum into a slab-primitive in
the kmalloc bucket holding the mbuf data. On the audit guest (no
SMAP/SMEP/KASLR) a forged struct ucred placed in userspace at a fixed
address and a corrupted m_data pointer aimed at it would be the
escalation vector. That work is not done here because the bug is
genuinely unreachable; the harness documents the primitive precisely so
this chain can be picked up the moment the module is buildable.
Deterministic userspace harness (df0611_harness.c)
Because the kernel module cannot be built, the missing-validation logic
of ng_nat_rcvdata() lines 690β763 is reproduced byte-for-byte in a
userspace harness operating on heap buffers (fake mbufs). The harness:
- BUGGY path β ports the C logic of
ng_nat_rcvdata()lines 700-763 (omittingLibAliasIn/LibAliasOut, which are not validation checkpoints and whose sources are gone β this is conservative). - FIXED path β ports the same function with the three guards from
fix.diffadded: minimum-IP-header check,ip_hl/ip_lensanity, and TCP-header-fits check. - Runs five test frames (4 malformed, 1 well-formed) through both and reports the OOB offsets and accept/reject outcome.
Decisive output (deterministic across 3 runs, see run.log):
test cap read@off write@off BUG_OOB? FIX_rej?
------------------------------------------------------------------------------
trigger-frame cap=80 ip_hl=15 ip_len=80 80 ACCEPT
-> BUG read@72 write@76 m_len=80 (cap=80) (in-bounds)
lying-ip_len cap=24 ip_hl=5 ip_len=200 24 n/a REJECT
-> BUG read@32 write@-1 m_len=200 (cap=24) *** OOB ***
sub-min-ip cap=12 ip_hl=5 ip_len=20 12 n/a n/a REJECT
huge-ip_hl cap=40 ip_hl=15 ip_len=80 40 n/a REJECT
-> BUG read@72 write@-1 m_len=80 (cap=40) *** OOB ***
well-formed cap=40 ip_hl=5 ip_len=40 40 n/a ACCEPT
------------------------------------------------------------------------------
BUG: 2/5 frames drove OOB access.
FIX: 3/5 frames rejected.
RESULT: BUG CONFIRMED β 2 frames drive OOB under the buggy logic; FIX rejects
all 3 malformed frames and accepts both valid ones.
The harness demonstrates:
- lying-ip_len (cap=24, ip_len=200) drives an OOB read of th_x2 at
offset 32 (cap is 24) and inflates m_len to 200, which in_delayed_cksum
would then walk.
- huge-ip_hl (cap=40, ip_hl=15, ip_len=80) drives an OOB read of th_x2
at offset 72 (cap is 40), exactly as the finding describes.
- The FIXED path rejects all three malformed frames and accepts both
valid ones (the trigger-frame case is in-bounds for th_x2/th_sum
because cap happens to equal ip_len; the well-formed 40-byte TCP/IP
packet is the legitimate case both paths accept).
PoC changes
- Added
df0611_harness.cβ deterministic userspace harness that ports the vulnerable and fixed code paths and proves the missing-validation bug + the proposed fix. (The originalREADME.mdis the finding's craft-and-send recipe, retained as the in-kernel reproduction recipe for when the module is buildable.) - Added
fix.diffβ standalonegit apply-able unified diff againstsys/netgraph7/ng_nat.c, adds three guards: minimum-IP-header check beforem_megapullup;ip_hl/ip_lenvalidation before any field is trusted; TCP-header-fits check before the fixup block. - Added
build.sh,run.shβ exact repro commands. - Added
VERDICT.md,manifest.json,env.txt,build.log,run.log.
Recommended fix
The fix adds three guards to sys/netgraph7/ng_nat.c (full diff in
fix.diff, applies cleanly with git apply -p1):
- Before
m_megapullup(currently at line 690): reject any frame withm->m_pkthdr.len < sizeof(struct ip)(returnsEINVAL). - After
ip = mtod(m, struct ip *)(line 701): reject unlessip->ip_hl >= 5 && ntohs(ip->ip_len) >= (ip->ip_hl << 2) && ntohs(ip->ip_len) <= m->m_pkthdr.len. - Before the TCP-fixup block (line 724): additionally require
ntohs(ip->ip_len) >= (u_int)(ip->ip_hl << 2) + sizeof(struct tcphdr)sothis fully inside the packet.
These match (and slightly refine) the finding markdown's own
## Recommended fix proposal. The harness proves they close every
malformed case while accepting both valid ones. The KASSERT at line 703
becomes redundant once guard #2 is in place but is left in place for
debug kernels.
Phase 8 β fix validation status
not_testable. The bug does not reproduce on the running kernel
(module cannot be built or loaded), so the standard Phase 8
before/after comparison against a built-and-booted single-fix kernel is
not meaningful: the patched kernel still cannot build the module either,
since libalias/ and m_megapullup() are absent from the source tree
itself β they are not addressed by fix.diff (which is correctly scoped
to the validation logic only, not the missing dependencies).
What WAS validated:
- git apply --check -p1 fix.diff succeeds against a pristine
sys/netgraph7/ng_nat.c (the read-only audit tree).
- The harness ports both the buggy and the fixed logic side-by-side and
shows the FIXED path rejects every malformed frame the BUGGY path
mishandles, while accepting both valid frames. This is the
code-level proof that the fix closes the bug.
A future fix-validation that builds a single-fix kernel can be done once
the netgraph7_nat module is buildable again (i.e. once libalias and
m_megapullup are reintroduced). At that point the same fix.diff
becomes live-testable, and the harness's ng_nat_rcvdata_FIXED ports
1:1 to the guards in the diff.
Fix verification
not_testablenot_testable: the bug does not reproduce on the running kernel (netgraph7_nat module cannot be compiled: sys/netinet/libalias/ is absent from the source tree and m_megapullup() is undefined), so there is no in-kernel 'before' behavior to compare against a single-fix kernel. The patched kernel would still not build the module either, because fix.diff correctly scopes itself to the validation logic and does not reintroduce libalias or define m_megapullup. What WAS validated: (a) 'git apply --check -p1 fix.diff' against a pristine sys/netgraph7/ng_nat.c succeeds; (b) the harness's FIXED path (which ports the diff's guards 1:1) rejects every malformed frame (lying-ip_len, sub-min-ip, huge-ip_hl) and accepts both valid frames (trigger-frame cap=80, well-formed cap=40). The fix is therefore validated at the code-logic level; a live before/after kernel test can be run once the netgraph7_nat module is buildable again (libalias + m_megapullup reintroduced).
baseline (running #0 kernel): kldload ng_nat -> 'can't load ng_nat: No such file or directory'; standalone kmod build fails 'netinet/libalias/alias.h: No such file or directory'. Harness baseline (buggy logic): 'lying-ip_len cap=24 -> BUG read@32 m_len=200 *** OOB ***', 'huge-ip_hl cap=40 -> BUG read@72 *** OOB ***'. Harness after fix.diff guards: 'lying-ip_len -> FIX REJECTs', 'sub-min-ip -> FIX REJECTs', 'huge-ip_hl -> FIX REJECTs', 'trigger-frame -> FIX accepts', 'well-formed -> FIX accepts'. 'git apply --check -p1 fix.diff' -> APPLYCHECK_OK against pristine sys/netgraph7/ng_nat.c. No single-fix kernel was built because the module cannot be linked on either kernel.
Confirmed kernel references
Detail
Exploit chain
none (valid hard blocker: the vulnerable code path is dead/unreachable at runtime on this guest AND no in-kernel harness can exercise it, because sys/netinet/libalias/ is absent from the source tree and m_megapullup() is undefined; netgraph7_nat cannot be compiled, linked, or loaded, so ng_nat_rcvdata() cannot run on any packet). The code-level primitive is characterized in the userspace harness df0611_harness.c: OOB read of th_x2 (offset ip_hl4+12) and OOB write of th_sum (offset ip_hl4+16..17) when ip_hl is attacker-inflated, plus m_len inflation from a lying ip_len that drives in_delayed_cksum->in_cksum_skip to walk heap it does not own, with a NULL-deref panic at sys/netinet/ip_output.c:952 on m_pullup failure. If libalias + m_megapullup are ever reintroduced, the harness's ports map 1:1 to the in-kernel guards and a kernel-module harness + ng_socket-driven graph (nat:in <- ng_iface) with a crafted raw frame becomes the live trigger; the OOB-write of th_sum in the mbuf-data bucket would be the slab primitive. That escalation work is intentionally not done here because the bug is genuinely unreachable on this kernel -- the bright-line rule's 'dead code' hard blocker applies.
Evidence (decisive lines)
Guest (root): kldload ng_nat -> 'can't load ng_nat: No such file or directory'; ls /boot/kernel/ng_nat* -> 'No such file or directory'. Standalone kmod build: 'ng_nat.c:45:10: fatal error: netinet/libalias/alias.h: No such file or directory *** Error code 1'. Harness (deterministic across 3 runs): 'lying-ip_len cap=24 ip_hl=5 ip_len=200 -> BUG read@32 write@-1 m_len=200 (cap=24) *** OOB *** -> FIX REJECTs the frame'; 'huge-ip_hl cap=40 ip_hl=15 ip_len=80 -> BUG read@72 write@-1 m_len=80 (cap=40) *** OOB *** -> FIX REJECTs the frame'; 'RESULT: BUG CONFIRMED -- 2 frames drive OOB under the buggy logic; FIX rejects all 3 malformed frames and accepts both valid ones.' git apply --check -p1 fix.diff against pristine sys/netgraph7/ng_nat.c -> APPLYCHECK_OK.
PoC changes
Added df0611_harness.c (deterministic userspace harness that ports both buggy and fixed ng_nat_rcvdata logic and proves the OOB + the fix). Added fix.diff (git-apply-able unified diff with 3 guards in sys/netgraph7/ng_nat.c, validated to apply cleanly). Added build.sh / run.sh / VERDICT.md / manifest.json / env.txt / build.log / run.log. Original README.md (the finding's craft-and-send recipe) retained as the in-kernel repro recipe for when the module is buildable.
Verified recommended fix
fix.diff supersedes (and slightly refines) the finding markdown's Recommended fix proposal. Add three guards to sys/netgraph7/ng_nat.c: (1) before m_megapullup at line 690, reject m->m_pkthdr.len < sizeof(struct ip) with EINVAL; (2) after ip = mtod(...) at line 701, reject unless ip->ip_hl >= 5 && ntohs(ip->ip_len) >= (ip->ip_hl<<2) && ntohs(ip->ip_len) <= m->m_pkthdr.len; (3) at line 724 add a third conjunct requiring ntohs(ip->ip_len) >= (u_int)(ip->ip_hl<<2) + sizeof(struct tcphdr) before the TCP-fixup block dereferences th. These do not address the missing libalias/ and m_megapullup() dependencies (out of scope for the validation bug); they close the missing-validation logic the finding describes. Validated to apply cleanly via 'git apply --check -p1 fix.diff' against the pristine audit tree, and validated logically via the side-by-side harness which shows the FIXED path rejects every malformed frame the BUGGY path mishandles.
Verdict
CONFIRMED-LATENT (not reproduced on the running kernel because the sink is unreachable). Line-by-line trace of sys/netgraph7/ng_nat.c:690-763 confirms the missing-validation logic the finding describes is real: (1) no check m->m_pkthdr.len >= sizeof(struct ip) before ip->ip_len is sampled at line 703 (only a debug-only KASSERT); (2) m_len reassigned directly from attacker-controlled ip->ip_len at line 722 with no upper-bound check; (3) the tcphdr th pointer at lines 726-727 is derived purely from the 4-bit attacker-controlled ip_hl with no offset-fits-in-packet check, so ip_hl=15 places th at ip+60 and the subsequent th_x2 read / th_x2+th_sum writes at lines 751-753 land at offsets 72..77 regardless of true packet length; (4) the downstream in_delayed_cksum() at sys/netinet/ip_output.c:928-952 then walks m_len bytes and NULL-derefs m at line 952 if m_pullup fails. HOWEVER the netgraph7_nat module is genuinely dead on this kernel: sys/netinet/libalias/ is absent from the source tree (and from /usr/src on the guest) and m_megapullup() (called at ng_nat.c:692) is defined nowhere in sys/, so the module cannot compile. Empirically verified on the guest: kldload ng_nat fails ('No such file or directory'), /boot/kernel/ng_nat and /boot/kernel/libalias do not exist, and a standalone bsd.kmod.mk build of ng_nat.c aborts at 'netinet/libalias/alias.h: No such file or directory'. The finding markdown honestly flags this as LATENT (BUILD CAVEAT, 'Real-world exposure today: NONE'). Because the cited code path cannot run on any currently-buildable kernel, no in-kernel reproduction is possible; instead a deterministic userspace harness (df0611_harness.c) ports the buggy and fixed ng_nat_rcvdata logic byte-for-byte and proves that 2 of 5 malformed frames drive OOB (lying-ip_len cap=24 ip_len=200 -> read@32, m_len inflated to 200; huge-ip_hl cap=40 ip_hl=15 -> read@72 past 40-byte cap) and the proposed fix rejects all 3 malformed frames and accepts both valid ones. Bug is real as a latent code defect; unreachable on this guest.
No comments yet.