Recovering Removed TCC Grants from TCC.db-wal
How deleted or changed TCC permissions can survive in TCC.db-wal and freed pages: SQLite WAL format basics, what tccutil reset leaves behind, and the limits.
TL;DR. TCC.db keeps only the current state of each permission: a new decision replaces the row, and tccutil reset deletes it. But TCC.db runs in SQLite's write-ahead-log mode, and the TCC.db-wal file next to it can still hold earlier copies of the pages that contained those rows: older commits, frames from a previous WAL generation, sometimes uncommitted frames. Freed pages in the main file can hold remnants too. TCC Parser replays the committed WAL to show the current state and puts every earlier or removed row it can recover in the History tab, next to the current value. None of this is guaranteed to survive, so collect the WAL early and treat what you find as supporting evidence.
Why history is missing in the first place
The access table has one row per client, service and target (the primary key is service, client, client_type, indirect_object_identifier). When a user flips a toggle, answers a prompt or an MDM profile changes a grant, that row is overwritten, and last_modified moves forward. When someone runs:
tccutil reset Accessibility
tccutil reset ScreenCapture com.example.agent
the matching rows are deleted (tccutil takes the service name without the kTCCService prefix, and optionally a bundle ID). A reset in System Settings or removal of an app does the same. The main database then shows no trace that the grant existed. That is a problem when an intruder grants a capability, uses it, and removes it before leaving.
SQLite WAL in five minutes
In write-ahead-log mode, SQLite does not write changes into the main file straight away. It appends them to the -wal file and copies them back later, during a checkpoint. The format is documented on sqlite.org; the parts that matter for recovery:
WAL header (32 bytes)
| Field | Notes |
|---|---|
| Magic | 0x377f0682 or 0x377f0683 (selects checksum byte order) |
| Format version | 3007000 |
| Page size | Same as the database |
| Checkpoint sequence | Increments with each checkpoint |
| Salt-1, Salt-2 | Random values, changed each time the WAL restarts |
| Checksum-1, Checksum-2 | Over the header |
Frames, one after another: a 24-byte frame header followed by a full copy of one database page.
| Frame header field | Notes |
|---|---|
| Page number | Which page of the database this frame replaces |
| Database size | Size in pages after the commit, for commit frames; 0 otherwise |
| Salt-1, Salt-2 | Must match the WAL header for the frame to be valid |
| Checksum-1, Checksum-2 | Running checksum over the frames so far |
A reader replays frames whose salts match the header and whose running checksum is valid, up to the last commit frame, and uses the newest committed copy of each page. That is the current state of the database.
Where the old rows hide
Replaying gives you the present. The past is in the frames that the replay does not use:
| Source | What it holds | How likely |
|---|---|---|
| Earlier commits in the current WAL | Older copies of the same page, from before a later commit in the same WAL | Common when several changes happened between checkpoints |
| Stale-generation frames | Frames left from an earlier WAL generation (different salts), not yet overwritten | Common: after a checkpoint the WAL is reused and restarted with new salts, not zeroed |
| Uncommitted frames | Frames after the last commit frame | Occasional; changes that were never committed |
| Freed pages in the main file | Pages released after rows or tables shrank | Occasional; depends on how SQLite reused the space |
TCC.db is small, so the table pages that hold access rows are rewritten often, and each rewrite leaves another copy in the WAL. That is why a removed grant from a few minutes or hours ago often survives, while one from months ago usually does not.
What TCC Parser does with it
When a -wal file sits next to a database (same base name), TCC Parser:
- Validates the WAL header and replays committed frames, as SQLite would, to build the current state. The
-shmfile is not needed. - Decodes the row cells in the other copies of each page: older commits, uncommitted frames and stale-generation frames, plus freed pages in the main file.
- Compares each recovered row with the current state by primary key and shows it in the History tab as a removed row (no longer present) or an earlier version (a different
auth_value,auth_reason,csreqorlast_modified).
The Findings tab raises "grants removed or changed" when a recovered row differs from the current one, for example an Allowed row for a high-impact service that is no longer in the database.
Example: a removed Accessibility grant
In the fictional FIN-MBP-03 sample, the system database's current state has no Accessibility row for the path-based helper /Users/dana.whitlock/Library/Caches/.sync/sync-helper. The WAL tells a different story:
| Source | service | client | auth_value | last_modified (UTC) |
|---|---|---|---|---|
| WAL, earlier commit | kTCCServiceAccessibility | /Users/dana.whitlock/Library/Caches/.sync/sync-helper | 2 (allowed) | 2026-09-14 10:19:05 |
| Current state | (no row) |
The removal happened at 10:47:12 UTC in the sample story. Deletions do not leave a last_modified of their own in the table, so the time of removal comes from other evidence: the timing of the WAL commit sequence, the unified log (subsystem == "com.apple.TCC"), shell history showing tccutil, or EDR telemetry. On macOS 15.4 and later, Endpoint Security exposes TCC modifications to security tools (ES_EVENT_TYPE_NOTIFY_TCC_MODIFY), so your EDR may have recorded the change directly.
Limits you should state in a report
- Survival is opportunistic. A checkpoint followed by enough new writes overwrites old frames. A quiet database can keep an old frame for a long time; a busy one loses it quickly.
- No reliable deletion time. A recovered row gives the state and its
last_modified, not when it was deleted. - Order within a WAL generation is reliable; across generations it is inferred. Stale frames from an older generation come from before the current one, but gaps are unknown.
- Collection destroys evidence easily. Opening the database with a tool that writes, or letting SQLite checkpoint on close, rewrites the WAL. Several collectors (UAC's
tcc.yaml, Aftermath's TCC module) copy onlyTCC.db. See TCC.db location and acquisition. - Recovered is not the same as current. Report recovered rows as "an earlier state recovered from the WAL", with the source frame, not as live grants.
Other places to look
- APFS local snapshots and Time Machine: complete older copies of the database. The most reliable way to see a grant that was removed.
- The
expiredtable: clients whose grants expired, withexpired_at. - Unified log:
tccddecisions undercom.apple.TCC, while retention allows. - Endpoint Security telemetry (macOS 15.4+): TCC modification events.
Checklist
- Collect
TCC.db,TCC.db-walandTCC.db-shmtogether for the system and each user, before anyone runstccutilor opens the files. - Hash, then work on copies of the copies.
- Load the folder in TCC Parser and open History.
- For each removed or changed high-impact row, note the source (WAL commit, stale generation, uncommitted, freed page).
- Look for the matching change in the unified log, shell history and EDR data.
- Check snapshots and backups for a full earlier copy.
Glossary: SQLite WAL, tccutil.