今年4月,FreeBSD修复了Wi-Fi协议栈中潜藏了13年的堆溢出漏洞,该漏洞允许网络附件的攻击者在受影响的FreeBSD Kernel安装中执行任意代码。这个漏洞最初是由一位名为m00nbsd的研究人员报告给ZDI的,并在2022年4月作为FreeBSD-SA-22:07.wifi_meshid进行了修复。该研究人员慷慨地提供了这份关于该漏洞的详细文章,以及演示该漏洞的PoC代码。
我们的目标是利用FreeBSD内核的Wi-Fi协议栈中的堆溢出漏洞,从而在目标FreeBSD系统上实现内核远程代码执行。这个漏洞已分配编号CVE-2022-23088,并影响到2009年以来的所有FreeBSD版本,以及许多FreeBSD的衍生产品,如pfSense和OPNsense。目前,该漏洞已经于2022年4月被FreeBSD项目组所修复。
漏洞详情
当系统在扫描到可用的Wi-Fi网络时,它会监听其附近的Wi-Fi接入点所发出的管理帧。实际上,存在多种类型的管理帧,但我们只对一种类型感兴趣:信标管理子类型,其格式如下所示:
struct ieee80211_beacon { uint8_t i_fc[2]; uint8_t i_dur[2]; uint8_t i_addr1[IEEE80211_ADDR_LEN]; uint8_t i_addr2[IEEE80211_ADDR_LEN]; uint8_t i_addr3[IEEE80211_ADDR_LEN]; uint8_t i_seq[2]; // ... sequence of ieee80211_option structures of varying lengths ... }; struct ieee80211_option { uint8_t id; uint8_t len; // ... ‘len’ bytes of data ... };
在FreeBSD中,信标帧是通过ieee80211_parse_beacon()函数进行解析的。该函数将遍历帧中的选项序列,并保留指向稍后将使用的选项的指针。其中,我们对选项IEEE80211_ELEMID_MESHID特别感兴趣:
wh = mtod(m, struct ieee80211_frame *); frm = (uint8_t *)&wh[1]; efrm = mtod(m, uint8_t *) + m->m_len; // [...] // Iterate over the sequence of options in the packet while (efrm - frm > 1) { // [...] switch (*frm) { // [...] case IEEE80211_ELEMID_MESHID: // Keep a pointer to the packet’s MeshId option scan->meshid = frm; break; // [...] } // Advance to the next option frm += frm[1] + 2; }
注意,这里并没有对选项长度(frm[1])执行健全性检查。稍后,在函数sta_add()中,memcpy调用会将该选项长度作为size参数,并将一个固定大小的缓冲区作为目标缓冲区:
if (sp->meshid != NULL && sp->meshid[1] != 0) memcpy(ise->se_meshid, sp->meshid, 2+sp->meshid[1]);
由于缺乏对选项长度的必要检查,因此这就满足一个理想的缓冲区溢出条件:攻击者可以同时控制溢出的大小(sp->meshid[1]) 以及将要写入的内容(sp->meshid)。
因此,当FreeBSD系统正在扫描可用的网络时,攻击者可以通过发送一个具有超大的IEEE80211_ELEMID_MESHID选项的信标帧来触发内核堆溢出。
构建Write-What-Where原语
下面,让我们来看看ise->se_meshid,它就是在内存复制过程中被溢出的缓冲区——其定义位于ieee80211_scan_entry结构体中:
struct ieee80211_scan_entry { // [...] uint8_t se_meshid[2+IEEE80211_MESHID_LEN]; // #define IEEE80211_MESHID_LEN 32 struct ieee80211_ies se_ies; // [...] };
这里,se_meshid的溢出允许攻击者覆盖后面的se_ies字段。而se_ies字段的类型为struct ieee80211_ies,定义如下:
struct ieee80211_ies { uint8_t *wpa_ie; uint8_t *rsn_ie; uint8_t *wme_ie; uint8_t *ath_ie; uint8_t *htcap_ie; uint8_t *htinfo_ie; uint8_t *tdma_ie; uint8_t *meshid_ie; uint8_t *vhtcap_ie; uint8_t *vhtopmode_ie; uint8_t *vhtpwrenv_ie; uint8_t *apchanrep_ie; uint8_t *bssload_ie; uint8_t *spare[4]; uint8_t *data; // [1/2] Remember these two fields int len; // [2/2] };
在这里,我们覆盖的目标是最后两个字段,即data字段与len字段。
回到sta_add(),在调用memcpy函数之后不久,就有一个函数调用用到了se_ies字段:
if (sp->meshid != NULL && sp->meshid[1] != 0) // This can overwrite ‘se_ies’ memcpy(ise->se_meshid, sp->meshid, 2+sp->meshid[1]); // [...] // This uses ‘se_ies’ as first argument (void) ieee80211_ies_init(&ise->se_ies, sp->ies, sp->ies_len);
这个ieee80211_ies_init()函数的定义为:
// ‘ies’ can be overflown into, so we can fully control its contents int ieee80211_ies_init(struct ieee80211_ies *ies, const uint8_t *data, int len) { memset(ies, 0, offsetof(struct ieee80211_ies, data)); $0 if (ies->data != NULL && ies->len != len) { // data, M_80211_NODE_IE); ies->data = NULL; } if (ies->data == NULL) { ies->data = (uint8_t *) IEEE80211_MALLOC(len, M_80211_NODE_IE, IEEE80211_M_NOWAIT | IEEE80211_M_ZERO); if (ies->data == NULL) { ies->len = 0; return 0; } } $1 memcpy(ies->data, data, len); // len = len; return 1; }
这里的data参数指向帧中信标选项的开头位置,len为所有信标选项的整体长度。换句话说,(data,len)对描述了选项缓冲区,它是攻击者所发送的帧的一部分。
图1 选项缓冲区
正如代码片段中所指出的,由于缓冲区的溢出问题,IES结构体将完全处于攻击者的控制之下。
因此,在$1处:
我们可以控制len字段,考虑到其中存放的是选项缓冲区的整体长度,我们可以通过在帧中添加或删除选项来决定其值。
我们可以控制data字段的值,因为它是选项缓冲区的内容。
我们可以通过溢出来控制ires->data指针。
这样,我们就得到了一个近乎完美的write-what-where原语,我们可以利用它在任意内核内存地址处写入任意数据,为此,我们只需发送一个具有超大MeshId选项的信标帧。
该原语所面临的各种约束
该原语具有下列约束:
FreeBSD内核希望帧中含有SSID和Rates选项。这意味着有2x2=4字节的选项缓冲区是我们无法控制的。此外,我们的超大MeshId选项有一个2字节的头部,因此共有6字节是我们无法控制的6。为了方便和简单起见,我们将把这些字节放在选项缓冲区的开头位置。
我们的超大MeshId选项必须大到可以覆盖IES->data和IES->len字段,但不能覆盖其他字段。具体来说,这个长度就是MeshId选项182字节的长度,加上其2字节头部。为了容纳MeshId选项和上面提到的两个选项,我们将使用一个188字节的选项缓冲区。
我们覆盖的IES->data和IES->len字段分别是选项缓冲区中的最后8个字节和4个字节,因此它们也受到约束。
ies->len字段必须等于len,这样$0处的分支才不会被执行。
给定前面提到的约束条件后,帧的总体布局如下所示:
图2 帧布局示意图
保持目标的稳定性
假设我们发送一个信标帧,以触发我们的原语覆盖内核内存的一个区域。当内核随后试图释放ies->data时,就会出现问题。这是因为ies->data应该指向一个malloc分配的缓冲区,而在我们的覆盖它之后,就未必还是这样了。
为了保持稳定性并避免目标崩溃,我们可以发送第二个纠正信标帧,将ies->data覆盖为NULL,将ies->len覆盖为0。之后,当内核试图释放ies->data时,它将发现指针为NULL,因此不会执行任何操作。这就能保持目标的稳定性,并确保我们的原语不会使其崩溃。
选择写什么,写到哪里
现在我们有了一个很好的write-what-where原语,只需一个信标帧和一个纠正帧就能触发它。那么,我们可以用它来做什么呢?
首先想到的,就是使用该原语覆盖内核在内存中执行的指令。幸运的是,在FreeBSD中,内核的text段是不可写的,所以我们无法使用该原语直接覆盖内核指令。此外,由于内核实现了w^x机制,因此没有可写页是可执行的。
但是,映射可执行页的页表是可写的。
注入implant
在这里,我们将向目标的内核注入一个implant,它用于处理通过Wi-Fi帧发送给它的“命令”——如果你愿意,这也可以是一个完整的内核后门。
注入这个implant的过程将需要四个信标帧。
这是一个有点颠簸的技术旅程,所以请系好安全带。
第1帧:注入payload
背景知识:直接映射是一个特殊的内核内存区域,它将系统的整个物理内存连续映射为可写页,但是只读text段的物理页除外。
我们使用该原语在物理地址0x1000处使用直接映射写入数据。
图3 第1帧
这里选择的物理地址为0x1000,因为它未被使用。我们写入的数据如下所示:
图4 第1帧
其中,该implant的shellcode区域存放的是我们的implant的指令。其余的字段将在下面解释。
第2帧:覆盖L3 PTE
背景知识:x64 CPU使用页表将虚拟地址映射到物理RAM页,具有从L4(根)到L0(叶)的4级层次结构,这方面的更多细节,请参考AMD和Intel的官方规范。
在这一步中,我们使用原语来覆盖L3页表项(PTE),并使其指向我们在物理地址0x1000处作为第1帧的一部分写入的L2 PTE。我们精心设计的L2 PTE精确地指向我们的三个L1 PTE,这些PTE本身指向两个不同的区域:(1)内核text段的两个物理页,和(2)我们implant的shellcode。
换句话说,通过覆盖一个L3 PTE,我们在页面表中创建了一个分支,将存放内核代码的两页内存映射为可写的,同时,将我们的shellcode所在内存映射为可执行的。
图5 第2帧
现在,我们的shellcode已经被映射到了目标的虚拟内存空间,并为执行做好了充分的准备。我们将在下面解释,为什么要对内核text段的两个页面进行映射。
细心的读者可能会关心这里的原语所面临的限制。实际上,并没什么好担心的:L3 PTE空间大部分是未填充的。我们只需要选择一个未填充的地址范围,只要在这个范围内覆盖188个字节,就不会出现任何问题。
第3帧:给text段打补丁
为了跳转到我们新映射的shellcode,我们将修补text段中sta_input()函数的开头部分。这个函数是Wi-Fi协议栈的一部分,每当内核收到Wi-Fi帧时都会调用它,所以,这里似乎是调用我们的implant的最佳位置。
但是,内核text段是不可写的,我们怎样才能修补它呢?方法是将包含该函数的text页映射为可写的。这就是我们发送第1帧和第2帧的缘故:有了前两个L1 PTE,我们就能得到sta_input()的指令字节的可写视图:
图6 sta_input()的可写视图
通过第1帧和第2帧创建的可写视图,我们就可以使用前面的原语来修补sta_input()函数开头部分的字节,并将其替换为:
48 b8 77 66 55 44 movabs $AddressOfOurImplantShellCode,%rax 33 22 11 00 ff d0 callq *%rax
这样就能调用我们的shellcode了。然而,我们的原语的约束条件也开始发挥作用了:
原语的第一个约束是,我们无法控制覆盖的前6个字节。覆盖sta_input的内存并非上上之策,因为它将在函数的开头部分放置6个字节的垃圾内容,导致目标崩溃。
然而,如果我们看一下sta_input的指令转储,就会发现其内存布局还是很不错的:
8f90 8f90: 5e pop %rsi 8f91: 41 5f pop %r15 8f93: 5d pop %rbp 8f94: c3 retq 8f95: 66 2e 0f 1f 84 00 nopw %cs:0x0(%rax,%rax,1) 8f9c: 00 00 00 00 8f9f: 90 nop 8fa0 8fa0: 55 push %rbp | GETS PATCHED 8fa1: 48 89 e5 mov %rsp,%rbp | GETS PATCHED 8fa4: 41 57 push %r15 | GETS PATCHED 8fa6: 41 56 push %r14 | GETS PATCHED 8fa8: 41 55 push %r13 | GETS PATCHED 8faa: 41 54 push %r12 | GETS PATCHED 8fac: 53 push %rbx 8fad: 48 83 ec 48 sub $0x48,%rsp
所以,我们可以覆盖sta_input开头部分前6个字节的内存,因为这6个字节的内容,只是覆盖之前sta_newstate()函数的不可达的NOP操作,对执行并没有影响。
通过这个技巧,我们就无需担心这前6个字节,因为它们被有效地废弃了。
原语的第二个约束是,我们被强制覆盖182个字节,因此,我们不能只覆盖前几个字节。这其实不是一个问题,因为可以用内存中已经存在的相同指令字节来填充其余的字节。
原语的第三个约束是,我们写入的最后12个字节是ires->data和ires->len字段,这些字段不能解析为有效的指令。这的确是一个问题,因为这些字节位于 sta_input()内。如果内核试图执行这些字节,很快就会崩溃。为了解决这个问题,我们必须在implant中设置相应的纠正代码。当第一次调用时,我们的implant必须纠正写入sta_input()的最后12个字节的内容。虽然稍微有点复杂,但还是可以搞定的。
解除这些约束之后,sta_input()函数每次被调用时,我们的implant就会被执行,也就是说,每次目标机器收到Wi-Fi帧时都会执行它。
第4帧:纠正帧
最后,为了保持目标的稳定性,我们还需发送最后一个信标帧:在这个帧中,我们将ies->data覆盖为NULL,将ies->len覆盖为0。
这样就能确保目标不会崩溃。
后续的通信信道
利用前面提到的四个帧,我们就能可靠地将implant注入到目标机器的内核中。并且,我们的implant将在与sta_input()完全相同的上下文中被调用:
static int sta_input(struct ieee80211_node *ni, struct mbuf *m, const struct ieee80211_rx_stats *rxs, int rssi, int nf)
值得注意的是,第二个参数m是包含内核当前正在处理的帧的缓冲区的内存区域。因此,implant可以查看该缓冲区(通过%rsi)的内容,并根据其内容执行相应的操作。
换句话说,我们为implant找到了一条有效的通信信道。因此,我们可以将implant编码为服务器后门,并通过该通道向其发送命令。
完整步骤
现在,让我们来回顾一下:
FreeBSD内核中存在一个堆缓冲区溢出漏洞;并且,攻击者可以通过发送一个带有过大MeshId选项的信标帧来触发这个漏洞。
利用这个漏洞,攻击者可以实现一个write-what-where原语,对发送的每个信标帧执行一次内核内存覆盖。
只需将这个原语执行三次,就能把implant注入到目标系统的内核中。
第4个信标帧被用来清理es->data和es->len字段,以防止崩溃。
最后,我们的implant就能在目标系统的内核中运行,作为一个完整的后门,可以处理发送给它的后续Wi-Fi帧。
漏洞利用
实际上,完整的漏洞利用代码可以从https://github.com/thezdi/PoC/tree/master/CVE-2022-23088处找到。这份代码的运行机制是:先注入一个简单的implant程序,然后,该程序将使用攻击者随后发送的字符串来调用printf()函数。为了在Wi-Fi网卡名为wifi0的Linux 机器上使用该exploit,需要使用以下命令将网卡切换到监控模式:
$ sudo ifconfig wifi0 down $ sudo iwconfig wifi0 mode monitor $ sudo ifconfig wifi0 up
然后,为所需的目标系统构建并运行该剥削。举例来说,如果目标系统为pfSense 2.5.2,则可以使用下面的命令:
$ ./build.py kernels/pfSense-2.5.2-RELEASE $ sudo ./exploit.py wifi0 kernels/pfSense-2.5.2-RELEASE [+] Phase 1: writing page1 [+] Phase 2: writing L3 [+] Phase 3: patching kernel [+] Phase 4: repairing [+] Finished > Hello there, I’m in the kernel
请务必在安全的测试环境中运行上述命令,因为这会对您附近的所有易受攻击的系统造成影响。
完成上面的操作后,查看目标系统的tty0控制台。如果一切顺利的话,应该会看到“Hello there, I’m in the kernel”。
本文翻译自:https://www.zerodayinitiative.com/blog/2022/6/15/cve-2022-23088-exploiting-a-heap-overflow-in-the-freebsd-wi-fi-stack如若转载,请注明原文地址