Scott Knight
21 min read

Categories

Tags

Inspired by a recent post on the MDSec blog, I wanted to take a deeper look at virus scanning on macOS. When I ran into issues getting McAfee kexts approved and loaded correctly it gave me a good excuse to dig deeper. In this post I will provide a brief overview of the different Kernel APIs available to virus scanning tools on macOS and specifically how McAffee’s implementation works. Finally I will cover some things that I think could be improved in the McAfee implementation.

I’d like to include a similar disclaimer as the MDSec blog post. Root level permissions are needed to connect and interact with the McAfee implementation. If an attacker has root level permissions, then regardless of specific security product, the attacker is going to have fairly wide open access to the machine. That said, it’s certainly worthwhile to understand how the security products we choose protect us and any issues there might be with them.

Kernel APIs

Any virus scanning solution on any platform is going to want to inspect files on the computer. Additionally most have the ability to block malicious files before they’re opened. The section below is meant to provide a brief overview of the most relevant KPIs that macOS has and provide some additional links to more documentation.

Kauth

From Apple’s excellent tech note on Kauth:

Mac OS X 10.4 Tiger introduced a new kernel subsystem, Kernel Authorization or Kauth for short, for managing authorization within the kernel. The Kauth subsystem exports a kernel programming interface (KPI) that allows third party kernel developers to authorize actions within the kernel, modify authorization decisions, and extend the kernel’s authorization landscape. It can also be used as a notification mechanism.

The tech note goes on to say that these KPIs can specifically be used for developing an anti-virus product. The Kauth KPIs allow you to register listeners for different scopes built into the macOS kernel. There are two different scopes that are most useful for virus scanning: the file operation scope and the vnode scope. The file operation scope unfortunately does not let you block access to files in real time, only receieve notifications about actions happening to them. The vnode scope is a little more complex but allows you to authorize or deny the different file operations. In either case you need to make sure your kext code is not slow otherwise all file operations for the system will be slowed down.

MAC

Mandatory Access Control, or MAC for short, came from the FreeBSD TrustedBSD framework. Unlike Kauth which only provides a handful of scopes, the MAC interface provides very granular hooks into almost every major part of the kernel. Unfortunately this is not an official Apple approved KPI. This means that Apple uses it, but they don’t document it and don’t recommend that you use it. This KPI is how Apple themselves implement their sandboxing mechanism in the kernel. Most security products will make use of this unofficial KPI in some fashion since it is so powerful. To get an idea of all the different hook points just take a look at the mac_policy.h header in the kernel.

For a good overview of writing a kext that uses the MAC framework, head over to the Objective-See blog.

Kernel Control API

Most kernel extensions are going to need to communicate with user space applications as well. There are different ways that a kext can communicate with user space but I’ll just touch on the Kernel Control API here. This KPI provides a socket-based communication mechanism for your kext. After your extension registers itself, user space applications can use socket APIs along with a new PF_SYSTEM domain and SYSPROTO_CONTROL protocol. This allows your user space software to set options in your kext as well as send and receive information back and forth.

McAfee Endpoint Security for Mac

The following analysis applies to McAfee Endpoint Security for Mac Version 10.2.3 (3096)

As I mentioned at the start of this post, one of the things that prompted me to dig deeper into McAfee was having issue getting all the kexts approved and loaded. As of macOS 10.13 users are required to approve any third party kernel extension before they can be loaded. To start off, I searched for all of the kexts installed by McAfee.

$ sudo find /Library/Application\ Support/McAfee -name *.kext -print
/Library/Application Support/McAfee/AntiMalware/AVKext.kext
/Library/Application Support/McAfee/StatefulFirewall/SFKext.kext
/Library/Application Support/McAfee/FMP/FileCore.kext
/Library/Application Support/McAfee/FMP/mfeaac.kext
/Library/Application Support/McAfee/FMP/FMPSysCore.kext

Since I wanted to specifically learn more about virus scanning on macOS I started by analyzing the AVKext.kext.

AVKext.kext

