“Castles Made of Sand”: The fragility of enforced privacy protections
One nuance of TCC (Transparency, Consent, and Control) on macOS that always bugged me was why regular users' home directories and the root user's are treated differently.
Researchers conducting macOS vulnerability research into TCC bypasses will have noticed that enabling and logging in as root will:
- Allow immediate read and write access to
/var/root/Library/Application Support/com.apple.TCC/TCC.db; - Grant root (and all other administrative users) access to protected TCC resources such as
/var/root/Documents,/var/root/Library/Messages, and/var/root/Library/Containers/*without annoying TCC prompts (a questionable design decision when you consider that root is asked to connect an iCloud account upon first login); - Not prompt for password authentication when making modifications to “Privacy & Security” items (e.g., Full Disk Access / kTCCServiceSystemPolicyAllFiles)
- I know, I know - you’re already root, however, modifying user preferences or disabling the lock screen will still initiate a credential prompt.
Conversely, interacting with the System as root will inhibit the use of hardware, such as the camera and microphone, even when explicit access is granted in "Privacy & Security".
butWhy.gif
It turns out that sandboxd is responsible for this contrast in behaviour, and that answer starts to make sense once we look at how it works.
Background:
The sandboxd system daemon is the user-mode counterpart to sandbox.kext, the core macOS kernel extension that enforces system-wide app security and sandboxing rules. Like all system daemons, its existence is managed by launchd, with the associated LaunchDaemon plist residing at /System/Library/LaunchDaemons/com.apple.sandboxd.plist.
% plutil -p /System/Library/LaunchDaemons/com.apple.sandboxd.plist
{
"EnablePressuredExit" => true
"EnableTransactions" => true
"Label" => "com.apple.sandboxd"
"LaunchEvents" => {
"com.apple.notifyd.matching" => {
"com.apple.tcc.access.changed" => {
"Notification" => "com.apple.tcc.access.changed"
}
}
"com.apple.OpenDirectory.ODTrigger" => {
"Record Creation" => {
"Events" => "Add"
"Nodes" => "/Local/Default"
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "Records"
}
"Record Deletion" => {
"Events" => "Delete"
"Nodes" => "/Local/Default"
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "Records"
}
"Record Modification" => {
"Nodes" => "/Local/Default"
"RecordAttributes" => [
0 => "dsAttrTypeStandard:NFSHomeDirectory"
1 => "dsAttrTypeStandard:UniqueID"
]
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "RecordAttributes"
}
}
}
"MachServices" => {
"com.apple.sandboxd" => {
"HostSpecialPort" => 14
}
}
"POSIXSpawnType" => "Interactive"
"ProgramArguments" => [
0 => "/usr/libexec/sandboxd"
]
"RunAtLoad" => true
"StartInterval" => 3600
}
sandboxd exposes itself as a mach service named com.apple.sandboxd on port 14 (7 + HOST_MAX_SPECIAL_KERNEL_PORT). This means that other processes can connect to it via XPC/IPC using this service name, or by the expected HostSpecialPort designation "HOST_SEATBELT_PORT” as defined in https://github.com/apple-oss-distributions/xnu/blob/main/osfmk/mach/host_special_ports.h.
#define HOST_MAX_SPECIAL_KERNEL_PORT 7 /* room to grow */
...
#define HOST_DYNAMIC_PAGER_PORT (1 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_AUDIT_CONTROL_PORT (2 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_USER_NOTIFICATION_PORT (3 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_AUTOMOUNTD_PORT (4 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_LOCKD_PORT (5 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_KTRACE_BACKGROUND_PORT (6 + HOST_MAX_SPECIAL_KERNEL_PORT)
#define HOST_SEATBELT_PORT (7 + HOST_MAX_SPECIAL_KERNEL_PORT)
com.apple.sandboxd.plist also defines LaunchEvents, or triggers that sandboxd uses to protect newly created and modified user home directories.
"LaunchEvents" => {
"com.apple.notifyd.matching" => {
"com.apple.tcc.access.changed" => {
"Notification" => "com.apple.tcc.access.changed"
}
}
"com.apple.OpenDirectory.ODTrigger" => {
"Record Creation" => {
"Events" => "Add"
"Nodes" => "/Local/Default"
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "Records"
}
"Record Deletion" => {
"Events" => "Delete"
"Nodes" => "/Local/Default"
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "Records"
}
"Record Modification" => {
"Nodes" => "/Local/Default"
"RecordAttributes" => [
0 => "dsAttrTypeStandard:NFSHomeDirectory"
1 => "dsAttrTypeStandard:UniqueID"
]
"RecordTypes" => "dsRecTypeStandard:Users"
"TriggerType" => "RecordAttributes"
}
}
This enables launchd to wake up sandboxd and take action when specific things happen in the OS, which is how it keeps user home directories protected.
com.apple.notifyd.matching: Listens for TCC changes. This means that whenever you modify TCC permissions (e.g., you go into “System Settings” >> “Privacy & Security” and toggle a permission),sandboxdis immediately notified and initiates a mac_syscall to clear the TCC approval cache (0x28 via the sandbox kernel extension), and emits a log event “got notification: com.apple.tcc.access.changed”[*].com.apple.OpenDirectory.ODTrigger: Listens for user directory changes; creation, modification and deletion.
It’s not enough for LaunchEvents to be listed in the plist alone; the process is expected to use the XPC Events API to enumerate/consume pending events. In our case, sandboxd registers a handler for the com.apple.OpenDirectory.ODTrigger event stream.
This is the path we will focus on next.
Building sandcastles:
Symbols in the screenshots and code interpretation may have been modified for ease of understanding and context.
/usr/libexec/sandboxdbinary CDHash:958c0494c26476ca96d610d084764b3b110e40b3. Screenshots from Binary Ninja (5.3.9757-stable) mac-aarch64 HLIL view.
The handler block invoke function will generate a log event (under “com.apple.sandbox.sandboxd” subsystem, category: runtime) and then call sandcastle_update_user_home_directories().
What’s worth noting is that our function sandcastle_update_user_home_directories() is called unconditionally, which is significant as it reveals to us that the XPC event types "Record Creation", "Record Modification", and "Record Deletion" follow the same logic, and the context of the original trigger has no impact on the code branch executed. The key is not carried forward, so a modification to any of these will awaken this entire code path.
Again, sandboxd emits a log event (this time under the “com.apple.sandbox“ subsystem, category: sandcastle), and validates the kernel state for variable name: "security.mac.sandbox.sandcastle.enabled". Any case other than a "security.mac.sandbox.sandcastle.enabled"=0 result will ensure that a block is submitted for asynchronous execution on a dispatch queue.
Effectively, this equates to:
int sandcastleEnabled = 0;
size_t size = sizeof(sandcastleEnabled);
int result = sysctlbyname("security.mac.sandbox.sandcastle.enabled", &sandcastleEnabled, &size, NULL, 0); // returns 0 on success
if (result != 0) // sysctlbyname failed (possible error codes include EFAULT, EINVAL, ENOMEM, ENOTDIR, EISDIR, ENOENT, and EPERM)
{
if (errno != ENOENT)
{
if (os_log_type_enabled(log, OS_LOG_TYPE_DEFAULT))
{
os_log(log, "sysctlbyname(\"%{public}s\")","security.mac.sandbox.sandcastle.enabled");
}
}
}
if (sandcastleEnabled)
{
// continue building sandcastles
}
Once passed, sandboxd will emit another sandcastle log event and will make an ODQuery, a call to the Open Directory Framework, which typically involves creating a session and identifying the node to search, which in our case is /Local/Default.
// Create an ODSession and connect to the local directory node
ODSession *session = [ODSession defaultSession];
ODNode *node = [ODNode nodeWithSession:session
type:kODNodeTypeLocalNodes
error:&error];
// Define the attributes to retrieve
NSArray *attributes = @[kODAttributeTypeNFSHomeDirectory,
kODAttributeTypeUniqueID];
After some error checking to ensure both the OD session and node were created properly, we can view the entire OD query at 0x10000d754:
Replication in Objective-C:
#import <Foundation/Foundation.h>
#import <OpenDirectory/OpenDirectory.h>
int main(void) {
@autoreleasepool {
NSError *err = nil;
ODSession *session = [ODSession defaultSession];
ODNode *node = [ODNode nodeWithSession:session
type:kODNodeTypeLocalNodes
error:&err];
if (!node) { NSLog(@"node failed: %@", err); return 1; }
NSArray *attrs = @[kODAttributeTypeNFSHomeDirectory,
kODAttributeTypeUniqueID];
ODQuery *query = [ODQuery queryWithNode:node
forRecordTypes:kODRecordTypeUsers
attribute:nil
matchType:kODMatchEqualTo
queryValues:nil
returnAttributes:attrs
maximumResults:0
error:&err];
if (!query) { NSLog(@"query failed: %@", err); return 1; }
NSArray *results = [query resultsAllowingPartial:NO error:&err];
if (!results) { NSLog(@"results failed: %@", err); return 1; }
printf("%-30s %-10s %s\n", "RecordName", "UniqueID", "NFSHomeDirectory");
for (ODRecord *r in results) {
NSArray *uids = [r valuesForAttribute:kODAttributeTypeUniqueID error:nil];
NSArray *homes = [r valuesForAttribute:kODAttributeTypeNFSHomeDirectory error:nil];
NSString *uid = uids.count ? [uids componentsJoinedByString:@","] : @"<nil>";
NSString *home = homes.count ? [homes componentsJoinedByString:@","] : @"<nil>";
printf("%-30s %-10s %s\n",
[[r recordName] UTF8String], [uid UTF8String], [home UTF8String]);
}
}
return 0;
}
Build with
clang -framework Foundation -framework OpenDirectory -fobjc-arc -o od_query od_query.m
Open Directory will return something like this:
rdowd@Ryans-MacBook-Air odquery % ./od_query
RecordName UniqueID NFSHomeDirectory
_appowner 87 /var/empty
_knowledgegraphd 279 /var/db/knowledgegraphd
_dovenull 227 /var/empty
rdowd 501 /Users/rdowd
_netstatistics 228 /var/empty
_softwareupdate 200 /var/db/softwareupdate
... <snip> ...
root 0 /var/root,/private/var/root
_nsurlsessiond 242 /var/db/nsurlsessiond
_teamsserver 94 /var/teamsserver
daemon 1 /var/root
_biome 289 /var/db/biome
_backgroundassets 291 /var/empty
... <snip> ...
_krb_changepw 232 /var/empty
Not only do we initiate this action contextless (i.e., the code doesn’t care if it was a creation, modification, or deletion), but we also recalculate ALL users' time when this is called.
Following this query, two NSMutableSets are created to sort the results. At 0x10000d7f4 we enumerate over the results: the equivalent of for (ODRecord *record in results) {}:
This function serves a singular purpose: enumeration and integer validation of UniqueID against > 0x1f4. 0x1f4 is 500 in decimal, which is important as on macOS, UID 500 is the historical boundary between system accounts and real user accounts.
Again, we can represent this in Objective-C:
bool sub_100010a94(ODRecord *record) // is_regular_user_record()
{
NSError *err = nil;
NSArray *uidValues = [record valuesForAttribute:kODAttributeTypeUniqueID error:&err];
if (uidValues != nil)
{
for (id uid in uidValues) // iterates all uid values contained within a single record
{
if ([uid integerValue] > 500)
{
return true;
}
}
}
else
{
// log uid could not be retrieved
}
return false;
}
is_regular_user_record() will return 1 if the UID > 500, or 0 if it is <=500. So the function copy_home_directories() enumerates all local user home directories from Open Directory and partitions them by sandbox policy.
Once a user (>500 UID) home directory path (NFSHomeDirectory exists and != null) is then formatted into a UTF-8 string at 0x10000f160 and forms part of a sandbox query along with the operation string “file-write-data” via the __mac_syscall system call (or more specifically, via the __sandbox_ms wrapper function).
To replicate the mac_syscall wrapper function invocation, look at _hook_policy_syscall in sandbox.kext and note the function selections.
NB: I used Blacktop’s ipsw tool to extract sandbox.kext from the relevant Apple IPSW restore image.
Full enumeration of the switch statement:
| Case | Call |
|---|---|
| case 1 | return _syscall_set_kext_profile(arg1, arg3) |
| case 2 | return _syscall_check_sandbox(arg1, arg3) |
| case 3 | return _syscall_sandbox_note(arg1, arg3) |
| case 4 | return _syscall_sandbox_container(arg1, arg3) |
| case 5 | return _syscall_extension_issue(arg1, arg3, x23, x28) |
| case 6 | return _syscall_extension_consume(arg1, arg3) |
| case 7 | return _syscall_extension_release(arg1, arg3) |
| case 8 | return _syscall_extension_update_file(arg1, arg3) |
| case 0xa | return _syscall_sandbox_suspend(arg1, arg3) |
| case 0xb | _syscall_sandbox_unsuspend() |
| case 0xc | return _syscall_passthrough_access(arg3) |
| case 0x10 | return _syscall_inspect(arg1, arg3) |
| case 0x15 | return _syscall_check_sandbox_bulk(arg3) |
| case 0x16 | return _syscall_builtin_query(arg3) |
| case 0x1c | return _syscall_reference_retain(arg1, arg3) |
| case 0x1d | return _syscall_reference_release(arg1, arg3) |
| case 0x1f | return _syscall_sandcastle_pattern_set(arg1, arg3) |
| case 0x20 | return _syscall_sandcastle_pattern_get(arg1, arg3) |
| case 0x21 | return _syscall_approval_result(arg1, arg3) |
| case 0x28 | _syscall_approval_cache_clear() |
| case 0x29 | return _syscall_approval_watchdog(arg1, arg3) |
| case 0x2e | return _syscall_mount_flag_set(arg1, arg3) |
| case 0x33 | return _syscall_spawnattrs_query(arg3) |
| case 0x34 | return _syscall_message_filter_check(arg3) |
| case 0x35 | return _syscall_message_filter_release(arg1, arg3) |
| case 0x37 | return _syscall_autobox_list() |
| case 0x3b | return _syscall_profile_registration(arg1, arg3) |
| case 0x3d | return _syscall_message_filter_retain(arg1, arg3) |
| case 0x3e | return _syscall_sandcastle_appbundle_register(arg1, arg3) |
| case 0x3f | return _syscall_sandcastle_appbundle_unregister(arg1, arg3) |
| case 0x41 | return _syscall_enable_state_flag(arg3) |
| case 0x42 | return _syscall_sandbox_set_container(arg1, arg3) |
| case 0x43 | return _syscall_sandbox_get_container_expected(arg1, arg3) |
| case 0x44 | return _syscall_bastion_profile_registration(arg1, arg3) |
| case 0x45 | return _syscall_syncroot_register(arg1, arg3) |
| case 0x47 | return _syscall_sandcastle_exception(arg1, arg3) |
| case 0x4b | return _syscall_sandcastle_appcontainer_register(arg1, arg3) |
| case 0x4c | return _syscall_sandcastle_appcontainer_unregister(arg1, |
| case 0x4d | return _syscall_sandcastle_appcontainer_check(arg1, arg3) |
| case 0x4f | return _syscall_extension_update_file_by_fileid(arg1, arg3) |
| case 0x50 | return _syscall_extension_release_and_detect_last_reference() |
When a home directory candidate is validated, it is inserted into one of the two relevant destination sets as determined by is_regular_user_record():
- regular_user_home_dirs; or
- system_user_home_dirs.
Once the entire ODQuery output has been calculated, these NSMutableSets are passed to the appropriate function for processing.
Both of these functions behave in a somewhat similar way; update_regular_user_home_patterns() will build Sandcastle-protected and intermediate folder pattern data for regular-user home directories and publish the resulting blobs, while update_system_user_home_patterns() creates the same pattern data for system-user home directories. Both ultimately culminate in a call to publish_sandcastle_pattern_blob() with a slot reference of:
- 0: regular-user protected-folder blob.
- 1: regular-user intermediate-folder blob.
- 5: system-user protected-folder blob.
NB: A folder included in an OD listing for a user that does not yet exist triggers an action to “watch” the location, with the protective action applied when it is ultimately created.
The request is packaged up and sent to __sandbox_ms opcode 0x1f, aka “sandcastle_pattern_set” as per the above table:
We can observe the occurrence of the above-mentioned observations of sandboxd in the following terminal:
Note the subtle changes in the sandcastle pattern set(#) when we create a new user with UID < 500:
Furthermore, the changes enforced to protected locations can be found in /private/var/db/Sandbox (root + FDA required):
/private/var/db/Sandbox
-rw------- 1 root apps.v2
-rw------- 1 root containers
-rw------- 1 root homedir_ancestors
-rw------- 1 root homedirs
-rw------- 1 root network-exemptions
-rw------- 1 root role_account_homedirs
-rw------- 1 root translation
With an understanding of how requests are created in user-land and passed to the kernel-side handler, we can focus on edge cases that lead to unexpected outcomes.
Exploit #1: CVE-2024-44219 - “Home Wrecker”:
With a few foreshadowing comments in the section above, let’s replicate the user creation above, but with a subtle difference.
Note the changes in the sandcastle pattern set(#) when we create a new user with UniqueID of 402:
While user creation is a privileged action, the modification of the NFSHomeDirectory attribute has been TCC-guarded since the early 2020’s, when Matt Shockley tampered with $HOME, followed shortly thereafter by Wojciech Reguła, JBO’s PowerDir, and Csaba Fitzl with their own takes. However, as Csaba noted, we can still set the NFSHomeDirectory for newly created users without hitting a TCC gate.
What happens when we create a new user with a UniqueID < 500 (or perhaps no UID at all) and set NFSHomeDirectory to that of an existing user?
ODQuery will return
for values that do not exist. For instance, if we create a new user with no UID, but NFSHomeDirectory specified, it will automatically be sorted as a service account. As is still logically <=500, it will be sorted into the system user-protected path bucket.
With privileged access, we can create the conditions necessary for the victim’s home directory to meet the criteria.
Exploit code: # dscl . -create /Users/newuser NFSHomeDirectory <victim-home-directory>
That’s it. The victim’s home directory is calculated as both the regular user and system user locations, and the system-user-protected directory wins out. For as long as this new user exists (as creations, modifications, and deletions of user directory attributes will force a full re-calculation), TCC protections on our victim’s account are reduced to that of a system account, allowing complete enumeration of protected content.
This vulnerability was identified in macOS Sequoia 15.0 beta 5 and was patched by Apple in macOS 15.1.
Exploit #2: CVE-2025-31249 - “Home Squatter”:
Alongside exploit #1, I identified an adjacent bug that impacted (at the time) Sequoia 15.1, Sonoma 14.6.1, Ventura 13.6.9 and Monterey 12.7.6.
The application of the intermediate folder protection was vulnerable to tampering. When providing an NFSHomeDirectory attribute for a new user (UniqueID > 500) whose home directory is an intermediate folder of an existing user, the regular-user protected folder protection will not apply.
E.g., Alice has a UID = 501 and a home directory at /Users/Alice/. Eve requests a UID = 502 and a home directory at /Users/. When enforced, /private/var/db/Sandbox/homedirs will reflect the user protection at /Users/ but will omit any protection at /Users/Alice/.
Exploit code: # dscl . -create /Users/user UniqueID "502"; dscl . -create /Users/user NFSHomeDirectory /Users/
sh-3.2# cd /private/var/db/Sandbox/
sh-3.2# cat homedirs
M/users/macuser@/?
sh-3.2# dscl . -create /Users/user UniqueID "502"; dscl . -create /Users/user NFSHomeDirectory /Users/
sh-3.2# cat homedirs
E/users@/?
sh-3.2# dscl . -create /Users/user2 UniqueID "503"; dscl . -create /Users/user2 NFSHomeDirectory /
sh-3.2# cat homedirs
@/
This bug was finally patched in Sequoia 15.5, but it had remained present in all previous versions. I believe it was patched eventually, but there was no mention in the release notes, and I never followed up with Apple.
In response to these bugs, Apple made changes modifications to both sandboxd with explicit focus on the validation of user paths at an almost molecular level (many of my early POCs leveraged symlinks), as well as changes to sandbox.kext that validate suspicious / edge-cases such as the existence of dual class home directories (regular user + system user), and ultimately applying the more protective of both patterns (regular user).
Ultimately, Apple awarded a cumulative bounty of US$36,000 for these bugs.
Final Thoughts:
Interestingly, while the logic bugs have been addressed, modifications to UniqueID for existing users are still only guarded by a single TCC gate. While we still observe macOS stealer attacks in the wild that leverage osascript prompts to elevate privileges via fraudulent system update messages, even when spawned from a Terminal with FDA applied, they still generate a plethora of TCC prompts.
At the time of writing (Tahoe 26.5.2), a modification to this user attribute will result in the following TCC prompt, which more closely aligns with the pageantry of “please enter your password so that your administrator can apply system updates” than that of “Terminal wishes to access Desktop/Documents/Downloads/Notes, etc”. It is also worth noting that attempts to access the AddressBook or Messages database will not even reach a prompt; they are denied immediately. The ability to set and reset the UID of a victim account within the 60-second approval window of this prompt will provide you with more than enough time for any stealer to make off with the loot. Furthermore, TCC.db can be modified during this window, allowing an attacker to revert the UID changes and have an established foothold amongst the privacy controls.
The Apple Security Bounty program will not acknowledge any vulnerability that requires the user to click through such prompts.
Fits with the ruse, doesn't it?
This is not new or novel, with the impact on tccd of changing the NFSHomeDirectory being known for the better part of a decade, and raises the question of why we don’t see more of it. Perhaps we aren’t looking.
Is your macOS EDR subscribed to the OpenDirectory ES event?
ES_EVENT_TYPE_NOTIFY_OD_ATTRIBUTE_SET has been available since macOS 14.0. The defined structure of this event, as obtained from the ESMessage.h header file, is as follows:
typedef struct {
es_process_t *_Nullable instigator;
int error_code;
es_od_record_type_t record_type;
es_string_token_t record_name;
es_string_token_t attribute_name;
es_string_token_t attribute_value;
es_string_token_t node_name;
es_string_token_t db_path;
audit_token_t instigator_token; // Available in msg versions >= 8.
} es_event_od_attribute_value_add_t;
Modification of either the UniqueID attribute will result in something like the following:
{
"mach_time": 5056488275,
"seq_num": 0,
"event": {
"od_attribute_set": {
"record_type": 0,
"instigator": {
"cdhash": "716C1A0D220385E40A4EE9D64A08AFE866A04615",
"team_id": null,
"session_id": 858,
"signing_id": "com.apple.dscl",
"is_platform_binary": true,
"audit_token": {
...
},
"is_es_client": false,
"executable": {
"path_truncated": false,
"stat": {
...
},
"path": "/usr/bin/dscl"
},
"start_time": "2026-07-23T05:43:06.356135Z",
"cs_validation_category": 1,
"parent_audit_token": {
...
},
"codesigning_flags": 637606673,
"group_id": 859,
"ppid": 858,
"original_ppid": 858,
"tty": {
"path_truncated": false,
"stat": {
...
},
"path": "/dev/ttys003"
},
"responsible_audit_token": {
...
}
},
"attribute_name": "dsAttrTypeStandard:UniqueID",
"attribute_values": [
"402"
],
"instigator_token": {
...
},
"record_name": "macuser",
"error_code": 0,
"node_name": "/Local/Default",
"db_path": "/var/db/dslocal/nodes/Default"
}
},
"version": 10,
"thread": {
"thread_id": 8219
},
"global_seq_num": 0,
"schema_version": 1,
"action": {
"result": {
"result": {
"auth": 0
},
"result_type": 0
}
},
"time": "2026-07-23T05:43:06.400441201Z",
"process": {
"cs_validation_category": 1,
"team_id": null,
"responsible_audit_token": {
...
},
"codesigning_flags": 637623057,
"start_time": "2026-07-23T05:39:25.058078Z",
"ppid": 1,
"session_id": 128,
"tty": null,
"is_es_client": false,
"executable": {
"path": "/usr/libexec/opendirectoryd",
"stat": {
...
},
"path_truncated": false
},
"group_id": 128,
"audit_token": {
...
},
"original_ppid": 1,
"parent_audit_token": {
...
},
"cdhash": "85D20BDB864F0C3913ED5363D1796EE9562ADBE8",
"is_platform_binary": true,
"signing_id": "com.apple.opendirectoryd"
},
"action_type": 1,
"event_type": 140
}
Consider the following pseudocode condition for detections:
event_type == 140 // NOTIFY_OD_ATTRIBUTE_SET
AND event.od_attribute_set.error_code == 0 // the set actually succeeded
AND event.od_attribute_set.attribute_name == "dsAttrTypeStandard:UniqueID"
AND ANY v IN event.od_attribute_set.attribute_values
WHERE to_int(v) < 500 // numeric cast, per-element
AND NOT startswith(event.od_attribute_set.record_name, "_")// avoid FP for service accounts
For what it's worth, I don’t build macOS EDR tooling (I wish I did). As with any detection rule, ensure it’s correct for your environment and underpinned by the appropriate triage process(es) to ensure validation and appropriate process lineage validation.
Thanks for reading!
--
[*] Private Data was Enabled in Unified Logs: https://www.jamf.com/blog/unified-logs-how-to-enable-private-data/


















