CVE-2021-3156 漏洞,可以实现 sudo提权,在20.10上的危害也非常大。
漏洞环境
操作系统:ubuntu 18.04.1
sudo:1.8.21p2
glibc:2.27
exp:https://github.com/blasty/CVE-2021-3156.git
漏洞分析
CVE-2021-3156 ——sudo在处理单个反斜杠结尾的命令时,发生逻辑错误,存在堆溢出漏洞。当 sudo通过 -s 或 -i命令行选项在 shell模式下运行命令时,他将在命令参数中使用反斜杠转义特殊字符。但使用 -s或 -i标志运行 sudoedit时,实际上并未进行转义,从而导致堆溢出。
代码分析
sudo加上 -s选项会设置 MODE_SHELL,加上 -i选项会设置 MODE_SHELL和 MODE_LOGIN_SHELL。在 main()(sudo.c)函数中调用了parse_args(),parse_args()会连接所有命令行参数,并给元字符加反斜杠来重写 argv。
1 | //sudo.c |
parse_args()下面一段代码的主要功能是先判断是否启用了 -s或 -i的 MODE_SHELL,如果启用了就对参数前面加上反斜杠重写参数。
1 | //parse_args.c parse_args() |
在sudoers_policy_main()中调用了 set_cmnd()函数
1 | //sudoers.c sudoers_policy_main() |
在 set_cmnd()函数中,首先根据参数使用 strlen()函数计算了参数的 size,再调用 malloc()函数分配了 size大小的堆空间 user_args 。随后判断是否开启了 MODE_SHELL,如果开启了将会 连接命令行参数并存入堆空间 user_args。
1 | // sudoers.c set_cmnd() |
上面将命令行参数拷贝给堆空间的逻辑,如果命令行参数以1个反斜杠结尾例如 $ sudo -s / 112233 :
from[0]是反斜杠,from[1]是null结束符(非空格),满足如下要求if (from[0] == '\\' && !isspace((unsigned char)from[1]));- 所以,
from加1,指向null结束符; null结束符被拷贝到user_args堆缓冲区,from又加1,from指向了null结束符后面第1个字符(超出参数的边界,此时为 1);- 随后会继续循环将越界字符拷贝到
user_args堆缓冲区,发生了堆溢出漏洞
漏洞触发
上面指出,在 parse_args()会对启用了 -s或 -i的 MODE_SHELL和 MODE_RUN 的 sudo的参数加上 反斜杠 转义。
1 | //parse_args.c parse_args() |
而 set_cmnd()函数中触发堆溢出前,会判断是否启用了 MODE_SHELL 和 MODE_RUN、MODE_EDIT、MODE_CHECK 中的一个。那么就存在一个矛盾,如果要触发漏洞就需要启用 MODE_SHELL,但是如果启用了 MODE_SHELL,在 parse_args()函数中就会对所有参数转义,触发漏洞的 \,将会被转义为 \\,这样就无法触发漏洞了。
1 | //sudoers.c set_cmnd() |
所以这里 并没有使用 sudo,而是使用 sudoedit。原因在于如果使用 sudoedit,其还是会被软链接到使用 sudo命令,但是在 parse_args()函数中会自动设置 MODE_EDIT和不会重置 valid_flags,则 MODE_SHELL仍然在 valid_flags中 ,而且不会设置 MODE_RUN,这样就能跳过 parse_args()函数中转义参数的部分,同时满足 set_cmnd()函数中漏洞触发的部分。
1 | //parse_args.c parse_args() |
注意:这里还要解释一下该漏洞可利用的几个有利点(参考该文):
user_agrs堆空间的size是可控的,就是我们输入的 命令参数合并后的长度;我们溢出的内容是可控的,取决于我们输入的
\后的字符内容,该字符会全部被溢出写到堆块后;可以写
null字节到user_args,每个以单反斜杠结尾的命令行参数或环境变量,都能往user_args写1个null字节可以写连续多个
null,环境变量并不一定得是env_name=XXX这种形式,环境变量可以是字符串数组。C代码中用execve执行shell命令,环境变量设置2个连续的\即可插入2个连续的null字节。
1 | char *env[] = { "AAA", "\\", "\\", "BBB", NULL }; |
基础知识
在进行漏洞调试之前,首先需要对漏洞利用用到的各种知识有一个大概了解。
setlocale函数
漏洞利用会使用 setlocale函数来进行堆布局。_nl_global_locale 是一个全局变量
1 | extern struct __locale_struct _nl_global_locale attribute_hidden; |
主要关注其 _names成员,_name是一个数组,长度为13,下标值在代码中称为 category,不同 category值表示含义如下所示:
1 | //glibc-2.31\locale\locale.h |
除了 LC_ALL,如果其余值一样,比如都是 C.UTF-8,那么 LC_ALL的值也是 C.UTF-8。
如果不是完全一样,那么 LC_ALL的值就是 LC_CTYPE= ...;LC_NUMERIC=...;LC_IDENTIFICATION=....
setlocale(LC_ALL, “”)
1 | //glibc-2.31\locale\findlocale.c |
cloc_name的值来源是先读取环境变量LC_ALL,若没有再根据category的值去读取对应的环境变量,exp代码都是通过环境变量来控制clonc_name的,因此cloc_name的值最初就是来源于设置的环境变量,且cloc_name的值最终会拷贝到堆块,并将字符串指针存入_nl_global_locale._names- 函数
_nl_find_locale设置的是除LC_ALL以外的其他category的值,LC_ALL的值是由new_composite_name函数确定,逻辑已在上述中说明 - 设置 LC_ 的值是从尾部开始的,也就是
category的值是从12~0来遍历的(跳过6,即 LC_ALL)
setlocale(LC_ALL, "")函数主要就是会根据环境变量申请对应字符大小的堆块,并设置 _nl_global_locale.__names的值为该堆块指针。这里相当于存在一个 malloc操作。
setlocale(LC_ALL,NULL)
该函数将会返回 _nl_global_locale.__names中 LC_ALL对应的值
1 | //glibc-2.31\locale\setlocale.c |
setlocale(LC_ALL, “C”)
C是 _nl_global_locale.__names的默认值或初始值,在代码中以 _nl_c_name表示。 setlocale(LC_ALL,"C")执行的结果是将 _nl_global_locale.__names的值都变成指向字符串 C的指针
1 | //glibc-2.31\locale\findlocale.c |
setname()
该函数是用于设置 _nl_global_locale.__names的代码,此处的 name与上述代码的 name不是同一个变量,但是指向的字符串内容是一样的,并且 setname函数中的 name是指向堆的(除了 _nl_c_name是个全局变量),每次修改 _nl_global_locale.__names的值,会将原先的 chunk进行free.
1 | //glibc-2.31\locale\setlocale.c |
setlocale(LC_ALL, “XXX”)
如果”xxx”是一个正常值,那么就是会分析出 xxx是否存在分号来判断是设置全部 LC_的值为同一个还是各自设置的不一样。
若存在 ;,那么 xxx的结果应该是如 LC_CTYPE=...;LC_NUMERIC=...;...LC_IDENTIFICATION=...
如 setlocale函数中的代码:
1 | //in function setlocale |
下面解释在这篇文章中所说的,setlocale执行顺序:
https://p5.ssl.qhimg.com/t018eacccc24f75407b.png
setlocale(LC_ALL, ""),从环境变量中设置_nl_global_locale.__names,此时里面包含;x=x的形式的值,但不会被检测到saved_LC_ALL = setlocale(LC_ALL,NULL),返回 LC_ALL的值,其中包含了;x=x的形式的值setlocale(LC_ALL,"C"),将_nl_global_locale.__names中存储的堆区的字符串指针都释放了,值都变成了_nl_C_name的地址setlocal(LC_ALL, saved_LC_ALL),由于saved_LC_ALL中存在;x=x导致直接返回,因此未修改_nl_global_locale.__names- 再次执行
saved_LC_ALL = setlocale(LC_ALL, NULL),saved_LC_ALL="C",因此之后LC_ALL的值都会是C,因为后面不会再执行setlocale(LC_ALL, "")
service_user结构体的创建
可以先分析一下 service_user的调用链,来确定要具体的函数。这里需要重点关注的函数是 nss_parse_file\nss_getline\nss_parse_service_list。
nss_parse_file
解析的文件是 /etc/nsswitch.conf
1 | # /etc/nsswitch.conf |
nss_parse_file代码如下,
1 | static name_database * |
nss_getline
1 | //glibc-2.31\nss\nsswitch.c |
这个函数是对 nssswitch.conf文件中的每一行进行解析,这里会根据 name的长度申请对应大小的chunk,并为其创建 service_user结构体。
name_database_entry结构体声明如下:
1 | //glibc-2.31\nss\nsswitch.h |
在 nssswitch.conf中每一行冒号前面的单词会对应一个 name_database_entry结构体,结构体包含两个指针以及一个字符数组。两个指针固定为 0x10大小,当name的长度小于 8字节时,申请chunk也为 0x20。
nss_parse_service_list
1 | //glibc-2.31\nss\nsswitch.c |
该函数就是创建 service_user结构体的函数,service_user结构体内容如下:
1 | //glibc-2.31\nss\nsswitch.h |
不算 name字段,也就是 sizeof(service_user)=0x30,那么申请 chunk大小计算原理同 name_database_entry一样。
总结
通过对 setlocale和 service_user的分析,那么我们能够找到 malloc和 free原语,通过这两者的结合,可以方便我们很好的调整堆内存布局。
漏洞调试
漏洞触发点
最开始想自己编译一个 sudo,带符号的调试更方便。但是这样会导致漏洞执行不成功。所以就只有用系统自带的无符号的sudo进行调试。
首先使用如下命令运行 exp:
1 | sduo gdb --args ./sudo-hax-me-a-sandwich 0 |
随后,在 execve下断点:
1 | catch exec |
再运行该 continue。
随后,gdb会断在 execve函数。我们在下断点 b setlocale,在继续运行,此时就会停在 setlocale函数。该函数是我们在执行 sudo最开始时会调用的。我们 finish后,就能够进入 sudo的 main函数中。
1 | //sudo.c |
随后,我们需要进入 set_cmnd函数。这里我是先通过 sudo的main函数运行加载完 sudoers.so动态库后,下的地址断点。通过分析 sudoers.so的汇编,能够找到下图是上面分析的漏洞代码的开始处:
1 | //sudoers.so |

