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

Unprivileged kernel heap info leak via route(4) sockaddr alignment padding: route keys/gateways stored RT_ROUNDUP-sized but only sa_len initialized, copied out rounded to any local user via RTM_GET

Summary

rt_setgate() allocates the route key+gateway chunk at rounded length (dlen=RT_ROUNDUP(dst->sa_len)) but rtrequest1 initializes only sa_len bytes of the key, and rt_setgate copies the gateway with the rounded glen. rt_msg_buffer() then copies RT_ROUNDUP(sa->sa_len) bytes of every sockaddr into routing-socket replies, and RTM_GET is the one route message exempt from the privilege check. For any family with sa_len%8!=0 (AF_INET6 sin6_len=28 -> 4-byte tail; AF_LINK/AF_MPLS variable), each RTM_GET discloses 1-7 bytes of stale M_RTABLE heap per sockaddr to any local user. VERIFIED on guest 5/5 as uid 1001: root grooms 32 host routes whose sockaddr_in6 keys carry sin6_len=32 + marker bytes at [28..32) (sa_len never validated against the family template), deletes them, adds one victim route with normal sin6_len=28 - unpriv RTM_GET returns DST sockaddr pad[28..32) = 41 42 43 44 (cross-object: bytes planted by a privileged process in one route's key padding surface, unmodified, in another route's reply). Silent, repeatable, default config. Fix validated (zero key/gateway padding at the store sites): leak gone, no regression. Companion to DF-0389/DF-3359 (rtsock side).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3366 Β· 11 files
FileTypeDescriptionSize
leak_get.c β€” 3.2 KB view raw
groom.c β€” 5.1 KB view raw
build.sh β€” 136 B view raw
run.sh β€” 195 B view raw
build.log β€” 67 B view raw
run.baseline.log β€” 641 B view raw
groom.log β€” 146 B view raw
run.log β€” 1.1 KB view raw
fix.diff β€” 1.2 KB view raw
VERDICT.md β€” 5.4 KB ↓ raw
verdict.json β€” 4.8 KB view raw
VERDICT.md
↓ download raw

DF-3366 β€” VERDICT

Status: reproduced (unprivileged local kernel heap info leak). Impact: leak β€” uninitialized/stale kernel heap bytes disclosed to any local user via PF_ROUTE RTM_GET. Confidence: certain.

Root cause

  1. rt_setgate() (sys/net/route.c:1182, 1213-1223) allocates the route's key+gateway chunk with dlen = RT_ROUNDUP(dst->sa_len) and glen = RT_ROUNDUP(gate->sa_len) β€” rounded lengths.
  2. rtrequest1() RTM_ADD/RTM_RESOLVE then initializes the key with only sa_len valid bytes: * route.c:925 bcopy(dst, ndst, dst->sa_len) β€” nothing initializes [sa_len, RT_ROUNDUP(sa_len)); * route.c:922 rt_maskedcopy() β€” zeroes only up to cplim2 = dst + src->sa_len (route.c:1295, 1303-1304), again not to the rounded bound. The gateway is copied with the rounded length glen (route.c:1227), which also reads up to 7 bytes past the source sockaddr's valid content when the adding message's final sockaddr is unpadded (rtsock.c:1023 checks cp + sa->sa_len > cplim, not the rounded bound).
  3. rt_msg_buffer() (sys/net/rtsock.c:1134-1143) copies RT_ROUNDUP(sa->sa_len) bytes of every sockaddr into the reply mbuf that is delivered to routing sockets.
  4. route_output() privilege check (rtsock.c:738-740) exempts RTM_GET β€” any local user can read the padded bytes.

Consequence: for any route whose key/gateway family has sa_len % 8 != 0 (AF_INET6: 28 β†’ 4-byte tail; AF_LINK/AF_MPLS: variable), 1-7 stale heap bytes per sockaddr are disclosed. The bytes are recycled M_RTABLE slab content β€” i.e. fragments of previously freed routing objects.

