ng_pppoe_rcvdata PRIMED branch: get_tag/scan_tags walk with unvalidated ph->length -> kernel heap OOB read disclosed in emitted PADO (DF-0414 sibling sink)
Summary
ng_pppoe_rcvdata() handles a PADI echoed by the server daemon on a PPPOE_PRIMED hook. It only m_pullup()s sizeof(struct pppoe_full_hdr) and never validates ph->length against the actual mbuf length before get_tag(PTT_SRV_NAME), get_tag(PTT_HOST_UNIQ) and scan_tags(). get_tag/scan_tags bound their walk by &ph->tag[0]+ntohs(ph->length) under the comment 'assume we already sanity checked ph->length' - the session branch checks and rcvdata_ether omits it (DF-0414); this is a third, distinct sink. VERIFIED on stock INVARIANTS guest: 32-byte PADI with ph->length=1024 and a Service-Name tag claiming 900 data bytes yields a 944-byte PADO containing 896 bytes read past the mbuf (668 bytes past the 256-byte mbuf object), including kernel heap pointers (0xfffff80117632908), mbuf headers, ifnet names and a complete in-flight TCP segment; identical 3/3 runs per boot. ph->length up to 0xFFFF can walk ~64KB into unmapped kernel VA -> remote single-packet panic potential. Unauthenticated LAN peer whenever the PPPoE server daemon relays/echoes PADI payload back down the PRIMED session hook (the standard PADI->PADO offer flow); direct trigger also by the root ng_socket user. Fix validated (mirror the session-branch check): identical input rejected, no PADO, no panic, 2/2.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3487 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.9 KB | β raw | |
| VERDICT.md | β | 5.1 KB | β raw | |
| ppsbx.c | β | 11.0 KB | view raw | |
| Makefile | β | 94 B | β download | |
| build.sh | β | 262 B | view raw | |
| run.sh | β | 211 B | view raw | |
| run.log | β | 7.3 KB | view raw | |
| leak1.txt | β | 627 B | view raw | |
| leak2.txt | β | 627 B | view raw | |
| leak3.txt | β | 627 B | view raw | |
| leak_sample.txt | β | 1.8 KB | view raw | |
| fixleak1.txt | β | 103 B | view raw | |
| fixleak2.txt | β | 103 B | view raw | |
| fixrun.log | β | 7.0 KB | view raw | |
| env.txt | β | 463 B | view raw | |
| fix.diff | β | 628 B | view raw | |
| manifest.json | β | 1.3 KB | view raw | |
| verdict.json | β | 4.0 KB | view raw |
DF-3487 β ng_pppoe_rcvdata() PRIMED branch: heap OOB read via unvalidated
ph->length (DF-0414 sibling sink)
Kernel heap info leak (and adjacent-packet disclosure) in
sys/netgraph7/pppoe/ng_pppoe.c ng_pppoe_rcvdata() PPPOE_PRIMED branch β
the counterpart of DF-0414 which covered only ng_pppoe_rcvdata_ether().
Reproduce (guest, root)
# 1. build + load netgraph core, pppoe node, harness
cd /usr/src/sys/netgraph7/netgraph && make obj depend && make
kldload /usr/obj/usr/src/sys/netgraph7/netgraph/netgraph.ko
cd /usr/src/sys/netgraph7/pppoe && make obj depend && make
kldload /usr/obj/usr/src/sys/netgraph7/pppoe/ng_pppoe.ko
cd /root/ppsbx && make && kldload ./ppsbx.ko
# 2. create pppoe node, put session hook "svc1" into PPPOE_PRIMED
sysctl debug.ppsbx_cmd=1 # MKPEER eth0<->ethernet, CONNECT sess<->svc1,
# NGM_PPPOE_OFFER(svc1) => PRIMED
# 3. fire the crafted 32-byte PADI (ph->length=1024, SRV tag len=900)
sysctl debug.ppsbx_cmd=2
# 4. observe
sysctl debug.ppsbx_len # baseline: 944 (patched: 0)
sysctl -b debug.ppsbx_capture | od -A x -t x1 | less
Expected output (baseline, stock kernel)
debug.ppsbx_len: 944β the node emits a PADO whose Service-Name tag contains 896 bytes read out of bounds past the 32-byte PADI mbuf (the copy runs to m_data+924 of a header mbuf whose real payload ends at byte 32; ~668 bytes past the 256-byte mbuf object).- Leaked bytes contain adjacent-mbuf-zone memory: kernel heap pointers
(e.g.
08 29 63 17 01 f8 ff ff= 0xfffff80117632908 β mbuf m_next / m_data), mbuf headers, ifnet names (.vn1), and on an active-traffic boot a complete captured TCP packet (45 00 00 2c ... 0a 00 02 0f, seq numbers of the ssh session itself). Seeleak1.txt,leak_sample.txt(boot A rich sample),run.log. - 3/3 runs identical per boot (
leak1/2/3.txt). - With
fix.diffapplied to ng_pppoe.c and the module rebuilt:debug.ppsbx_len: 0, empty capture, no crash (fixleak1/2.txt,fixrun.log).
Threat model
The PRIMED branch parses a PADI that the PPPoE server daemon sends back
down its session hook. Any daemon that relays/echoes PADI payload from an
unauthenticated LAN peer (relay/proxy deployments; the normal
PADIβPADO flow re-embeds peer-supplied tags) delivers attacker-controlled
ph->length to this path. Direct trigger also possible by the
netgraph-controlling user (root via ngctl/ng_socket). Impact: kernel heap
disclosure (pointers + cross-object data) and potential panic when the tag
walk crosses an unmapped page (ph->length up to 0xFFFF).
Files
ppsbx.cβ harness kernel module (setup + fire + PADO capture)Makefile,run.sh,build.shleak1/2/3.txt,leak_sample.txt,run.log,env.txtβ baselinefixleak1/2.txt,fixrun.logβ patched-module runsfix.diffβ one-hunk fix (validated)VERDICT.md,manifest.json,verdict.json
DF-3487 β VERDICT
Status: reproduced (kernel heap info leak, deterministic, 3/3 + 3/3 runs across two boots) Β· Fix validated (same input cleanly rejected, 2/2 runs).
Root cause
ng_pppoe_rcvdata() (sys/netgraph7/pppoe/ng_pppoe.c:1176-1258) handles a
PADI echoed by the PPPoE server daemon on a PPPOE_PRIMED session hook. It
only guarantees m->m_len >= sizeof(struct pppoe_full_hdr) via
m_pullup() (ng_pppoe.c:1194-1198) and then runs
get_tag(ph, PTT_SRV_NAME) (ng_pppoe.c:1231) get_tag(ph, PTT_HOST_UNIQ) (ng_pppoe.c:1244) scan_tags(sp, ph) (ng_pppoe.c:1247)
get_tag()/scan_tags() bound their tag walk with
end = &ph->tag[0] + ntohs(ph->length) (ng_pppoe.c:292-293) and carry the
comment "assume we already sanity checked ph->length"
(ng_pppoe.c:298-299) β but the PRIMED branch never does. The session
branch of the same file does check (m->m_pkthdr.len < length β
EMSGSIZE, ng_pppoe.c:1632), and DF-0414 established that the discovery
branch of ng_pppoe_rcvdata_ether() (lines 1314-1619) omits it. This is
the third sink with the identical omission.
Reproduction
Harness ppsbx.c (kernel module):
1. MKPEER ppsbx:eth0 <-> pppoe:ethernet; CONNECT ppsbx:sess <-> pppoe:svc1.
2. NGM_PPPOE_OFFER on "svc1" β session is PPPOE_PRIMED.
3. Fire a 32-byte PADI (header mbuf, no cluster):
ph->length = 1024, Service-Name tag header claims 900 data bytes
(8 real). Pre-send bytes verified in run.log:
... 88 63 11 09 00 00 04 00 | 01 01 03 84 | SERVNAME.
4. get_tag() accepts the tag (ptn = 24+900 = 924 <= end = 20+1024);
make_packet() copies the "tag" (4+900 bytes) into the PADO, which is
emitted on the ethernet hook and captured by the harness
(debug.ppsbx_capture).
Baseline (stock ng_pppoe.ko, INVARIANTS kernel #0):
* debug.ppsbx_len: 944 β PADO = 20(hdr) + 8(AC_NAME) + 904(SRV) + 12(COOKIE).
* The SRV tag payload contains bytes [32..924) of the mbuf's address space:
896 bytes read past the real packet, ~668 bytes past the 256-byte mbuf
object itself, out of the adjacent mbuf-zone slab.
* Boot A sample (leak_sample.txt): kernel heap pointers
0xfffff80117632908 / 0xfffff80117632b08 at PADO offsets 0x110/0x310
(mbuf m_next/m_data), mbuf header fields (3a 00 00 00 02 80 00 00),
and a complete captured TCP segment at 0x210
(45 00 00 2c ... 0a 00 02 02 -> 0a 00 02 0f, ports e4f4->0016 = the
harness' own ssh session, seq a5 fc 00 53).
* Boot B sample (leak1.txt): same 944-byte leak, ifnet name .vn1,
sess_con pointer 0xfffff8008976bb00 in AC_COOKIE.
* Identical across 3 runs per boot (leak1/2/3.txt, fixleak-β¦ baselines).
No panic occurred with ph->length=1024 because the walk stayed inside the mbuf-zone slab; with ph->length up to 0xFFFF the walk (and scan_tags's garbage-driven tag headers) can run ~64KB past the object into unmapped kernel VA β page fault/panic (remote single-packet DoS potential, same as DF-0414's ether-side).
Fix validation
fix.diff adds the missing bound check to the PRIMED branch, mirroring
the session branch (:1632):
if (m->m_pkthdr.len < (int)(sizeof(*wh) + length))
LEAVE(EMSGSIZE);
Applied to the guest's /usr/src copy; ng_pppoe.ko rebuilt and reloaded
(same stock kernel #0: DragonFly 6.5-DEVELOPMENT #0 β¦ X86_64_GENERIC;
ng_pppoe is a KLD on this system, so the module is the executing code
for this path). Result (fixrun.log, fixleak1/2.txt): identical PADI
input β no PADO emitted, debug.ppsbx_len: 0, capture empty, no
crash, 2/2 runs. The patch was authored against the read-only sys/ tree
(patch -p1 --dry-run clean) and never applied there.
Threat / impact ceiling
- Attacker: unauthenticated LAN peer whenever the server daemon echoes PADI content back down the PRIMED hook (relay/offer flow), or the netgraph-controlling user directly (root via ng_socket/ngctl).
- Primitive: out-of-bounds read of kernel heap (mbuf zone + neighbors), contents copied into a PADO observable by the attacker β kernel pointer disclosure (KASLR-defeat / heap-layout reconnaissance) and disclosure of unrelated in-flight packet data. Up to 0xFFFF-length walks β panic.
- No write primitive (make_packet copies INTO a correctly sized cluster and caps tag output at 1494 bytes).
Classification: Medium severity (daemon-relay precondition for the remote chain; direct trigger is root-gated), CWE-125, confidence certain.
kernel_refs: * sys/netgraph7/pppoe/ng_pppoe.c:1194 (only header pullup, no length check) * sys/netgraph7/pppoe/ng_pppoe.c:1231,1244,1247 (get_tag/scan_tags sinks) * sys/netgraph7/pppoe/ng_pppoe.c:292-331 (next_tag/get_tag walk bound) * sys/netgraph7/pppoe/ng_pppoe.c:1632 (session branch showing the missing check)
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Confirmed kernel references
β
Detail
Evidence (decisive lines)
["findings/poc/DF-3487/run.log: 'PPSBX: PADI pre-send dump (m_len=32): ... 01 01 03 84 SERVNAME' followed by captured 944-byte PADO", "findings/poc/DF-3487/leak1.txt: 944-byte capture; OOB window mbuf+32..mbuf+924 with sess_con pointer and '.vn1' ifnet name", 'findings/poc/DF-3487/leak_sample.txt: boot-A sample with kernel pointers 0xfffff80117632908/0xfffff80117632b08 and a full leaked TCP segment at PADO offset 0x210', 'findings/poc/DF-3487/fixrun.log + fixleak1/2.txt: with fix.diff applied, ppsbx_len: 0 and empty capture on 2/2 runs, no crash']
PoC changes
Harness authored fresh (no seed). Two iterations needed: (1) netgraph type must be registered before node creation (setup moved to first sysctl command); (2) tag type/length bytes must be written in big-endian wire order - an initial little-endian write made tag_len read as 0x8403 so get_tag correctly rejected it; after the byte-order fix the leak reproduced immediately.
Verified recommended fix
In ng_pppoe_rcvdata() PRIMED branch, reject the PADI when m->m_pkthdr.len < sizeof(*wh) + ntohs(wh->ph.length), mirroring the session-branch check at ng_pppoe.c:1632 (see validated fix.diff).
Verdict
Heap OOB read in ng_pppoe_rcvdata() PPPOE_PRIMED branch reproduced deterministically on the stock INVARIANTS kernel: a 32-byte PADI with ph->length=1024 and a Service-Name tag claiming 900 data bytes makes get_tag()/scan_tags() walk past the packet and make_packet() copy 896 bytes of out-of-bounds mbuf-zone memory into the emitted PADO, where the harness captures it. Leaked content includes kernel heap pointers (0xfffff80117632908 etc.), mbuf header fields, ifnet names and, on an active-traffic boot, a complete in-flight TCP segment. Identical 3/3 runs per boot, two boots. Same root cause class as DF-0414 but a distinct sink (PRIMED/daemon-echo path, not the ether-side discovery branch). Root cause is the missing ph->length-vs-packet-length check that the session branch (ng_pppoe.c:1632) already performs.
No comments yet.