When reversing kexts, I always start by inspecting their Info.plist file. This provides an overview of what KPIs it uses as well as what other kexts it depends on. In the case of AVKext.kext it had the following defined:

<key>CFBundleIdentifier</key>
<string>com.McAfee.AVKext</string>
<key>OSBundleLibraries</key>
<dict>
    <key>com.apple.kpi.bsd</key>
    <string>8.0.0</string>
    <key>com.apple.kpi.libkern</key>
    <string>8.0.0</string>
    <key>com.intelsecurity.FileCore</key>
    <string>1</string>
</dict>

This kext uses the bsd and libkern kpis as well as one of the other McAfee kexts. Since this kext does not use IOKit, it means it will have a start and stop function that run when the kext is loaded or unloaded. If you look for the _start symbol it references a pointer called _realmain. This points to the function the kernel calls when the kext is loaded. In this case it is AVKext_start.

kern_return_t AVKext_start(kmod_info_t * ki, void *d)
{
    printf("AV Kext: Entered");
    gAVLogLevel = LOG_ERROR;
    
    gAVMallocTag = OSMalloc_Tagalloc("com.McAfee.AVKext", OSMT_DEFAULT);
    if (!gAVMallocTag) {
        if (gAVLogLevel >= LOG_ERROR) {
            printf("MFE_AV: ERROR\"Could not allocate memory for kext\"\n");
        }
        
        return KERN_FAILURE;
    }
    
    gAVLockGroup = lck_grp_alloc_init("com.McAfee.AVKext", NULL);
    if (!gAVLockGroup) {
        if (gAVMallocTag) {
            OSMalloc_Tagfree(gAVMallocTag);
            gAVMallocTag = NULL;
        }
        return KERN_FAILURE;
    }
    
    if ((AVScan_init("com.McAfee.AVKext", 0x0) == 0x0) && 
        (AVScanBooster_init("com.McAfee.AVKext", 0x0) == 0x0)) {
        if (AV_userkernintf_init() == 0x0) {
            sysctl_register_oid(&sysctl__kern_com_mcafee_AV_log);
            gRegisteredOID = 0x1;
        }
    }

    return KERN_SUCCESS;
}

There’s not a ton happening during startup. The kext creates a lock group and malloc tag. Then it initializes the AVScan and AVScanBooster subsystems which initialize some more locks and global data structures. Then it calls AV_userkernintf_init which sets up the kernel control api interface and finally it sets up a sysctl entry to control the log level. Using the commands below you can enable more detailed logging which is helpful while analyzing interactions with the kext.

$ sysctl -a | grep -i "mcafee\|intelsecurity"
kern.com_intelsecurity_filecore_log: 1
kern.com_intelsecurity_filecore_timeout: 5
kern.com_mcafee_AV_log: 2
kern.com_mcafee_syscore_log: 1
kern.com_mcafee_firewall_log: 1

$ sudo sysctl -w kern.com_mcafee_AV_log=5
kern.com_mcafee_AV_log: 2 -> 5

log stream --predicate 'processID == 0 && eventMessage CONTAINS[c] "MFE"'

The HandleSet method takes care of most of the orchestration between the user space scanner and the kext. There are many different commands that it understands but I will only cover some of the ones most relevant to basic virus scanning functionality. For more details on the HandleSet function please see the reversed AVKext.c file.

PING_KEXT

This command calls the AV_ping_kext function. It calls the microuptime function passing in a global gWatchDogTime variable. Even though the command doesn’t do a lot, it’s important to call out because other functions expect that the scanner is sending this command approximately once a minute to indicate that it’s still alive and functioning.

ENABLE_KERNEL_HOOK

This command calls the AV_hook_register function. The important thing to note is that this calls into the Mfe_registerKextClient function which is part of the FileCore.kext. The FileCore.kext is the code actually responsible for using the Kernel APIs mentioned above like Kauth and MAC. Additionally one of the parameters to the Mfe_registerKextClient function is AV_Handle_Auth_Events. This handle function is responsible for responding to file operations that FileCore.kext sends to AVKext.kext. Additionally, AV_Handle_Auth_Events is what actually calls ctl_enqueuedata which sends data over the established kernel control socket to the user space scanning application. The data sent to user space scanner takes the following format:

