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

Unprivileged permanent kernel memory consumption and routing-plane exhaustion via RTM_GET + RTA_GENMASK (rtmask_add_global pollution)

Summary

route_output() hoists the genmask installation in front of the message-type switch, so rtmask_add_global() runs for EVERY message type - including RTM_GET, the single type the privilege check explicitly exempts for unprivileged users. The author even selected a lower netisr priority for GET-carried genmasks (showing awareness that unpriv users send GETs) but never gated the install itself. Each previously-unseen genmask makes every CPU permanently allocate 128 bytes in the per-CPU global mask trees; mask-tree nodes have no free path anywhere, and the 32-byte key space is attacker-chosen, so consumption is unbounded and permanent. VERIFIED as uid 1001 on stock INVARIANTS guest: 100k messages -> routetbl malloc type 234 -> 586K allocations (+73.3MB) in 9.2s (5.86 allocs/msg = 6 CPUs x 128B); same-mask control zero growth; sustained to the 390M type limit (ENOBUFS at iteration 532,426) after which even root gets 'routing table overflow' on route add, getifaddrs fails inside route(8), and the kernel logs arpresolve/arplookup llinfo allocation failures - the routing plane is bricked until reboot (~60s of unprivileged work). Any local unprivileged user (PF_ROUTE socket requires no privilege; the write need not even match a route). Steals 768B/message on a 6-CPU box (~8MB/s), permanently, from the same M_RTABLE pool that backs all route entries. No uid0 route. Fix validated (require privilege for genmask-carrying RTM_GETs): unpriv PoC fails EPERM at iteration 0, root installs still grow, unpriv plain RTM_GET unchanged.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3358 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 4.9 KB ↓ raw
rtmask_poc.c β€” 3.6 KB view raw
rtget_plain.c β€” 824 B view raw
build.sh β€” 94 B view raw
run.sh β€” 159 B view raw
build.log β€” 9 B view raw
run.log β€” 400 B view raw
run.2.log β€” 441 B view raw
run.3.log β€” 1.5 KB view raw
fix.run.log β€” 909 B view raw
env.baseline.txt β€” 265 B view raw
env.txt β€” 552 B view raw
fix.diff β€” 691 B view raw
verdict.json β€” 4.2 KB view raw

DF-3358 β€” Unprivileged permanent rtmask-table pollution via RTM_GET + RTA_GENMASK

  • File: sys/net/rtsock.c (route_output, lines ~738-748)
  • Class: unprivileged kernel resource exhaustion / permanent memory consumption (local DoS)
  • Bucket: base:net

Prerequisites

  • local unprivileged user account (no privilege, no jail escape, nothing)
  • DragonFly default kernel β€” verified on DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS stock)

Build

cc -O2 -Wall -o rtmask_poc rtmask_poc.c        # on the guest, base gcc

Run

# baseline (root):
vmstat -m | grep routetbl
# exploit (unprivileged):
/tmp/rtmask_poc 100000                 # unique genmask per RTM_GET
vmstat -m | grep routetbl              # +~586K allocs, +73MB, permanent
# control (unprivileged):
/tmp/rtmask_poc 20000 same             # same mask repeated -> ZERO growth

Expected result

Every RTM_GET message carrying a previously-unseen RTA_GENMASK sockaddr makes the kernel permanently allocate RN_MAXKEYLEN + 2*sizeof(struct radix_node) = 128 bytes per CPU (guest has 6 CPUs β†’ 768 B/message) in the M_RTABLE ("routetbl") pool. The mask trees never shrink and the key space is attacker-chosen.

Measured on the guest (uid 1001 maxx):

run routetbl Count routetbl MemUse
baseline 234 38.0K
after 100k unique-mask RTM_GET (9.2 s) 586K 73.3M
after 20k same-mask RTM_GET (control) 586K (unchanged) 73.3M
after 600k more unique (ENOBUFS at iter 532,426) 3.05M 390M (type limit)

After exhaustion the system-wide routing plane is bricked until reboot: route add fails ("routing table overflow") even for root, getifaddrs fails inside route(8), and the kernel logs arpresolve: can't allocate llinfo for any new neighbor.

Fix

fix.diff β€” require SYSCAP_RESTRICTEDROOT when an RTM_GET carries a genmask (root semantics unchanged; plain unpriv RTM_GET unchanged).

