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) viainsert_tag(), which are transmitted back on the segment.send_acname()copies up to 31 bytes of OOB data into aNGM_PPPOE_ACNAMEcontrol 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->lengthset 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 = 0xFFFFin 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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-0414 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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:
- Computes
length = ntohs(wh->ph.length)at:925β attacker-controlled 16-bit value taken straight from the packet header. m_pullups the mbuf tom_pkthdr.lenso the data is contiguous (:952β:968).- 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 (seepanic.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:982is only reachable ifget_tag()returns NULL, which withph->length=0xFFFFand 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 sameendcalculation and callsinsert_tag()for any OOB byte sequence that happens to look liketag_type == PTT_RELAY_SID. In the PADR/PADO code paths this would copy OOB heap bytes into outgoing discovery responses. A malicious PADO/PADR withph->length > mbufcould 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 - 4bytes per packet (reachable viascan_tags/insert_tagin 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 baselinesanity.cβ well-formed-packet regression test for the fixbuild.sh/run.shβ exact build & run commandsfix.diffβ git-apply-able patch closing the bugpanic.txt,baseline_panic.log,boot_full.logβ crash evidenceenv.txtβ guest environment
Fix verification
fixedVALIDATED: baseline Fatal trap 12; patched EMSGSIZE 50x/run x3 no panic. Legit PADI accepted.
BEFORE: panic get_tag+0x12. AFTER: EMSGSIZE no panic.
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)
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.
No comments yet.