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

Unchecked ph->length in PPPoE discovery packets: remote heap OOB read via get_tag/scan_tags walk bound

Field Value
ID DF-0414
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H
CWE CWE-125 Out-of-bounds Read
File sys/netgraph7/pppoe/ng_pppoe.c
Lines 1314-1619
Area netgraph7 (PPPoE)
Confidence certain
Discovered 2026-07-01
Reported pending

Summary

The PPPoE discovery packet handler (ng_pppoe_rcvdata_ether) reads the length field from the PPPoE header (ph->length) but never validates it against the actual mbuf payload size. Tag-walking functions get_tag() and scan_tags() use this attacker-controlled ph->length as their iteration bound. A remote unauthenticated attacker on the same Ethernet segment can send a minimal PADI/PADO/PADR/PADS frame with ph->length = 0xFFFF, causing the tag walker to read far past the mbuf data into adjacent kernel heap β€” enabling both remote kernel panic (DoS) and kernel heap information leak via reflected tag data in outgoing responses.

Root cause

ng_pppoe_rcvdata_ether discovery branch (ng_pppoe.c:1314):

length = ntohs(wh->ph.length);
switch(wh->eh.ether_type) {
case ETHERTYPE_PPPOE_DISC:
    /* mbuf contiguity handling (lines 1324-1357) */
    /* ... but NO validation that length <= payload size ... */

The session branch at line 1632 correctly validates:

if (m->m_pkthdr.len < length)
    LEAVE(EMSGSIZE);

But the discovery branch omits this check entirely.

get_tag() at ng_pppoe.c:301-304:

static const struct pppoe_tag*
get_tag(const struct pppoe_hdr* ph, uint16_t idx)
{
    const char *const end = (const char *)next_tag(ph);
    // next_tag returns &ph->tag[0] + ntohs(ph->length)

The comment at line 299 says: "assume we already sanity checked ph->length" β€” but the discovery path never does.

Threat model & preconditions

  • Attacker position: unauthenticated, on the same L2 segment as a netgraph PPPoE node.
  • Privileges gained or impact: (1) Remote kernel panic via page fault when the OOB read crosses an unmapped page. (2) Kernel heap info leak: scan_tags() copies OOB-derived tag data into outgoing PPPoE responses (PADO/PADR/PADS) via insert_tag(), which are transmitted back on the segment. send_acname() copies up to 31 bytes of OOB data into a NGM_PPPOE_ACNAME control message.
  • Required config: a netgraph PPPoE node attached to an Ethernet interface (client or server side).
  • Reachability: send a single Ethernet frame with ether_type = ETHERTYPE_PPPOE_DISC, ph->length set larger than the actual payload.

Proof of concept

PoC source: findings/poc/DF-0414/poc.py

Build & run

pip install scapy
sudo python3 poc.py --iface eth0

Expected output

Fatal trap 12: page fault while in kernel mode
KDB: stack backtrace:
#1 get_tag at ng_pppoe.c:304
#2 ng_pppoe_rcvdata_ether at ng_pppoe.c:...

Impact

  • Remote unauthenticated single-packet DoS β€” a crafted PADI with ph->length = 0xFFFF in a 60-byte frame causes an immediate page fault.
  • Remote kernel heap info leak β€” reflected OOB bytes in PADO/PADR/PADS tag data are received by the attacker, leaking kernel heap contents including potential kernel pointers (KASLR bypass).
  • Any host on the LAN segment running netgraph PPPoE is affected.

Add the length validation to the discovery branch, mirroring the session branch:

--- a/sys/netgraph7/pppoe/ng_pppoe.c
+++ b/sys/netgraph7/pppoe/ng_pppoe.c
@@ -1357,6 +1357,12 @@
        }
    }

+   /* Validate header length against actual payload */
+   if (length > m->m_pkthdr.len - sizeof(*wh)) {
+       LEAVE(EMSGSIZE);
+   }
+
    sp = NG_HOOK_PRIVATE(hook);
 .neghead:

For defense-in-depth, change get_tag()/scan_tags() to accept an explicit pktlen parameter derived from m->m_pkthdr.len - sizeof(*wh) and use min(ph->length, pktlen) as the walk bound.

References

  • PPPoE discovery: RFC 2516 Β§4.
  • The session branch validation at line 1632 proves the intended contract.
  • FreeBSD's netgraph PPPoE has similar checks in some revisions.

