!exploitable Episode One - Breaking IoT
引言
在我们上一次的公司团建活动中,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挂钩到服务器进程以获取更多关于崩溃的 insights。
通过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中函数的地址替换为任意地址,并在调用被劫持的函数时重定向执行。
- **堆栈保护符(stack 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,但函数需要原始的开始和结束时间,因此需要在-字符上拆分它。为此,程序使用格式字符串"%[^-]-%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()。关于这一点,我有个小 confession 要做。尽管我到现在一直在谈论返回指令,但在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剧集。