CVE-2021-3156_二进制漏洞高阶利用:堆风水
CVE-2021-3156
漏洞描述
1.9.5p2之前的Sudo包含一个off-by-one错误,该错误可能导致基于堆的缓冲区溢出,这允许通过sudoedit -s和以单个反斜杠字符结尾的命令行参数将权限提升到root。关于此漏洞的详细信息请参阅下表。关于此漏洞的更多信息,请参阅阿里云漏洞库和NVD:
| 描述项 | 具体值 |
|---|---|
| CVE编号 | CVE-2021-3156 |
| NVD评分 | 7.8 |
| 披露时间 | 2021-01-27 |
| 漏洞类型 | 堆缓冲区溢出、Off-by-one错误、跨界内存写 |
| 漏洞危害 | 本地提权 |
| 影响范围 | 1.8.2<sudo<1.8.31p2、1.9.0<sudo<1.9.5p1 |
POC 验证
1 | |

堆溢出点
溢出函数(sudo-1.8.21/pluguns/sudoers.c/set_cmnd)
以下代码错误地处理了以 \ 结尾的参数,导致循环复制时越过了字符串边界(\0),从而将后续内存中的大量数据(命令行中生成的80个’A’)写入了 user_args 的缓冲区,引发了溢出。
1 | |
漏洞函数讲解:
外层 for 循环 - 第一次迭代 (处理参数 <font style="color:rgb(0, 0, 0);">"-s"</font>)
<font style="color:rgb(0, 0, 0);">from = *av;</font>-><font style="color:rgb(0, 0, 0);">from</font>指向<font style="color:rgb(0, 0, 0);">"-s"</font>。- 内层
<font style="color:rgb(0, 0, 0);">while (*from)</font>循环开始:- 它会一个一个地复制
<font style="color:rgb(0, 0, 0);">'-'</font>和<font style="color:rgb(0, 0, 0);">'s'</font>到<font style="color:rgb(0, 0, 0);">user_args</font>缓冲区。 - 这个过程是正常的,没有触发任何特殊逻辑。
- 循环结束后,代码会向
<font style="color:rgb(0, 0, 0);">user_args</font>追加一个空格。
- 它会一个一个地复制
- 外层
<font style="color:rgb(0, 0, 0);">for</font>循环结束本次迭代,<font style="color:rgb(0, 0, 0);">av++</font>,现在<font style="color:rgb(0, 0, 0);">av</font>指向<font style="color:rgb(0, 0, 0);">NewArgv[2]</font>,也就是<font style="color:rgb(0, 0, 0);">\</font>。
<font style="color:rgb(0, 0, 0);">user_args</font> 缓冲区现在的内容: <font style="color:rgb(0, 0, 0);">"-s "</font>
外层 for 循环 - 第二次迭代 (处理参数 <font style="color:rgb(0, 0, 0);">\</font> - 漏洞触发点!)
<font style="color:rgb(0, 0, 0);">from = *av;</font>-><font style="color:rgb(0, 0, 0);">from</font>现在指向<font style="color:rgb(0, 0, 0);">\</font>。- 内层
<font style="color:rgb(0, 0, 0);">while (*from)</font>循环开始。当前<font style="color:rgb(0, 0, 0);">*from</font>的值是<font style="color:rgb(0, 0, 0);">'\\'</font>,不是<font style="color:rgb(0, 0, 0);">\0</font>,所以循环体执行。 - 进入
**<font style="color:rgb(0, 0, 0);">if</font>**判断:**<font style="color:rgb(0, 0, 0);">if (from[0] == '\\' && !isspace((unsigned char)from[1]))</font>**<font style="color:rgb(0, 0, 0);">from[0] == '\\'</font>-> True。当前字符确实是反斜杠。<font style="color:rgb(0, 0, 0);">!isspace((unsigned char)from[1])</font>-> 这是最关键的一步!<font style="color:rgb(0, 0, 0);">from[1]</font>是什么?它是在内存中紧跟在<font style="color:rgb(0, 0, 0);">\</font>后面的那个字符。根据我们上面的内存布局图,它就是字符串<font style="color:rgb(0, 0, 0);">\</font>的结束符<font style="color:rgb(0, 0, 0);">\0</font>。<font style="color:rgb(0, 0, 0);">isspace('\0')</font>这个函数会返回<font style="color:rgb(0, 0, 0);">false</font>(因为空字符不被视为空格、制表符等)。- 所以
<font style="color:rgb(0, 0, 0);">!isspace(...)</font>的结果是<font style="color:rgb(0, 0, 0);">!false</font>-> True。
- 整个
<font style="color:rgb(0, 0, 0);">if</font>条件<font style="color:rgb(0, 0, 0);">(True && True)</font>结果为 True。
**<font style="color:rgb(0, 0, 0);">if</font>**语句体被执行:**<font style="color:rgb(0, 0, 0);">from++;</font>**<font style="color:rgb(0, 0, 0);">from</font>指针向后移动了一位。它越过了<font style="color:rgb(0, 0, 0);">\</font>字符串的结束符<font style="color:rgb(0, 0, 0);">\0</font>,现在指向了下一个字符串(那80个’A’)的第一个<font style="color:rgb(0, 0, 0);">'A'</font>!
- 执行
**<font style="color:rgb(0, 0, 0);">*to++ = *from++;</font>**<font style="color:rgb(0, 0, 0);">*from</font>当前是<font style="color:rgb(0, 0, 0);">'A'</font>。这个<font style="color:rgb(0, 0, 0);">'A'</font>被复制到<font style="color:rgb(0, 0, 0);">user_args</font>缓冲区。<font style="color:rgb(0, 0, 0);">to</font>和<font style="color:rgb(0, 0, 0);">from</font>指针都向后移动。
- 内层
**<font style="color:rgb(0, 0, 0);">while</font>**循环继续- 下一次循环的条件检查
<font style="color:rgb(0, 0, 0);">while (*from)</font>:<font style="color:rgb(0, 0, 0);">*from</font>现在是第二个<font style="color:rgb(0, 0, 0);">'A'</font>,不是<font style="color:rgb(0, 0, 0);">\0</font>,循环继续! - 这个
<font style="color:rgb(0, 0, 0);">while</font>循环根本不会停止!它会一直复制,把所有的80个’A’都复制到<font style="color:rgb(0, 0, 0);">user_args</font>缓冲区中。 - 不仅如此,在复制完80个’A’和它的
<font style="color:rgb(0, 0, 0);">\0</font>之后,如果<font style="color:rgb(0, 0, 0);">from</font>指针后面还有其他数据(比如环境变量),它会继续复制下去,直到在内存中遇到一个<font style="color:rgb(0, 0, 0);">\0</font>字节为止。
- 下一次循环的条件检查
如何进入set_cmnd 漏洞函数
要触发漏洞,我们需要sudo处于shell模式(例如sudo -s),这样set_cmnd()里的漏洞代码才会被执行。
若直接使用 sudo -s
1 | |
使用 sudo -s 会导致 flag 即设置MODE_SHELL又设置MODE_RUN,会进入parse_args函数,该函数会将所有非字母数字的字符前方增加一个’',会把我们用于触发漏洞的\转义成\,这样漏洞就无法触发了。
绕过 parse_args 转义函数
1 | |
若使用sudoedit调用,程序会给mode设置MODE_EDIT,同时-s 参数会设置 MODE_SHELL。重要的是,MODE_RUN 不会被设置,程序不会经过parse_args 函数。
可利用堆块分析
现在程序中存在一个明显的堆溢出漏洞,因此梳理一下堆溢出如何利用:
- 找到一个堆块,该堆块的值会影响程序的执行流程,称之为可利用堆块
- 找到可以随意控制堆块位置的操作,将漏洞函数申请的堆块部署在可利用堆块的上方,当堆溢出发生时,可以将可利用堆块的值改写为我们构造的值

