通过AFL++复现sudo漏洞的一次尝试
2023-2-24 18:7:40 Author: 看雪学苑(查看原文) 阅读量:22 收藏


本文为看雪论坛精华文章

看雪论坛作者ID:zackery

下载源码,源代码下载连接:https://www.sudo.ws/
$ wget https://www.sudo.ws/dist/sudo-1.8.21.tar.gz$ tar xzf sudo-1.8.21.tar.gz
须安装AFL++,可以使用官方docker镜像;如果已经在本地安装,也可直接使用。
$ docker pull aflplusplus/aflplusplus# 拉取afl++$ docker run -ti -v /location/of/your/target:/src \[-v /location/of/your/afl_src/:AFLplusplus \]aflplusplus/aflplusplus /bin/bash# 启动docker afl# 映射代码文件夹,如果本地没有afl++的源代码的话,建议也映射一份,便于后续操作
$ cd /src/sudo-1.8.21查看sudo源代码目录:

先不进行插桩编译,使用原版安装,测试一下poc是否符合预期。
$ ./configure --prefix=/src/origin_compile$ make$ make install$ cd /src/origin_compile/bin$ ./sudoedit -s '\' aaaaaaaaaaaaaaa
可以看到成功产生了一个崩溃:

我们接下来的任务就是将该崩溃用afl复现出来。
sudo程序具有SUID,普通用户通过输入密码,利用sudo执行命令,获得暂时的权力提升。对于sudo的测试,需要通过非预期的输入,致使sudo程序产生崩溃,进而找到漏洞的利用点。
sudo的输入有两处,执行参数与密码输入,本文暂不考虑密码输入引发的异常。测试的场景为,非特权用户输入恶意构造程序执行参数,引起sudo程序崩溃。
sudo程序由root用户和其他用户启动的表现是不同的。sudo的所有权是root,但却是由普通用户调用的。然而我们在使用afl模糊测试时,使用的是root身份,这不能完成测试的需求,虽然这并不影响本CVE的复现。因此我们需要使sudo程序即使以root身份运行,但让其认为是普通用户执行的。
这可以通过查看sudo调用getuid()的代码来实现,只需将值硬编码为1000,这是一个普通用户的用户ID。

这个补丁,可以通过在源代码文件夹中搜索getuid,将getuid()和getgid()修改为1000即可。
--- ./src/sudo.c+++ ./src/sudo.c@@ -522,9 +524,9 @@     }     ud->sid = getsid(0); -    ud->uid = getuid();+    ud->uid = 1000;     ud->euid = geteuid();-    ud->gid = getgid();+    ud->gid = 1000;     ud->egid = getegid();
afl原生并不支持对argv参数进行Fuzzing。afl的fuzzing模式一般是将变异得到的文件,重定向到程序,作为程序的标准输入,然后运行被测程序,等待程序结束、崩溃或超时。
注意:afl的启动命令中可以使用 @@ 作为占用符,但其作用并不是对占位符的位置进行fuzzing,@@占位符表示此处应有文件的输入,且这个输入的文件应是fuzzing得到的,由afl自动填入。

为了实现对argv的fuzzing,我们可以将/aflplusplus/utils/argv_fuzzing/argv-fuzz-inl.h复制到sudo的源代码目录../sudo/src下,并对sudo.c进行相应的补丁。
+++ sudo.c    2023-01-22 01:09:38.175635142 -0800--- sudo.c_org1    2023-01-22 01:06:27.035319663 -0800 @@ -14,7 +14,6 @@  * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.  */ +  #include "argv-fuzz-inl.h"   #ifdef __TANDEM   # include <floss.h>   #endif  @@ -134,7 +133,6 @@   int   main(int argc, char *argv[], char *envp[]){+    AFL_INIT_ARGV();     int nargc, ok, status = 0;     char **nargv, **env_add;     char **user_info, **command_info, **argv_out, **user_env_out;
我们看一下argv-fuzz-inl.h。
#define AFL_INIT_ARGV()          \  do {                           \                                 \    argv = afl_init_argv(&argc); \                                 \  } while (0) ······ #define MAX_CMDLINE_LEN 100000#define MAX_CMDLINE_PAR 50000 static char **afl_init_argv(int *argc) {   static char  in_buf[MAX_CMDLINE_LEN];  static char *ret[MAX_CMDLINE_PAR];   char *ptr = in_buf;  int   rc = 0;   if (read(0, in_buf, MAX_CMDLINE_LEN - 2) < 0) {}   while (*ptr && rc < MAX_CMDLINE_PAR) {     ret[rc] = ptr;    if (ret[rc][0] == 0x02 && !ret[rc][1]) ret[rc]++;    rc++;     while (*ptr)      ptr++;    ptr++;   }   *argc = rc;   return ret;}
argv-fuzz-inl.h定义了一个宏函数AFL_INIT_ARGV(),调用相当于执行argv = afl_init_argv(&argc);。afl_init_argv从标准输入中读取输入,以'\0'表示一个参数的结束,以'\0\0'表示输入的结束。argv作为一个指针数组的指针,该指针数组中最后一个指针应为0,其余的每一项为一个字符串指针。
'\0'作为字符串结束的标志,因此参数中'\0'后的字符没有意义,因此以'\0'表示一个参数的结束是一个合适的操作。注意到afl_init_argv函数中,存在对0x02的判断,编写这个文件的作者解释到,以单独一个0x02作为参数表示空参数,因此将其跳过。
也就是说,生成的输入文件,如果存在0x...000200...的话,0x02会被删除,该参数直接变为以0x00开始且结束的空字符串。进行这个处理的原因是,默认的方法无法生成空字符串的参数,而0x02很少用,可以用它来表示空参数,影响很小。
在执行sudo程序的时,会从标准输入中读取密码,来进行权限认证。我们已经通过使用标准输入来输入argv参数。当执行到输入密码时,会再次从标准输入读取,sudo程序会一直等待密码输入,因此被测程序就会因超时而被挂起,重复的挂起将会导致测试时间被严重拉长。

