本文所述内容仅用于技术研究和网络安全学习目的。文章中提及的漏洞分析和技术细节旨在提高网络安全意识,帮助相关人员更好地防御潜在威胁。
请勿利用本文所述知识从事任何非法网络活动,包括但不限于未经授权的网络入侵、数据窃取等行为。如因滥用本文内容而造成的任何法律或道德后果,本人及银弹实验室概不承担任何责任。
一、背景
近日,银弹实验室的威胁狩猎平台捕获到多条针对DrayTek Vigor2960蜜罐的漏洞攻击,经过深入的分析,可确定该漏洞为2024年12月27日公开的CVE-2024-12987,但CVE官方信息对于漏洞的描述以及影响范围的评估均存在一定的误差。该漏洞是一个未授权栈溢出以及格式化字符串漏洞,而非命令注入漏洞,其次,该漏洞影响了DrayTek Vigor2960/3900/300B共计三款企业级路由设备,涉及2024年之前发布的所有版本固件(小于等于v1.5.1.5),漏洞威胁等级很高。DrayTek官方在2024年3月发布的最新固件版本v1.5.1.6中修复了该漏洞,鉴于漏洞影响范围较大,建议尽快做好自查及防护。本文主要分享漏洞复现过程以及2种利用手法。
二、漏洞定位和验证
2.1 漏洞定位
根据捕获到的漏洞攻击请求得知漏洞接口为/cgi-bin/mainfunction.cgi/apmcfgupload?session=......,对v1.5.15版本的mainfunction.cgi程序进行分析,通过关键字符串apmcfgupload定位到如下处理逻辑。
当PATH_INFO等于apmcfgupload时,如果QUERY_STRING不为空且包含"session="子串时,则会执行一个格式化字符串函数snprintf(),奇怪的是buf的长度仅为0x20,但snprintf第二个参数(size)却为0x3721c远超0x20存在明显的溢出问题,疑惑的是这么明显的漏洞之前竟没被曝光,毕竟网上对该设备分析文章不少,设备关注度还是挺高的。
对最新版本固件处理逻辑进行分析:
发现snprintf()参数与v1.5.1.5版本相比有了较大变化,不仅修复了长度限制为0xb。并且进一步仔细对比,发现v1.5.1.5版本第三个和第四个参数也存在问题,这里查看snprintf()函数原型:
snprintf()函数第一个参数是指向输出缓冲区的指针,用于存储格式化后的字符串。第二个参数为缓冲区的大小,表示写入的最大字节数。第三个参数为格式化字符串,其中的%s、%p等等用于控制格式化类型的称为转换说明符。后续为格式化参数,数量是可变的,根据第三个参数格式化字符串中提供的转换说明符,依次传递值。
但在v1.5.1.5版本的实现中,其第3个参数是session字段的内容属于用户可控,那么这进一步会导致存在格式化字符串漏洞的问题,第4个参数为0xb,与最新版固件中传递的第2个参数size一致。到这里应该可以肯定的是开发人员在调用snprintf()函数时,写错了参数的位置,将原本要传递的第2个参数处(控制拷贝的最大size)的内容传递到了第4个参数,将原本要写在末尾的格式化参数写到了第3个参数处,而原本要写入到第3个参数处的格式化字符串写到了第2个参数,内容为字符串"%s",但由于参数类型为size_t,所以这里被解析为字符串地址即0x3721c。因此,这里不仅存在栈溢出漏洞还存在格式化字符串漏洞。
后面经过一些测试,发现该漏洞之所以隐藏了这么久,可能与部分反编译工具生成的伪代码效果有一定的关系,如某著名逆向工具的伪代码没有像Ghidra一样将第2个参数按照size_t类型进行解析为字符串的地址,而是显示为字符串"%s",虽然在"%s"前面加上了size_t数据类型的标识,但相较于显示地址还是不够直接,这样会导致对snprintf()函数原型不够了解的分析人员一眼看到后并不会觉得有问题。
接下来我们对2种漏洞分别进行简单的验证。
2.2 格式化字符串漏洞验证
这里我们构造环境变量QUERY_STRING="session=aaaa----%p----%p----%p----%p----%p----%p"
进行测试。
可以看到在即将执行snprintf函数时,第一个参数的地址为0x407FFE20,第三个参数则是我们传入的session字段的内容"aaaa----%p----%p----%p----%p----%p----%p",第四个参数为0xb。根据格式化字符串的原理,第一个%p会格式化snprintf()函数的第四个参数0xb(也就是第一个格式化参数),第二个%p由于snprintf()无更多格式化参数,因此会从栈上$sp指针开始的地方取数据作为第二个格式化参数,即$sp指针指向的0x409df6e0,剩下4个%p依次类推,分别取0x1、0x0、0x408037e8、0x407ffedc作为第三、四、五、六个格式化参数,这也是格式化字符串漏洞的根本原因。
当执行完snprintf函数后,查看0x407FFE20的内存数据,与我们上面推测的一致,6个%p分别被格式化为相应的数据,其中第一个%p对应snprintf()中传入的唯一一个格式化参数,剩下5个%p格式化的内容均来自栈上,从栈低地址依次向高地址取值,因此我们可以通过控制session参数的内容实现对栈上数据(大于$sp寄存器值的任意地址均可)的读取,并且格式化后的数据内容是直接存储在栈上的,可以确定这里存在格式化字符串漏洞。
2.3 栈溢出漏洞验证
这里我们通过cyclic生成模式字符串,构造环境变量QUERY_STRING="session=aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa"
进行测试。
可以看到在即将执行snprintf函数时,第一个参数的地址为0x407ffee0,该地址为一个栈地址,第三个参数则是我们传入参数session的内容即生成的模式字符串,而第二个参数解析成了字符串"%s"的地址0x3721C,远比第一个参数定义的64长度的字符数组要大。
这里直接执行到函数返回之前,下一条指令为BX LR
,$LR寄存器的值是在上一条指令中从栈上POP出来的,原本存储的应该是返回到上层函数的地址,但发现此时$LR寄存器的值已被修改为0x61616170("paaa"),是我们传入的session字段中某个子串,因此我们通过snprintf处的溢出成功控制了程序的执行流,所以可以确定这里存在栈溢出漏洞。
进一步还可以计算出填充大小为60个字节。
三、漏洞利用
这里介绍2种不同的漏洞利用方法和调试过程,大家如果有更好的方法欢迎留言讨论。
3.1 信息收集
这里先对影响漏洞利用方法的一些信息进行收集:
-
程序是arm32小端
-
CGI程序未开启任何保护机制,加载基址为0x8000,地址较低
-
GET请求调用CGI时参数是中间件通过环境变量QUERY_STRING传输给CGI的,在CGI中再通过get_env函数获取,get_env存在\x00截断问题,因此无法通过session参数传递\x00字节
-
设备的/proc/sys/kernel/randomize_va_space = 1,因此动态库、栈基地址会随机
3.2 利用思路分析
根据上述收集的信息,对漏洞利用方法进行初步思考和测试:
首先,能够明确的是程序加载基址固定为0x8000,堆地址也是固定的。而栈基地址和动态库加载基址是随机的,无法直接布局shellcode到栈上进行ret2shellcode,也不好通过ret2libc的方式进行利用。
由于堆地址固定,且具有可执行权限,因此可考虑在堆上布局shellcode,然后跳转到堆上实现利用,不过需要明确堆空间中有没有我们可控的数据。根据CGI调用时常见的数据传递方法,HTTP body会在中间件调用CGI程序时写入其标准输出中,在CGI程序中则通过读取标准输入来获取HTTP body,由于HTTP body长度是不一定的,因此CGI程序往往会将HTTP body存到堆空间中进行后续解析。cgiInit函数(库函数,位于/usr/lib/libcgi.so.1.0.0)实现了这个过程中,如下图所示。因此初步判断此方法可行,这是我们接下来要介绍的第一种利用手法。
其次,CGI程序加载基址固定可考虑ret2text的方法,但由于CGI程序加载基址为0x8000是一个比较低的地址且CGI程序只有213K,因此该程序加载运行后所有程序段的内存地址至少都会包含一个\x00字节,并且由于get_env函数的限制导致我们无法在session参数中传递\x00,难以构造地址。好在程序是小端序,因此可以利用溢出漏洞覆盖返回地址的部分字节控制一次在CGI中的跳转(利用其原本地址中高位的\x00字节),但现实是残酷的,因为漏洞点位于main函数中,函数结束后会返回到_start函数,而_start函数的加载地址往往很高,经过调试发现其地址在0x40000000以上,高位没有\x00字节可被借用,如下图所示。因此需要一种方法能在不传入\x00字节的情况将覆盖的部分字节转换成\x00字节,这就是我们后面要介绍的第二种利用手法。
3.3 利用方法一
3.3.1 调试环境构建
由于漏洞点位于CGI程序中,而该设备中间件lighttpd使用普通的cgi调用而非fastcgi,因此每次请求时都会调用execv执行cgi程序,这种情况下想要调试mainfunction.cgi程序还是比较麻烦的,这里提供4种思路供参考:
- 直接调试cgi程序而不通过中间件,因为mainfunction.cgi是C语言编写的ELF程序,可以直接执行,但需要自己构造相应的环境变量,并且会忽略掉中间件对于请求的一些特殊处理,可能出现Exploit在实际环境中不生效的问题
-
写一个死循环监测进程列表,当出现mainfunction.cgi时及时attach进程,但存在以下问题:一是不一定能及时attach住,往往需要构建一个较大的请求来增加cgi的处理时间;二是即使attach住了程序大概率已经执行完一些指令了,而如果想要调试的函数比较靠前的话,那大概率无法成功断下
-
对mainfunction.cgi程序进行patch,如在程序main函数开头处修改某条指令汇编会跳转到自身,然后使用patch后的程序对原程序进行替换,这样mainfunction.cgi程序在运行时就会进入死循环,此时在对其进行attch调试,并动态将patch的指令进行恢复即可
-
通过hook的方式,构造一个动态库,将其路径添加到/etc/ld.so.preload文件中,在动态库的init函数中判断当前进程是否是mainfunction.cgi,如果是则可以sleep一段时间,这样我们就可以在这段时间对其进行attach了,并且程序处于刚启动阶段,即使位于比较前面的函数也可以下断点调试,相较于方法3更为灵活一点,不过会导致进程多加载一个动态库,造成内存地址与实际情况出现一定的偏差
/etc/ld.so.preload hook原理:
/etc/ld.so.cache文件是一个可选的动态链接器配置文件,用于列出在程序启动,即在其它通常的库被加载前,必须预先加载的共享库。
当一个程序要使用一个函数或者对象位于某个共享库时,动态链接器会负责检索和加载这个库。该过程通常按照 /etc/ld.so.cache 文件中指定的顺序来进行搜索和加载。但是,如果 /etc/ld.so.preload 文件存在的话,那些在此文件中列出的库将先于任何其它库被加载和链接。
这里主要说明下方法4的实施过程,首先编写一份简单的动态库代码,在动态库的init函数中通过读取/proc/self/cmdline获取进程启动命令,判断是否存在mainfunction子串,如果存在则sleep一段时间等待调试,示例代码如下:
#include <stdio.h> #include <string.h> #include <unistd.h> // typedef int (*orig_strcmp_type)(const char *s1, const char *s2); // orig_strcmp_type orig_strcmp = NULL; void __attribute__((constructor)) init(void) { FILE* f; static char name[1024]; f = fopen("/proc/self/cmdline", "r"); if(f == NULL) { perror("fopen"); } if(fgets(name, sizeof(name), f) == NULL) { perror("fgets"); fclose(f); } fclose(f); if(strstr(name, "mainfunction")){ sleep(60); } }
编译后发现该动态库依赖于libc.so动态库,而设备中的libc库名为libc.so.6,可创建符号链接来解决。
这样在我们发送对mainfunction.cgi的请求时,进程mainfunction.cgi会sleep 60秒,此时便可使用gdbserver attach该进程进行远程调试了。
3.3.2 确定可利用性
有条件的读者可根据上述调试方法在实体设备进行研究,这里我们使用qemu来进行仿真调试,实体设备上的堆地址可能会不一样,但思路是一致的。首先将虚拟机/proc/sys/kernel/randomize_va_space文件内容改为1,然后使用cyclic生成一个长度1000的body文件,仿真调试命令如下:
chroot . ./qemu-arm-static -E PATH_INFO=/cvmcfgupload -E QUERY_STRING="session=aaaabbbb" -g 1234 ./www/cgi-bin/mainfunction.cgi < body
在cgiInit函数返回处设置断点,程序断下后,发现cgiInit函数的返回地址为0x45008,根据静态分析的结果,我们传入的body内容应该会存入返回地址指向的堆空间中
查看0x45008中的内容,发现我们传入的body位于0x45018开始的内存区域,符合静态分析的结果
查看进程的内存布局,如下图所示,发现唯一的堆块起始地址是0x45000,结束地址为0x66000(实体设备中该内存区域是可执行的),大小为135168字节,地址比较低,最高位为\x00字节,根据前面的分析可知,我们无法在覆盖的返回地址处直接填充\x00,因此无法直接跳转到当前堆地址中。
但如果传入的body内容大小大于135168字节,内存分布又是怎样的呢?构造大于135168字节的body进行调试,当执行完cgiInit函数后,发现0x45008处的堆区中没有我们传入的body数据。
对比两种情况下进程内存布局情况,发现当传入的body内容大小大于135168字节时,新增了一段内存映射区域,地址区间为0x40b4e000~0x40b71000(实际设备中新映射区域也是可执行的)。
查看其中的内容发现是我们传入的body数据,因此可以推断当我们传入的body长度大于135168字节时,进程会申请一块新的内存区域存放body数据,该空间起始地址固定且大于0xffffff,可以不用考虑传入\x00字节的问题。
综上,我们可以将shellcode放在body中传入CGI,为了避免堆地址在某些情况下出现一定的偏移,可以在shellcode前加入一定长度的滑块指令,这样堆空间中就会存储 N * "nop" + shellcode的数据,接下来就只需利用snprintf栈溢出漏洞将返回地址指向N个"nop"指令中的任意一个,最终就可以执行shellcode从而完成漏洞利用。
3.3.3 小结
在利用方法一中,我们通过观察系统处理不同大小body时的内存情况,发现当body长度大于135168字节时,系统会映射一段新的堆空间用于存储body,并且由于地址较大无需考虑返回地址高位字节需覆盖为\x00的问题,因此可构建shellcode布局到上述堆空间中,最后利用栈溢出漏洞控制$pc指针跳转到shellcode以实现利用。
3.4 利用方法二
在本节中,我们还是直接使用qemu来仿真运行mainfunction.cgi程序进行调试,实体设备上的栈环境可能会不一样,但思路是一致的。
根据3.2节中的思路二,我们在mainfunction.cgi程序中,找到如下Gadget,地址为0xb430:
.text:0000B430 0D 00 A0 E1 MOV R0, SP ; command .text:0000B434 1F FC FF EB BL system
因此如果能覆盖返回地址为0xb430,且在后面放置需要执行的命令,即payload内容为padding + 0x0000b430 + cmd,那么便可完成最终漏洞利用。但根据上述分析,get_env函数存在\x00字节截断问题,无法直接在session字段中传入\x00字节,因此现在的问题就是如何不通过传入\x00字节来将返回地址高2字节变成\x00。在上面的漏洞验证分析中我们知道该漏洞不仅是一个栈溢出漏洞还是一个格式化字符串漏洞,格式化字符串内容是可控的并且格式化后的内容是直接存放到栈上的,那么是否可以格式化出2个\x00字节来填充返回地址的高2个字节呢?
这里我们先构造QUERY_STRING="session=aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaa`echo -e \"\x30\xb4\"`%p"的payload进行调试。
可以看到$LR寄存器值被修改为0x7830b430,其中低2个字节0xb4和0x30是我们在session中传入的,而高2个字节为0x78和0x30,其ASCII码分别为字符x和0,即将snprintf()最后一个参数0xb按%p格式化后的结果(字符b位于下一个字节处),所以我们的目的就是要将0x78和0x30变成0x00。
3.4.1 强大的%c
转换说明符%p会将参数格式化为void *指针,无法格式化出\x00字节。查看printf()等格式化字符串类函数的man手册,可看到对转换说明符%c的描述,如下图:
其中标红的大概意思是说:如果不存在l修饰符,则将int参数转换为无符号char类型。如果将参数0xb按%c进行格式化,结果是什么呢?这里我们将上述%p改为%c进行调试
可以看到$LR寄存器内容变成了0xbb430,同样低2个字节0xb4和0x30是我们在session中传入的,而高2个字节分别是0x00和0x0b,其中0x0b是参数0xb按%c进行格式化后的结果,而0x00是snprintf()函数执行完成后在目标缓冲区末尾自动添加的。因此,利用转换说明符%c能够将1个4字节参数格式化成单个字节,这里的单个字节其实是参数值的最低1个字节。我们可以继续在%c后再添加一个%c进行调试验证。
发现$LR寄存器最高字节变成了0xe0,由于传入的第2个%c按照顺序应该是格式化第2个参数,在上述2.2小节的格式化字符串漏洞验证中,可以知道第2个格式化参数值为0x409df6e0,其最低1字节就为0xe0,与$LR寄存器最高字节相同。因此,可以知道一个4字节 的数据经过转换说明符%c格式化后的结果为该数据的最低1字节,所以只要一个参数值为0或者其最低1个字节为0,使用%c就可以格式化出0x00字节。
但还存在一些问题,一是我们需要覆盖返回地址的高2个字节都为0x00,那么按上述的结果就需要有相邻的2个参数都能被格式化为0x00;二是即使能找到符合条件的2个相邻参数(首先肯定的是第一个参数为0xb是不可以的,那么参数位置肯定是大于等于2),根据2.2小节中的描述,格式化参数会按照格式化字符串中的转换说明符依次被格式化,即格式化字符串中的第1个%x会格式化第1个参数,第2个%x格式化第2个参数,依次类推,如果符合条件的参数距离$sp指针有19个参数的距离(每四个字节为1个参数),即相较于第1个参数0xb而言就是第21和22个参数,那么就需要前面20个参数均被格式化后,2个%c才能恰好格式化第21和22个参数,虽然可以在padding中布局20个格式化转换说明符来把前面20个参数给格式化掉,但由于在填充到返回地址高2字节之前的数据长度是固定的(padding加上Gadget地址的低2字节0xb4和0x30),如果实际情况下符合条件的2个栈上参数距离$sp指针非常远,那么就无法布局足够多的格式化转换说明符。并且符合条件的2个相邻参数也不一定存在,总体来说限制较多。
3.4.2 格式化任意位置参数
针对上述问题,有PWN经验的大佬应该一下就能想到解决办法了,这里为了让不太熟悉格式化字符串漏洞的读者更为清晰,还是通过几次动态调试来引出解决方法。翻看格式化字符串类函数的man手册,可以看到如下示例:
示例的意思是在使用格式化字符串函数来格式化时间时,由于不同国家的习惯不一样,如有些国家习惯年-月-日的顺序,而有些国家则习惯日-月-年的顺序,此时如果不改变格式化参数的位置仅修改格式化字符串内容,如何格式化出不同顺序的结果呢?这里用到了%x$y形式的转换说明符,如示例中的%1$s、%3$d等等。其中x表示取第几个格式化参数,%3$d就表示取第3个格式化参数,而y则是上面提到的用于控制类型的说明符,如p、c等等。这里还是使用上述2个%c的例子来进行调试说明,假设我现在需要将返回地址的高2字节顺序进行调换,即将0xe00bb430改成0x0be0b430,这里将将%c%c修改为%2$c%1$c进行调试,结果如下:
可以看到我们成功调换了返回地址的高2字节,进一步可以想到利用该方法是否可以格式化同一个参数呢?将控制符修改为%2$c%2$c进行调试,结果如下:
可以看到成功格式化出了2个0xe0字节,说明可以格式化同一个参数。因此,利用上述方法就不需要找2个相邻且都能格式化出0x00字节的参数了,并且可以控制格式化任一位置的参数,也就无需考虑在返回地址前的padding中布局其他格式化控制符了,只需找到任意一个数值稳定的参数,其值恒为0或者最低一个字节恒为0x00即可完成漏洞利用。
3.4.3 小结
在利用方法二中,我们找到了一段简单的Gadget指令可用于命令执行,但由于其地址高2字节为\x00,而我们又无法直接在session字段中传入\x00,针对该问题,我们利用其格式化字符串漏洞的问题,发现%c格式化转换说明符会将一个4字节数据格式化为其最低1字节,因此可以在栈上寻找恒为0或最低1字节恒为0的参数来格式化出\x00字节。但还存在一个问题就是参数是根据格式化字符串中的转换说明符按序进行格式化的,如何让2个连续的%c恰好能格式化2个符合条件的参数呢,这里引出了%x$c的用法,可直接格式化索引为x位置的参数,并且可格式化同一个参数,因此只要找到一个符合条件(恒为0或最低1字节恒为0)的参数,然后用x定位到该参数即可解决返回地址高2字节置0的问题,从而完成最终漏洞利用。
四、修补和防范
4.1 升级固件
DrayTek官方已于2024年3月发布新版固件修复了该漏洞,可从如下地址下载最新版固件安装来防范此漏洞攻击。
https://fw.draytek.com.tw/Vigor2960/Firmware/v1.5.1.6/
https://fw.draytek.com.tw/Vigor3900/Firmware/v1.5.1.6/
https://fw.draytek.com.tw/Vigor300B/Firmware/v1.5.1.6/
4.2 网络隔离
由于该漏洞无需口令即可利用,因此单纯修改管理后台密码无法缓解此漏洞攻击,如果您无法及时更新固件,强烈建议您关闭远程管理(WAN Web访问)功能以防止您的DrayTek设备被从互联网攻击。
4A评测 - 免责申明
本站提供的一切软件、教程和内容信息仅限用于学习和研究目的。
不得将上述内容用于商业或者非法用途,否则一切后果请用户自负。
本站信息来自网络,版权争议与本站无关。您必须在下载后的24个小时之内,从您的电脑或手机中彻底删除上述内容。
如果您喜欢该程序,请支持正版,购买注册,得到更好的正版服务。如有侵权请邮件与我们联系处理。敬请谅解!
程序来源网络,不确保不包含木马病毒等危险内容,请在确保安全的情况下或使用虚拟机使用。
侵权违规投诉邮箱:4ablog168#gmail.com(#换成@)