堆漏洞在二进制中是非常常见的,之前一直觉得CTF-Pwn的堆题没有任何实战价值,之后开始实战漏洞挖掘后发现大部分挖出来的奔溃样本都是堆内存相关的,这就引发了思考,堆内存触发的奔溃大部分都只能触发溢出到底该如何利用呢?但是我觉得这个应该是我自己的知识范围不够,之后去分析了很多其他的堆相关漏洞利用,但往往利用思路都不能够通用,所以出了这篇文章记一次sudo堆溢出如何逆向分析出提权思路,旨在以 sudo 提权漏洞为例,进行从漏洞挖掘到提权思路的全链路分析,为漏洞研究与防护提供更全面的参考。 本文只在提供一个在实战中堆溢出漏洞的可利用思路,网上的其他文章都只是零散的将 POC 进行分析,并没有解析出其中可以通用的堆操作手法,比如利用setlocale
构造堆风水等等。分析和编写过程中可能有疏漏,如有问题请各位读者不吝指正
一,文章概述 二,sudo调研与Fuzz方式考察 三,修改sudo源码与进行AFL++模糊测试:主要是学习命令行参数fuzz 四,收集奔溃样本与分析奔溃原因:使用pwngdb动态调试定位堆溢出漏洞成因 五,奔溃样本逆向分析进行可利用性评估:通过分析整个程序的堆操作,思考提权和堆构造思路 六,劫持NSS服务用户对象实现提权原理:有思路后具体分析提权原理 七,解析setlocale能够进行堆布局原理:整理调试弄清setlocale的堆构造规则 八,基于已有的思路构造可以利用的POC
CTF-Pwn的堆题和实战堆漏洞的相关联系:Pwn的堆题提供了,操控堆结构的明确接口增删改查都有,但是在实际漏洞利用的时候是不会提供这些接口的,但是程序中必然会有堆操作,需要了解整个Linux机制,和逆向分析程序中的每一个堆操作,来筛选出可以控制的增删改查的堆操作!进而提升堆漏洞的可用性和实际价值!
sudo(superuser do)是 Linux 和 Unix 系统中常用的命令,可以让普通用户以root权限执行命令 。它通过配置文件 /etc/sudoers 来管理用户权限。普通用户需要安装软件包,就可以通过 sudo 命令以 root 权限调用apt,无需切换到 root 账户,非常便捷。
当用户执行 sudo 命令时,会检查用户是否在 sudoers 文件中有相应的权限配置。有权限,sudo 会提示用户输入自己的密码,验证通过后,sudo 会切换到root身份,执行后续命令。执行完毕后,再恢复到原用户身份。
而常规的Fuzz测试是通过向程序输入大量随机或变异数据,检测程序是否会出现崩溃、内存泄漏等异常情况。一般的输入方式是通过stdin或者文件输入,但是sudo的使用方式大部分是通过命令行参数实现的,且sudo的常规使用逻辑也会和Fuzz 存在诸多冲突之处: 1.密码输入问题 :sudo 执行需要输入密码,在自动化的 Fuzz 测试过程中,若等待密码输入,程序会挂起,导致测试中断。 2.命令行参数处理 :向 sudo 导入模糊测试数据时,需要考虑如何正确传递参数,避免因参数格式错误导致 sudo 无法执行或执行错误。 3.参数控制难题 :sudo 测试过程中的第一个参数(通常是要执行的命令)需要精准控制,若参数错误,可能无法触发有效测试或引发系统错误。 4.程序内部实现复杂性 :sudo 内部不仅实现了 sudo 功能,sudoedit 工具的代码实现也在 sudo 程序内部,通过软链接替换启动程序名称来区分功能。因此,进行 Fuzz 测试时,必须指定正确的启动程序名称,才能对 sudo 及其相关功能进行全面检测。
一、argv_fuzz 功能概述 传统的 AFL 工具支持对文件输入或标准输入进行模糊测试,而 AFL++ 的argv_fuzz工具,位于官方 GitHub 仓库的utils/argv_fuzzing目录下AFLplusplus/utils/argv_fuzzing at stable · AFLplusplus/AFLplusplus (github.com) ,专注于对通过命令行界面传递给程序的参数进行模糊测试。这意味着它能够模拟各种不同的命令行参数组合,帮助我们发现程序在处理参数过程中可能存在的漏洞. 在实际应用中,例如在对 Curl 程序的审核中,通过对argv的模糊测试,成功挖掘出了程序中的重大安全隐患 ,案例:Curl 审核:一句玩笑话引出的重大发现_测试_参数_argv
当我们拥有目标程序的源代码时,可以借助argv-fuzz-inl.h头文件中的宏,改变程序的行为,使其从标准输入构建argv,进而实现对命令行参数的模糊测试。准备工作 :首先,在源文件中包含argv-fuzz-inl.h头文件,即添加#include "argv-fuzz-inl.h"语句。接着,找到程序中负责解析参数的主函数,通常形式为int main(int argc, char **argv) 。
宏的使用 :在主函数开头附近,根据需求选择合适的宏来初始化argv。若无需保留argv[0]
(程序名称),可使用AFL_INIT_ARGV();;若希望保留argv[0]
,则使用AFL_INIT_SET0("prog_name");,其中"prog_name"需替换为实际的程序名称。具体使用示例可参考argv_fuzz_demo.c文件(argv_fuzz_demo.c )。
在进行针对 sudo 的 Fuzz 测试前,对 sudo 源码的修改以及相关准备工作起着关键作用。能让后续测试流程更为顺畅地开展,另一方面,合理地准备如控制测试过程中的参数、准备模糊测试种子样本以及搭建好合适的测试环境(像 Docker 容器环境、配置编译开启 Asan 模式等),都是为了能更全面、高效且准确地对 sudo 及其相关功能进行模糊测试,从而提高目标程序的覆盖率以及获取更多有价值的测试样本。
为解决 sudo 密码输入导致程序挂起问题,直接将sudo的密码验证函数patch掉,想要利用sudo的漏洞肯定是要站在无密码的角度来的,不然漏洞价值不高,将verify_user的返回值为0,表示密码错误!
默认返回0确保密码错误!
通过编写测试用例,明确规定 sudo 测试过程中第一个参数的取值范围和格式。在 Fuzz 测试工具中设置参数生成规则,确保生成的参数符合要求。可以结合正则表达式等方式,对参数进行合法性校验,保证测试的有效性。
添加头文件:#include "/AFLplusplus/utils/argv_fuzzing/argv-fuzz-inl.h"
在进行 Fuzz 测试前,需要根据测试目标,明确指定启动程序名称。可以在测试脚本中设置变量,根据不同的测试场景切换启动程序名称,如sudo或sudoedit,从而实现对 sudo 及其相关功能的全面测试。
AFL_INIT_ARGV和AFL_INIT_SET0为了限制sudo的参数来触发漏洞! AFL++生成的数据将输出生成到文件或stdout中,但是需要模糊测试命令行参数。需要自行修补sudo源码,使用这两个宏都可以忽略实际的argv并将其改为从stdin。
该函数会将输入的程序如果为sudoedit的话,在该函数会被修改为sudo,所以也需要patch掉! 小技巧,在修改argv的参数后,直接在程序结束的时候打印argv的参数查看是否被修改过!
准备测试用例种子:
在源码中添加的AFL_INIT_ARGV或者AFL_INIT_SET0这些宏,会将命令行参数根据'\0'拆分为程序实际的argv!
Docker 容器环境搭建:
调试编译目标程序:
首次配置编译开启Asan模式:
可以使用 tmux 创建多个 AFL 实例进行并行测试,通过配置并发 fuzz 执行,提高目标程序的覆盖率和获取更多的测试样本。
图示:
该样本影响了sudo
工具的sudoers.c
文件中的set_cmnd
函数,详细的可以查看Asan报告,触发漏洞:
向地址 0x607000001487
写入了超出范围的1个字节数据,该地址是堆内存分配的尾部,所以是 heap overflow
溢出发生的位置,plugins/sudoers/sudoers.c的868行的set_cmnd函数! 发生溢出的堆块在 sudoers.c
的 第854行 ,由 set_cmnd
调用 malloc
申请,溢出发生在 第868行 ,所以是在同一个函数内,申请了内存后向该堆块写入过量的数据导致溢出!
开始基于Asan报告进行漏洞原因探究!
主要的函数链:
从main
函数开始分析输入流的处理,首先调用parse_args
函数解析输入的参数,-s
选项会被剔除,剩余的参数传递到后续函数中。 同时-s
选项也会设置sudo_mode
为MODE_EDIT
,引导程序进入set_cmnd
函数导致堆溢出。
可以简单调试一下,原本为-s选项在argv中: 经过parse_args处理后,在后续流程赋值给NewArgv,参数中-s消失了
剩下的步骤就是简单的源码跟踪到关键函数了!
无法直接通过源码查看调用的函数,使用gdb调试识别出函数名称!
继续向下跟进:
然后调用了sudoers.c 中的sudoers_policy_main 函数。
这里的NewArgc 和 NewArgv这两个变量提之前提到过,后续在漏洞触发的地方会用到,这里是NewArgc 和 NewArgv 在sudoers_policy_main函数初始化。
动态调试定位到楼的函数位置:
成功打下断点来到目标位置!
接下来就是分析漏洞成因的关键代码段了!
这里就是漏洞奔溃的关键位置了,动调查看下NewArgv的内容来确定申请堆块的大小,发现是0x54:
溢出发生在*to++ = *from++
拷贝的时候,根据代码可以看出,堆溢出发生在向堆中拷贝时,这段代码原来是将NewArgv中的所有参数都拷贝到堆中,按照空格分割,遇到\+非空格类字符
则只拷贝该字符 但是这个校验逻辑没有考虑充分,!isspace函数只是非空格类字符,但是们没有考虑到\x00
不属于空格类字符,可以在while循环中发现如果遍历到\x00
循环就会结束,但是NewArgv[0]
通过from[0] == '\\' && !isspace((unsigned char)from[1])
判断后两次from++导致,from指向了NewArgv[1]
的内容从而出现了非预期行为,即继续拷贝后续内容,也就是拷贝我们输入的’11111111111111...‘,当拷贝到’11111111111111...‘后面的\x00,就av++又会将NewArgv[1]
再向堆写一次,导致堆溢出! 可以动态调试一下: to整个变量申请的堆块大小是0x54,但是由于参数长度识别失败,导致被放入堆块的长度会到达"\x00"+"1"*81"+"1"*81"
的长度导致程序出现溢出漏洞 也就说,程序可以溢出的大小和内容就是"\
" 参数后面接的字符串长度! 成功向堆块溢出一个字节的内容!并且修改了下一个堆块的内容! 由于堆块结构被破坏了,再次运行,就会出现如下报错,这个就是漏洞的成因分析了!:
分析完奔溃样本可以知道漏洞发生在sudo的堆空间上,想实现这个漏洞的利用毫无疑问就是通过该漏洞让普通用户在无密码的情况下提权,那么利用手法就只能是覆盖堆上数据来达到提权目的,所以要先收集sudo历史漏洞和提权手法,以及搜集sudo堆块结构控制手法,通过这些信息来构造利用手法从而来达到提权目的!
CVE编号
影响版本
漏洞描述
修复建议
参考链接
CVE-2019-14287
Sudo <1.8.28
权限绕过漏洞 :当用户通过sudo -u#-1
或sudo -u#4294967295
执行命令时,系统错误地将UID解析为0(root)。需满足特定配置条件(如允许用户以非root身份执行命令)。
升级至1.8.28或更高版本,检查/etc/sudoers
配置安全性。
Mitre CVE Qualys分析
CVE-2021-3156
Sudo 1.8.2–1.8.31p2 Sudo 1.9.0–1.9.5p1
堆缓冲区溢出漏洞 :攻击者通过sudoedit -s
命令及以反斜杠结尾的参数触发溢出,无需密码即可提权至root。漏洞存在长达10年,影响默认配置的系统。
升级至1.9.5p2或更高版本,临时禁用sudoedit
。
Red Hat公告 Qualys技术分析
CVE-2023-22809
Sudo 1.8.0–1.9.12p1
任意文件读写漏洞 :通过环境变量EDITOR
或SUDO_EDITOR
注入--
参数,攻击者可绕过路径限制,读写任意文件(如/etc/passwd
或/etc/shadow
),导致权限提升
升级至1.9.12p2或更高版本,限制用户使用sudoedit
的权限。
Mitre CVE GitHub PoC
想要全面评估该漏洞的可利用性,需要先了解sudo
历史上的漏洞和攻击方法。
该漏洞发生在堆上,那么提权的方法必然和堆操作相关,和堆操作不相干的函数就可以直接跳过,降低漏洞分析成本,为了理解与堆相关的执行流程,我编写了 gdb 脚本追踪sudo执行过程中 malloc、realloc、calloc 和 free 函数的堆使用情况。编写一个普通的gdb脚本来观察该漏洞在奔溃前的堆操作!
得出一系列的操作函数的调用栈避免过多的函数分析减少逆向分析的成本!
得到log文件后就可以通过一系列的脚本进行处理得到我们需要的数据: 主要使用的工具就是GPT生成筛选脚本再使用dot生成流程图就可以了! 下载调用链生成图片工具:
将python赛选出来的数据生成callgraph.dot
可以收集到下面函数: 发现被main函数调用过且存在堆操作的函数就成功筛选出来了,直接将分析成本指数级别的降低了,只需要分析这些函数是否可以控制其堆结构就可以判断出这个堆溢出漏洞的实际价值了! 结合IDA的分析出函数的调用顺序: 整理出函数调用顺序后就可以具体整理每个函数的作用了!继续详细跟进每个函数!
结合AI加上源码阅读分析出的主程序流程 (main()),分析其中可能的提权手法:
总结sudoers_policy_main() 详细流程,分析其中可能的提权手法
提权的关键在于修改堆上数据,比如堆溢出覆盖堆中的虚函数表,函数指针、结构体指针等关键数据。 然后分析溢出操作完成后再到密码验证之前,有哪些函数使用了漏洞函数之前已经存在的堆块,并且分析这些堆块被覆盖后是否可以达到提权的目的! 结合前面动态调试和静态分析收集到的信息就可以开始确定可能可以覆盖的提权对象了!下面只是分析出了部分,应该还有更多的提权思路!
在get_user_info()
中,getpwuid()
函数通过UID查询用户信息,parse /etc/nsswitch.conf
则用来解析NSS配置。service_user
对象应该是与NSS(Name Service Switch)模块相关的,用于认证用户身份。在漏洞利用中,攻击者可能通过篡改或操纵这个对象来伪造身份或绕过认证。
在sudoers_policy_init()
函数中,有strdup(def_timestampdir)
调用,复制时间戳目录路径。如果路径处理不当,可能会导致路径的注入或不安全的文件操作。在漏洞利用场景下,攻击者可能利用这个路径来操控权限或绕过某些安全检查。
在sudo_file_parse()
中,/etc/sudoers
文件被解析,其中定义了sudoers
策略和用户权限。如果userspecs
对象在解析时没有进行充分的验证或边界检查,可能会被篡改以获得不正当的权限。攻击者可以操纵sudoers
文件或相关数据结构来提升权限。
这些都可以实现提权具体的利用POC可以看这里:worawit/CVE-2021-3156: Sudo Baron Samedit Exploit
汇总评估后发现NSS 劫持,该提权思路的实现可用性最高! 该漏洞主要是劫持nss_load_library函数的指针从而进行提权,发现NSS服务在堆块溢出前进行了堆操作并初始化,在发生堆块溢出后,NSS服务再次被使用,又进行了堆操作读取,完美的契合了劫持NSS服务的提权思路!
CTF-Pwn的堆题和实战堆漏洞的相关联系:Pwn的堆题提供了,操控堆结构的明确接口增删改查都有,但是在实际漏洞利用的时候是不会提供这些接口的,但是程序中必然会有堆操作,需要了解整个Linux机制,和逆向分析程序中的每一个堆操作,来筛选出可以控制的增删改查的堆操作!进而提升堆漏洞的可用性和实际价值! 首先从上面筛选出大量进行堆操作的函数进行分析,整理并且归纳一下,这些堆操作,哪些是可以控制的,哪些是不可以控制的,可以控制的可以进行哪些操作!
在sudoers_policy_main() -> find_editor()
函数路径中,函数中使用 strdup
复制用户设置的编辑器路径。可以通过设置 SUDO_EDITOR
、VISUAL
或 EDITOR
环境变量为任意长度的字符串,触发大块堆分配。
在policy_open() -> sudoers_policy_init() -> init_defaults()
函数路径中,函数中从 SUDO_TIMESTAMP_DIR
复制时间戳目录路径。设置 SUDO_TIMESTAMP_DIR
环境变量控制 strdup
分配的长度。
在_GI_setlocale()
函数中,glibc 的 _nl_find_locale
函数会根据环境变量动态加载区域设置数据,触发多次 malloc 和 free,可以通过环境变量 LC_*
(如 LC_ALL
, LC_IDENTIFICATION
)控制内存分配大小和次数。
在__tzset()
函数中,tzset 解析时区信息时,若 TZ 为空或格式特殊(如 :),会跳过复杂时区文件解析,仅分配少量内存 。设置 TZ=:
可减少堆使用量,使内存布局更可预测。
上面这些函数是经过源码分析后汇总出来的可能能够进行堆结构控制的相关函数,其中find_editor()
函数是在堆溢出之后调用几乎无可利用价值,tzset
函数的价值也不高,只能减少堆块,init_defaults()
该函数操作大概率可行,setlocale
最合适因为他可以释放指定大小的堆块在堆回收站中,并且可以构造堆块释放的顺序,所以使用setlocale
进行堆块布局最好用!
提权是由nss_load_library函数触发的,下面看看源码:
ni是堆上的service_user 结构体,当 ni->library->lib_handle
为NULL 时,就会调用__libc_dlopen
进行 so 装载。如果我们可以溢出到ni所在堆块,那么只需要将library 覆盖为0 即可触发nss_new_service
函数初始化,然后ni->library->lib_handle
的值就可以被置为NULL从而触发so 装载,构造一个恶意so,在加载的时候调用提权函数即可!
动调可以发现nss_load_library函数在溢出发生前就初始化了,说明堆块有被覆盖的可能,具体的覆盖手法,后面讲解:
先了解一下什么是NSS(Name Service Switch)!
1.统一管理多种名称解析服务 在 Linux 系统中,存在多种需要进行名称解析的场景,比如将主机名解析为 IP 地址(类似 DNS 解析域名到 IP 的功能,但也包括本地的主机名解析情况),或者将用户名解析为对应的用户 ID 等。NSS 提供了一个统一的框架,使得系统能够灵活地决定使用哪种具体的后端服务来完成这些名称解析操作。 例如,当需要查找某个用户的相关信息(如用户 ID、所属组等)时,系统可以通过 NSS 配置来决定是从本地的/etc/passwd
、/etc/shadow
文件中查找,还是借助诸如 LDAP(轻量级目录访问协议,常用于企业网络中集中管理用户等信息)等外部服务来获取相应信息。
2.NSS 的原理 配置文件驱动:NSS 的配置主要基于/etc/nsswitch.conf
文件(不同 Linux 发行版具体路径基本一致,但可能存在细微差异)。这个文件中针对不同的数据库类型(如passwd
代表用户数据库、hosts
代表主机名相关数据库等)定义了一系列的查找来源和查找顺序。
下面是具体的内容:
可以解读一下这个配置文件:
通过这种配置方式,NSS 可以根据不同的数据库类型,按照指定的顺序依次尝试从不同的数据源中查找所需的信息。当然这是放在应用层的理解,如果回归到代码底层的话就是通过这些配置的不同规则到指定目录加载so文件,并且调用so中的函数用来返回所需数据! 下面动态调试看看需要分析的源码函数有哪些,下面都是根据配置文件申请对于所需的堆块:
源码分析的时候直接搜索函数名是找不到的,NSS服务为了满足灵活的处理规则,大部分函数名都是通过宏来定义的,便于函数的替换,所以需要结合源秒调试来定位源码,下面是调试时候会经过的宏定义出来的函数,可以略过:
接下来会进行的函数链分析:get_user_info->getpwuid->__getpwuid_r->__nss_database_lookup->nss_parse_file->nss_getline->nss_parse_service_list
再锁定一下需要弄清的堆块目标有哪些!主要是存放下面这些结构体的堆块操作都需要详细关注,我们的目标就是修改下面的结构体从而实现提权:
service_user
表示一个具体的服务配置项,用于定义如何查找和使用某个服务。name_database_entry
表示一个数据库的配置项,关联数据库名称到具体的服务链。name_database
顶层结构,管理所有数据库及其关联的服务和库。 下面是具体的结构图:
第一个管理整个NSS服务的结构体肯定是第一个被申请的!static name_database *service_table;
chunk申请序号为1!
第一次调用__nss_database_lookup
的时候,service_table == NULL
整个条件必然为true,从而触发service_table结构体的初始化申请(具体申请在nss_parse_file)!
下面的函数会继续初始化,主要是将name_database_entry *this;
结构体申请堆块,由于该结构体存在多个堆块先不进行编号!
这部分函数会根据配置文件的具体内容和请求规则申请合适的堆块来存放所需数据,由于sudo是进行密码验证的提权操作,所以他会读取配置文件中密码相关的规则来申请和初始化相关数据结构。我调试环境的/etc/nsswitch.conf
的配置内容是: 所以这个while (!feof_unlocked (fp));
循环会循环读取密码相关规则的文本来进行初始化,然后调用nss_getline来初始化后续结构体。
这里会为具体的name_database_entry *this;
结构体申请堆块!继续往下就是我们要劫持的结构体service_user service
申请的地方了
这里就是根据配置文件初始化我们目标结构体service_user
的地方了,将结构体的每个字段初始化! 总结下来这里的堆块申请顺序就是基于配置文件/etc/nsswitch.conf
的内容一步步解析并且存储堆块,可以通过静态分析的到底的信息是这些堆块申请的非常紧凑和命令,很容易调试出具体布局!
开始调试:
开始调试堆块布局
查看一下nss的调用栈,发现所有的0x40大小的堆块都已经被使用了,动态调试发现每个service_user
结构体申请的大小都是0x40! 整个堆块的申请顺序是由第一次搜索的时候发现全局入口service_table
为空,则进行初始化,根据 /etc/nsswitch.conf
文件记录内容进行初始化,最后的数据结构如下所示,通过动态调试得出整个链表的结构:
布局主要由下面的逻辑实现的,向环境变量注入特定的LC_*
环境变量控制堆块的申请:
核心要点出现在_nl_find_locale这个函数上,他会寻找程序中符合LC_*
名称的环境变量,并获取他们具体的值进行堆块申请并存储起来__strdup
,最后将存储起来的环境变量的值都拼接到LC_ALL
变量中,也就是代码中composite != NULL
这个条件为true的时候执行的操作(代码省略了)!本来正常的操作是没有任何堆块是可控的,但是_nl_find_locale的内部有个函数会检测一系列环境变量LC_*
的值,判断他的值是否符合一定的规则,如果有一个不符合就将将直接符合的并且申请的堆块都一起释放掉,也就是代码中的free ((char *) newnames[category]);
,也就是这个操作可以保证向堆块回收站中放置多个指定大小的空闲堆块,从而达到控制任意堆块顺序结构的引子!
直接调试举例: 分析一下这两个环境变量:
毫无疑问如果需要指定大小的堆块或者数量放置到bins中,只要适当修改环境变量的值即可实现!
探究如何实现构造指定顺序的堆块结构:
根据sudo源码可以知道调用函数所传入的参数是setlocale(LC_ALL, '');
所以这个函数会通过环境变量中的值来进行本地化设置(包括语言、数字格式、货币格式、时间格式等),之后会进入这里setlocale->_nl_find_locale
。
进入_nl_find_locale后,LC_*
环境变量被获取并且进行解析,我们可以解析一下这个函数:
从代码中可以看出,获取每个类别的区域名称的优先级是 LC_ALL、LC_<类别名称>、LANG 环境变量。如果没有设置,则使用特殊的区域名称“C”。如果区域名称是“C”,_nl_find_locale
函数将立即返回,而不会触及堆。 下面是这些环境变量的值,会被一一获取:
cloc_name = getenv (_nl_category_names.str+_nl_category_name_idxs[category]);
这段代码会获取这些环境变量的值!
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课