直接在 malloc()函数的地址处下断点,就能够得到 user_args堆块的地址,如下图所示:

提权方法
这里需要先介绍一下该漏洞所使用的提权方法,先了解一个结构体 service_user 和一个函数 nss_load_library。在 service_user结构体中指定了要动态加载的动态链接库,如果能够修改 service_user->name,那么就能指定加载伪造的动态链接库。而 nss_load_library函数就是加载动态链接库的函数,其会调用 __libc_dlopen打开动态库。
1 | typedef struct service_library |
这里需要注意 nss_load_library需要满足 ni->library != null和ni->library->lib_handle == NULL才能加载新库。
也就是我们需要将 ni->library覆盖为 null,将 ni->name覆盖我们自己伪造的库名字,且伪造的库文件名必须是 libnss_xxx.so。
那么,难点就是如何仅通过一个 堆溢出去覆盖一个 service_user结构。这里的方法是,在一个 service_user结构体前面释放一个堆块,然后 分配 user_args分配到该堆块,随后使用堆溢出覆盖 service_user结构体。
然后,使用 search -s systemd [heap]命令搜索 堆块中的systemd字符串。来定位 service_user结构体的位置,如下所示,可以看到 0x5618621b5450处是一个 service_user结构体。

而,通过malloc分配的 0x80 tcache位于 service_user结构体之前,相差 0x100。

