引言
在我们最近的公司团建活动中,Doyensec团队乘坐邮轮游览地中海海岸。虽然每个停靠点都很精彩,但作为极客,我们需要用必要的黑客会话打破日常泳池派对的单调。我们的负责人Luca和John带来了三个挑战,旨在让我们绞尽脑汁寻找解决方案。每个挑战的目标都是分析一个没有已知漏洞利用的真实世界漏洞,并尝试自己制作一个。漏洞分为三类:物联网、Web和二进制利用;我们都选择了自己想要处理的类别,分成团队并开始工作。
这个团体活动的名称是"!exploitable"。对于那些不知道这是什么的人(我也不知道),它指的是微软为WinDbg调试器制作的扩展。使用!exploitable命令,调试器会分析程序的状态,告诉你存在什么类型的漏洞以及它是否看起来可利用。
漏洞
我们被指派调查的漏洞是Tenda AC15路由器固件中的缓冲区溢出,即CVE-2024-2850。公告还链接到GitHub上的一个markdown文件,其中包含更多细节和一个简单的概念验证。虽然仓库已被删除,但Wayback Machine存档了该页面。
GitHub文档将漏洞描述为基于堆栈的缓冲区溢出,并说漏洞可以从/goform/saveParentControlInfo端点的urls参数(路由器控制面板API的一部分)触发。然而,我们立即注意到公告中的一些不一致之处。首先,附加的截图清楚地显示urls参数的内容被复制到使用malloc分配的缓冲区(v18)中,因此溢出应该发生在堆上,而不是堆栈上。
该页面还包括一个非常简单的概念验证,旨在通过发送带有大负载的请求来使应用程序崩溃。然而,我们在这里发现了另一个不一致之处,因为PoC中使用的参数简称为u,而不是公告文本中描述的urls。
|
|
这些矛盾很可能只是复制粘贴问题,所以我们没有过多考虑。此外,如果你快速进行Google搜索,你会发现这个固件上以及更广泛的Tenda路由器上并不缺少漏洞 - 所以我们并不担心。
环境设置
第一步是建立一个运行易受攻击固件的工作环境。通常,你需要获取固件,提取二进制文件,并使用QEMU进行模拟(注意:中间不包括数百万个故障排除步骤)。但我们在船上,网络连接非常不稳定,没有StackOverflow我们无法让一切正常工作。
幸运的是,有一个名为EMUX的惊人项目,专为漏洞利用练习而构建,正是我们所需要的。简而言之,EMUX在Docker容器中运行QEMU。令人惊奇的是,它已经包含了许多易受攻击的ARM和MIPS固件(包括Tenda AC15);它还负责网络、修补特定硬件检查的二进制文件,并且预装了许多工具(如带有GEF的GDB),这非常方便。
从易受攻击端点的名称,我们可以推断受影响的功能与家长控制有关。因此,我们登录到控制面板,点击侧边栏上的"家长控制"项,并尝试创建一个新的家长控制规则。以下是Web界面中的表单样子:
这是发送到API的请求,确认了我们的怀疑,即这是触发漏洞的地方:
|
|
正如预期的那样,原始公告中的概念验证并没有开箱即用。首先,因为受影响的端点显然只能在认证后访问,其次是因为u参数确实不正确。在我们向脚本添加认证步骤并修复参数名称后,我们确实得到了崩溃。在手动对请求进行一些"模糊测试"并检查应用程序的行为后,我们决定是时候尝试将GDB挂钩到服务器进程以获取更多关于崩溃的见解。
通过EMUX,我们在模拟系统中生成了一个shell,并使用ps检查操作系统上运行的内容,实际上并不多(为清晰起见省略了一些不相关/重复的进程):
|
|
进程列表没有显示太多有趣的内容。从进程列表中你可以看到有一个dropbear SSH服务器,但这实际上是由EMUX启动的,用于在主机和模拟系统之间通信,它不是原始固件的一部分。一个telnetd服务器也在运行,这在路由器中很常见。httpd进程似乎是我们一直在寻找的;netstat确认httpd是监听端口80的进程。
|
|
此时,我们只需要将GDB附加到它。我们花了比我愿意承认的更多时间来构建交叉工具链、编译GDB,并弄清楚如何从我们的M1 mac附加到它。不要这样做,只需阅读手册即可。如果我们这样做了,我们会发现GDB已经包含在容器中。
要访问它,只需执行./emux-docker-shell脚本并运行emuxgdb命令,后跟你想要附加的进程。还有其他有用的工具可用,如emuxps和emuxmaps。
使用GDB分析崩溃帮助我们大致了解了发生了什么,但远未达到"让我们制作一个漏洞利用"的水平。我们确认saveParentControlInfo函数确实易受攻击,并一致认为是时候反编译该函数以更好地理解发生了什么。
调查
二进制文件
为了开始我们的调查,我们从模拟系统中提取了httpd二进制文件。首次启动后,路由器的文件系统在/emux/AC15/squashfs-root中提取,因此你可以简单地使用docker cp emux-docker:/emux/AC15/squashfs-root/bin/httpd ..复制二进制文件。
复制后,我们使用pwntool的checksec检查了二进制文件的安全标志:
|
|
以下是这些含义的分解:
- **NX(No eXecute)**是唯一应用的缓解措施;它意味着无法从某些内存区域(如堆栈或堆)执行代码。这有效地阻止了我们将一些shellcode转储到缓冲区并跳转到其中。
- **RELRO(只读重定位)**使某些内存区域变为只读,例如全局偏移表(GOT)。GOT存储动态链接函数的地址。当未启用RELRO时,任意写入原语可能允许攻击者将G中函数的地址替换为任意地址,并在调用被劫持函数时重定向执行。
- 堆栈保护是在最终返回指针之前放置在堆栈上的随机值。程序将在返回前检查堆栈保护是否正确,有效地防止堆栈溢出重写返回指针,除非你能够使用不同的漏洞泄露保护值。
- **PIE(位置无关可执行文件)**意味着二进制文件本身可以加载到内存中的任何位置,并且其基地址每次启动时都会随机选择。因此,“No PIE"二进制文件总是加载在相同的地址,本例中为0x8000。请注意,这仅适用于二进制文件本身,而其他段(如共享库和堆栈/堆)的地址如果ASLR激活,仍将被随机化。
关于ASLR,我们通过在模拟系统上运行cat /proc/sys/kernel/randomize_va_space来检查是否启用,结果为0(即禁用)。我们不确定真实设备上是否启用了ASLR,但鉴于时间有限,我们决定利用这一点。
由于几乎所有缓解措施都被停用,我们在使用哪种漏洞利用技术方面没有限制。
函数
我们启动了Ghidra,花了一些时间理解代码,同时修复变量和函数的名称和类型,希望能更好地了解函数的功能。幸运的是我们做到了,以下是函数功能的概述:
-
分配所有堆栈变量和缓冲区
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28int iVar1; byte bVar2; bool bVar3; char time_to [32]; char time_from [32]; int rule_index; char acStack_394 [128]; int id_list [30]; char parsed_days [8]; undefined parent_control_id [512]; undefined auStack_94 [64]; byte *rule_buffer; byte *deviceId_buffer; char *deviceName_param; char *limit_type_param; char *connectType_param; char *block_param; char *day_param; char *urls_param; char *url_enable_param; char *time_param; char *enable_param; char *deviceId_param; undefined4 local_24; undefined4 local_20; int count; int rule_id; int i; -
将主体参数读入单独的堆分配缓冲区
1 2 3 4 5 6 7 8 9 10deviceId_param = readBodyParam(client,"deviceId",""); enable_param = readBodyParam(client,"enable",""); time_param = readBodyParam(client,"time",""); url_enable_param = readBodyParam(client,"url_enable",""); urls_param = readBodyParam(client,"urls",""); day_param = readBodyParam(client,"day",""); block_param = readBodyParam(client,"block",""); connectType_param = readBodyParam(client,"connectType",""); limit_type_param = readBodyParam(client,"limit_type","1"); deviceName_param = readBodyParam(client,"deviceName",""); -
保存设备名称和MAC地址
1 2 3if (*deviceName_param != '\0') { setDeviceName(deviceName_param,deviceId_param); } -
将时间参数拆分为time_to和time_from
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15if (*time_param != '\0') { for (int i = 0; i < 32; i++) { time_from[i] = '\0'; time_to[i] = '\0'; } sscanf(time_param,"%[^-]-%s",time_from,time_to); iVar1 = strcmp(time_from,time_to); if (iVar1 == 0) { writeResponseText(client, "HTTP/1.1 200 OK\nContent-type: text/plain; charset=utf-8\nPragma: no-cache\nCache-Control: no-cache\n\n"); writeResponseText(client,"{\"errCode\":%d}",1); writeResponseStatusCode(client,200); return; } } -
在堆中分配一些缓冲区用于解析和存储家长控制规则
-
解析其他主体字段 - 主要是调用strcpy和atoi - 并将结果存储在一个大的堆缓冲区中
-
执行一些健全性检查(例如,规则已存在,达到最大规则数)并保存规则
-
发送HTTP响应
-
返回
你可以在我们的GitHub仓库中找到完整的反编译函数。
不幸的是,这个分析证实了我们一直以来的怀疑。urls参数总是在堆分配的缓冲区之间复制,因此这个漏洞实际上是一个堆溢出。由于时间有限且网络连接非常差,我们决定更改目标,尝试利用不同的漏洞。
一个立即引起我们注意的有趣代码片段是上面第4步中粘贴的代码段,其中时间参数被拆分为两个值。这个参数应该是一个时间范围,例如19.00-21.00,但函数需要原始的开始和结束时间,因此需要在-字符上拆分它。为此,程序调用带有格式字符串"%[^-]-%s"的sscanf。%[^-]部分将从字符串开头匹配到连字符(-),而%s将在找到空白字符时停止(两者都将在空字节处停止)。
有趣的是,time_from和time_to都在堆栈上分配,大小各为32字节,正如你从上面的第1步可以看到的。time_from似乎是溢出的完美目标,因为它没有空白字符限制;有效负载中唯一的"禁止"字节将是空(\x00)和连字符(\x2D)。
漏洞利用
漏洞利用的策略是实现一个简单的ROP链来调用system()并执行shell命令。对于不了解的人来说,ROP代表面向返回的编程,包括在堆栈中写入一堆返回指针和数据,使程序跳转到内存中的某个位置并运行从其他函数借用的少量指令片段(称为小工具),然后到达新的返回指令并再次跳转到其他地方,重复模式直到链完成。
首先,我们简单地在时间参数中发送一堆A,后跟-1(以填充time_to),并在GDB中观察崩溃:
|
|
我们确实得到了一个SEGFAULT,但是在strcpy中?确实,如果我们再次检查第1步中分配的变量,time_from出现在所有指向其他参数存储位置的char*变量之前。当我们覆盖time_from时,这些指针将导致无效的内存地址;因此,当程序尝试在第6步解析它们时,我们在到达甜蜜的返回指令之前得到了分段错误。
这个问题的解决方案非常简单:与其滥发A,我们可以用指向字符串的有效指针填充间隙,任何字符串都可以。不幸的是,我们无法提供主二进制内存的地址,因为其基地址是0x8000,当转换为32位指针时,开头总是有一个空字节,这将阻止sscanf解析剩余的有效负载。让我们利用ASLR被禁用的事实,直接从堆栈中提供一个字符串;time_to的地址似乎是完美的选择:
- 它在
time_from之前,所以在溢出期间不会被覆盖 - 我们可以将其设置为单个数字,例如1,当解析为字符串、整数或布尔值时它将有效
- 由于只有一个字节,我们确定不会溢出任何其他缓冲区
使用GDB,我们可以看到time_to始终分配在地址0xbefff510。经过一些试验和错误,我们找到了一个良好的填充量,让我们能够到达返回而不会在函数中间导致任何崩溃:
|
|
在GDB中检查崩溃,我们可以看到我们成功控制了程序计数器!
|
|
现在执行shell命令的最简单方法是找到一个让我们调用system()函数的小工具链。ARM架构中的调用约定是通过寄存器传递函数参数。具体来说,system()函数接受要执行的命令字符串作为在r0寄存器中传递的指针。
我们不要忘记,我们还需要将命令字符串写入内存中的某个位置。如果这是一个本地二进制文件而不是HTTP服务器,我们可以加载/bin/sh字符串的地址,这通常可以在libc中的某个地方找到,但在这种情况下,我们需要指定一个自定义命令以设置后门或反向shell。命令字符串本身必须以空字节终止,因此我们不能只是将其放在有效负载之前的填充中间。相反,我们可以做的是将字符串放在有效负载之后。没有ASLR,字符串的地址将是固定的,而字符串的空字节将是整个有效负载末尾的空字节。
将命令字符串的地址加载到r0后,我们需要"返回"到system()。关于这一点,我有一个小小的坦白。尽管我到现在一直在谈论返回指令,但在ARM32架构中没有这样的东西;返回只是通过将地址加载到pc寄存器来执行,这可以通过许多不同的指令完成。从堆栈加载地址的最简单示例是pop {pc}。
作为回顾,我们需要做的是:
- 将命令字符串的地址写入堆栈
- 将地址加载到r0中
- 将
system()函数地址写入堆栈 - 将地址加载到pc中
为了做到这一点,我们使用ropper寻找类似于pop {r0}; pop {pc}的小工具,但不容易找到地址中没有空字节的合适小工具。幸运的是,我们实际上在libc.so中找到了一个很好的pop {r0, pc}指令,同时完成了两个任务。
使用GDB,我们获取了__libc_system的地址(不要犯只搜索system的错误,它不是正确的函数)并计算了命令字符串将被写入的地址。我们现在拥有运行shell命令所需的一切!但是哪个命令?
我们检查了系统中存在哪些二进制文件,以寻找可以给我们反向shell的东西,如Python或Ruby解释器,但我们找不到任何有用的东西。我们可以交叉编译一个自定义的反向shell二进制文件,但我们决定采用更快的解决方案:只需使用现有的Telnet服务器。我们可以简单地在/etc/passwd中添加一行来创建一个后门用户,然后用它登录。命令字符串如下:
|
|
注意:你可以使用以下命令为/etc/passwd文件生成有效的哈希:
|
|
最后,这是完整的漏洞利用的样子:
|
|
漏洞利用完美运行,并向系统添加了一个新的"backdoor"用户。然后我们可以简单地使用Telnet连接以获得完整的root shell。
最终的漏洞利用也可以在GitHub仓库中找到。
|
|
结论
活动结束后,我们进行了一些调查,发现我们最终利用的特定漏洞已经被称为CVE-2020-13393。据我们所知,我们的PoC是这个特定端点的第一个工作漏洞利用。然而,由于该平台已有大量其他漏洞利用可用,其实用性降低了。
尽管如此,这个挑战是一个很好的学习经验。我们得以更深入地研究ARM架构并提高我们的漏洞开发技能。在没有可靠互联网的情况下一起工作也让我们能够分享知识并从不同的角度处理问题。
如果你已经读到这里,很好,干得好!请关注我们的博客,确保你不会错过下一个Web和Binary !exploitable剧集。