nss 分析(会调用不同的动态链接库)
nss(Name Service Switch)用于解析和获取不同类型的名称信息,例如如何通过用名称去获取用户信息,在sudo需要获取用户信息时则需要调用nss。
重点:在使用 nss 去获取信息的时候,其实是通过不同的动态链接库去执行相应的行为,这些库的文件名则存在于/etc/nsswitch.conf的配置文件中

以下为动态连接库的文件,比如 passwd 就会用到 libnss_compat.so;hosts 会用到 libnss_dns.so 和 libnss_files.so

在service_table 链表中装有 nss 中的 passwd、group、shadow、gshadow 等节点
关键代码
nss 在加载这些动态链接库的时候需要依赖 nss_load_library 函数,首先查看漏洞利用的关键代码:
1 | |
关键利用点:
我们的目的是加载一个自己写的恶意的 libc 库,这个库 libc 库中包含提升到 root 权限的相关操作。
恶意的 libc 库需要被__libc_dlopen 函数触发,调用到__libc_dlopen 函数需要 service_use 结构体中的动态库句柄(ni->library->lib_handle)为 null,如果我们可以通过堆溢出到 ni 所在的堆块,将 ni->library 覆盖为 0 即可(因为 ni->library 为 0 会执行第一个 if,执行第一个 if 会调用nss_new_service 对 library 进行初始化,初始化的 lib_handle 必然为 null)。
1 | |
漏洞利用梳理:
在用户执行sudoedit命令的时候,由于set_cmnd函数校验不严格导致用户输入可以绕过字符边界校验造成user_args堆溢出,接着我们分析到sudoedit在解析的时候会调用nss相关函数,而nss其实就是通过加载不同的动态链接库执行相应操作,那么我们可以通过user_args堆溢出覆盖service_user->library进行库描述结构体初始化操作,并且修改service_user->name来构造一个恶意的动态链接库,这个恶意的动态链接库会被__libc_dlopen函数加载并提权至root。
现在我们已经找到了可以利用的堆块,接下来我们需要构造堆布局,将可以发生溢出的堆(user_argv)布局到可利用堆块堆(service_user)的低地址处。
堆布局分析
已知使用环境变量LC_ALL 在setlocale 函数中完成的堆布局,经过分析,setlocale 中有非常多的堆申请和释放操作,所以这里我们重点关注我们可操作的部分。
glibc/locale/setlocale.c : 218
1 | |
setlocale 函数是关于一些语言环境有关的,相关环境变量参数有以下几种:
1 | |
根据传入参数 category 的值来去环境变量中寻找对应的参数采取行动。在sudo 中使用的是 setlocale(LC_ALL,””); 当传入参数是LC_ALL 时,会从 LC_IDENTIFICATION 开始向前遍历所有的变量。对于每一个调用 _nl_find_locale 函数,这个函数里面比较复杂,但返回的 newnames[category] 其实就是对应环境变量的值,会在接下来调用strdup 函数将该字符串拷贝到堆上。由于传入的是LC_ALL ,那么会生成一个对应的字符串数组,接下来会和全局变量默认值进行一次校验,如果校验失败,那么就会将其释放(很容易构造出失败的输入)。
换言之,我们可以通过操作在这里进行x次strdup 的堆申请与x 次的free 刚申请的chunk。
根据输入的环境变量的值进行strdup 操作,最后会将strdup 生成的多个chunk 一口气free 掉。
漏洞利用梳理:
- setlocale()函数的行为可以被环境变量(如LC_ALL, LC_CTYPE等)影响。通过设置大量特定长度的环境变量,可以在service_user结构体被分配之前,在堆上进行大量的malloc和free操作。这就像在下棋前预先排布好棋子,通过这些操作,在堆内存中“雕刻”出我们想要的布局:即在即将分配service_user结构体的位置旁边,留下一个大小合适的、空闲的内存块(chunk)。
- 当轮到漏洞函数set_cmnd()为user_args申请内存时,我们通过控制命令行参数的长度,使其申请的内存大小恰好等于我们预留的那个空闲内存块的大小。ptmalloc分配器就会把这块内存分配给user_args。如此一来,user_args的缓冲区就完美地坐落在了service_user结构体的前面,接下来的 user_args 堆精确溢出覆盖 service_user 结构体的 service_user->library 和 service_user->name
如果能够在service_table初始化之前,在堆的前面留下多个0x20大小的堆,并在较远处留下0x40大小的堆,就正好将name_database_entry以及service_user结构体分开较大距离,方便溢出,并在user_args分配前将靠近service_user前面的堆释放,留给user_args获取,这样就能完美构造堆溢出的条件。
EXP 实战
堆布局思路