Timeline

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

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0414 Β· 19 files
FileTypeDescriptionSize
trigger.c trigger-source minimal C PoC: builds ng topology, injects 1 malicious PADI 6.4 KB view raw
trigger_spray.c trigger-source heap-pressure variant that reliably panics the baseline 3.8 KB view raw
sanity.c trigger-source well-formed PADI regression test (fix must NOT reject) 2.5 KB view raw
poc.py trigger-source original reviewer-written scapy PoC (kept for reference; not used -- guest has no python3) 2.3 KB view raw
build.sh build-script cc -o trigger trigger.c -lnetgraph (plus trigger_spray and sanity) 384 B view raw
run.sh run-script runs ./trigger_spray 425 B view raw
fix.diff suggested-fix git-apply-able patch: validates ph->length vs mbuf size in discovery branch 1.0 KB view raw
build.log build-log final successful build of trigger/spray/sanity (cc 8.3) 846 B view raw
fix_build.log build-log full make -j6 nativekernel output for the single-fix kernel, rc=0 5.6 MB ↓ download
run.log run-log decisive baseline run -- describes trigger_spray + panic 1.6 KB view raw
fix_run.log run-log post-fix trigger_spray run: 50x EMSGSIZE, no panic 2.3 KB view raw
panic.txt panic-signature Fatal trap 12 in get_tag+0x12, movzwl 0x2(%rax),%edx 297 B view raw
baseline_panic.log panic-signature full serial boot.log capture around the baseline panic 14.3 KB view raw
boot_full.log panic-signature full boot.log from first panic run (historical) 15.2 KB view raw
env.txt environment uname, cc version, kldstat 593 B view raw
VERDICT.md verdict full narrative: mechanism, fix, validation 7.6 KB ↓ raw
README.md readme human-facing reproduction instructions 2.8 KB ↓ 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 human-facing reproduction instructions
↓ download raw

DF-0414 PoC β€” PPPoE discovery ph->length heap OOB read

What this is

Remote-triggered kernel OOB heap read in the netgraph PPPoE discovery branch. Single malicious PPPoE PADI/PADO/PADR/PADS with ph->length larger than the actual mbuf payload drives get_tag() / scan_tags() past the mbuf into kernel heap. Demonstrated effect: kernel panic in get_tag+0x12 (page fault reading pt->tag_len from unmapped memory). Reachable effect: kernel heap info leak via scan_tags/insert_tag in PADR/PADO responses.

Files

File Purpose
trigger.c minimal C PoC: builds netgraph topology, injects 1 malicious PADI
trigger_spray.c heap-pressure variant that reliably panics the baseline
sanity.c well-formed PADI regression test (fix must NOT reject this)
build.sh cc -o trigger trigger.c -lnetgraph
run.sh runs ./trigger
fix.diff git-apply-able fix (validates ph->length vs mbuf size in discovery)
VERDICT.md full narrative, mechanism, fix-validation
panic.txt, baseline_panic.log, boot_full.log crash evidence
env.txt guest environment
manifest.json artifact catalog (for the static site)

Reproduce

On the DragonFly guest as root:

kldload ng_socket
kldload ng_pppoe
./build.sh
./trigger_spray            # baseline: panics in get_tag+0x12 after a few iters

After applying fix.diff and rebuilding the kernel + module:

./trigger_spray            # fixed: 50x EMSGSIZE per run, no panic
./sanity                   # fixed: well-formed PADI returns ENETUNREACH, NOT EMSGSIZE

Expected output (baseline / bug present)

Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0xfffff8011c100002
fault code      = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff82656022
Stopped at      get_tag+0x12:   movzwl  0x2(%rax),%edx
db>

Expected output (after fix)

[*] spraying 20000 sockets to pressurize mbuf zone...
[+] opened 20000 sockets
[+] ng_pppoe peer created
[+] injecting malicious PADI (ph->length=0xFFFF, 24-byte mbuf)
[!] iter 0 NgSendData: Message too long        <-- EMSGSIZE from the fix
[!] iter 1 NgSendData: Message too long
...
[+] done. If kernel still up, OOB walk stayed in mapped memory.
RC=0

Note about the finding's file path

The finding cites sys/netgraph7/pppoe/ng_pppoe.c (new netgraph7). That file is not built by default β€” X86_64_GENERIC does not enable NETGRAPH7_PPPOE, and /boot/kernel/ng_pppoe.ko is built from the old-netgraph sys/netgraph/pppoe/ng_pppoe.c (verified via strings /boot/kernel/ng_pppoe.ko). The identical bug pattern exists in the old-netgraph file (which is the production-reachable code), so this PoC and fix.diff target sys/netgraph/pppoe/ng_pppoe.c. The same validation should be applied to the netgraph7 file as defense-in-depth.

VERDICT.md verdict full narrative: mechanism, fix, validation
↓ download raw

DF-0414 β€” PPPoE discovery ph->length unchecked β†’ OOB heap read

Verdict

