FreeBSD Wi-Fi协议栈中的堆溢出漏洞分析
2022-6-21 11:50:0 Author: www.4hou.com(查看原文) 阅读量:11 收藏

今年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)对描述了选项缓冲区,它是攻击者所发送的帧的一部分。

1655470671142451.png

图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处的分支才不会被执行。

给定前面提到的约束条件后,帧的总体布局如下所示:

1655470771180481.png

图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处使用直接映射写入数据。

1655470803168027.png

图3  第1帧

这里选择的物理地址为0x1000,因为它未被使用。我们写入的数据如下所示:

1655470820156512.png

图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所在内存映射为可执行的。

1655470836356227.png

图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()的指令字节的可写视图:

1655470855801426.png

图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如若转载,请注明原文地址


文章来源: https://www.4hou.com/posts/2JxA
如有侵权请联系:admin#unsafe.sh