Published
- 36 min read
macOS TCC Manipulation in the Wild: AppleScript Droppers That Grant Themselves Full Access
Stay Safe Online Without Making It Your Second Job
The Digital Fortress (Second Edition)
A warm, plain-English guide for people with real lives and finite patience. Learn the handful of habits that genuinely protect your money, accounts, and family, and get honest permission to ignore the rest.
For People Who Cannot Afford to Get Privacy Wrong
The Anonymity Playbook (Second Edition)
A practitioner’s field manual for journalists protecting sources, whistleblowers, and activists. It explains how the surveillance actually works, what each technique costs you, and exactly where it fails.
Write, Ship, and Maintain Code Without Shipping Vulnerabilities
Secure Software Development
A hands-on security guide for developers and IT professionals who ship real software. Build, deploy, and maintain secure systems without slowing down or drowning in theory.
Use AI Coding Agents Without Losing Control of Your Codebase
The Secure Harness
A calm, practical guide to letting agents do useful work inside boundaries you set, enforce, and audit. Ships with 15 copy-pasteable artifacts: hook scripts, permission configs, release gates, and MCP templates.
Stop Shipping Demos. Start Shipping Systems.
The AI Native Engineer
Sixteen hands-on chapters, one real product. Grow it from a single model call into a retrieved, tool-using, observable, production-grade system, with evaluation treated as a habit from the first feature.
What TCC is supposed to guarantee
Transparency, Consent, and Control is the macOS subsystem that decides whether an application may reach your Documents folder, your camera, your microphone, or another application’s data. When an app first tries, tccd presents the dialog and records the answer. Grants live in two SQLite databases: a system one under /Library/Application Support/com.apple.TCC/TCC.db, protected by System Integrity Protection, and a per-user one under ~/Library/Application Support/com.apple.TCC/TCC.db.
The security property that matters is that an application cannot grant itself a permission. Only the user, through a dialog tccd controls, or an administrator through an MDM configuration profile, can add a grant.
Research published in July 2026 documented malware breaking that property on older systems, in a campaign attributed to a subgroup distinct from but related to Sapphire Sleet, a suspected North Korean actor.
The schema behind the guarantee
Everything the attack does depends on the fact that this guarantee is stored in a table. Both databases carry a small schema, and the one that decides outcomes is access, which holds a row for each pairing of a service with a client.
-- Shape of the access table. Columns vary across releases, so
-- confirm with .schema on the machine you are examining rather
-- than assuming this layout holds.
CREATE TABLE access (
service TEXT NOT NULL, -- kTCCServiceSystemPolicyAppData
client TEXT NOT NULL, -- bundle id or absolute path
client_type INTEGER NOT NULL, -- 0 = bundle id, 1 = absolute path
auth_value INTEGER NOT NULL, -- 0 denied, 2 allowed, 3 limited
auth_reason INTEGER NOT NULL, -- provenance of the decision
auth_version INTEGER NOT NULL,
csreq BLOB, -- code requirement the client must meet
indirect_object_identifier TEXT, -- target of an AppleEvents grant
last_modified INTEGER, -- unix epoch seconds
PRIMARY KEY (service, client, client_type, indirect_object_identifier)
);
client_type is the first thing to read on any suspicious row. A value of 0 means the client is identified by bundle identifier, which is how a normal application grant looks. A value of 1 means the client is an absolute path to an executable, which is how a grant to a command line tool such as /usr/bin/osascript looks, and which on an end user machine is rare enough to deserve a second glance.
auth_reason records why the row exists, and the campaign’s choice here is deliberate.
auth_reason | Provenance | Prompt shown |
|---|---|---|
| 2 | User consent through a tccd dialog | Yes |
| 3 | User set the value in Privacy and Security | No |
| 4 | Set by the system | No |
| 6 | Delivered by an MDM configuration profile | No |
Writing a row with reason 3 makes the grant indistinguishable, in the settings pane, from a permission the user toggled on themselves. No prompt is owed, no notification fires, and the row reads as a deliberate human decision to anyone who opens System Settings later. That property explains why the operators bothered with the full move-modify-restore sequence instead of simply engineering a dialog and hoping for a click.
indirect_object_identifier gives AppleEvents grants their specificity. The column names the application being scripted, so an automation grant is a triple of the scripting client, the service, and the target, and reading only the first two tells you less than half of what the row permits.
last_modified is a unix timestamp in seconds. During an investigation it is the single most useful column in the table, and the triage section returns to it.
The chain
Delivery is an AppleScript compiled as a run-only .scpt, presented as a document compatibility wizard. Run-only compilation is the relevant choice: the script cannot be decompiled to readable source, so a defender who obtains the sample cannot simply read it, and static analysis is limited to strings and behaviour.
The script fetches a second stage. That stage drops decoy documents, which serve the social engineering by making the user believe the wizard did something, and compiles a backdoor into an ad hoc signed application bundle. Ad hoc signing satisfies the requirement that code be signed without requiring a Developer ID, which means no certificate for Apple to revoke.
The permission manipulation is the interesting part. The malware uses Finder AppleEvents to move the userspace TCC directory, which lets it read TCC.db as an ordinary file rather than through tccd. It writes grants directly with sqlite3. It restores the directory to its original location. Then it kills tccd, which restarts and reads the modified database as authoritative.
The grants it writes cover Documents, Downloads, Desktop, Finder AppleEvents, and kTCCServiceSystemPolicyAppData, the last of which reaches application support data including Mail and Notes.
Persistence is a LaunchAgent. The resulting beacon harvests filenames and hardware identifiers and communicates with cigalsn[.]com and ecoferros[.]com.
Reading the delivery chain stage by stage
That summary compresses several distinct stages, and each one is a separate opportunity for telemetry. Microsoft’s April 2026 analysis of Sapphire Sleet’s macOS intrusions documents the same primitive in a related chain, and the structure it describes is worth holding in mind, because the individual steps generalise well beyond one operator.
Two properties of this shape matter more than the specific payloads.
Execution is user-initiated throughout. Gatekeeper, quarantine enforcement, and notarization checks all key off how content arrived and how it is launched. A user who has been talked into opening a script and clicking run, or into pasting a command into Terminal, produces an execution path where none of those checks have an opinion. The operator never defeats the controls; the operator arranges for them never to be consulted, which is cheaper and leaves less evidence.
The intermediate stages also avoid landing on disk as executables. curl piped into osascript means the script text exists in a pipe and in memory. File-based detection has nothing to work with, and the artefacts that survive are only the ones the chain deliberately writes: the decoys, the bundle, the LaunchAgent, and the modified database.
Microsoft’s reporting notes that the operator tagged each curl stage with its own user agent string, an operational habit with a large detection consequence. Any environment that captures HTTP user agents at a proxy or through network telemetry can hunt for curl requests carrying non-default agents, and the same hunt catches unrelated tooling that borrows the trick.
# Local evidence of curl-to-interpreter execution.
grep -nE 'curl[^|]*\|[[:space:]]*(osascript|sh|bash|zsh|python3?)' \
~/.zsh_history ~/.bash_history 2>/dev/null
# Recently written .scpt files outside expected locations.
find ~/Downloads ~/Desktop ~/Library -name '*.scpt' -mtime -30 2>/dev/null
# Compiled scripts that are run-only carry no source resource.
osadecompile ~/Downloads/suspect.scpt 2>&1 | head -5
# osadecompile: ... errOSASourceNotAvailable
Shell history carries no forensic weight on its own, since it is editable by the same user the malware runs as and frequently unwritten until the shell exits. As a first-pass triage signal on a machine you already suspect, it is cheap and often productive.
Why moving the directory works
The technique rests on a distinction between what protects the database file and what protects the directory containing it.
tccd holds the database and mediates access to it. The userspace database is not protected by SIP in the way the system database is, and the protection that does exist is oriented around processes reading and writing the file directly while tccd has it open.
Relocating the parent directory through Finder AppleEvents is a filesystem operation performed by a process that has been granted automation rights over Finder, which is a permission users approve routinely for legitimate scripting. Once the directory is somewhere else, the file at the new path is an ordinary SQLite database with no special mediation, and sqlite3 opens it.
Killing tccd afterwards forces the daemon to re-read state from disk on restart, and the daemon has no mechanism for detecting that the file changed while it was not looking. There is no integrity check over the grant table, because the design assumed only tccd could write it.
Finder as the privileged proxy
The directory move works because of who performs it. Finder ships with Full Disk Access as a system component, so a rename of a TCC-protected directory issued by Finder is a rename issued by a process already permitted to touch protected paths. The malware never acquires that privilege. It acquires the right to ask a process that already holds it.
That is a confused deputy problem in its textbook form. The deputy holds authority, accepts instructions from a less privileged party, and performs the operation with no way to evaluate whether the instruction serves the requester’s stated purpose. AppleEvents is an instruction channel to a deputy, and kTCCServiceAppleEvents is the gate on that channel.
Which explains the grant the campaign writes back into the database. The row names /usr/bin/osascript as the client, kTCCServiceAppleEvents as the service, and com.apple.finder as the indirect object. The first invocation needed a human to approve a dialog. Every subsequent invocation needs nothing, because the operator has written the answer into the table.
A second-order effect is worth understanding here. TCC attributes automation requests to a responsible process rather than to the process making the call, and for a script the responsible process is the application the user launched. A script running under Script Editor is attributed to Script Editor; the same script invoked from Terminal is attributed to Terminal. Which is why one approval given during an unrelated task can cover a large amount of later activity, and why the prompts users actually see rarely name the thing that will act.
# What the current user has approved for automation, and against what.
sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT client, indirect_object_identifier, auth_value, auth_reason,
datetime(last_modified,'unixepoch','localtime') AS written
FROM access
WHERE service = 'kTCCServiceAppleEvents'
ORDER BY last_modified DESC;"
Run that across a handful of machines and the results will be more interesting than you expect. Automation grants accumulate. People approve them during troubleshooting, during a one-off script somebody wrote three years ago, and during the onboarding flow of a productivity tool, and macOS never once suggests removing them.
Rows naming com.apple.finder or com.apple.systemevents as the target carry the most authority, since both can drive the filesystem and other applications. A row naming /usr/bin/osascript as the client is the shape this campaign leaves behind, and on most user machines it has no benign explanation.
The csreq blob and what it actually validates
A reader who has followed the mechanics this far should be asking why the forged row survives validation, given that TCC stores a code requirement alongside every grant.
csreq holds a serialised code requirement, written in the same expression language codesign accepts. When a client asks for a service, tccd evaluates the running binary against the requirement stored on the row and refuses if it fails. The purpose is to stop the obvious attack of approving a benign application and then replacing the binary at that path with something else.
The campaign works within the check rather than against it. The client it names is /usr/bin/osascript, an Apple platform binary that is already present, already signed, and trivially satisfies a requirement derived from itself. Producing the blob takes one command.
# The designated requirement for a platform binary, in text form.
codesign -d -r- /usr/bin/osascript
# designated => identifier "com.apple.osascript" and anchor apple
# The same requirement compiled to the binary form stored in csreq.
csreq -r='identifier "com.apple.osascript" and anchor apple' -b /tmp/req.bin
xxd /tmp/req.bin | head -3
Nothing here is forged. The attacker copies a requirement that Apple’s own signing produced, attaches it to a row of their own authorship, and validation passes because the row tells the truth about which binary it points at. The dishonest part of the row is its provenance, and provenance is exactly what csreq says nothing about.
That has a direct consequence for detection design. Inspecting grants for malformed or absent requirement blobs will catch lazy implementations and miss careful ones. A row with csreq IS NULL still deserves a flag, since some tooling omits it, but treating a valid blob as evidence of legitimacy inverts what the field means.
-- Rows with no code requirement attached. Cheap to check,
-- catches unsophisticated writes, proves little on its own.
SELECT service, client, client_type, auth_value,
datetime(last_modified,'unixepoch','localtime') AS written
FROM access
WHERE csreq IS NULL
ORDER BY last_modified DESC;
The field worth trusting is the one TCC declines to store: which process wrote the row, and when. Answering that requires telemetry collected outside the database, which is the argument the detection section builds on.
The same idea, three times over
Setting this campaign alongside earlier work makes the pattern visible in a way no single case manages. Every published TCC bypass of consequence has attacked the gap between what tccd reads and what tccd believes it controls, and only the redirection method has changed.
CVE-2020-9934 went through the environment. tccd built the path to the user database by expanding the HOME variable, so a process that launched a helper with HOME pointing at a directory it owned ended up with a daemon reading grants the attacker had written moments earlier. No file protection was violated anywhere in that sequence. The daemon was pointed at a different file and did as it was told.
powerdir, tracked as CVE-2021-30970 and published by Microsoft in January 2022, went through the directory service. Changing a user’s home directory record and planting a prepared database at the new location reached the same outcome by another route.
The campaign in this post goes through a deputy. Finder renames the directory, the file becomes ordinary, sqlite3 writes to it, and the daemon reloads on restart.
| Technique | Year | Redirection method | Apple’s fix |
|---|---|---|---|
CVE-2020-9934 | 2020 | HOME environment variable | Path resolution stopped trusting the variable |
powerdir (CVE-2021-30970) | 2021 | Directory service home path | Home directory changes stopped redirecting the lookup |
| Directory relocation | 2026 | Finder AppleEvent rename | Version floors in Sequoia 15.7.7 and Tahoe 26.4.1 |
Three attacks, three fixes, and one assumption that has never changed: the contents of the file at the expected path represent decisions the daemon itself made. Each fix removed a specific way of violating that assumption while leaving the assumption in place, which is a defensible engineering choice and also the reason a fourth technique should be expected rather than treated as a shock when it arrives.
Predicting its shape takes no special insight. Something will convince tccd to read state it did not write, whether by moving the file, by moving the daemon’s understanding of where the file lives, or by writing through a process holding more authority than the writer. A detection built on provenance covers all three without modification, which is the practical argument for spending engineering time there instead of on the current indicator set.
Version boundaries
The technique fails on macOS Tahoe 26.4.1 and later, and on Sequoia 15.7.7 and later. On Sequoia the bypass remains possible in one configuration: where Script Editor holds Full Disk Access.
That exception is worth flagging to your own fleet, because Script Editor with Full Disk Access is a configuration that appears on developer and IT administrator machines, granted during some past troubleshooting session and never revoked. Those are also the machines with the most valuable data. Auditing for it is a small piece of work with a clear result.
# Which applications hold Full Disk Access on this machine.
# Requires the querying process to have FDA itself, so run from an
# MDM script or a management agent rather than a user shell.
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT client, auth_value FROM access
WHERE service = 'kTCCServiceSystemPolicyAllFiles';"
# Same question for the current user's database.
sqlite3 "$HOME/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, auth_value, last_modified FROM access
ORDER BY last_modified DESC LIMIT 40;"
Any row naming com.apple.ScriptEditor2, osascript, or a terminal emulator deserves a decision rather than a shrug.
Auditing the fleet against the version floors
Knowing the floors helps only if you can say how many machines sit below them, and the honest answer is usually worse than the patch compliance dashboard suggests.
| Track | Technique blocked from | Residual exposure |
|---|---|---|
| macOS Tahoe 26 | 26.4.1 | None reported for this technique |
| macOS Sequoia 15 | 15.7.7 | Script Editor holding Full Disk Access |
| Sequoia below 15.7.7 | Not blocked | Full technique available |
| Sonoma 14 and earlier | Not blocked, outside the fix window | Full technique available, no vendor remedy |
The last row decides your project scope. Machines on an unsupported major version cannot be patched into safety, and each one is a standing instance of this technique regardless of what you do to the rest of the estate. Counting them honestly is the first deliverable, and the count is usually the number that unlocks budget for the replacement programme nobody has approved yet.
-- osquery: machines below the floors, with the Sequoia exception
-- surfaced as its own state rather than folded into "patched".
SELECT
h.hostname,
v.version AS os_version,
CASE
WHEN v.major >= 26 AND v.version >= '26.4.1' THEN 'patched'
WHEN v.major = 15 AND v.version >= '15.7.7' THEN 'patched_with_exception'
ELSE 'exposed'
END AS tcc_status
FROM os_version v, system_info h;
Pair the version query with a check for the exception itself, because a Sequoia machine reporting patched_with_exception is only safe if no scripting host holds Full Disk Access on it. The population that does hold it skews badly: developers, IT staff, and anyone who once followed a support article that told them to grant it and never came back.
#!/bin/bash
# Run with Full Disk Access, from MDM or a management agent.
# Reports the specific combination that survives patching on Sequoia.
db="/Library/Application Support/com.apple.TCC/TCC.db"
ver=$(sw_vers -productVersion)
fda=$(sqlite3 "$db" \
"SELECT client FROM access
WHERE service = 'kTCCServiceSystemPolicyAllFiles'
AND auth_value = 2;")
case "$fda" in
*ScriptEditor*|*osascript*|*Terminal*|*iTerm*)
echo "EXPOSED ($ver): scripting host holds FDA"
printf '%s\n' "$fda" ;;
*)
echo "OK ($ver)" ;;
esac
Report the outcome as a count of machines rather than a list of findings. A list invites case-by-case triage and stalls in a spreadsheet; a count invites a policy decision, which is what the exception actually needs. The policy that resolves it fits in one sentence: no scripting host holds Full Disk Access, enforced through a configuration profile rather than argued individually with each engineer who wants it back.
What each granted permission reaches
The specific grants written by this malware were chosen deliberately, and reading them tells you what the operators wanted.
Documents, Downloads, and Desktop cover where people actually keep files. On a developer or executive machine that includes contracts, exported credentials, recovery codes saved during some past setup, and the miscellaneous sensitive material that accumulates in a Downloads folder over years. These three grants together approximate Full Disk Access for the purpose of finding valuable documents, without triggering the more heavily scrutinised Full Disk Access prompt.
Finder AppleEvents is the pivot rather than the objective. Automation rights over Finder give the ability to perform filesystem operations as a scripted user action, which is what makes the directory relocation possible in the first place. It also enables further automation of other applications, since Finder can be used to launch and manipulate them.
kTCCServiceSystemPolicyAppData is the quiet one and the most valuable. It grants access to application support directories, which is where applications keep their working state. That includes Mail’s local store and Notes’ database, both named in the research. It also includes, on a typical machine, browser profiles with session cookies, messaging application databases, and the local caches of cloud storage clients. Application support data is where the interesting material lives precisely because applications assume that directory is theirs alone.
The absence of a Full Disk Access grant is itself informative. The operators took the permissions that yield the most data for the least conspicuousness, which suggests attention to detection rather than smash-and-grab.
The service identifiers worth knowing
Reading a TCC database quickly is mostly a matter of recognising service strings, and the subset that carries real consequence is smaller than the full list.
| Service identifier | Reaches | Why an operator wants it |
|---|---|---|
kTCCServiceSystemPolicyAllFiles | Full Disk Access | Everything, at the cost of the most scrutinised prompt |
kTCCServiceSystemPolicyAppData | ~/Library/Application Support | Browser profiles, mail store, messaging databases |
kTCCServiceSystemPolicyDocumentsFolder | ~/Documents | Contracts, exports, working files |
kTCCServiceSystemPolicyDownloadsFolder | ~/Downloads | Years of accumulated attachments |
kTCCServiceSystemPolicyDesktopFolder | ~/Desktop | Whatever the user is working on right now |
kTCCServiceAppleEvents | Scripting another application | The pivot into privileged deputies |
kTCCServiceAccessibility | Synthetic input and window control | Keystroke injection, clicking consent dialogs |
kTCCServiceScreenCapture | Screen contents | Passwords typed elsewhere, MFA codes |
kTCCServiceListenEvent | Global keyboard events | Keylogging |
kTCCServiceSystemPolicySysAdminFiles | Administrative configuration | User and group manipulation |
kTCCServiceEndpointSecurityClient | Endpoint Security framework | Standing alongside, or in the way of, your EDR |
The three in the middle of that block deserve a different posture from the rest. Accessibility, screen capture, and global event monitoring together amount to complete observation and control of a user session, and the number of applications with a legitimate need for them is small enough to write down by name. Build that list once, deliver it through a configuration profile, and alert on anything outside it.
kTCCServiceEndpointSecurityClient belongs in a category of its own. A grant there means a process can register as an Endpoint Security client, which is a position from which security telemetry becomes negotiable. Your EDR vendor belongs on that list and nothing else does.
The campaign described here took none of the loud ones. Its grant set stops at file access and automation, which is the choice of an operator optimising for the ratio of collected data to attention drawn. Accessibility prompts are memorable and get talked about at the coffee machine; a Documents folder grant that never prompted at all leaves nobody with a story to tell.
One practical note on reading these rows. The same service strings appear in both databases, and the system database under /Library is where MDM configuration profiles write. A grant present in the system database with a matching profile behind it is expected and should be filtered out early. The same grant appearing only in a user database, with no profile to explain it, is the shape worth hunting.
Triage when you find modified grants
Finding an anomalous grant is the beginning of an investigation rather than the end, and the sequence matters.
Preserve before you remediate. Copy the user TCC database, the LaunchAgents directories at both ~/Library/LaunchAgents and /Library/LaunchAgents, and the unified log for the period in question. Deleting the grant first destroys the timestamp that tells you when the compromise happened, and last_modified on the access row is frequently the best available anchor for scoping.
Establish the window. The last_modified value on the anomalous row, correlated against LaunchAgent creation times and the first appearance of the C2 domains in DNS logs, gives you a start point. Everything the granted permissions could reach during that window should be considered accessed, which for kTCCServiceSystemPolicyAppData means browser sessions and mail content.
Look for the ad hoc signed bundle. Applications signed ad hoc rather than with a Developer ID are uncommon on a normal user machine, and enumerating them across the fleet is a productive hunt independent of this campaign.
# Ad hoc signed applications in user-writable locations.
for app in ~/Applications/*.app /Applications/*.app; do
[ -d "$app" ] || continue
auth=$(codesign -dv "$app" 2>&1 | grep -E 'Authority|Signature' | head -1)
case "$auth" in
*"Signature=adhoc"*) echo "ADHOC: $app" ;;
esac
done
# LaunchAgents referencing binaries outside standard locations.
for p in ~/Library/LaunchAgents/*.plist; do
[ -f "$p" ] || continue
prog=$(defaults read "${p%.plist}" ProgramArguments 2>/dev/null | head -3)
echo "$p -> $prog"
done
Then reimage. Removing the grant and the LaunchAgent addresses what you found; it does not address the stages you did not find, and a backdoor whose detection rate was zero at disclosure is not a thing to negotiate with. Rotate every credential the account held, with browser sessions and mail treated as compromised given the application data grant.
Reconstructing the timeline from the unified log
Preservation gives you material; the timeline turns material into scope. The unified log is the richest source available on a live machine, and also the one that expires fastest, which is the reason collection comes before analysis rather than after it.
Start with the database, since last_modified anchors everything else you will look at.
-- Grant history ordered by write time, with the epoch decoded.
SELECT
datetime(last_modified,'unixepoch','localtime') AS written,
service, client, client_type, auth_value, auth_reason
FROM access
ORDER BY last_modified DESC
LIMIT 50;
-- Grants clustered inside a single minute are one automated write,
-- not a series of independent human decisions.
SELECT last_modified / 60 AS minute_bucket, COUNT(*) AS grants
FROM access
GROUP BY minute_bucket
HAVING grants > 2
ORDER BY minute_bucket DESC;
Keep that second query. Humans approve permissions one dialog at a time, with pauses between them while they read and think. Five grants sharing a minute bucket is a script, and that bucket timestamp becomes the pivot point for everything downstream.
With a window established, pull the log around it.
# TCC subsystem activity, including daemon restarts.
log show --start "2026-07-18 09:00:00" --end "2026-07-18 11:00:00" \
--predicate 'subsystem == "com.apple.TCC"' --info --debug
# Interpreter and database activity in the same window.
log show --start "2026-07-18 09:00:00" --end "2026-07-18 11:00:00" \
--predicate 'eventMessage CONTAINS "osascript"
OR eventMessage CONTAINS "sqlite3"
OR eventMessage CONTAINS "Script Editor"' --info
# Persistence written inside the window.
find ~/Library/LaunchAgents /Library/LaunchAgents /Library/LaunchDaemons \
-name '*.plist' \
-newermt "2026-07-18 09:00:00" ! -newermt "2026-07-18 11:00:00" \
-exec ls -l {} \;
Retention for info and debug level messages on a busy machine is measured in hours, and the default capture level excludes much of what you want. Assume the interesting entries have already rolled off unless you have been forwarding the log to a collector, and treat a productive log show as a bonus rather than a plan.
Two artefacts survive considerably longer and belong in the same collection pass. Quarantine metadata records download provenance with timestamps and frequently identifies the original lure even after the file itself has been deleted.
sqlite3 ~/Library/Preferences/com.apple.LaunchServices.QuarantineEventsV2 \
"SELECT datetime(LSQuarantineTimeStamp + 978307200,'unixepoch','localtime') AS t,
LSQuarantineAgentName, LSQuarantineDataURLString
FROM LSQuarantineEvent
ORDER BY LSQuarantineTimeStamp DESC LIMIT 30;"
That 978307200 offset converts Apple’s epoch, which starts on the first of January 2001, into the unix epoch. Getting it wrong shifts every timestamp by thirty-one years, an error that survives review because the resulting dates still look plausible in isolation.
The second artefact is the shared file list under ~/Library/Application Support/com.apple.sharedfilelist, which retains recent document and application entries. Those entries place the user’s own activity around the moment of the write, which is how you distinguish a compromise the user walked into from one that happened while the machine sat idle.
Detection
Three signals distinguish this activity, and the third is the one worth engineering.
Unexplained tccd termination is the most direct. The daemon does not crash often, and a kill followed by a restart is anomalous on a healthy system. Alerting on it produces few false positives.
Movement of the TCC directory is observable through filesystem events. Any rename or move affecting ~/Library/Application Support/com.apple.TCC is worth an alert outright, since no legitimate software has a reason to relocate it.
The strongest detection compares state against provenance. Every legitimate grant is created by tccd in response to a user decision or an MDM profile, and on systems with Endpoint Security telemetry that produces a corresponding tcc_modify event. A grant present in the database with no matching event in your telemetry was written by something other than tccd. That is a high-confidence indicator, and it catches variants of this technique rather than this specific implementation.
Building it requires retaining tcc_modify events and periodically reconciling them against the database contents. The reconciliation is a small script; the value is that it detects the class of attack.
Engineering the provenance check
That reconciliation deserves more detail, because the telemetry it depends on is newer than most macOS detection content assumes and carries limitations that shape the design.
Apple added ES_EVENT_TYPE_NOTIFY_TCC_MODIFY to the Endpoint Security framework in macOS 15.4. The event fires when a permission is granted or revoked, and it carries the fields a provenance check needs.
| Field | Contents | Use in reconciliation |
|---|---|---|
service | The TCC service being changed | Join key against the access row |
identity | Bundle id, executable path, or policy id | Join key against client |
identity_type | Which of those formats identity uses | Maps to client_type |
update_type | Create, modify, delete, or unknown | Separates a new grant from a revocation |
right | Resulting permission | Maps to auth_value |
reason | User consent, user set, system set, MDM policy | Maps to auth_reason |
instigator | Process performing the modification | Expect the settings extension or tccd |
responsible | Optional originating process | Often an interpreter’s parent application |
Three limitations shape what you can build on top. The event is notify-only, so it reports a decision that has already taken effect and gives you no opportunity to block one. Events fire only on explicit grant or revocation, so entitlement-backed access and silently denied requests produce nothing at all. And responsible resolves to the application the user launched, so a payload running through an interpreter is attributed to Terminal or Script Editor rather than to itself.
None of that weakens the reconciliation. The check asks only whether an event exists for a grant that appeared, and accurate attribution is a bonus on top rather than a requirement underneath.
The implementation is smaller than the diagram makes it look. A collector snapshots both databases on a schedule, diffs against the previous snapshot, and asks the telemetry store whether a corresponding event exists.
#!/usr/bin/env python3
"""Reconcile TCC grants against tcc_modify telemetry.
Runs from a management agent holding Full Disk Access. Emits findings
as JSON for whatever pipeline consumes them. Strictly read-only against
the databases: a partial write to a live TCC.db is a self-inflicted
outage that looks exactly like the attack you are hunting.
"""
import json
import sqlite3
import sys
from contextlib import closing
from pathlib import Path
DBS = [
Path("/Library/Application Support/com.apple.TCC/TCC.db"),
Path.home() / "Library/Application Support/com.apple.TCC/TCC.db",
]
QUERY = """
SELECT service, client, client_type, auth_value, auth_reason, last_modified
FROM access
WHERE auth_value = 2
"""
def snapshot(path):
"""Read allowed grants without taking a lock on a live database."""
if not path.exists():
return {}
uri = f"file:{path}?mode=ro&immutable=1"
with closing(sqlite3.connect(uri, uri=True)) as conn:
rows = conn.execute(QUERY).fetchall()
return {(r[0], r[1], r[2]): (r[3], r[4], r[5]) for r in rows}
def reconcile(current, previous, events, window=300):
"""Yield findings for grants that changed without matching telemetry."""
for key, value in current.items():
if previous.get(key) == value:
continue
service, client, _ = key
_, auth_reason, written = value
matched = any(
e["service"] == service
and e["identity"] == client
and abs(e["timestamp"] - written) <= window
for e in events
)
if not matched:
yield {
"severity": "high",
"finding": "grant with no tcc_modify event",
"service": service,
"client": client,
"written": written,
"auth_reason": auth_reason,
}
elif auth_reason not in (2, 6):
yield {
"severity": "medium",
"finding": "grant with unexpected provenance",
"service": service,
"client": client,
"auth_reason": auth_reason,
}
The immutable=1 flag earns its place. Opening a live SQLite database read-only can still create lock files depending on journal mode, and these databases are held open by a daemon with strong opinions about interference. Marking the connection immutable tells SQLite to assume the file cannot change underneath it and skip locking entirely, which is correct for a snapshot and wrong for anything that intends to write.
Choose the correlation window with measurement rather than instinct. Five minutes is generous and forgiving of clock skew between the endpoint and the telemetry store. Tightening it raises precision and starts producing findings whose only cause is a laptop whose clock drifted while it was asleep.
Detection signals and what defeats each one
Every detection has a failure mode, so choosing a set means choosing which failure modes you can live with rather than hoping a single signal covers the technique.
| Signal | Source | Fidelity | Defeated by |
|---|---|---|---|
tccd terminated unexpectedly | ES process exit events | High | Waiting for a reboot instead of killing it |
Rename or move of com.apple.TCC | ES rename events | High | A different write path to the same file |
sqlite3 executed against a TCC path | ES exec events with arguments | Medium | Linking SQLite into the payload |
Grant with no tcc_modify event | Reconciliation | High | Suppressing the collector first |
curl piped to an interpreter | ES exec events, process ancestry | Medium | Staging through a temporary file |
| Ad hoc signed bundle in a user path | Periodic scan or ES exec | Medium | Obtaining a Developer ID certificate |
Non-default curl user agent | Network or proxy telemetry | Low | Using the default agent string |
The sqlite3 row is where most published detection content stops, and it is the weakest thing on the list that anyone actually deploys. Elastic’s public rule for this behaviour matches a process named sqlite* with an argument pointing at a TCC database path, narrowed by parent process name and code signature state, and it maps cleanly to MITRE ATT&CK technique T1548.006.
// Shape of the sqlite3 detection, following Elastic's published rule.
process where host.os.type == "macos" and event.type == "start" and
process.name like~ "sqlite*" and
process.args like "/*/Application Support/com.apple.TCC/TCC.db" and
(process.parent.name like~ ("osascript", "bash", "sh", "zsh", "Terminal", "Python*")
or process.parent.code_signature.trusted == false)
Deploy it anyway. Cheap rules that catch current tooling earn their keep, provided nobody in the reporting chain mistakes them for coverage of the technique. A payload that links SQLite as a library instead of shelling out to the binary evades this rule with about twenty lines of change, and that payload will exist the moment the rule becomes widespread enough to matter.
The file event on the directory rename is a better primitive, because the rename is structural to the approach rather than incidental to one implementation. Any route to writing the userspace database while tccd holds it open requires getting the file out from under the daemon’s protected view, and Endpoint Security surfaces that as ES_EVENT_TYPE_NOTIFY_RENAME with a source or destination under com.apple.TCC.
Layer the reconciliation underneath both. It runs on a schedule rather than in real time, which makes it the slowest signal in the table and simultaneously the only one that survives an implementation whose author designed it against your other rules.
Hardening
Patching resolves the technique, and the version floors above give a clear target. The complication is the population that lags: contractor devices, machines held back for software compatibility, and anything outside MDM enrolment.
Beyond patching, reduce what automation permissions can accomplish. Apple Events access to Finder is the pivot here, and it is granted casually. A fleet policy that requires justification for automation permissions, delivered through MDM rather than user prompts, removes the primary lever.
Manage TCC grants through configuration profiles where possible. Profile-delivered grants are administratively controlled and give you a canonical list of what should exist, which makes the reconciliation detection above far easier to operate, since anything outside the profile is by definition unexpected.
Finally, treat run-only AppleScripts as a category worth alerting on. Legitimate distribution of run-only .scpt files to end users is rare, and the format’s main practical property is resistance to inspection.
Writing the profile that removes the lever
Advice about managing grants through profiles becomes work at the moment somebody has to write one, so being specific about the payload saves an afternoon of searching.
Privacy Preferences Policy Control, delivered as a com.apple.TCC.configuration-profile-policy payload, is the supported mechanism for setting TCC grants administratively. Grants that arrive this way land in the system database, carry a reason value marking them as policy-set, and resist user modification, which is the property that makes them a usable baseline for the reconciliation described earlier.
<!-- PPPC fragment: deny AppleEvents from Script Editor to Finder.
Each entry pairs an identifier with the code requirement that
the installed binary must satisfy, obtained from:
codesign -d -r- /path/to/App.app -->
<key>Services</key>
<dict>
<key>AppleEvents</key>
<array>
<dict>
<key>Identifier</key>
<string>com.apple.ScriptEditor2</string>
<key>IdentifierType</key>
<string>bundleID</string>
<key>CodeRequirement</key>
<string>identifier "com.apple.ScriptEditor2" and anchor apple</string>
<key>AEReceiverIdentifier</key>
<string>com.apple.finder</string>
<key>AEReceiverIdentifierType</key>
<string>bundleID</string>
<key>Authorization</key>
<string>Deny</string>
</dict>
</array>
</dict>
Two constraints deserve attention before you plan a rollout.
Apple restricts which services a profile may set to Allow. Automation grants can be denied administratively on current releases while a user-facing prompt remains the only route to enabling one, so a profile can remove the lever without giving you a way to hand it back selectively. Design the exception process around that limitation rather than discovering it three days into deployment.
The CodeRequirement string has to match the signature of the installed application, and it changes whenever a vendor rotates certificates. A profile carrying a stale requirement fails quietly: the entry stops applying, the application prompts the user as though no profile existed, and your dashboard continues to report the profile as installed. Regenerate requirements inside the packaging pipeline instead of by hand.
# Generate the requirement string a PPPC payload needs.
codesign -d -r- /Applications/Example.app 2>&1 \
| sed -n 's/^designated => //p'
# Verify a profile actually took effect on a target machine.
sudo sqlite3 "/Library/Application Support/com.apple.TCC/TCC.db" \
"SELECT service, client, auth_value, auth_reason FROM access
WHERE auth_reason = 6;"
Once profiles carry the grants that ought to exist, the reconciliation inherits its allowlist for free. Anything in the system database matching a profile is expected, anything in a user database without a corresponding event is a finding, and the middle ground between those two shrinks to a list short enough for one person to read over coffee.
What the operator keeps after you patch
Patching removes a capability rather than an adversary, and an honest threat model accounts for what survives once the TCC write path closes.
The social engineering survives intact. A recruiter conversation that ends with a candidate opening a script and clicking run works identically on macOS 26.4.1 and on macOS 14. What changes is the permission acquisition step, and the operator has a serviceable fallback available: ask. A dialog stating that a document wizard needs access to the Documents folder, arriving in the middle of a task the user believes they started, gets approved far more often than security teams like to admit. The patch costs the operator one click of consent and a modest increase in detectability.
The credential harvesting stage survives too. Microsoft’s analysis describes a component that renders a native-looking password prompt and validates the entered password against the local authentication database before exfiltrating it, so the operator learns whether the credential is real while the user learns nothing. No TCC grant participates in any part of that exchange.
A large volume of valuable material also sits outside TCC’s protected set entirely. Shell history, git configuration and credential helpers, .env files scattered through project directories, cloud provider CLI configuration, container registry credentials, and the whole working contents of any repository the user has checked out are readable by any process running as that user. TCC protects a curated list of locations. Nobody curated that list with a developer’s home directory in mind.
# What an unprivileged process running as the user can already read,
# with no TCC grant of any kind. Run it on your own machine before
# deciding TCC is the boundary that matters.
for p in ~/.ssh ~/.aws ~/.config/gcloud ~/.kube ~/.docker/config.json \
~/.gitconfig ~/.netrc ~/.npmrc ~/.zsh_history; do
[ -r "$p" ] && echo "readable: $p"
done 2>/dev/null
# Project-local secrets, which are usually the richest find.
find ~/Repos ~/Projects ~/src -maxdepth 3 \
\( -name '.env' -o -name '*.pem' -o -name 'credentials' \) 2>/dev/null
Which points at where the control actually belongs. TCC is a consent mechanism for a specific list of resources, and asking it to be the boundary that keeps an attacker away from a developer workstation asks for a job it was never designed to do. The controls that outlast the operator’s fallback plan are the ones limiting what code can run at all, plus hardware-backed credentials that cannot be read out of a file regardless of who is doing the reading. Secure Enclave-bound SSH keys and passkeys move the interesting material somewhere a file read cannot reach it, which is a stronger position than any permission table can offer.
Running the reconciliation at fleet scale
A control that works on one laptop and collapses at ten thousand is a demo, so the operational shape deserves as much attention as the detection logic.
Endpoint Security clients are a constrained resource on macOS. The framework requires the com.apple.developer.endpoint-security.client entitlement, which Apple grants case by case, and the practical consequence for most teams is consuming telemetry the EDR vendor already collects rather than shipping a client of your own. Ask that vendor directly whether they subscribe to and retain ES_EVENT_TYPE_NOTIFY_TCC_MODIFY, because a rule joining against events nobody stores fails silently while continuing to look healthy on a dashboard.
Volume works in your favour here, which is a pleasant change. TCC modifications happen when a user approves or revokes a permission, and on a steady-state fleet that produces a small number of events per machine per day, clustered around onboarding and new software installs. Snapshots are similarly cheap, running to a few hundred rows per machine of which only the allowed grants matter at all.
The cost concentrates in three places, each with an obvious control.
- Snapshot frequency. Hourly is ample for a detection that already tolerates a five-minute correlation window. Running faster burns battery on machines that spend half their lives asleep and buys minutes of latency nobody will notice.
- Snapshot transport. Ship a diff rather than a full table. The complete snapshot goes up once at enrolment, and every run after that sends only rows that changed, which for most machines on most runs is nothing.
- Correlation storage. Thirty days of
tcc_modifyretention covers the realistic gap between a grant being written and a snapshot noticing, with slack for machines that stay offline through a holiday. Ninety days is better where the storage is already paid for.
One design decision has an outsized effect on how much the control is worth. Compute the reconciliation centrally rather than on the endpoint. An endpoint deciding whether its own grants are legitimate has been asked to audit itself, and an attacker who reached the point of writing TCC rows will have no trouble with a local config file listing which grants are approved. The endpoint reports state; the judgement belongs somewhere the attacker has not been.
Expect the first run to be noisy in a specific and useful way. Fleets that have never audited TCC find grants nobody can account for, most of which resolve to legitimate software approved years ago by people who have since left. Working through that backlog is the price of admission, and the artefact it produces is the profile-delivered allowlist that makes every subsequent run quiet.
What this says about permission systems generally
TCC is a good design that assumed its database was only writable through its own daemon, and the attack works by making that assumption false through a path the design did not consider. Permission systems that store state in a file, and mediate access at the daemon rather than at the storage layer, all have a version of this exposure.
The durable defence is the provenance check. Knowing what grants exist is a configuration audit. Knowing which grants arrived through a legitimate mechanism is a security control, and it is the one that survives the next technique.