struct FileScanMessage {
    int64_t inode;
    int32_t pid;
    int32_t uid;
    int32_t gid;
    int32_t devid;
    int32_t result;
    int32_t action;
    int32_t u1;
    int32_t u2;
    int64_t u3;
    char file[1024];
    int64_t u4;
};

The user space scanner can then inspect the file to make a decision about what to do. It then marks the action as approved or denied by setting a 1 or 2 respectively in the result field.

PUT_SCAN_RESULT_MSG

When the scanner wants to send it’s response back to the AVKext.kext it uses this command. It calls into the AV_put_scan_result function directly passing in the data and len arguements from the HandleSet function. This command is capable of hanlding multiple FileScanMessage records at a time. The size of the FileScanMessage is 1080 bytes. This command expects a buffer of at least 1088 bytes. The first 8 bytes are a count variable indicating how many FileScanMessage objects are included in the data buffer. AV_put_scan_result will cache some information about the message and then calls into Mfe_handleFileEventResult in the FileCore.kext.

FileCore.kext

Again I started by inspecting the Info.plist for this kext. It had the following information:

<key>CFBundleIdentifier</key>
<string>com.intelsecurity.FileCore </string>
<key>OSBundleLibraries</key>
<dict>
    <key>com.apple.kpi.bsd</key>
    <string>12.0.0</string>
    <key>com.apple.kpi.dsep</key>
    <string>12.4.0</string>
    <key>com.apple.kpi.libkern</key>
    <string>12.0.0</string>
    <key>com.apple.kpi.mach</key>
    <string>14.0</string>
</dict>

Again, this kext does not use a ton of KPIs. Additionally it does not depend on any other kexts and does not use IOKit. Reviewing the start function for the kext reveals that it also sets up a kernel control interface as well as a sysctl entry for log level.

Mfe_registerKextClient / HandleConnect

Whether you connect to the FileCore.kext extension using the kernel control api or directly from another kext both of these functions do basically the same thing. They add a new entry onto a list of clients and increment the global gNumClients variable. Then they call the registerForFileIOEvents function. This function is what’s responsible for hooking all of the file operations. It does the following three things:

  • Calls mac_policy_register passing in a policy_conf configured to hook mpo_vnode_check_rename
  • Calls kauth_listen_scope passing in the com.apple.kauth.vnode scope
  • Calls kauth_listen_scope again passing in the com.apple.kauth.fileop scope

Mfe_handleFileEventResult

This is the function called from the PUT_SCAN_RESULT_MSG command in AVKext.kext. This just calls into handleFileEventResult and the primary purpose of that is to call wakeup on the thread that was waiting on a response about a file.

VShieldScanManager

I did’t spend a lot of time looking into VShieldScanManager, but it can be found in the /usr/local/McAfee/AntiMalware/ directory. I looked at this executable only to confirm the communication back and forth between AVKext.kext and the scanner. I created a proof of concept program to demonstrate the functionality of a basic scanner. It will connect to AVKext.kext and deny access to any files with a known word in the file path. You can see the full code here:

https://gist.github.com/knightsc/f5417a577c14125426146be4d9a3864e

Overall Architecture

Zooming out a little bit the overall architecture of the McAfee scanning solution looks something like this:

McAfee Virus Scanning Architecture

VShieldScanManager is launched from a LaunchDaemon and is responsible for loading and connecting to AVKext.kext. AVKext.kext then uses the FileCore.kext to subscribe to file operations. FileCore.kext is what actually uses the Kauth and MAC kernel APIs to actually hook into file operations. When a FileScanMessage is sent to the VShieldScanManager it will ask a VShieldScanner process to inspect and make a decision.

What could be improved?

The following is a short list of things that can be improved in the kexts and installation of the McAfee product.

1. Verify the connecting scanning process