Files

  • rtmask_poc.c β€” PoC source
  • build.sh, run.sh β€” exact commands
  • build.log β€” compiler output
  • run.log β€” 100k unique run + routetbl before/after
  • run.2.log β€” 600k run hitting the 390M malloc-type limit (ENOBUFS @ 532,426)
  • run.3.log β€” post-exhaustion impact probe (route add fails as root, arpresolve failures)
  • env.baseline.txt β€” guest uname + baseline vmstat
  • fix.diff β€” the fix (validated: see VERDICT.md)
  • VERDICT.md, manifest.json, verdict.json
VERDICT.md
↓ download raw

DF-3358 β€” VERDICT

Finding: route_output() in sys/net/rtsock.c installs routing genmasks globally (rtmask_add_global()) for every message type, including RTM_GET β€” the single message type unprivileged users may send (the privilege check at sys/net/rtsock.c:738-740 explicitly skips RTM_GET; the genmask block at :742-748 runs before the type switch and is therefore not covered by that check).

Root cause chain (all line-cited against this tree): 1. sys/net/rtsock.c:738-740 β€” privilege gate exempts RTM_GET. 2. sys/net/rtsock.c:742-748 β€” if (rtinfo.rti_genmask != NULL) rtmask_add_global(...) runs for all types, incl. RTM_GET. The author even picked a lower message priority for GETs (RTREQ_PRIO_NORM) β€” aware unpriv users send GETs β€” but never gated the install itself. 3. sys/net/route.c:1762-1806 β€” rtmask_add_global forwards to every CPU; rtmask_add_msghandler calls rtmask_lookup() = _rtmask_lookup(mask, FALSE). 4. sys/net/route.c:1776-1792 + sys/net/radix.c:587-599 β€” _rtmask_lookup with search=false β†’ rn_addmask(..., false, ...) which, for a mask pattern not already in the per-CPU mask tree, kmallocs RN_MAXKEYLEN + 2*sizeof(struct radix_node) (= 32 + 2Γ—48 = 128 bytes) and inserts it. Mask-tree nodes have no free path (nothing in route.c/radix.c ever removes them; only route nodes are freed, not mask-tree entries). 5. The mask key is 32 attacker-chosen bytes (trailing-zero-trimmed), so the number of distinct installs is effectively unbounded (2^248 patterns).

Reachability: any local user. socket(PF_ROUTE, SOCK_RAW, 0) is unprivileged (routing monitors use it); write() of an RTM_GET message with RTA_DST|RTA_GENMASK triggers the install. The message does not even need to match a route β€” the genmask is installed before the lookup (ESRCH is returned after the memory is consumed).