我们要明确测试的目的,普通用户以特定的参数打开sudo,导致程序崩溃,因此权限认证不应该被通过,我们试着取消执行sudo时权限认证环节。

首先,我们需要找到权限认证的代码片段,这里可以通过gdb调试,查看sudo运行到输入密码时程序的状态。
为了避免docker用户管理与sudo用户管理引起的混乱,我还是建议在本机上编译,调试。注意一定要设置好安装目录,防止破坏主机环境。
$ make clean$ ./configure --prefix=~/sudo_gdb_test_auth --disable-shared$ make$ make install
安装完成后,找到对应的sudo文件,确保其用户属于root,并设置SUID。
$ sudo chown root:root ./sudo$ sudo chmod u+s ./sudo$ ls -l
使用非root用户测试运行$ ./sudo ls会停留在输入密码处。
$ ./sudo lsPassword:(expecting input)
保留当前窗口,再开一个终端。
$ ps -ef |grep sudoroot      206957  206889  0 19:15 pts/4    00:00:00 ./sudo ls
必须使用root权限调试,$ sudo gdb attach <pid>。如果成功的话,会断在read处。

我们查看backtrace。显然,可以考虑将verify_user优化掉。
pwndbg> bt#0  0x00007f1421f7dfd2 in __GI___libc_read (fd=[email protected]entry=5, buf=[email protected]entry=0x7ffd483eb217, nbytes=[email protected]entry=1) at ../sysdeps/unix/sysv/linux/read.c:26#1  0x00005622b6cac7af in read (__nbytes=1, __buf=0x7ffd483eb217, __fd=5) at /usr/include/x86_64-linux-gnu/bits/unistd.h:44#2  getln (fd=[email protected]entry=5, buf=[email protected]entry=0x5622b6d0b480 <buf> "", feedback=[email protected]entry=0, bufsiz=256) at ./tgetpass.c:311#3  0x00005622b6cacbd0 in tgetpass (prompt=0x5622b6e87bb0 "Password: ", timeout=300, flags=0, callback=[email protected]entry=0x7ffd483ebd50) at ./tgetpass.c:178#4  0x00005622b6c9c81e in sudo_conversation (num_msgs=<optimized out>, msgs=<optimized out>, replies=0x7ffd483eb918, callback=0x7ffd483ebd50) at ./conversation.c:70#5  0x00005622b6cd8485 in auth_getpass (prompt=0x5622b6e87bb0 "Password: ", timeout=<optimized out>, type=[email protected]entry=1, callback=[email protected]entry=0x7ffd483ebd50) at ./auth/sudo_auth.c:426#6  0x00005622b6cd88d6 in verify_user (pw=0x5622b6e7e158, prompt=<optimized out>, [email protected]entry=0x5622b6e87bb0 "Password: ", validated=[email protected]entry=2, callback=[email protected]entry=0x7ffd483ebd50) at ./auth/sudo_auth.c:282#7  0x00005622b6cd93f1 in check_user_interactive (auth_pw=0x5622b6e7e158, mode=<optimized out>, validated=2) at ./check.c:149#8  check_user (validated=[email protected]entry=2, mode=<optimized out>) at ./check.c:212#9  0x00005622b6cc2af2 in sudoers_policy_main (argc=[email protected]entry=1, argv=[email protected]entry=0x7ffd483ec1a0, pwflag=[email protected]entry=0, env_add=[email protected]entry=0x0, closure=[email protected]entry=0x7ffd483ebeb0) at ./sudoers.c:423#10 0x00005622b6cbcfc4 in sudoers_policy_check (argc=1, argv=0x7ffd483ec1a0, env_add=0x0, command_infop=0x7ffd483ebf28, argv_out=0x7ffd483ebf30, user_env_out=0x7ffd483ebf38) at ./policy.c:775#11 0x00005622b6c9ae57 in policy_check (plugin=0x5622b6d0c000 <policy_plugin>, user_env_out=0x7ffd483ebf38, argv_out=0x7ffd483ebf30, command_info=0x7ffd483ebf28, env_add=0x0, argv=0x7ffd483ec1a0, argc=1) at ./sudo.c:1149#12 main (argc=[email protected]entry=2, argv=[email protected]entry=0x7ffd483ec198, envp=0x7ffd483ec1b0) at ./sudo.c:247#13 0x00007f1421e94083 in __libc_start_main (main=0x5622b6c9aa50 <main>, argc=2, argv=0x7ffd483ec198, init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7ffd483ec188) at ../csu/libc-start.c:308#14 0x00005622b6c9c6be in _start () at ./sudo.c:798
我们找到源代码../auth/sudo_auth.c处,直接让函数verify_user返回。

