icmp6_redirect_input dereferences stale ip6/nd_rd pointers after IP6_EXTHDR_CHECK may reallocate the mbuf (latent UAF read)
| Field | Value |
|---|---|
| ID | DF-2610 |
| Status | new |
| Severity | Low |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-825 Expired Pointer Dereference |
| File | sys/netinet6/icmp6.c |
| Lines | 2160-2211 |
| Area | net |
| Confidence | speculative |
| Discovered | 2026-08-28 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
icmp6_redirect_input captures ip6 = mtod(m, ...) at line 2160, then
IP6_EXTHDR_CHECK at 2187 may replace m via m_pullup (M_LOOP branch,
ip6.h:277-306), whose slow path allocates a new mbuf and frees the old chain
(uipc_mbuf.c:2122-2153). nd_rd (2188), the 32-byte read of
nd_rd_target/nd_rd_dst (2196-2197) and ip6->ip6_hlim (2211) then read
freed memory. This violates the invariant the file itself documents at
icmp6.c:412 ("m might change if M_LOOP. So, call mtod after this") and that
every sibling function honors.
Root cause
icmp6.c:2160 ip6 = mtod(m, struct ip6_hdr *); precedes icmp6.c:2187
IP6_EXTHDR_CHECK(m, off, icmp6len,). In the non-PULLDOWN build the macro's
first branch (ip6.h:280-284) reassigns m = m_pullup(m, off+icmp6len) for
M_LOOP packets whose first mbuf is short; m_pullup's slow path (first mbuf
M_EXT or lacking tail room, uipc_mbuf.c:2122-2134) moves the pkthdr to a
fresh mbuf, copies the data, frees each exhausted old mbuf (uipc_mbuf.c:2146
n = m_free(n)), and returns the new chain. All uses of the pre-check ip6
afterwards β nd_rd at 2188, redtgt6/reddst6 at 2196-2197, ip6->ip6_hlim
at 2211 β are dangling. Contrast icmp6_input (mtod at 420 after the check at
411, with the explicit comment), icmp6_notify_error (re-derives at 861 and
again at 978), and icmp6_error (re-derives oip6 at 340).
Reachability today is conjunctive: the copy delivered by icmp6.c:787-793
(m_copym) never carries M_LOOP (M_COPYFLAGS, mbuf.h:281-284, excludes
it), so only the m_copym-failure fallback at 789 passes an original
loopback-delivered (M_LOOP, if_loop.c:274) redirect; the freeing pullup
additionally requires an M_EXT short first mbuf plus off+icmp6len <= MHLEN β
a chain shape current producers do not generate. Defect certain at the code
level; exploitation speculative β a live invariant violation waiting on any
future mbuf-producer change.
Threat model & preconditions
- Attacker position: local privileged user (raw ICMPv6 socket needs
SYSCAP_NONET_RAW,raw_ip6.c:530-533) or traffic routed over lo0, plus mbuf-cluster pressure to force them_copymNOWAIT failure. - Privileges gained or impact: 32+ bytes read from freed mbuf memory flow
into redirect validation and potentially
rtredirect/nd6_cache_lladdr(route-table poisoning with heap garbage) or a panic. - Required config or capabilities: raw-socket capability or loopback routing; mbuf pressure.
- Reachability: ND_REDIRECT over lo0 under memory pressure today; remotely reachable if any future driver/LRO change produces (M_LOOP, M_EXT-short-first, pullup<=MHLEN) chains.
Proof of concept
Not presently reproducible end-to-end (needs the mbuf shape described); verification of the defect is the code-level trace above plus an instrumented guest run.
Build & run
boot DF guest; enable IPv6 on lo0; as root use a raw ICMPv6 socket to send to ::1 an ND_REDIRECT (type 139, code 0, hlim 255, >=40-byte ICMPv6 payload, chain split as [short cluster first mbuf][rest]) while spraying mbufs to force the m_copym at icmp6.c:787 to fail.
Expected output
kernel reads nd_rd from freed cluster (garbage redtgt6/reddst6 in the 'ICMP6 redirect' log lines at icmp6.c:2234/2246/2264) or panics in rtpurelookup on a garbage sockaddr.
Impact
Latent UAF-read on redirect input; today root-gated and shape-gated, but a one-line fix removes the class entirely before any producer change makes it remotely reachable.
Recommended fix
Re-derive the header pointer after the check, exactly as every sibling in this file does:
--- a/sys/netinet6/icmp6.c
+++ b/sys/netinet6/icmp6.c
@@ -2184,7 +2184,11 @@ icmp6_redirect_input(struct mbuf *m, int off)
#ifndef PULLDOWN_TEST
IP6_EXTHDR_CHECK(m, off, icmp6len,);
+ /*
+ * m may have been reallocated by m_pullup() in the M_LOOP case
+ * (see the comment in icmp6_input()); re-derive ip6.
+ */
+ ip6 = mtod(m, struct ip6_hdr *);
nd_rd = (struct nd_redirect *)((caddr_t)ip6 + off);
#else
IP6_EXTHDR_GET(nd_rd, struct nd_redirect *, m, off, icmp6len);
References
- icmp6.c:412 (documented invariant), ip6.h:277-306 (IP6_EXTHDR_CHECK)
- uipc_mbuf.c:2122-2153 (m_pullup slow path frees old chain)
Timeline
- 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2610 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| redir6.c | β | 4.7 KB | view raw | |
| ic6stat.c | β | 2.1 KB | view raw | |
| raw137listen.c | β | 2.2 KB | view raw | |
| build.sh | β | 169 B | view raw | |
| run.sh | β | 1.5 KB | view raw | |
| run.log | β | 7.0 KB | view raw | |
| build.log | β | 75 B | view raw | |
| env.txt | β | 429 B | view raw | |
| fix.diff | β | 557 B | view raw | |
| VERDICT.md | β | 4.1 KB | β raw | |
| verdict.json | β | 4.9 KB | view raw |
DF-2610 β icmp6_redirect_input stale ip6/nd_rd (latent UAF-read)
What this investigates
icmp6_redirect_input captures ip6 = mtod(m, ...) at icmp6.c:2160
BEFORE IP6_EXTHDR_CHECK at icmp6.c:2187. In the non-PULLDOWN build
(no PULLDOWN_TEST anywhere in sys/ β verified) that macro can replace
m via m_pullup when the mbuf is M_LOOP and short
(netinet/ip6.h:277-306), and m_pullup's slow path frees the old first
mbuf (uipc_mbuf.c:2125-2153). Every later use β nd_rd (2188),
redtgt6/reddst6 (2196-2197), ip6->ip6_hlim (2211) β would then read
freed memory. This violates the file's own documented invariant
(icmp6.c:412: "m might change if M_LOOP. So, call mtod after this").
Verdict
Code-level defect CONFIRMED in the current tree (identical guest
/usr/src, md5-matched). Live manifestation: NOT REPRODUCIBLE from
userspace on this kernel β the reallocating m_pullup branch is provably
unreachable for any packet a userspace process can cause to be delivered
to icmp6_redirect_input. The finding's own assessment ("latent class
violation", confidence: speculative) is accurate.
Live evidence (run.log, full untrimmed)
- Path traversal proven: raw-socket ND_REDIRECT (type 137) sends
to
::1with src forced tofe80::1and hlim 255 traverse the entire validation block β dmesg showsICMP6 redirect rejected; not equal to gw-for-src=::0001 (must be same): (src=fe80:0002::0001 dst=::0001 tgt=fe80:0002::)which is the log aticmp6.c:2241-2246, i.e. execution passed thend_rdreads (2196-2197), the hlim check (2211) andrtpurelookup(2227).icp6s_inhist[137]counted every send (1 -> 71 over the run).raw137listenconfirms the kernel-chosen source/hlim on the wire (from fe80::1 scope=2 ... hlim=255). - No corruption observed: sends at payload sizes 40..8000 (single
mbufs AND
[40B header mbuf][cluster]chains βlo0= 23multi-mbuf inputs in ip6 stats), multi-iov sendmsg shapes, and a 50-packet burst all left the guest alive, no INVARIANTS trip, no panic (uptimeafter).
Why the stale pointer cannot fire today (trace)
- The dispatch copy
n = m_copym(m, 0, M_COPYALL, M_NOWAIT)(icmp6.c:787) never carriesM_LOOP:M_COPYFLAGS(sys/sys/mbuf.h:281-284) excludes it. With!M_LOOPthe macro takes theM_EXT/plain else-branches (ip6.h:285-297) which either pass (data contiguous) or free-and-return β neverm_pullup. - The only
M_LOOPpacket that can reachicmp6_redirect_inputis the ORIGINAL loopback mbuf, delivered solely when thatm_copymfails (icmp6.c:789-791) β requiring cluster-zone exhaustion. - For the pullup to reallocate (free the first mbuf
ip6points into), the first mbuf must beM_EXT(or lack room) withoff+icmp6len <= MHLEN(uipc_mbuf.c:2125-2135). - No userspace producer creates such a chain:
sosendsizes the first mbuf to the full send viam_getl(resid, ...)(uipc_socket.c:866-887) β first mbuf is either a full/small internal mbuf or a FULL cluster;rip6_output'sM_PREPEND(raw_ip6.c:326) then splits off a new internal header mbuf (cluster has no headroom). So chains are always[internal hdr][...], taking the in-place pullup path (uipc_mbuf.c:2119-2124) that leaves the first mbuf (andip6) valid. Want > MCLBYTES (e.g. 2100/3000/8000-byte sends) makesm_pullupreturn NULL -> macro frees and returns before line 2188. bpf/tun injection produce single mbufs. - Therefore the dangling dereference requires a future mbuf-producer
change (e.g. an LRO/driver change emitting
M_EXT-short-first chains over loopback, plus mbuf pressure for step 2). A one-line fix removes the class.
Fix
fix.diff re-derives ip6 = mtod(m, ...) after the check, exactly as
icmp6_input (420-421), icmp6_notify_error (861/978) and icmp6_error
(340) already do. Kernel-rebuild validation not performed: the bug did not
reproduce live (nothing to observe a behavioral delta against); the change
is mechanical and mirrors the sibling functions' proven pattern.
Fix verification
not_testableFix kernel not built: the defect never manifests at runtime on this kernel (unreachable mbuf shape), so there is no observable behavioral delta to validate a patched kernel against. fix.diff is a mechanical pointer re-derivation identical in shape to the already-correct sibling functions (icmp6.c:420, 861, 978, 340); compile-correctness follows the same pattern.
VERDICT.md reachability trace (uipc_socket.c:866 m_getl, raw_ip6.c:326 M_PREPEND, uipc_mbuf.c:2119-2135 in-place-vs-realloc conditions, mbuf.h:281-284 M_COPYFLAGS) plus run.log survival of all chain shapes.
Confirmed kernel references
- sys/netinet6/icmp6.c:2160
- sys/netinet6/icmp6.c:2186-2188
- sys/netinet6/icmp6.c:2196-2197
- sys/netinet6/icmp6.c:2211
- sys/netinet6/icmp6.c:412
- sys/netinet6/icmp6.c:781-793
- sys/netinet/ip6.h:277-306
- sys/kern/uipc_mbuf.c:2103-2153
- sys/sys/mbuf.h:281-284
- sys/kern/uipc_socket.c:866-887
- sys/netinet6/raw_ip6.c:326
- sys/net/if_loop.c:274
Detail
Evidence (decisive lines)
run.log: dmesg line 'ICMP6 redirect rejected; not equal to gw-for-src=::0001 (must be same): (src=fe80:0002::0001 dst=::0001 tgt=fe80:0002::)' (log at icmp6.c:2241-2246, i.e. execution passed the would-be-stale reads at 2196-2197); ic6stat inhist[137] 1->71 across all sends; 'ALIVE_AFTER_SENDS' + uptime after chain/burst shapes; raw137listen confirming wire src=fe80::1 scope=2 hlim=255.
PoC changes
Standalone toolkit written from scratch (redir6 sender, ic6stat binary-stats decoder, raw137listen observer). Two material fixes found during verification: (1) sender originally used ICMPv6 type 139 (NI query) instead of 137 (ND_REDIRECT) - corrected after ic6stat inhist and the nodeinfo responder exposed the mix-up; (2) nd_rd_dst pointed at an unroutable address so rtpurelookup returned NULL silently - changed to the queried destination (local host route) to make the gateway-validation log fire as the traversal oracle.
Verified recommended fix
Re-derive ip6 = mtod(m, struct ip6_hdr *) immediately after IP6_EXTHDR_CHECK in icmp6_redirect_input, mirroring icmp6_input/icmp6_notify_error/icmp6_error (see fix.diff).
Verdict
Source-level defect CONFIRMED verbatim in the current tree (guest /usr/src md5-identical to audit tree): icmp6.c:2160 takes ip6=mtod(m) BEFORE IP6_EXTHDR_CHECK at 2187, and the non-PULLDOWN branch compiles (no PULLDOWN_TEST anywhere in sys/), so nd_rd (2188), redtgt6/reddst6 (2196-2197) and ip6->ip6_hlim (2211) would dangle if the check's m_pullup reallocated the mbuf - violating the file's own invariant at icmp6.c:412. LIVE MANIFESTATION NOT REPRODUCIBLE from userspace on this kernel: (1) the dispatch copy at icmp6.c:787 strips M_LOOP (M_COPYFLAGS, mbuf.h:281-284), so the copy always takes the macro's non-pullup branches (ip6.h:285-297); (2) the M_LOOP original reaches icmp6_redirect_input only if that m_copym fails (cluster exhaustion); (3) the reallocating m_pullup path (uipc_mbuf.c:2125-2135) requires an M_EXT-or-no-room FIRST mbuf with off+icmp6len<=MHLEN, but sosend always sizes the first mbuf to the full send via m_getl (uipc_socket.c:866-887) and rip6_output's M_PREPEND (raw_ip6.c:326) splits off an internal header mbuf - so loopback chains are always [internal-hdr][cluster...] and take the in-place pullup (uipc_mbuf.c:2119-2124) that keeps ip6 valid; want>MCLBYTES makes m_pullup NULL and the macro frees+returns before line 2188. Live evidence: 70+ type-137 redirects of every shape (40..8000 bytes, multi-iov, bursts) traversed the full validation block (dmesg rejection log from icmp6.c:2241-2246; icp6s_inhist[137] 1->71) with zero panics/corruption. Defect is a latent class violation as filed (confidence: speculative); the one-line re-derive fix is warranted.
No comments yet.