环境配置
下载源码,源代码下载连接:https://www.sudo.ws/
1 2 | $ wget https: / / www.sudo.ws / dist / sudo - 1.8 . 21.tar .gz
$ tar xzf sudo - 1.8 . 21.tar .gz
|
须安装AFL++,可以使用官方docker镜像,如果已经在本地安装,也可直接使用
1 2 3 4 5 6 7 | $ docker pull aflplusplus / aflplusplus
$ docker run - ti - v / location / of / your / target: / src \
[ - v / location / of / your / afl_src / :AFLplusplus \]
aflplusplus / aflplusplus / bin / bash
|
$ cd /src/sudo-1.8.21
查看sudo源代码目录
poc复现
先不进行插桩编译,使用原版安装,测试一下poc是否符合预期。
1 2 3 4 5 | $ . / configure - - prefix = / src / origin_compile
$ make
$ make install
$ cd / src / origin_compile / bin
$ . / sudoedit - s '\' aaaaaaaaaaaaaaa
|
可以看到成功产生了一个崩溃
我们接下来的任务就是将该崩溃用afl复现出来。
测试思路
sudo程序具有SUID,普通用户通过输入密码,利用sudo执行命令,获得暂时的权力提升。对于sudo的测试,需要通过非预期的输入,致使sudo程序产生崩溃,进而找到漏洞的利用点。sudo的输入有两处,执行参数与密码输入,本文暂不考虑密码输入引发的异常。测试的场景为,非特权用户输入恶意构造程序执行参数,引起sudo程序崩溃。
编译前处理
设置用户id
sudo程序由root用户和其他用户启动的表现是不同的。sudo的所有权是root,但却是由普通用户调用的。然而我们在使用afl模糊测试时,使用的是root身份,这不能完成测试的需求,虽然这并不影响本CVE的复现。因此我们需要使sudo程序即使以root身份运行,但让其认为是普通用户执行的。这可以通过查看sudo调用getuid()的代码来实现,只需将值硬编码为1000,这是一个普通用户的用户ID。
这个补丁,可以通过在源代码文件夹中搜索getuid
,将getuid()
和getgid()
修改为1000即可。
1 2 3 4 5 6 7 8 9 10 11 12 | - - - . / 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();
|
argv Fuzzing
afl原生并不支持对argv参数进行Fuzzing。afl的fuzzing模式一般是将变异得到的文件,重定向到程序,作为程序的标准输入,然后运行被测程序,等待程序结束、崩溃或超时。注意:afl的启动命令中可以使用 @@ 作为占用符,但其作用并不是对占位符的位置进行fuzzing,@@占位符表示此处应有文件的输入,且这个输入的文件应是fuzzing得到的,由afl自动填入。
为了实现对argv的fuzzing,我们可以将/aflplusplus/utils/argv_fuzzing/argv-fuzz-inl.h复制到sudo的源代码目录../sudo/src下,并对sudo.c进行相应的补丁。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | + + + 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.
* /
+
@@ - 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | do { \
\
argv = afl_init_argv(&argc); \
\
} while ( 0 )
······
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用户管理引起的混乱,我还是建议在本机上编译,调试。注意一定要设置好安装目录,防止破坏主机环境。
1 2 3 4 | $ make clean
$ . / configure - - prefix = ~ / sudo_gdb_test_auth - - disable - shared
$ make
$ make install
|
安装完成后,找到对应的sudo文件,确保其用户属于root,并设置SUID。
1 2 3 | $ sudo chown root:root . / sudo
$ sudo chmod u + s . / sudo
$ ls - l
|
使用非root用户测试运行$ ./sudo ls
会停留在输入密码处。
1 2 | $ . / sudo ls
Password:(expecting input )
|
保留当前窗口,再开一个终端。
1 2 | $ ps - ef |grep sudo
root 206957 206889 0 19 : 15 pts / 4 00 : 00 : 00 . / sudo ls
|
必须使用root权限调试,$ sudo gdb attach <pid>
。如果成功的话,会断在read
处。
我们查看backtrace。显然,可以考虑将verify_user
优化掉。
我们找到源代码../auth/sudo_auth.c处,直接让函数verify_user
返回。
非预期运行
我们可以看到,sudoedit只是sudo的一个符号链接。
1 2 3 | - rwsr - xr - x 1 root root 1647712 Jan 23 06 : 50 sudo
lrwxrwxrwx 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代码片段。
1 2 3 4 5 6 7 8 9 | $ echo - ne "sudoedit\0id\0\0" | . / sudo
usage: sudo - h | - K | - k | - V
usage: 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
初始化程序名。
1 2 3 4 5 6 7 8 9 10 | int
os_init_common( int argc, char * argv[], char * envp[])
{
initprogname(argc > 0 ? argv[ 0 ] : "sudo" );
preload_static_symbols();
gc_init();
return 0 ;
}
|
但是initprogname
会优先使用__progname
宏获取程序名,而不是传递进来的argv[0]
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | void
initprogname(const char * name)
{
extern const char * __progname;
if (__progname ! = NULL && * __progname ! = '\0' )
progname = __progname;
else
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
的部分删除。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | - - - .. / 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)
{
-
- extern const char * __progname;
-
- if (__progname ! = NULL && * __progname ! = '\0' )
- progname = __progname;
- else
-
if ((progname = strrchr(name, '/' )) ! = NULL) {
progname + + ;
} else {
|
编译
再完成上述四个补丁后,我们再次编译得到最终文件。
重新开一个容器,将最终代码放入/src,创建/fuzz,同时设置好主机跟踪目录,便于观察fuzz情况及其进展。
1 2 3 4 | $ sudo docker run - it \
- v path / to / all_change: / src \
- v path / to / trace_paper_fuzz: / fuzz\
aflplusplus / aflplusplus / bin / bash
|
在docker中添加uid为1000的普通用户
1 | $ useradd - u 1000 aflfuzzer
|
使用afl-clang-fast进行插桩编译,使用afl-gcc编译得到的文件无法运行,相关分析我放到文末。
1 2 3 4 5 | $ cd / src
$ make clean
$ CFLAGS = "-g" LDFLAGS = "-g" CC = afl - clang - fast . / configure - - prefix = / fuzz / release - - disable - shared
$ make
$ make install
|
编译成功后,再次验证poc能够引起崩溃。
1 2 | echo - ne "sudoedit\x00-s\x00\x5c\x00aaaaaaaaaaaaaaaaaaaaaaaaaa\x00\x00" | . / sudo
|
Fuzzing
经历总总曲折,总算可以开始测试了。
1 2 3 4 5 6 7 | $ cd / fuzz
$ mkdir { input ,output}
$ echo - ne "sudo\x00id\x00\x00" > input / payload1
$ echo - ne "sudoedit\x00id\x00\x00" > input / payload2
afl - fuzz - i input / - o output / - D - M Master / fuzz / release / bin / sudo
afl - fuzz - i input / - o output / - D - S slave1 / fuzz / release / bin / sudo
|
可以发现,很快就能获得一个崩溃。
查看崩溃,符合预期
afl-gcc失败原因
在进行插桩编译的过程中,笔者一开始使用的是afl-gcc编译,但是编译出来的文件运行会直接崩溃。查找相关资料推荐使用llvm模式编译。为了弄清楚afl-gcc编译的文件失败的原因,笔者对此做出必要探索。
使用afl-gcc编译
1 2 3 4 5 | $ 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
函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | __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汇编文件,在需要插桩的地方,添加插桩的汇编代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | 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进行插桩,就不会出现这个问题。
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界
最后于 2023-1-30 02:36
被zackery编辑
,原因: 部分图片显示异常