RTM_CHANGE with gateway==destination permanently leaks the rtentry (key chunk + ifa ref) and desynchronizes per-CPU routing tables; subsequent re-ADD of the same destination panics the kernel
Summary
route_output_change_callback calls rt_setgate(rt, rt_key(rt), gw); for a host-gateway route whose new gateway equals its own destination, rt_setgate's EADDRNOTAVAIL path deletes the route from the current CPU's tree via nested RTM_DELETE and returns an error. rtsearch_msghandler's temporary ref is then dropped to zero without a finalizing rtfree: the rtentry, key chunk and ifa reference are orphaned forever (out of tree, refcnt 0, no holders). Because the search replies on the first CPU error, CPUs 1..N never see the change and keep the route - per-CPU table divergence; a later RTM_ADD of the same destination succeeds on CPU0 and hits EEXIST on CPU1 -> deterministic panic at route.c:763. VERIFIED with plain route(8): 200 add/change cycles -> routetbl 1.48K/257K -> 3.82K/669K (+412KB permanent leak); routes invisible on cpu0 while 'route delete' succeeds via the surviving CPU1..N copies (divergence proven); panic leg traced but deliberately not triggered. Privileged trigger only (RTM_CHANGE gated): permanent memory exhaustion + routing-plane divergence + one-command delayed panic. Fix: RTFREE-style finalization of the temp ref (or make rt_setgate's failure path non-mutating atomically on all CPUs).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3367 Β· 5 files| File | Type | Description | Size | |
|---|---|---|---|---|
| run.sh | β | 227 B | view raw | |
| leakcheck.log | β | 18 B | view raw | |
| leakcheck2.log | β | 287 B | view raw | |
| README.md | β | 2.7 KB | β raw | |
| verdict.json | β | 3.1 KB | view raw |
DF-3367 β RTM_CHANGE with gateway == destination permanently leaks the rtentry and desynchronizes the per-CPU routing tables
Root cause (all in sys/net/route.c)
route_output_change_callback (rtsock.c:908-972) calls
rt_setgate(rt, rt_key(rt), gateway) for RTM_CHANGE. For a host-gateway
route whose new gateway equals its own destination, rt_setgate hits the
EADDRNOTAVAIL path (route.c:1193-1205): it deletes the route from the
current CPU's tree via a nested rtrequest(RTM_DELETE, ...) and returns
an error.
Refcount trace at that moment (rtsearch_msghandler route.c:1726-1729):
- callback entry:
rt->rt_refcnt++β 1 - nested RTM_DELETE (route.c:824
++rt->rt_refcntβ 2; route.c:866rtfreeβ 1; route removed from tree,RTF_UPcleared,rt_key/rt_ifaintact) - callback returns error β
rt->rt_refcnt--β 0
Nobody holds a pointer anymore: the rtentry, its key chunk, and its ifa
reference are never released β a permanent leak on that CPU. Because
rtsearch_msghandler replies (and stops) on the first CPU error
(route.c:1751), CPUs 1..N-1 never run the change and keep the route: the
per-CPU tables diverge. A later RTM_ADD of the same destination then
succeeds on CPU0 and fails EEXIST on CPU1 β guaranteed
panic("rtrequest1_msghandler: rtrequest table req ...") (route.c:763-768).
(The panic leg was traced but deliberately not triggered during
verification; the leak and divergence legs were verified empirically.)
Threat
Trigger requires routing-socket write privilege (root), so this is a
robustness/lifetime bug, not an escalation: a privileged (or compromised
routing daemon, e.g. via route(8)) process can permanently consume kernel
memory and panic the system.
Verified on the guest (leakcheck.log / leakcheck2.log)
BEFORE
routetbl 1.48K 257K 0 390M 3.16K
AFTER-200-CYCLES
routetbl 3.82K 669K 0 390M 5.89K
routes visible on cpu0: 0
delete host 10.99.2.1
rc=0 <-- succeeded although cpu0 has no such route:
cpus 1..5 still do (divergence proven)
200 add+change cycles (+2340 in-use routetbl allocations, +412 KB); every route is invisible on cpu0 yet still present on the other CPUs; cpu0's copies are out-of-tree with refcount 0 β unfreeable.
Fix sketch
In rtsearch_msghandler, drop the temporary reference via rtfree()-safe
logic (e.g. RTFREE(rt) after the callback β route.h:446 decrements and
finalizes at 0), or make rt_setgate's EADDRNOTAVAIL path not mutate the
tree and instead return the error to the outer RTM_CHANGE, which should then
fail cleanly on every CPU. Also revisit rtredirect_oncpu (route.c:425,
DF-0399) which reaches the same nested-delete with the return value ignored.
Fix verification
not_testableConfirmed kernel references
Detail
Exploit chain
root (or compromised routing daemon): route add -host A GW; route change -host A A -> leaked rtentry+ifa on cpu0, table divergence; re-adding A later panics the kernel (rtrequest1_msghandler route.c:763-768). No unprivileged trigger exists (route_output requires privilege for RTM_CHANGE, rtsock.c:738-740).
Evidence (decisive lines)
["findings/poc/DF-3367/leakcheck2.log β vmstat -m routetbl BEFORE 1.48K/257K -> AFTER-200-CYCLES 3.82K/669K; 'routes visible on cpu0: 0'; `route delete -host 10.99.2.1` returns rc=0 (route exists only on cpus 1..N = divergence proven)", 'findings/poc/DF-3367/leakcheck.log β first 100-cycle confirmation', 'findings/poc/DF-3367/README.md β full refcount trace route.c:1726-1729 + 1193-1205']
PoC changes
no C PoC needed β plain route(8) add/change cycles reproduce; measurement via vmstat -m (M_RTABLE = 'routetbl')
Verified recommended fix
Make rtsearch_msghandler drop its temp ref with RTFREE()-style finalization (route.c:1729), or stop rt_setgate's EADDRNOTAVAIL path from mutating the tree and fail the outer RTM_CHANGE atomically on all CPUs.
Verdict
RTM_CHANGE whose new gateway equals the route's own destination takes rt_setgate's EADDRNOTAVAIL path (route.c:1193-1205), which deletes the route from the current CPU's tree, while rtsearch_msghandler's temporary reference (route.c:1726-1729) is dropped to zero without a finalizing rtfree: the rtentry, its key chunk and its ifa reference leak permanently on that CPU, and the per-CPU routing tables diverge (CPUs 1..N keep the route). Verified empirically as root with plain route(8): 200 add+change cycles grew the routetbl malloc type from 1.48K/257K to 3.82K/669K in-use allocations with zero routes visible on cpu0, and route delete -host 10.99.2.1 (a route cpu0 does not have) succeeded via the surviving CPU1..N copies. A subsequent RTM_ADD of the same destination deterministically panics via route.c:763-768 (cpu0 adds, cpu1 EEXIST) β traced, deliberately not triggered during verification. Privileged trigger only (routing socket write), hence Low severity: robustness/lifetime bug, memory-exhaustion and panic DoS by root or a compromised routing daemon.
No comments yet.