我们可以看到,sudoedit只是sudo的一个符号链接。
-rwsr-xr-x 1 root root 1647712 Jan 23 06:50 sudolrwxrwxrwx 1 root root       4 Jan 23 06:50 sudoedit -> sudo-rwxr-xr-x 1 root root  318384 Jan 23 06:50 sudoreplay
但在测试的过程中发现,如果打开的是sudo,即使在程序起始处将argv[0]修改为了sudoedit,程序的执行流依旧是sudo代码片段。
$ echo -ne "sudoedit\0id\0\0" | ./sudousage: sudo -h | -K | -k | -Vusage: sudo -v [-AknS] [-g group] [-h host] [-p prompt] [-u user]usage: sudo -l [-AknS] [-g group] [-h host] [-p prompt] [-U user] [-u user] [command]usage: sudo [-AbEHknPS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] [VAR=value] [-i|-s] [<command>]usage: sudo -e [-AknS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] file ... $ echo "sudo\0id\0\0" | ./sudoedit usage: sudoedit [-AknS] [-C num] [-g group] [-h host] [-p prompt] [-T timeout] [-u user] file ...
我们可以发现main函数在开头处调用os_init(argc, argv, envp);,而os_init被宏定义为
os_init_common,os_init_common会调用initprogname初始化程序名。
intos_init_common(int argc, char *argv[], char *envp[]){    initprogname(argc > 0 ? argv[0] : "sudo");#ifdef STATIC_SUDOERS_PLUGIN    preload_static_symbols();#endif    gc_init();    return 0;}
但是initprogname会优先使用__progname宏获取程序名,而不是传递进来的argv[0]。
voidinitprogname(const char *name){# ifdef HAVE___PROGNAME    extern const char *__progname;     if (__progname != NULL && *__progname != '\0')    progname = __progname;    else# endif    if ((progname = strrchr(name, '/')) != NULL) {    progname++;    } else {    progname = name;    }     /* Check for libtool prefix and strip it if present. */    if (progname[0] == 'l' && progname[1] == 't' && progname[2] == '-' &&    progname[3] != '\0')    progname += 3;}
因此,为了使被测程序通过argv[0]获取程序名,我们将HAVE___PROGNAME的部分删除。
--- ../origin_file/progname.c    2023-01-27 01:21:37.829958000 -0800+++ progname.c    2023-01-27 01:23:42.824771537 -0800@@ -59,13 +59,6 @@ void initprogname(const char *name) {-# ifdef HAVE___PROGNAME-    extern const char *__progname;--    if (__progname != NULL && *__progname != '\0')-    progname = __progname;-    else-# endif     if ((progname = strrchr(name, '/')) != NULL) {     progname++;     } else {
再完成上述四个补丁后,我们再次编译得到最终文件。

重新开一个容器,将最终代码放入/src,创建/fuzz,同时设置好主机跟踪目录,便于观察fuzz情况及其进展。
$ sudo docker run -it \-v path/to/all_change:/src \-v path/to/trace_paper_fuzz:/fuzz\aflplusplus/aflplusplus /bin/bash
在docker中添加uid为1000的普通用户  
$ useradd -u 1000 aflfuzzer
使用afl-clang-fast进行插桩编译,使用afl-gcc编译得到的文件无法运行,相关分析我放到文末。
$ cd /src$ make clean$ CFLAGS="-g" LDFLAGS="-g" CC=afl-clang-fast ./configure --prefix=/fuzz/release --disable-shared$ make$ make install
编译成功后,再次验证poc能够引起崩溃。
echo -ne "sudoedit\x00-s\x00\x5c\x00aaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x00" | ./sudo# 为啥不用直接用\0,我觉得可以,但是我本机解析经常出问题,因此本文全篇都是用\xHH

经历总总曲折,总算可以开始测试了。
$ cd /fuzz$ mkdir {input,output}$ echo -ne "sudo\x00id\x00\x00" > input/payload1$ echo -ne "sudoedit\x00id\x00\x00" > input/payload2afl-fuzz -i input/ -o output/ -D -M Master /fuzz/release/bin/sudo# 可以创建多个从fuzzer辅助测试afl-fuzz -i input/ -o output/ -D -S slave1 /fuzz/release/bin/sudo

可以发现,很快就能获得一个崩溃。

查看崩溃,符合预期:

在进行插桩编译的过程中,笔者一开始使用的是afl-gcc编译,但是编译出来的文件运行会直接崩溃。查找相关资料推荐使用llvm模式编译。为了弄清楚afl-gcc编译的文件失败的原因,笔者对此做出必要探索。

使用afl-gcc编译:
$ make clean$ CFLAGS="-g" LDFLAGS="-g" CC=afl-gcc ./configure --prefix=/src/gcc_compile --disable-shared$ make$ make install$ ./sudoedit
回顾一下插桩程序的运行过程:被测程序会在第一次执行__afl_maybe_log时进行初始化,第一次调用时共享内存指针__afl_area_ptr为空,进而调用__afl_setup初始化forkserver。

__afl_setup首先检查__afl_setup_failure是否为空,如果不为空代表已经初始化失败过,调用__afl_return返回,否则调用__afl_setup_first进行初始化。

__afl_setup_first会保存所有寄存器的值,然后调用getenv获取SHM_ENV_VAR(fuzz程序保存的共享内存id)

然而跟进getenv发现,getenv又调用了__afl_maybe_log,也就是说getenv也被插桩了。

然后,本来是调用__afl_maybe_log进行初始化,但是初始化的过程又调用了__afl_maybe_log,而此时还未初始化完毕,于是又会进行初始化操作,就导致了程序执行流程的疯狂套娃。由于执行过程中,会保留寄存器到栈上,因此栈资源被疯狂使用,最终进程被操作系统杀掉。

为什么getenv会被插桩呢,getenv原本是c库函数,但是在sudo源代码中的env_hook.c中定义同名的getenv函数。
__dso_public char *getenv(const char *name){    char *val = NULL;     switch (process_hooks_getenv(name, &val)) {    case SUDO_HOOK_RET_STOP:        return val;    case SUDO_HOOK_RET_ERROR:        return NULL;    default:        return getenv_unhooked(name);    }}
而afl-gcc的插桩是通过解析编译过程中的.s汇编文件,在需要插桩的地方,添加插桩的汇编代码。
afl-as.h     "\n"    "/* --- AFL TRAMPOLINE (64-BIT) --- */\n"    "\n"    ".align 4\n"    "\n"    "leaq -(128+24)(%%rsp), %%rsp\n"    "movq %%rdx,  0(%%rsp)\n"    "movq %%rcx,  8(%%rsp)\n"    "movq %%rax, 16(%%rsp)\n"    "movq $0x%08x, %%rcx\n"    "call __afl_maybe_log\n"    "movq 16(%%rsp), %%rax\n"    "movq  8(%%rsp), %%rcx\n"    "movq  0(%%rsp), %%rdx\n"    "leaq (128+24)(%%rsp), %%rsp\n"    "\n"    "/* --- END --- */\n"    "\n";
因此afl-gcc在编译env_hook.c时,也无可避免的对getenv进行了插桩。而使用CC=afl-clang-fast编译,在llvm模式下对编译中间码IR进行插桩,就不会出现这个问题。

看雪ID:zackery

https://bbs.kanxue.com/user-home-970576.htm

*本文由看雪论坛 zackery 原创,转载请注明来自看雪社区

# 往期推荐

1、CVE-2016-7255提权漏洞学习笔记

2、从新生赛入门PWN

3、Chrome v8 Issue 1307610漏洞及其利用分析

4、CVE-2015-2546提权漏洞学习笔记

5、EXP编写学习之绕过GS

6、EXP编写学习之网络上的EXP

球分享

球点赞

球在看

点击阅读原文查看更多


文章来源: http://mp.weixin.qq.com/s?__biz=MjM5NTc2MDYxMw==&mid=2458495629&idx=1&sn=cd163140cd2a646d1c721aaa2078d841&chksm=b18e9a0786f913115a83c9b0921476507e40f2b654c8f74909a28cc2986296f5c4688ecbeeed#rd
如有侵权请联系:admin#unsafe.sh