引言
在我们最近的公司团建中,Doyensec团队乘坐游轮游览了地中海沿岸。尽管每一站都令人惊叹,但作为极客,我们需要通过一些急需的黑客会议来打破日常泳池派对的单调。我们的负责人Luca和John带来了三个挑战,旨在让我们绞尽脑汁寻找解决方案。每个挑战的目标是分析一个没有已知漏洞利用的真实漏洞,并尝试自己制作一个。漏洞分为三类:物联网、Web和二进制利用;因此我们各自选择想要处理的类别,分成团队开始工作。
整个小组活动的名称是“!exploitable”。对于不了解的人(我之前也不知道),它指的是微软为WinDbg调试器制作的一个扩展。使用!exploitable命令,调试器会分析程序的状态,告诉你存在哪种漏洞以及是否看起来可利用。
正如标题所暗示的,第一篇帖子是关于物联网挑战的。
漏洞
我们被要求调查的漏洞是Tenda AC15路由器固件中的缓冲区溢出,即CVE-2024-2850。公告还链接到GitHub上的一个markdown文件,其中包含更多细节和一个简单的概念验证。虽然仓库已被删除,但Wayback Machine存档了该页面。
GitHub文档将漏洞描述为基于堆栈的缓冲区溢出,并说漏洞可以通过路由器控制面板API的/goform/saveParentControlInfo端点的urls参数触发。然而,我们立即注意到公告中的一些不一致之处。首先,附加的截图清楚地显示urls参数的内容被复制到一个用malloc分配的缓冲区(v18)中,因此溢出应该发生在堆上,而不是堆栈上。
页面还包括一个非常简单的概念验证,旨在通过发送带有大负载的请求来使应用程序崩溃。然而,我们在这里发现了另一个不一致之处,因为PoC中使用的参数简称为u,而不是公告文本中描述的urls。
|
|
这些矛盾很可能只是复制粘贴问题,所以我们没有过多考虑。此外,如果你快速谷歌搜索,你会发现这个固件上以及更广泛的Tenda路由器上并不缺少漏洞——所以我们并不担心。
设置
第一步是获取一个可工作的设置来运行易受攻击的固件。通常,你需要获取固件,提取二进制文件,并使用QEMU进行模拟(注意:中间不包括无数的故障排除步骤)。但我们在船上,网络连接非常不稳定,没有StackOverflow我们根本无法让一切正常工作。
幸运的是,有一个名为EMUX的惊人项目,专为漏洞利用练习而构建,正是我们所需要的。简而言之,EMUX在Docker容器中运行QEMU。令人惊叹的部分是它已经包含了许多易受攻击的ARM和MIPS固件(包括Tenda AC15);它还负责网络、修补特定硬件检查的二进制文件,并且预装了许多工具(如带有GEF的GDB),这非常方便。如果你对Tenda AC15如何被模拟感兴趣,可以在这里找到工具作者的博客文章。
从易受攻击端点的名称,我们可以推断受影响的功能与家长控制有关。因此,我们登录控制面板,点击侧边栏的“家长控制”项,并尝试创建一个新的家长控制规则。以下是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未启用时,任意写入原语可能允许攻击者将GOT中的函数地址替换为任意地址,并在调用被劫持的函数时重定向执行。
- 堆栈canary是放置在最终返回指针之前的堆栈上的随机值。程序将在返回前检查堆栈canary是否正确,有效地防止堆栈溢出重写返回指针,除非你能够使用不同的漏洞泄漏canary值。
- 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]; byte 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,但函数需要原始的开始和结束时间,因此需要在-字符上拆分它。为此,程序调用sscanf,格式字符串为"%[^-]-%s"。%[^-]部分将从字符串开头匹配到连字符(-),而%s将在找到空白字符时停止(两者都会在空字节处停止)。
有趣的部分是time_from和time_to都在堆栈上分配,大小各为32字节,正如你从上面的步骤1中可以看到的。time_from似乎是溢出的完美目标,因为它没有空白字符限制;有效负载中唯一“禁止”的字节是空(\x00)和连字符(\x2D)。
漏洞利用
漏洞利用的策略是实现一个简单的ROP链来调用system()并执行shell命令。对于不了解的人,ROP代表面向返回的编程,包括在堆栈中写入一堆返回指针和数据,使程序跳转到内存中的某个位置并运行从其他函数借来的小指令片段(称为gadget),然后到达新的返回指令并再次跳转到其他地方,重复模式直到链完成。
首先,我们简单地在时间参数中发送一堆A,后跟-1(以填充time_to),并在GDB中观察崩溃:
|
|
我们确实得到了一个SEGFAULT,但在strcpy中?确实,如果我们再次检查步骤1中分配的变量,time_from在所有指向其他参数存储位置的char*变量之前。当我们覆盖time