Reproduced on the single-tenant QEMU guest (DragonFly dfbsd 6.5-DEVELOPMENT #0 ... X86_64_GENERIC, 6 CPUs, stock INVARIANTS kernel), as uid 1001 maxx:

  • Baseline vmstat -m | grep routetbl: Count 234, MemUse 38.0K.
  • 100,000 RTM_GETs with unique genmasks (9.2 s wall): Count 586K, MemUse 73.3M β€” β‰ˆ 5.86 allocations/message = one per CPU (6), 128 B each. (run.log)
  • Control: 20,000 RTM_GETs repeating ONE mask β†’ zero growth (Count 586K unchanged) β€” proves the unique-pattern insert path is what allocates.
  • Ceiling: continued to the M_RTABLE malloc-type limit β€” ENOBUFS at iteration 532,426 with Count 3.05M, MemUse 390M/390M (run.2.log).
  • Post-exhaustion impact (run.3.log): route add fails ("routing table overflow") as root; route(8)'s getifaddrs probe fails ("Cannot allocate memory"); kernel logs repeated arpresolve: can't allocate llinfo for any new neighbor. Existing cached traffic keeps working. Nothing frees the 390M β€” reboot is the only recovery.

Total cost to brick the routing plane: ~60 s of unprivileged userspace.

Not a panic/corruption primitive: allocations are in-bounds and correctly freed-free (they are simply never supposed to be freed); this is a pure unbounded permanent-consumption DoS, so the escalation question (uid=0) does not arise. Verdict: reproduced, impact dos, confidence certain.

Fix (fix.diff): require SYSCAP_RESTRICTEDROOT when an RTM_GET carries a genmask, right after the existing privilege check. Root semantics unchanged; unprivileged plain RTM_GET (no genmask) unchanged.

Fix validation (performed on the guest, same run β€” fix.run.log): vm.sh reset with-src β†’ applied fix.diff to the guest's /usr/src/sys/net/rtsock.c (patch: Hunk #1 succeeded at 739) β†’ make -j6 nativekernel + make installkernel β†’ rebooted into the patched kernel (#1: Fri Sep 11 09:08:32, see env.txt) β†’ re-ran the exact PoC as uid 1001: - Baseline routetbl on patched kernel: 234 / 38.0K. - 100,000 unique-genmask RTM_GETs: EPERM at iteration 0 (PoC exits as designed on the unexpected error); routetbl Count unchanged (234 / 38.0K, Requests 283β†’284) β€” no allocation occurs. - Positive control as root (10,000 unique-genmask GETs): Count 234 β†’ 58.8K, MemUse β†’ 7.36M (β‰ˆ6 allocs/cpu/message) β€” the privileged install path is fully preserved. - Positive control as uid 1001: plain RTM_GET without genmask (rtget_plain.c) β†’ write=168 errno=0, not EPERM β€” unprivileged route(8)-style RTM_GET monitors keep working.

fix_status: fixed β€” bad behavior gone on the patched kernel, root behavior and unpriv plain-GET behavior preserved.

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 applied to guest /usr/src/sys/net/rtsock.c, make -j6 nativekernel + installkernel + reboot. Baseline (stock #0): +73.3MB/100k msgs unpriv, ceiling 390M, routing bricked. Patched (#1): unpriv unique-genmask PoC denied EPERM at iteration 0 with routetbl unchanged (234/38.0K); root genmask installs still work (+58.8K allocs/7.36M per 10k msgs); unprivileged plain RTM_GET without genmask still succeeds (write=168, not EPERM). Bad behavior eliminated, no compatibility regression.

findings/poc/DF-3358/fix.run.log; findings/poc/DF-3358/env.txt (both unames); findings/poc/DF-3358/fix.diff
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 09:08:32 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3358/run.log β€” 100k unique-mask run: routetbl 234/38.0K -> 586K/73.3M in 9.2s as uid 1001; control (20k same-mask) shows zero growth', 'findings/poc/DF-3358/run.2.log β€” 600k run: ENOBUFS at iteration 532,426, routetbl pinned at 3.05M allocs / 390M (type limit)', "findings/poc/DF-3358/run.3.log β€” post-exhaustion: 'route add' fails as root (routing table overflow), arpresolve/arplookup llinfo failures, recovery = reboot only", 'findings/poc/DF-3358/fix.run.log β€” patched kernel (#1 Sep 11 09:08:32): unpriv PoC -> EPERM at iter 0, routetbl unchanged 234/38.0K; root control +58.8K/7.36M per 10k; unpriv plain RTM_GET write=168 no EPERM', 'findings/poc/DF-3358/rtmask_poc.c β€” PoC source (sha256 45b50d3e...)', 'findings/poc/DF-3358/fix.diff β€” one-hunk privilege gate, applied+validated in-guest']

PoC changes

none β€” pack authored fresh in this run (no seed); PoC built unmodified on the guest

Verified recommended fix

Require caps_priv_check(SYSCAP_RESTRICTEDROOT) when an RTM_GET carries an RTA_GENMASK before calling rtmask_add_global (see fix.diff)

Verdict

Any local unprivileged user permanently consumes kernel memory by writing RTM_GET messages carrying unique RTA_GENMASK sockaddrs to a PF_ROUTE socket: route_output() (sys/net/rtsock.c:742-748) installs genmasks globally for every message type, including the one type (RTM_GET) exempted from the privilege check at :738-740. Each unique mask allocates 128B per CPU (rn_addmask, sys/net/radix.c:587) into never-shrinking per-CPU mask trees; masks have no free path. Verified as uid 1001 on the stock INVARIANTS guest: 100k messages -> routetbl 234 -> 586K allocs / +73.3MB in 9.2s; same-mask control -> zero growth; sustained to the 390M M_RTABLE type limit (ENOBUFS at iteration 532,426) after which root cannot add routes ('routing table overflow'), getifaddrs fails inside route(8), and arpresolve cannot allocate llinfo for new neighbors until reboot. fix.diff (require SYSCAP_RESTRICTEDROOT for genmask-carrying RTM_GET) validated on a rebuilt kernel: unpriv gets EPERM with zero growth, root installs unchanged (+58.8K/10k msgs), unpriv plain RTM_GET unchanged.