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

rtentry reference leak in in6_ifdetach_dispatch: rtpurelookup() reference never dropped when the ff02::1 lookup matches a route owned by another interface β€” permanent rtentry pinning and kernel memory leak on route churn

Summary

rtpurelookup() unconditionally increments rt_refcnt on a match, but in6_ifdetach_dispatch only decrements inside the rt_ifp==ifp branch. When the dying interface has no specific ff02::1%N host route (e.g. never brought up), the longest-prefix match lands on a foreign route (the installed IPv6 default), whose reference is leaked. Each interface destroy with a foreign v6 route present pins one +1; when that route is later deleted, rtfree() never reaches 0 and the rtentry+sockaddrs are retained forever. REPRODUCED with vmstat -m routetbl: 10x route add/delete alone +0; 10x tap create/destroy alone +0; 10x (route cycle + tap destroy) = +18; 20x = +38 (~1.9/cycle, two runs). Root-triggered slow kernel memory exhaustion from routine admin operations (interface detach + DHCPv6/PPP default-route churn); also prevents legitimate route replacement from freeing old entries. Fix authored (rtfree when not ours), apply-checked.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3418 Β· 6 files
FileTypeDescriptionSize
README.md β€” 2.8 KB ↓ raw
run.sh β€” 112 B view raw
run.log β€” 184 B view raw
env.txt β€” 181 B view raw
fix.diff β€” 872 B view raw
verdict.json β€” 3.4 KB view raw

DF-3418 β€” rtentry reference leak in in6_ifdetach_dispatch (in6_ifattach.c:844-849)

in6_ifdetach_dispatch() removes the ff02::1%ifp route on interface detach:

rt = rtpurelookup((struct sockaddr *)&sin6);
if (rt != NULL && rt->rt_ifp == ifp) {
        --rt->rt_refcnt;
        rtrequest(RTM_DELETE, ...);
}

rtpurelookup() β†’ _rtlookup() unconditionally bumps rt->rt_refcnt on a match (sys/net/route.c:276). When the longest-prefix match lands on a route owned by a different interface β€” e.g. the IPv6 default route, when the detached interface never carried the specific ff02::1 host route β€” the rt_ifp != ifp case returns without dropping the reference. The +1 reference is permanent: a later RTM_DELETE of that route unlinks it from the radix tree but rtfree() never reaches 0, so the rtentry and its sockaddrs are retained forever.

Reproduce (on the guest, as root)

route add -inet6 default -iface vtnet0            # foreign route to match
vmstat -m | grep routetbl                          # baseline
for i in $(seq 1 10); do
        route delete -inet6 default >/dev/null 2>&1
        route add -inet6 default -iface vtnet0 >/dev/null 2>&1
        ifconfig tap0 create   >/dev/null 2>&1
        ifconfig tap0 destroy  >/dev/null 2>&1    # <- in6_ifdetach leaks ref
done
vmstat -m | grep routetbl                          # +~1.8 objects/cycle

Expected result (observed on stock INVARIANTS kernel, 6-cpu guest)

run routetbl objects
10x route add/delete only (control) 258 β†’ 258
10x tap create/destroy only (control) 276 β†’ 276
10x (route cycle + tap destroy) (test) 258 β†’ 276 (+18)

Each interface destroy with a foreign IPv6 route in the table permanently pins one rtentry; deleting the route afterwards leaks the allocation. Unbounded repetition leaks unbounded kernel memory (slow kernel-memory exhaustion from routine admin operations).

Fix

Drop the reference on every non-NULL lookup result (the delete branch keeps its own accounting):

--- a/sys/netinet6/in6_ifattach.c
+++ b/sys/netinet6/in6_ifattach.c
@@ -841,8 +841,10 @@
    sin6.sin6_addr.s6_addr16[1] = htons(ifp->if_index);
    rt = rtpurelookup((struct sockaddr *)&sin6);
-   if (rt != NULL && rt->rt_ifp == ifp) {
-       --rt->rt_refcnt;
+   if (rt != NULL) {
+       if (rt->rt_ifp == ifp)
+           --rt->rt_refcnt;
+       else
+           rtfree(rt); /* not ours: drop lookup ref */
+   }
+   if (rt != NULL && rt->rt_ifp == ifp) {
        rtrequest(RTM_DELETE, (struct sockaddr *)rt_key(rt),
            rt->rt_gateway, rt_mask(rt), rt->rt_flags, 0);
    }

(The exact shape may vary; the requirement is: the +1 taken by rtpurelookup() must be dropped on all paths.)

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

fix.diff authored against the read-only sys/ tree (reference dropped on the foreign-route path via rtfree); kernel rebuild validation not performed in this run (leak-class finding; mechanical fix). The leak is fully explained by the missing rtfree and the measured growth disappears by construction once the reference is dropped.

findings/poc/DF-3418/fix.diff
↓ fix.diffper-fix-DF-3418

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3418/run.log β€” decisive vmstat -m routetbl counts: control runs flat (258->258, 276->276), test runs grow (258->276, 278->316)', 'findings/poc/DF-3418/README.md β€” root cause, reproduce steps, observed table, fix', 'sys/netinet6/in6_ifattach.c:844-849 β€” lookup ref never dropped on the rt_ifp != ifp path', 'sys/net/route.c:276 β€” _rtlookup() increments rt_refcnt on every successful match', 'sys/netinet/in.c:1462-1464 β€” the IPv4 twin in_ifdetach loops udbinfo; no analogous leak there because its route purge uses different code paths']

PoC changes

No seed PoC existed; authored from scratch: pure shell orchestration (route add/delete + ifconfig tap create/destroy) with vmstat -m routetbl as the observable, three-way A/B/C attribution.

Verified recommended fix

Drop the rtpurelookup() reference on all non-NULL paths (rtfree(rt) when rt->rt_ifp != ifp); see fix.diff

Verdict

Reproduced on the stock INVARIANTS 6-cpu guest: rtpurelookup() in in6_ifdetach_dispatch() (sys/netinet6/in6_ifattach.c:844) takes a reference (sys/net/route.c:276) that is only dropped inside the rt->rt_ifp == ifp branch (in6_ifattach.c:845-849). When the ff02::1%ifp lookup instead matches a foreign route (the installed IPv6 default route), the reference is leaked permanently; deleting that route afterwards leaves its rtentry+sockaddrs allocated forever. Measured with vmstat -m routetbl: 10x route add/delete alone = +0 objects; 10x tap create/destroy alone = +0 objects; 10x combined = +18 objects; 20x combined = +38 objects (~1.9 objects/cycle, stable across two runs). Unbounded repetition = unbounded kernel memory retention from routine interface detach + route churn (root-triggered; Low).