Ideally you only want your own software connecting and configuring your kext. Apple makes use of entitlement checks to do things like this but unfortunately they do not provide any standard way for kext developers to do the same. I have seen some kexts take the approach of requiring connecting code to pass in an authorization token. While this doesn’t provide any real security, since it’s easily reversed, it does raise the bar a little. By default macOS will only run software that has been code signed. So a better solution would be for the kext to validate the client based on it’s code signature. The kext could use something like csblob_get_teamid to check and validate that the process connecting is a trusted one. In the case of McAfee, the VShieldScanManager is signed and they could validate that it’s signed for Team ID: GT8P3H7SPW before allowing the process to connect.

2. Handle multiple connections properly

I mentioned earlier with the FileCore.kext that it handles multiple connections and keeps track of them all. Unfortunately the AVKext.kext does not. Take a look at the respective connect and disconnect functions for the kernel control interface.

errno_t
HandleConnect(kern_ctl_ref ctlref, struct sockaddr_ctl *sac, void **unitinfo)
{
    if (gUnit == 0) {
        gUnit = sac->sc_unit;
        if (gAVLogLevel >= LOG_DEBUG) {
            printf("MFE_AV: DEBUG\"Scanner connected to kernel module\"\n");
        }
        return KERN_SUCCESS;
    } else {
        return ECONNREFUSED;
    }
}

errno_t
HandleDisconnect(kern_ctl_ref ctlref, unsigned int unit, void *unitinfo)
{
    if (gAVLogLevel >= LOG_DEBUG) {
        printf("MFE_AV: DEBUG\"Scanner disconnected from kernel module\"\n");
    }
    gUnit = 0;
    AV_hook_deregister();
    return KERN_SUCCESS;
}

This code is only expecting a single connection. When the connection is made it pulls out the sc_unit value and puts it into a gUnit global variable. On disconnect it resets this variable and then deregisters the hooks in FileCore.kext.

What this means is when VShieldScanManager is connected, if a second process tries to connect and disconnect, the HandleDisconnect function will blindly reset gUnit and deregister the hooks. If the second process then tries to connect again it will be successful since gUnit was set back to zero. You can try this for yourself using the ScanManager.c sample code. (Reminder it has to be run as root). If you run it once on a machine with McAfee it will fail. If you run it a second time it will connect and you will start receiving the stream of file events on the command line. What’s worse, is since the scanner connection has been hijacked, you will no longer receive real time virus notification from the McAfee user interface. Also since VShieldScanManager is still technically running (just not connected) the user interface thinks everything is fine and threat prevention is operating normally.

I think there’s a simple fix here. When HandleDisconnect is called, before doing anything else, the value of the passed in unit variable should be compared with the gUnit global variable. If it doesn’t match, then this isn’t the currently connected scanner and an error code should be returned. This won’t completely stop the ability to hijack the scanning process but it would make it harder. If this change were made you would need to first kill the scanning process and then try to connect before it does.

3. Sanitize the kext output

Any kext that passes information back to user space should be careful to sanitize the data it sends back. As anyone who follows the iOS jailbreak scene knows, a leaked kernel pointer can help calculate where the kernel is loaded in memory and make further explotation possible. Take a look at a sample of the data sent from the AVKext.kext to the scanner process below.

inode:    0x00000000003c950e
pid:      444
uid:      502
gid:      20
devid:    0x01000004
result:   0x00000001
action:   0x00000064
unknown1: 0x00000001
unknown2: 0x00000001
unknown3: 0xffffff8000000000
file:     /Users/user1/Downloads/Clapzok/Clapzok
unknown4: 0xffffff802a8d8550

This data looks fairly straight forward but remember the file variable is defined as char file[1024]. So if we inspect the full set of data sent back from the kext to user space we’ll see the following:

There’s all sort of extra data in the file array after the actual end of the string. A lot of the data looks to be kernel memory addresses. 00 10 D4 20 80 FF FF FF for example, would be the 64 bit number 0xffffff8929d41000. This looks like a structure that was initially allocated on the stack in the kernel, some fields were set and then it was sent back over to user space.