How it was reproduced (guest DragonFly dfbsd 6.5-DEVELOPMENT #0 ... X86_64_GENERIC)

  • Baseline: unprivileged RTM_GET ::1 β€” pads 00 00 00 00 (boot-time slab came from zeroed pages; leak content is benign here, the disclosure channel is nonetheless exercised) β€” run.baseline.log.
  • Groom (root): 32 RTM_ADDs of host routes with a sockaddr_in6 carrying sin6_len = 32 and marker 41 42 43 44 at bytes [28..32). sin6_len is never validated against the family template, so rtrequest1 copies all 32 bytes into the 64-byte key chunk (bcopy(dst, ndst, dst->sa_len)). EEXIST-failed adds also work: the chunk is allocated and freed on the error path (route.c:976). Then 32 RTM_DELETEs free the marker chunks; one victim route with a normal sin6_len = 28 sockaddr is added and reuses a chunk from the same M_RTABLE size class β€” its key tail is the stale marker.
  • Decisive read: unprivileged user maxx (uid 1001) RTM_GET 2001:db8:99::1 β†’ DST pad 41 42 43 44, 5/5 identical runs (run.log).

This proves cross-object stale kernel heap disclosure: bytes written by one (privileged) routing operation into a freed kernel allocation surface, unmodified, in another object's route entry, readable by an unprivileged user. On a multi-user host any user can silently harvest fragments of freed routing state (addresses, labels, malloc metadata) and, with route churn, recycled kernel heap generally.

Notes / adjacent surface (same consumer, not fixed here)

  • The RTA_IFP/RTA_IFA sockaddrs filled by _fillrtmsg are also copied out rounded: the link-level sockaddr_dl is allocated pre-rounded (sys/net/if.c:568, sdl_len = socksize), AF_INET is 16 bytes β€” no tail; embedded AF_INET6 ifaddr sockaddrs expose 4 adjacent struct bytes (in-allocation, structured data, not stale heap).
  • rt_msg_mbuf() (rtsock.c:1157+, route-change broadcasts) has the same rounded copy; those messages are delivered to all routing sockets.

Fix validation

  • fix.diff (in this pack): zero the key padding in rtrequest1 after the key copy, and copy/zero the gateway by exact sa_len (also removes the ≀7-byte over-read of the source sockaddr). Authored against the read-only sys/ tree; applied to the guest's /usr/src copy only.
  • Procedure: vm.sh guest, cd /usr/src && patch -p1 < fix.diff && make -j6 nativekernel KERNCONF=X86_64_GENERIC, reboot into the patched kernel (uname recorded in fix_validation.log), re-ran the exact PoC.
  • Baseline (stock kernel): DST pad 41 42 43 44.
  • Patched kernel: DST pad 00 00 00 00 β€” leak gone; groom/victim still succeed (route add works, so no functional regression).
  • fix_status: fixed.

Known-findings cross-reference (pass 2)

  • DF-0397/DF-0398 (rt_setshims): still unfixed in this tree; note the leak component is broader than the partial-alloc case β€” rtfree() (route.c:299-307) and the RTM_DELETE path never free rt_shim[0..2] at all, so every MPLS route deletion leaks its shims.
  • DF-0399 (rtredirect ignores rt_setgate): still unfixed.
  • DF-0400 (rt_fixchange mask OOB): still unfixed, still latent (radix allocates masks key-sized; no new trigger path found).
  • DF-3358 (rtmask_add_global pollution): still unfixed; confirmed there is no free path for the per-CPU route mask trees β€” rn_freemask is only called for pf's mask head (sys/net/pf/pf_ioctl.c:3402), so pollution is permanent for the lifetime of the booted kernel.

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (2 hunks in sys/net/route.c) applied to guest /usr/src; make -j6 nativekernel + installkernel + reboot; identical groom sequence runs clean (32/32 adds, victim created) and the unprivileged RTM_GET pads are 00 00 00 00 (5/5) vs 41 42 43 44 (5/5) on stock. Leak eliminated, no functional regression.

findings/poc/DF-3366/fix_validation.log (patched run), fix_uname.txt (patched kernel identity), run.log (baseline), fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 13:29:21 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unpriv user: socket(PF_ROUTE) -> RTM_GET -> parse reply sockaddrs -> bytes [sa_len..RT_ROUNDUP(sa_len)) are stale kernel heap. Cross-object proof: root (any routing daemon / route(8) churn) sprays routes with marker-tainted key tails, frees them; the next route created reuses the chunk; unpriv RTM_GET discloses the marker. Enables silent harvesting of recycled routing-state fragments by any local user.

Evidence (decisive lines)

['findings/poc/DF-3366/run.log β€” decisive: unpriv RTM_GET 2001:db8:99::1 -> RTAX[0] pad 41 42 43 44, 5/5 runs, uid=1001(maxx)', 'findings/poc/DF-3366/groom.log β€” root groom: spray 32/32 (marker keys), delete 32/32, victim added', 'findings/poc/DF-3366/run.baseline.log β€” unpriv ::1 baseline (zero pads from zeroed boot pages; channel exercised)', 'findings/poc/DF-3366/fix_validation.log β€” patched kernel #1: same groom, pads 00 00 00 00 5/5', 'findings/poc/DF-3366/fix.diff β€” validated fix (route.c: key-tail zero; gateway exact-length copy + tail zero)']

PoC changes

seed concept reworked: original sketch had no runnable code. Fixed inet_pton form (marker dsts need :: compression), non-blocking PF_ROUTE reads with poll+drain (rcvbuf flooded by broadcast replies), 28-byte delete form (rn_match compares min(clen) so tails never block deletion), gateway switched to a global on-link prefix (link-local gw needs KAME scope embedding).

Verified recommended fix

Zero sockaddr alignment padding when storing route keys and gateways (route.c:925-after, route.c:1227): bcopy only sa_len valid bytes and bzero the RT_ROUNDUP remainder.

Verdict

Uninitialized/stale kernel heap bytes are disclosed to unprivileged local users through routing-socket replies. rt_setgate() allocates route keys/gateways at RT_ROUNDUP(sa_len) size (sys/net/route.c:1213-1223) but only sa_len bytes are initialized (route.c:925 bcopy; rt_maskedcopy route.c:1299-1304 zeroes only to sa_len), while rt_msg_buffer() copies the full rounded length back to userland (rtsock.c:1140-1141) and RTM_GET requires no privilege (rtsock.c:738-740). Any AF with sa_len%8!=0 (AF_INET6: 4-byte tail; AF_LINK/AF_MPLS: variable) leaks 1-7 stale M_RTABLE bytes per sockaddr. Reproduced on the stock INVARIANTS guest: a root-side groom planted marker 41 42 43 44 at key offset [28..32) of 32 to-be-freed 64-byte route-key chunks; after free+realloc, unprivileged user maxx (uid 1001) read the marker back in the DST sockaddr padding of RTM_GET 2001:db8:99::1 replies β€” 5/5 deterministic runs β€” proving cross-object stale-heap disclosure. Baseline boot-time routes show zero pads only because fresh pages are zeroed. Fix validated by in-guest kernel rebuild: with the two zero-the-padding hunks applied, the same groom+read yields 00 00 00 00 (5/5) with no functional regression.