- 由于1246chunk 都是0x20大小的chunk,空间太小,不关注。
- 关注 3,5 号chunk 和7号chunk之间如何插入一个大小特别的0xX0 的chunk(不会在 user_args chunk 申请之前被消耗掉)。大致如图:

整个堆布局过程中参与的chunk 都是setlocale 申请的内存
- 所以最终我们的思路就是在setlocale申请两个0x40 大小的chunk,再申请一个0xa0大小的chunk(即上面提到的0xX0的chuank),再申请一个0x40的chunk,这样会按照相反的顺序释放,然后再nss_parse_file 函数中会按照相同的顺序申请,并且,在nss_parse_file 函数中 getline 会申请0x80 的chunk 将我们预留的 0xa0 chunk “保护” 起来
计算
计算被移除 chunk 和溢出 chunk 之间的距离

0x5576b5ac7000-0x5576b5ac69b0=0x650
可以将输入参数总共0xa0 分成两个部分 x 个<font style="color:rgb(31, 35, 40);background-color:rgba(129, 139, 152, 0.12);">\\</font> (每个是一个独立字符串,占两个字节) 和一个<font style="color:rgb(31, 35, 40);background-color:rgba(129, 139, 152, 0.12);">'a' * y</font> (y个字符a是一个字符串,占y+1字节),2x+y = 0xa0-0x10 (这里0xa0-0x10是因为我们的 user_args chunk 是0xa0大小,但实际申请需要减 0x10),最后的命令形如 :
1 | |
计算x, y使:
1 | |
第一个等式的原理就是,由于输入有多个 <font style="color:rgb(31, 35, 40);background-color:rgba(129, 139, 152, 0.12);">\\</font> 所以每次拷贝都会溢出,每次溢出会比上次少1字节,所以等差数列相加。化简得到:
1 | |
解得:
1 | |
最后通过sudoedit 参数可以溢出的长度是0x5f9,剩下的部分用环境变量中的 <font style="color:rgb(31, 35, 40);background-color:rgba(129, 139, 152, 0.12);">\\</font> 补齐即可。环境变量只会拷贝一次。最后覆盖结构体的时候注意,so名字符串在结构体偏移0x30的位置,字符串前的结构体元素都要覆盖成 <font style="color:rgb(31, 35, 40);background-color:rgba(129, 139, 152, 0.12);">\x00</font> 。
最终 EXP
1 | |
docker 复现
1 | |
命令行美化:
python3 -c “import pty;pty.spawn(‘/bin/bash’)”
参考:
CTF Wiki:https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/introduction/
CVE-2021-3165:
https://a1ex.online/2021/02/01/cve-2021-3156%E8%B0%83%E8%AF%95%E5%88%86%E6%9E%90/
https://blog.csdn.net/IronmanJay/article/details/139379712
https://github.com/chenaotian/CVE-2021-3156?tab=readme-ov-file
https://www.52pojie.cn/thread-1439734-1-1.html
验证过程:https://blog.csdn.net/IronmanJay/article/details/139379712