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 许可协议。转载请注明出处!