I think the fix here is easy as well. The data structures sent back to user space should be properly initialized. If this structure was zeroed out before use we wouldn’t have any of the extra garbage that we see above.

4. Sanitize the kext input

In addition to sanitizing the data sent to user space, any kext should be extra careful to sanitize data coming in from user space. When AVKext.kext starts up and calls the ctl_register function it sets the ctl_sendsize and ctl_recvsize large enough to hold up to a thousand FileScanMessage instances. When the PUT_SCAN_RESULT_MSG message is sent the first field is a count. It does not look like any bounds checking is done on the count sent in. If you set the count to 0xffffffffffffffff the result is a kernel panic with the following stack trace:

(lldb) bt
* thread #2, name = '0xffffff80200d3530', queue = '0x0', stop reason = signal SIGSTOP
  * frame #0: 0xffffff800f1a405b kernel.development`memcpy + 11
    frame #1: 0xffffff7f918e6d84 AVKext`AV_put_scan_result + 340
    frame #2: 0xffffff7f918e75ea AVKext`HandleSet + 202
    frame #3: 0xffffff800f804378 kernel.development`ctl_ctloutput(so=0x00000000000122c3, sopt=0xffffff801b9db680) at kern_control.c:1206 [opt]
    frame #4: 0xffffff800f888137 kernel.development`sosetoptlock(so=0xffffff8020bb26b0, sopt=0xffffff90aadcbee8, dolock=1) at uipc_socket.c:4802 [opt]
    frame #5: 0xffffff800f89712e kernel.development`setsockopt(p=<unavailable>, uap=0xffffff802012a000, retval=<unavailable>) at uipc_syscalls.c:2421 [opt]
    frame #6: 0xffffff800f958358 kernel.development`unix_syscall64(state=0xffffff801c1bb140) at systemcalls.c:381 [opt]
    frame #7: 0xffffff800f223466 kernel.development`hndl_unix_scall64 + 22

The AV_put_scan_result function blindly tries to read past the end of the data and the result is a crash. I don’t believe that this crash can lead to full kernel control but there’s no reason to allow this to happen. Since there seems to be an implied limit passed on the ctl_register call it makes sense to fix this issue by adding a simple bounds check to the receieved count.

5. Improve self protections

McAfee Endpoint Security alows administrators to set an ePO administrator password. Users get asked for this password before being allowed to modify preferences. If a local user has root access though it seems like most components can be directly controlled from the command line.

For example if you want to disable the real time notifications you do not need to hijack the scanner you can simply execute the following commands:

$ ps xa | grep VShieldScan | grep -v grep
 1064   ??  Ss     0:03.98 /usr/local/McAfee/AntiMalware/VShieldScanner
 1067   ??  Ss     0:00.05 /usr/local/McAfee/AntiMalware/VShieldScanManager
 1089   ??  S      0:00.00 /usr/local/McAfee/AntiMalware/VShieldScanner
 1090   ??  S      0:00.00 /usr/local/McAfee/AntiMalware/VShieldScanner
 1091   ??  S      0:00.00 /usr/local/McAfee/AntiMalware/VShieldScanner

$ sudo /usr/local/McAfee/AntiMalware/VSControl stopoas
Stopping OAS...

$ ps xa | grep VShieldScan | grep -v grep
$

This would seem to defeat the purpose of having the ability to set an ePO administrator password required for preference changes. Additionally, at least in my current setup, a root user can completely uninstall the software even if they don’t know the ePO administrator password. Now this might be intended functionality, but overall I didn’t see much in the way of self protection features. Adding improved self protection features would probably be a larger change but might really be worth it.

Conclusion

Overall, I didn’t find any major issues with the McAfee implementation. I do think the suggesstions above would help improve the reliability and stability of it though. Similar to how Apple has used SIP to lock down the damage a root level user can do it makes sense that kext developers should try to take the same approach. I’d love to see Apple help provide improved KPIs to make the process of securing kexts even easier.