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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3487 Β· 18 files
FileTypeDescriptionSize
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). See leak1.txt, leak_sample.txt (boot A rich sample), run.log.
  • 3/3 runs identical per boot (leak1/2/3.txt).
  • With fix.diff applied 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.sh
  • leak1/2/3.txt, leak_sample.txt, run.log, env.txt β€” baseline
  • fixleak1/2.txt, fixrun.log β€” patched-module runs
  • fix.diff β€” one-hunk fix (validated)
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

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.