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).