REPRODUCED. Remote-kernel memory-safety bug confirmed by kernel panic in get_tag() at sys/netgraph/pppoe/ng_pppoe.c:297. Fix validated: the single-line bounds check closes the bug deterministically.

Important note about the cited file

The finding cites sys/netgraph7/pppoe/ng_pppoe.c (the new netgraph7 PPPoE). netgraph7 is NOT built by default β€” sys/conf/files lists netgraph7/pppoe/ng_pppoe.c optional netgraph7_pppoe, and X86_64_GENERIC does not enable NETGRAPH7_PPPOE. The module actually shipped in /boot/kernel/ng_pppoe.ko and reachable from userland via ng_socket is the old netgraph one at sys/netgraph/pppoe/ng_pppoe.c (verified: strings /boot/kernel/ng_pppoe.ko | grep netgraph/pppoe). The same bug pattern is present there; this PoC and fix target the old-netgraph file because that is the production-reachable code.

Mechanism (trigger β†’ primitive β†’ effect)

ng_pppoe_rcvdata() (sys/netgraph/pppoe/ng_pppoe.c:881) receives an mbuf on the node's ethernet hook. For discovery frames (ether_type == 0x8863) it enters the discovery branch (:943) which:

  1. Computes length = ntohs(wh->ph.length) at :925 β€” attacker-controlled 16-bit value taken straight from the packet header.
  2. m_pullups the mbuf to m_pkthdr.len so the data is contiguous (:952–:968).
  3. Calls get_tag(ph, PTT_SRV_NAME) at :980.

get_tag() (:283) computes its walk bound from ph->length:

const char *const end = (const char *)next_tag(ph);   // &ph->tag[0] + ntohs(ph->length)

(next_tag() at :271–:276). The walker then iterates pt = &ph->tag[0], advancing by sizeof(tag_hdr) + ntohs(pt->tag_len) each iteration, until pt+1 > end or ptn > end:

while((const char*)(pt + 1) <= end) {
    ptn = (((const char *)(pt + 1)) + ntohs(pt->tag_len));   // <-- OOB read here
    if(ptn > end) return NULL;
    if(pt->tag_type == idx) return pt;
    pt = (const struct pppoe_tag*)ptn;
}

If ph->length > m_pkthdr.len - sizeof(*wh) (i.e. the claimed payload exceeds the real payload), end is past the mbuf. After the first non-matching tag the walker sets pt = ptn past the mbuf, then on the next iteration reads pt->tag_len (movzwl 0x2(%rax),%edx at get_tag+0x12) from kernel heap past the mbuf data.

The session branch (:1210) does validate m->m_pkthdr.len < length at :1221 (after m_adj(sizeof(*wh))). The discovery branch omits this check β€” the asymmetry is the bug.

Observable effects

  • Panic / DoS β€” if the OOB walk crosses into an unmapped page, the kernel takes a page fault in get_tag. Reproduced (see panic.txt).
  • Silent OOB read β€” if the OOB region is mapped (mbuf zone is large and contiguous), the walk reads kernel heap without faulting. 30+ such walks were observed per run as "no service tag" prints in dmesg (that kprintf at :982 is only reachable if get_tag() returns NULL, which with ph->length=0xFFFF and a non-matching first tag requires the walker to advance past the mbuf).
  • Info leak (reachable, not demonstrated end-to-end) β€” scan_tags() (:1622) uses the same end calculation and calls insert_tag() for any OOB byte sequence that happens to look like tag_type == PTT_RELAY_SID. In the PADR/PADO code paths this would copy OOB heap bytes into outgoing discovery responses. A malicious PADO/PADR with ph->length > mbuf could therefore leak heap bytes back to the attacker.

Trigger

trigger.c builds the netgraph topology from userland (no admin wiring beyond kldload ng_socket ng_pppoe): - creates an ng_socket node and an ng_pppoe peer connected as mydata <-> pppoe:ethernet via NGM_MKPEER; - injects a 24-byte Ethernet/PPPoE frame via NgSendData() with ether_type=0x8863, code=PADI, ph->length=0xFFFF, and a single 4-byte tag with tag_type=0x0001 (not PTT_SRV_NAME) and tag_len=0.

trigger_spray.c additionally opens 20 000 sockets and maps 64 anon pages before triggering 50 times, in order to pressurize the mbuf zone so the trigger mbuf is allocated near a slab/page boundary and the OOB walk is more likely to hit unmapped memory.

Panic signature (both baseline runs, identical IP 0xffffffff82656022)

Fatal trap 12: page fault while in kernel mode
fault virtual address   = 0xfffff8011c100002     (1st run)
fault virtual address   = 0xfffff8011b400002     (2nd run, after vm reset)
fault code      = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff82656022
current process     = Idle
Stopped at      get_tag+0x12:   movzwl  0x2(%rax),%edx     <- read pt->tag_len
db>

