Apple Internals: Endpoint Security ships in iOS 27.2 beta
Is antivirus coming to iPhone?
Apple Internals: Endpoint Security ships in iOS 27.2 beta
While doing my usual ipsw diffing for the community, I compared iOS 27.0 (24A437) against the 27.2 beta (24B5084k) and found a new KEXT (kernel extension): com.apple.iokit.EndpointSecuritySE. That is Endpoint Security (ES), the framework endpoint-detection-and-response (EDR) vendors build on for macOS. Apple documents the public API here. Does this mean antivirus is coming to iOS?
TL;DR: Not in this beta. The KEXT only comes alive on devices in research mode. In this image, watchdogd is the only binary the entitlement scan found holding the key required for the ordinary ES client connection. I found no subscription calls in watchdogd. Any client that can connect appears to be limited to notifications: it can observe the underlying operation but cannot block it. The rest of this post shows how I got to each of those claims with ipsw.
How Endpoint Security works, briefly
On macOS, ES is how security software watches the system. A client process (an EDR agent) holds an ES entitlement (a key in its code signature that the kernel reads as proof Apple approved that binary for ES access), opens a connection to the ES kernel driver through libEndpointSecurity.dylib, and subscribes to event types. Events come in two kinds. Notify events (ES_EVENT_TYPE_NOTIFY_*) tell the client that something happened, such as a process exec or a file open, after the fact. Auth events (ES_EVENT_TYPE_AUTH_*) pause the operation while the client decides to allow or deny, subject to a response deadline, and that is what lets an EDR block things.
Inside the kernel, the driver gets its view of the system by registering a policy with the Mandatory Access Control (MAC) framework in XNU, Apple's kernel, the same callback table the sandbox is built on. A MAC policy is a struct of function pointers, one per operation the kernel is willing to ask about (open this file, create a thread in that task, etc). Each populated slot is a hook.
Two userspace daemons support the driver on macOS:
endpointsecuritydmanages client registration and maintains a system-wide default mute list, a set of paths whose events clients don't receive unless they opt back in.sysextdloads the system extensions that host third-party clients.
Neither daemon relays the kernel-originated events discussed here; clients receive those directly from the kernel over their own connection.
Keep those three pieces in mind, the MAC policy in the kernel, the client connection, and the daemons around it. On iOS each one turns out to be present, restricted, or missing.
A new KEXT and dylib
First, both halves are in the image:
❯ ipsw kernel kexts kernelcache.release.iPhone19,2 | rg -i endpoint
0xfffffe00079347a0: com.apple.iokit.EndpointSecuritySE (1)
❯ ipsw dsc info dyld_shared_cache_arm64e_x1 -l | rg -i endpoint
4414: (657.40.26.0.0) /usr/lib/libEndpointSecurity.dylib
The KEXT and dylib both report source version 657.40.26.0.0. The dylib exports 67 es_* symbols, close to macOS 27.2's set; the notable absence is the three es_*_early_boot_client* calls, which on macOS are used by endpointsecurityd. es_subscribe and the es_respond_* families are present, but exports only say what the library offers, not what the kernel accepts.
The KEXT uses the same IOKit class names as macOS, EndpointSecurityDriver and EndpointSecurityExternalClient, and its diagnostic strings carry an esk_ios_se prefix. One of them names the restriction directly:
esk_ios_se: device is not in research mode; refusing IOService::init
There is no endpointsecurityd and no sysextd in the iOS filesystem, so whatever ES does on iOS, it does without these daemons.
Why doesn't it run on retail iPhones?
It turns out that the ES KEXT only starts on devices in research or extended-research mode.
Research mode is the configuration a Security Research Device (SRD) boots into; a retail iPhone never reports it. Both flags come from the Trusted Execution Monitor at boot (txm_ro_data->buildType.research and .extendedResearch in XNU's txm.c); XNU's own header describes each as requiring "research fusing and the use of a security research device" and doesn't distinguish them further.
The first check is in the KEXT's start routine (pseudocode reconstructed from the disassembly):
kern_return_t esk_kext_start(kmod_info_t *ki, void *data)
{
int v = 0;
if (PE_parse_boot_argn("es_disable", &v, sizeof(v)) && v != 0) {
g_es_disabled = true;
es_log(INFO, "EndpointSecurity disabled via es_disable boot-arg; "
"skipping MAC policy registration");
return KERN_SUCCESS;
}
if (!research_mode_state() && !extended_research_mode_state()) {
es_log(INFO, "esk_ios_se: device is not in research mode; kext inert");
return KERN_SUCCESS;
}
/* only reached in research or extended-research mode */
... TimeSource, EventManager, mac_policy_register() ...
}
EndpointSecurityDriver::init repeats it before calling IOService::init:
bool EndpointSecurityDriver::init(OSDictionary *dict)
{
if (g_driver_instance != nullptr)
panic("EndpointSecurity driver should never be initialized twice");
if (g_es_disabled) {
es_log(INFO, "EndpointSecurity disabled via es_disable boot-arg; "
"refusing IOService::init");
return false;
}
if (!research_mode_state() && !extended_research_mode_state()) {
es_log(INFO, "esk_ios_se: device is not in research mode; "
"refusing IOService::init");
return false;
}
g_driver_instance = this;
return IOService::init(dict);
}
The two early returns matter for different reasons. Returning from esk_kext_start before mac_policy_register means XNU never learns the policy exists, so none of its hooks are ever called. Returning false from init means the EndpointSecurityDriver service is never published, so there is nothing for a client to open. On a retail device both happen. The KEXT sits in the kernelcache doing nothing, and es_new_client has nowhere to connect. The es_disable boot argument lets a research-mode device switch it off as well.
What the MAC policy hooks
On a research-mode device (with es_disable unset) the policy does register, so the next question is which operations it can see. The answer is the mac_policy_ops struct the KEXT passes to mac_policy_register. This is the table of callbacks XNU will invoke, one per operation. I dumped the iOS table and used the macOS 27.2 KDK (Apple's Kernel Debug Kit, which ships debug-symbol files for released kernels) to name each populated slot. The offsets line up with the macOS layout; I'm relying on that correspondence for the names below.
# mac_policy_register is called at 0xfffffe0009d1cc20; its config at
# 0xfffffe000bb55788 points to mpc_ops at 0xfffffe0008327590
❯ ipsw macho dump kernelcache.release.iPhone19,2 0xfffffe0008327590 --count 335 --addr
❯ ipsw kernel dwarf --type mac_policy_ops \
/Library/Developer/KDKs/KDK_27.2_26B5086k.kdk/System/Library/Kernels/kernel.release.t8142.dSYM
The table has 42 populated slots. The 36 that describe observable operations are below; the other six are housekeeping:
mpo_policy_initmpo_policy_initbsdmpo_thread_userretmpo_cred_check_label_update_execvempo_cred_label_associate_forkmpo_cred_label_update_execve
Most of the process-side hooks watch how one process reaches into another. Task-port acquisition (get_task_with_flavor, task_id_token_get_task), remote thread creation, thread_set_state, exception-port and special-port changes, mprotect, anonymous mmap, set_cs_info, and mpo_proc_check_debug (which ptrace goes through) are the primitives of process injection and debugging. On the file side it hooks opens, file-backed mappings, truncation, creation, unlinks, renames and filesystem remounts.
| offset | hook | offset | hook |
|---|---|---|---|
| 0x120 | mpo_file_check_mmap | 0x3f8 | mpo_proc_check_set_host_exception_port |
| 0x150 | mpo_file_notify_close | 0x4e8 | mpo_proc_check_debug |
| 0x158 | mpo_proc_check_launch_constraints | 0x520 | mpo_proc_check_mprotect |
| 0x160 | mpo_proc_check_service_port_derive | 0x560 | mpo_proc_check_remote_thread_create |
| 0x168 | mpo_proc_check_set_task_exception_port | 0x5f8–0x620 | mpo_proc_check_set{,e,re}{u,g}id (×6) |
| 0x170 | mpo_proc_check_set_thread_exception_port | 0x798 | mpo_proc_notify_exit |
| 0x178 | mpo_thread_check_set_state† | 0x7d0 | mpo_proc_check_set_cs_info |
| 0x180 | mpo_exc_action_check_exception_send2† | 0x858 | mpo_vnode_check_open |
| 0x188 | mpo_proc_notify_sigaction† | 0x8d0 | mpo_vnode_check_truncate |
| 0x258 | mpo_proc_check_set_task_special_port | 0x8d8 | mpo_vnode_check_unlink |
| 0x2c0 | mpo_mount_check_remount | 0x978 | mpo_vnode_notify_create |
| 0x308–0x318 | mpo_proc_check_{expose,get}_task_with_flavor, task_id_token_get_task | 0x9a0 | mpo_proc_check_suspend_resume |
| 0x3c0 | mpo_vnode_check_rename | 0x9d8 | mpo_proc_check_map_anon |
| 0x3d0/0x3d8 | mpo_proc_notify_exec_complete / _cs_invalidated | 0x3f0 | mpo_proc_check_set_host_special_port |
† The 27.0 KDK still labels these slots mpo_reserved08 through mpo_reserved10; the names come from the macOS 27.2 KDK.
Many of those names carry _check_, meaning the kernel calls them at the point where it could refuse the operation. That says where the callback sits, not what the ES handler does with it. Whether a client can request to block is decided by the subscription filter next.
The 42 hooks above and the event types in the next section are different inventories. Hooks are where the kernel calls into ES; event types are what a client asks to receive. The two counts aren't expected to match.
What a client can subscribe to
Subscriptions go through EndpointSecurityExternalClient::updateSubscription, which checks each recognized event type in the client's request against a fixed iOS allow-list and rejects the whole request if any of them is off it. Concretely, subscribing to ES_EVENT_TYPE_NOTIFY_EXEC alone passes this filter; adding ES_EVENT_TYPE_AUTH_EXEC to the same request fails it entirely with "esk_ios_se: rejecting subscription to unsupported event(s)".
The allow-list has 37 entries: 27 documented NOTIFY_* types and ten new types I infer are notification-only from the corresponding macOS senders.¹ No AUTH_* type is on it. So a client that passes the filter hears about an exec or a task-port grab after it has happened; the kernel never pauses an operation waiting for the client's answer. On this evidence, ES on iOS is observe-only. Passing the filter means the request is accepted, not that every one of the 37 is emitted end-to-end on this build; confirming that would take a live trace on a research device.
The 37 cover:
- process lifecycle (
NOTIFY_EXEC,FORK,EXIT) - file operations (
OPEN,CLOSE,CREATE,MMAP,MPROTECT,RENAME,UNLINK,TRUNCATE,REMOUNT) - task-port and control (
GET_TASK,GET_TASK_NAME,GET_TASK_READ,GET_TASK_INSPECT,PROC_SUSPEND_RESUME,TRACE,REMOTE_THREAD_CREATE,CS_INVALIDATED) - credential changes (
SET{,E,RE}{U,G}ID) XPC_CONNECT- the ten process-state events new in 27.2 (exception ports, special ports,
thread_set_state,set_cs_info, anonymous mappings,sigaction)
The implementation turns the caller's list into a 172-bit set — one bit per event type, anything at or above 172 logged as "Ignoring invalid event type" and dropped — and tests it against three hard-coded 64-bit forbid masks.
¹ Types 162–171 aren't in the 27.0 SDK's ESTypes.h. I inferred their names (set_host_exception_port, set_task_exception_port, set_thread_exception_port, exception_deliver, thread_set_state, set_host_special_port, set_task_special_port, set_cs_info, map_anon, sigaction) from type information retained in watchdogd's compiled ES handler. The macOS 27.2 kernel symbols associate all ten with sendNotifyOnly* senders, so I classify the iOS types as notify-only; that stays an inference until the iOS SDK or sender paths confirm it. The macOS side of these events is covered in a companion post (coming soon).
Who uses it?
A process talks to an IOKit driver by opening a user client: a kernel object the driver creates to represent that process's connection and route its calls.
Getting one from the ES driver is a chain of checks:
- The sandbox asks: is this process allowed to open this driver's user-client class at all?
- The driver's
newUserClientmethod asks: which kind of connection is wanted, and is the caller root (the Unix superuser)? - An entitlement check asks: was this binary signed with Apple's permission for ES?
- And a TCC check — Apple's Transparency, Consent and Control layer, the one behind "Allow X to access…" prompts — asks: has the user approved it?
I looked for candidate clients three ways: who links the library, who holds the entitlements, and whose sandbox profile names the driver.
Who links the library:
❯ ipsw dsc imports dyld_shared_cache_arm64e_x1 /usr/lib/libEndpointSecurity.dylib
libEndpointSecurity.dylib Imported By:
======================================
In FileSystem DMG (Apps)
------------------------
/usr/libexec/watchdogd
One binary: watchdogd, the daemon that restarts system services when they hang. The link is weak (LC_LOAD_WEAK_DYLIB), so watchdogd still launches if the dylib is missing.
Who holds the entitlements the KEXT checks, from a scan of the IPSW filesystem. .embeddedclient is one of the requirements for the ordinary ES client connection, alongside root and TCC; the others are here for reference.
| Entitlement | Holder |
|---|---|
com.apple.developer.endpoint-security.client | watchdogd |
com.apple.private.endpoint-security.client | watchdogd |
com.apple.private.endpoint-security.embeddedclient | watchdogd |
com.apple.private.endpoint-security.default-muter | watchdogd |
com.apple.private.endpoint-security.manager | (none found) |
com.apple.private.endpoint-security.exclusive-mode | (none found) |
Which of those matters is decided when the connection opens. IOServiceOpen passes the caller's integer type argument to the driver's newUserClient; the driver uses it to pick which user-client class backs the connection and which checks that class applies. On iOS, EndpointSecurityDriver::newUserClient handles five values:
| type | class | gate |
|---|---|---|
| 0 | EndpointSecurityDriverClient | com.apple.private.endpoint-security.manager |
| 1 | EndpointSecurityExternalClient | clientHasPrivilege(task, "root") and com.apple.private.endpoint-security.embeddedclient and a TCC check |
| 2 | EndpointSecurityNoAuthClient | none in newUserClient |
| 3 | — | rejected: "esk_ios_se: descendants clients are not supported" |
| 4 | — | rejected: "esk_ios_se: async reply ports are not supported" |
Type 1 is the path an ordinary ES client takes, and the one es_new_client uses. Its three checks run in order, each with its own failure message:
IOUserClient::clientHasPrivilegewithkIOClientPrivilegeAdministrator("Connection attempt from unprivileged client disallowed" if the caller isn't root)IOTaskHasEntitlementfor.embeddedclient("Task does not have permission to create a connection")- TCC for
kTCCServiceEndpointSecurityClient("Task has not been granted user permission to connect").
watchdogd holds .embeddedclient and lists kTCCServiceEndpointSecurityClient in its com.apple.private.tcc.allow entitlement, the private mechanism a system binary uses to pre-satisfy a TCC service without a prompt. Those are entitlement values read from the binary; I haven't verified on-device that the TCC layer honors the pre-grant or that watchdogd runs as root. It's the only .embeddedclient holder the entitlement scan returned (four binaries had blobs the parser skipped), and holding .client without it isn't enough, even on an SRD.
Type 2 is a diagnostic client that only exposes a memoryStats call. newUserClient applies no entitlement check to it. Sandbox still applies, and I looked for two routes: a profile that names the class in an explicit iokit-open allow (none in the compiled profile set), and a binary that lists EndpointSecurityNoAuthClient in its com.apple.security.exception.iokit-user-client-class entitlement (none in this image). That covers the routes I checked; it doesn't rule out an unsandboxed root process reaching it directly.
com.apple.private.endpoint-security.client adds no subscription capability on the type-1 path. It doesn't appear in newUserClient at all; it's read in EndpointSecurityClientManager::addClient, which records client-or-embeddedclient as a capability flag on the client. updateSubscription reads that flag ahead of the allow-list masks and rejects types 162–171 with kIOReturnUnsupported if it's clear. Since .embeddedclient is already required to open type 1, every admitted type-1 client has the flag by construction, and the check never fires on that path.
.manager is the entitlement endpointsecurityd holds on macOS to drive EndpointSecurityDriverClient (early-boot registration, cache stats, setDaemonPort). The scan found no holder on iOS, which matches the daemon's absence.
None of these four ES entitlements are new to watchdogd; it held all of them (developer .client, private .client, .embeddedclient and .default-muter) in iOS 27.0 (24A437). Three other things did change in 27.2: the KEXT appeared, the dylib appeared, and watchdogd's sandbox profile gained iokit-open allow rules naming the ES driver and client classes:
# Protobox-Autobox/watchdogd
+ (iokit-registry-entry-class "EndpointSecurityExternalClient")
...
+ (iokit-registry-entry-class "EndpointSecurityDriver")
watchdogd keeps the mute list
Given all that, I expected watchdogd to be subscribing to something, maybe ES_EVENT_TYPE_NOTIFY_EXIT on the daemons it monitors. It imports exactly four symbols from the library:
❯ nm -m watchdogd | rg 'from libEndpointSecurity'
(undefined) weak external _es_new_client
(undefined) weak external _es_delete_client
(undefined) weak external _es_default_mute_path_events
(undefined) weak external _es_default_unmute_path_events
No es_subscribe. Its message handler logs "Ignore incoming ES message %u" for anything it receives. What it does with the connection is in its log strings:
0x100015bd7: "ESK seems not available"
0x100016377: "Notify ESK about watchdogd enrolled"
0x1000163c1: "Notify ESK to mute service %s"
0x1000163f9: "Notify ESK to unmute service %s"
0x10001645e: "Notify ESK about %s enrolled"
0x1000164a6: "Notify ESK about %s unenrolled"
The connection is used to maintain the default mute list; I found no subscription calls in watchdogd. Could anything on that list stop an iOS ES client from receiving one of the 37 notifications the filter allows?
Muting is per path and per event type. If a client wanted to skip file-open events from a busy daemon, for example, it would tell the kernel "don't deliver NOTIFY_OPEN for processes running from /usr/sbin/cfprefsd". Each client keeps its own such list.
Separately the kernel keeps a system-wide default that every new client inherits and can then override for itself with es_unmute_path. On macOS endpointsecurityd maintains that shared default; on iOS watchdogd has taken the job — that's what the .default-muter entitlement permits and es_default_mute_path_events performs — muting a service's path when it enrolls in watchdog monitoring and unmuting when it leaves.
watchdogd's runtime es_default_mute_path_events and _unmute_ calls both pass the same 38-entry event array, and every entry is an ES_EVENT_TYPE_AUTH_*. The KEXT also ships two static default lists, registered at boot:
# group 1: 43 event types (39 ES_EVENT_TYPE_AUTH_*, all except AUTH_EXEC,
# plus 4 ES_EVENT_TYPE_RESERVED_* SDK-labelled slots)
/usr/libexec/runningboardd /usr/libexec/trustd /usr/bin/sample
/usr/libexec/watchdogd /usr/sbin/cfprefsd /usr/bin/heap
/usr/libexec/remoted /usr/libexec/diagnosticd /usr/bin/lskq
/usr/libexec/configd /usr/sbin/spindump /usr/sbin/lsof
/usr/libexec/amfid /usr/libexec/sandboxd
# group 2: muted for AUTH_EXEC only (event 0)
/usr/sbin/spindump /usr/bin/tailspin /usr/appleinternal/bin/tailspin
/usr/bin/sample /usr/bin/heap /usr/bin/lskq /usr/sbin/lsof
Every one of the 44 event types across the two static groups (43 in group 1, AUTH_EXEC in group 2\) is an AUTH_* or RESERVED_* type. The 37 types the iOS filter accepts are all NOTIFY_* or inferred notify-only. The intersection, static and runtime, is empty. Neither the KEXT's static defaults nor watchdogd's runtime updates suppress any notification type the iOS filter accepts. It's macOS default-muter behavior running on a platform where the events it mutes are already off the allow-list.
Conclusion
Endpoint Security is in the iOS 27.2 beta: a MAC policy with 42 populated hook slots, a client library exporting 67 es_* symbols, and the same IOKit class names as macOS. In this build it's fenced off at every step I checked. The KEXT registers nothing unless the device reports research or extended-research mode. The type-1 connection requires root, .embeddedclient, and TCC, and watchdogd is the only .embeddedclient holder our scan found. The subscription filter accepts 37 event types, all NOTIFY_* or inferred notify-only, so no client appears able to block an operation through ES. I found no subscription calls in the only linked client, watchdogd; the mute list it maintains covers only event types the iOS filter already rejects. This beta does not make ES available to antivirus apps on ordinary iPhones: ES on iOS is research-mode-only and, on the evidence here, notify-only.
It reminds me of our Swift-in-the-Kernel post, where the macOS 27.0 beta shipped a Swift runtime with no consumers and the iOS beta shipped none at all (still true in 27.2). Apple's spring release train, which has landed as the .3 some years and the .4 in others, is where the bigger mid-cycle security changes have shipped; if broader ES access or real kernel Swift use is coming, that's where I'd watch for it.
Reproduce it
# grab the beta and the kernelcache
❯ ipsw download ipsw --device iPhone19,2 --build 24B5084k
❯ ipsw extract --kernel iPhone19,2,iPhone19,3,iPhone19,7_27.2_24B5084k_Restore.ipsw
# confirm the kext
❯ ipsw kernel kexts 24B5084k__*/kernelcache.release.iPhone19,2 | rg -i endpoint
# strings + IOKit classes
❯ ipsw macho info 24B5084k__*/kernelcache.release.iPhone19,2 \
--fileset-entry com.apple.iokit.EndpointSecuritySE --strings
❯ ipsw kernel cpp 24B5084k__*/kernelcache.release.iPhone19,2 | rg -i endpoint
# count populated MAC-policy slots (reject incomplete dumps)
# mpc_ops lives at 0xfffffe0008327590 (config 0xfffffe000bb55788,
# mac_policy_register call at 0xfffffe0009d1cc20)
❯ ipsw macho dump 24B5084k__*/kernelcache.release.iPhone19,2 0xfffffe0008327590 \
--count 335 --addr --no-color | \
awk '/^0x[[:xdigit:]]+$/ { rows++; if ($1 !~ /^0x0+$/) used++ }
END { if (rows != 335) exit 1; print used+0 }'
# who links the dylib
❯ ipsw extract --dyld iPhone19,2,iPhone19,3,iPhone19,7_27.2_24B5084k_Restore.ipsw
❯ ipsw dsc imports 24B5084k__*/dyld_shared_cache_arm64e_x1 \
/usr/lib/libEndpointSecurity.dylib
# who holds the entitlements (repeat --has for each key in the table)
❯ ipsw ent iPhone19,2,iPhone19,3,iPhone19,7_27.2_24B5084k_Restore.ipsw --fs \
--has com.apple.private.endpoint-security.embeddedclient
# who has any ES iokit-user-client-class in a sandbox exception (type-2 reach)
# (bash loop)
❯ for c in EndpointSecurityExternalClient EndpointSecurityNoAuthClient \
EndpointSecurityDriver EndpointSecurityDriverClient; do
ipsw ent iPhone19,2,iPhone19,3,iPhone19,7_27.2_24B5084k_Restore.ipsw \
--fs --value "$c" --file-only
done
# watchdogd's default-mute event set (38 × es_event_type_t at 0x100010ec0)
❯ ipsw extract iPhone19,2,iPhone19,3,iPhone19,7_27.2_24B5084k_Restore.ipsw \
--files --pattern 'usr/libexec/watchdogd$'
❯ ipsw macho dump 24B5084k__*/usr/libexec/watchdogd 0x100010ec0 \
--size 152 --bytes | od -An -v -tu4
The full 27.0→27.2 diff this came out of is at blacktop/ipsw-diffs.
About Calif
We push offensive security research to its limits, understand what is becoming possible with AI, and use those insights to help our customers defend themselves.
Get in touch, and subscribe to our newsletter for more research like this:
Check your inbox to confirm.
Related research