可以看到 service_user偏移 0x30处 是 systemd,而我们通过堆溢出可以看到我们将该结构体中的 name覆盖为 X/POP_SH3LLZ_(这里的 library在覆盖完后应该为 Null,但是我这里截图是在执行了 nss_new_service所截图,所以这里 library已经有了值)。


将离 user_args最近的 service_user结构体覆盖后,程序会调用 getgrgid()函数,最后去调用 nss_load_library。
1 | //sudoers.so |
在 nss_load_libray中,构造了满足调用新动态链接库的条件,所以会通过 ni->name构造动态链接库的名字 shlib_name为 libnss_X/POP_SH3LLZ_ .so.2。最终会通过 __libc_dlopen(shlib_name)打开。

而 libnss_X/POP_SH3LLZ_ .so.2中只含有一个 init函数,该函数的作用就是id(0)调用 execv('/bin/sh'),自此完成了提权。
1 | static void _init(void) { |
堆布局
上面我们已经知道通过给 user_args分配堆块,利用其堆溢出覆盖其后面的 service_user堆块。那么这里有一个很重要的点,我们如何将需要利用的 service_user堆块放到 user_args堆块后,且让两者之间相隔较近。这就是exp中最精妙的堆布局部分。
setlocale(LC_ALL,””)
首先进入 sudo.c就会执行 setlocale(LC_ALL,""),根据上面分析,这里是会从环境变量中获取值,从而分别申请堆块,申请堆块大小与环境变量中各个值有关。申请完成后,可以在 _nl_global_locale.__names中查看。
1 | pwndbg> p _nl_global_locale.__names |
1 | LC_CTYPE = 0x55c6a770c990 |
setlocale(LC_ALL,NULL)
随后执行 setlocale(LC_ALL,NULL),会申请一个新的堆块,用于存储当前 _nl_global_locale.__names中的值。堆块的大小,如果 _nl_global_locale.__names中的值相同,则申请一个堆块,存储一次即可;如果不相同,则需要申请大堆块将不同的值都存储进去。
1 | pwndbg> bt |
1 | pwndbg> p _nl_global_locale.__names[category] |
setlocale(LC_ALL,”C”)
然后执行 setlocale(LC_ALL,"C"),会释放当前 _nl_global_locale.__names中的堆块,总共释放11个堆块,然后将 _nl_global_locale.__names中的值指向全局变量 C.
1 | pwndbg> bt |
1 | pwndbg> p _nl_global_locale.__names |
setlocale(LC_ALL,saved_LC_ALL)
然后执行 setlocale(LC_ALL,saved_LC_ALL),将保存的值又重新赋给 _nl_global_locale.__names。这里是否需要重新申请堆块,以及更新 _nl_global_locale.__names中的值,需要按照上述分析的要求。
1 | pwndbg> bt |
1 | pwndbg> p _nl_global_locale.__names |
1 | pwndbg> bin |
此时,我们在 unsortedbin中就已经有了 一些空闲堆块。
setlocale(LC_ALL,NULL)
再次执行 setlocale(LC_ALL,NULL),获取当前的值
setlocale(LC_ALL,”C”)
最后,再次释放当前 _nl_global_locale.__names中的堆块,此时可以重点关注一个 0x80的堆块,和下面不位于 tcache中的堆块。我们可以看到该 0x80的堆块位于 samllbins和 largebins的上方。其此时没有 0x40的空闲堆块。
1 | pwndbg> bin |
nss_parse getline
为读取/etc/nsswitch.conf每行数据申请一个chunk,而该 chunk的大小正好是 0x80,也就是会申请 tcache中的 0x80堆块。可以看到此时的 line的值为 文件中的第一行。
1 | pwndbg> p line |
passwd行申请name_database_entry
当读取非注释内容时,首先会申请 name_database_entry,该结构体初始为 0x20,对应的值为 文件中的第一列,例如 passwd
1 | pwndbg> p sizeof(name_database_entry)+len |
申请 service_user
然后会为文件的第2列申请 service_user,该结构体大小初始为 0x40。而由于之前 tcache中没有 0x40的堆块,这里的 service_user结构体会从 samllbin或者 largebin中申请。而申请的堆块 肯定位于 0x80堆块的下方。
1 | pwndbg> p *(struct service_user*)0x55c6a770d390 |
我们重点需要关注的是 group中的 systemd,如下所示:
1 | pwndbg> p *(struct service_user*)0x55d388d63470 |
因为,我们后续使用 user_args申请的堆溢出就是要覆盖该堆块。这里还需要注意 我们之前 getline中申请的 0x80堆块在使用完毕后,会被释放再次进入 tcache中。
malloc(user_args)
当我们分配 user_args是,总体的命令如下:
1 | env -i LC_ALL=C.UTF-8@+"C"*212 sudoedit -s 56*'A'+'\' '\' 54*'B'+'\' |
NewArgv[1] = 56‘A’+’\’,总长度为58。NewArgv[2] = ‘\’,总长度为2。NewArgv[3] = 54‘B’+’\’,总长度为56。user_args需要的长度就是58+2+56=0x74(与调试情况一致)。
所以会申请 0x80的堆块,而我们之前 有一块 0x80的堆块位于 tcache中,且位于 group systemd堆块的上方。所以这里正好将这个 0x80的堆块分配出来。随后利用堆溢出覆盖 group systemd堆块。
覆盖偏移
这里还需要清楚,我们如何精准覆盖 group systemed中的 name。当我们知道 user_args与 group systemd堆块之间的差值 offset后,exp中各个偏移 计算方法如下:
smash_len_a和 smash_len_b等于 size(user_args)/2
而 null_stomp_len即为 offset/3。
因为,我们的漏洞是没遇到一次 /,就会将后面的参数再次复制一遍,当我们参数如下:
1 | "sudoedit", "-s", smash_a, "\\", smash_b, NULL, envp |
当复制 smash_a时,由于 smash_a以 \\结尾,所以会将 \\,smash_b,NULL,envp复制一遍;
当继续遍历 \\时,又会将 smash_b,NULL,envp复制一遍;
再遍历 smash_b时,又会将NULL,envp复制一遍。
最后相当于 smash_b复制了两遍,envp复制了三遍。
参考文献
【kernel exploit】CVE-2021-3156 sudo漏洞分析与利用
Heap-based buffer overflow in Sudo (CVE-2021-3156)
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/02/01/cve-2021-3156调试分析/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!