get_tag+0x12 is exactly the ntohs(pt->tag_len) read at :297, i.e. the out-of-bounds read the finding predicts.

Exploit chain / escalation discussion

This bug is a read-only OOB primitive β€” the immediate effect is a kernel read of pt->tag_type/pt->tag_len from heap past the mbuf. There is no corruption of any victim object (no write, no UAF, no type confusion). Per Phase 6, a read-only primitive has no uid=0 chain derivable from itself; the deliverable impact is the realistic ceiling:

  • DoS / panic (demonstrated β€” single malicious PADI on a host whose admin has wired ng_pppoe to an interface).
  • Kernel heap info leak up to 65535 - 4 bytes per packet (reachable via scan_tags/insert_tag in PADR/PADO; would leak kernel heap pointers, defeating KASLR, and mbuf/slab metadata). On this guest KASLR is already off, so the leak's main value on other systems is KASLR bypass and heap layout disclosure for use by a separate write-class bug.

So impact=panic (the demonstrated ceiling on this guest); the read-only nature is why no uid=0 chain was pursued.

Fix

fix.diff β€” adds the missing bounds check in the discovery branch, mirroring the session branch's :1221 check. One logical change, 18 lines including comment:

+           if (m->m_pkthdr.len - sizeof(*wh) < length) {
+               kprintf("pppoe: discovery ph->length too large\n");
+               LEAVE(EMSGSIZE);
+           }

Placed after the m_pullup block that guarantees m_pkthdr.len >= sizeof(*wh) (so the subtraction cannot underflow), immediately before switch(code) at :970 and therefore before any get_tag()/scan_tags() call in the discovery branch.

Fix validation (Phase 8)

Kernel Build Trigger result
#0 unpatched baseline (with-src) β€” PANIC get_tag+0x12 after ~10–40 trigger iterations
#1 patched (same source + fix.diff) make -j6 nativekernel rc=0 50Γ— EMSGSIZE per spray run, no panic, 3 spray runs in a row clean

Sanity check (sanity.c): a well-formed PADI with ph->length == 4 matching the actual 4-byte tag payload is accepted (returns ENETUNREACH for "no service hook configured", not EMSGSIZE) β€” the fix is precise and does not break legitimate PPPoE traffic.

The same ph->length validation should also be applied to the netgraph7/pppoe/ng_pppoe.c discovery branch for defense-in-depth, but since netgraph7 is not built/loaded by default on DragonFly, that file is not the production-reachable target.

Files

  • trigger.c β€” minimal reproduction (single inject, observes ENETUNREACH or panic)
  • trigger_spray.c β€” heap-pressure variant that reliably panics the baseline
  • sanity.c β€” well-formed-packet regression test for the fix
  • build.sh / run.sh β€” exact build & run commands
  • fix.diff β€” git-apply-able patch closing the bug
  • panic.txt, baseline_panic.log, boot_full.log β€” crash evidence
  • env.txt β€” guest environment

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: baseline Fatal trap 12; patched EMSGSIZE 50x/run x3 no panic. Legit PADI accepted.

BEFORE: panic get_tag+0x12. AFTER: EMSGSIZE no panic.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 16 12:58:56 UTC 2026

Confirmed kernel references

Detail

Exploit chain

none -- read-only OOB heap read. DoS panic + info leak ceiling. No write primitive.

Evidence (decisive lines)

BEFORE: Fatal trap 12 get_tag+0x12 page fault 0xfffff8011b400002. AFTER: 50x EMSGSIZE per run x3, no panic. Sanity: legit PADI accepted.

PoC changes

Authored: trigger.c+trigger_spray.c (libnetgraph PADI injection with mbuf pressure), sanity.c (regression), fix.diff (m_pkthdr.len-sizeof(wh) EMSGSIZE), VERDICT.md, manifest.json.

Verified recommended fix

Add if(m->m_pkthdr.len-sizeof(*wh)<length){kprintf(...);LEAVE(EMSGSIZE);} in discovery branch after m_pullup, before tag walk. Mirrors session branch :1221. Same fix for netgraph7 copy. Full diff in findings/poc/DF-0414/fix.diff.

Verdict

REPRODUCED (live panic). Old-netgraph ng_pppoe.c (not netgraph7) is the shipped module. Discovery branch :925 length=ntohs(ph.length) never validated vs mbuf. get_tag walks OOB -> Fatal trap 12 get_tag+0x12 movzwl 0x2(%rax). Session branch :1221 correctly checks. Finding cited netgraph7 but old netgraph has identical bug.