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

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.c is conditionally compiled (optional netgraph7_nat in sys/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 calls LibAliasInit/LibAliasProxyRule/LibAliasIn/…). - m_megapullup() β€” referenced at ng_nat.c:692, defined nowhere in the tree.

So netgraph7_nat as 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_megapullup are 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 in or out hook β€” 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 via NGM_NAT_SET_IPADDR.
  • The speculative element: whether libalias (LibAliasIn/LibAliasOut at 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 via NGM_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_x2 from uninitialized content), corruption of mbuf trailing data (writes to th_x2/th_sum), or kernel panic (NULL-deref in in_delayed_cksum's m_pullup failure path, or a page fault while walking a crafted ip_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 KASSERT
  • m_pkthdr.len == 80 β€” matches ip_len so the production KASSERT is a no-op
  • body bytes 12..17 of the claimed TCP region set to non-zero so th_x2 reads 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_nat module 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:H reflects 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.

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

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0611 Β· 11 files
FileTypeDescriptionSize
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
README.md readme original craft-and-send recipe (retained; in-kernel repro for when the module is buildable)
↓ download 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 KASSERT
  • m_pkthdr.len == 80 β€” matches ip_len so the production KASSERT is a no-op
  • body bytes 12..17 of the claimed TCP region set to non-zero so th_x2 reads 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_nat node with both hooks connected (in, out) and an alias address set via NGM_NAT_SET_IPADDR.
  • The attacker delivers IP frames into nat:in via whatever peer node is connected (ng_iface over a tunneled interface, an ether node, etc.).
  • The frame must pass libalias (LibAliasIn/LibAliasOut at 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_megapullup must exist.
  • Primary verification task: determine whether libalias actually passes a malformed frame (large ip_hl, crafted ip_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 with git apply findings/poc/DF-0611/fix.diff.
VERDICT.md verdict full source-level trace, unreachability proof, harness explanation, fix analysis
↓ download raw

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:

  1. The entire sys/netinet/libalias/ directory is missing. Confirmed on the host (find sys -name libalias returns only dfbsd-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).
  2. m_megapullup() is undefined. grep -rn 'm_megapullup' sys/ finds exactly one occurrence β€” the call site at ng_nat.c:692. There is no definition anywhere in sys/.

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:

  1. BUGGY path β€” ports the C logic of ng_nat_rcvdata() lines 700-763 (omitting LibAliasIn/LibAliasOut, which are not validation checkpoints and whose sources are gone β€” this is conservative).
  2. FIXED path β€” ports the same function with the three guards from fix.diff added: minimum-IP-header check, ip_hl/ip_len sanity, and TCP-header-fits check.
  3. 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 original README.md is the finding's craft-and-send recipe, retained as the in-kernel reproduction recipe for when the module is buildable.)
  • Added fix.diff β€” standalone git apply-able unified diff against sys/netgraph7/ng_nat.c, adds three guards: minimum-IP-header check before m_megapullup; ip_hl/ip_len validation 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.

The fix adds three guards to sys/netgraph7/ng_nat.c (full diff in fix.diff, applies cleanly with git apply -p1):

  1. Before m_megapullup (currently at line 690): reject any frame with m->m_pkthdr.len < sizeof(struct ip) (returns EINVAL).
  2. After ip = mtod(m, struct ip *) (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. Before the TCP-fixup block (line 724): additionally require ntohs(ip->ip_len) >= (u_int)(ip->ip_hl << 2) + sizeof(struct tcphdr) so th is 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_testable
baseline no→ patch + rebuild →patched clean

not_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.
↓ fix.diffn/a -- single-fix kernel not built

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.