Skip to content

Recover Deleted QuarantineEventsV2 Records

How deleted QuarantineEventsV2 rows survive in SQLite freeblocks, freelist pages, WAL frames and journals, how to preserve them and what recovery proves.

Published on 7 min read

TL;DR. Deleting a row from QuarantineEventsV2 with sqlite3 does not wipe it. The record usually stays in the file as free space inside the table page, and older versions of pages can also sit in freelist pages, the WAL and the rollback journal. Quarantine Parser carves LSQuarantineEvent records out of all of these by anchoring on the record's serial types, which survive when the first 4 bytes of a freed cell are overwritten. Recovery depends on secure_delete and later writes, and absence of recovered rows proves nothing.

How quarantine rows disappear

The QuarantineEventsV2 database has no documented automatic purge; rows typically outlive the files they describe. They disappear in two ways:

  • Row deletion. Anyone with access to the user's files can run a DELETE against LSQuarantineEvent with sqlite3, removing one incriminating row or the whole history. Routine clearing of the download history can remove rows as well.
  • File removal. The whole database, with its companions, can be deleted. What remains then is a matter for file-system recovery, not SQLite carving.

The first case is where carving helps, because by default SQLite does not erase what it deletes.

Where deleted records survive

Free space inside table pages

SQLite stores rows as cells in fixed-size pages (4096 bytes on the macOS 26.6 system observed). When a row is deleted, its cell is not zeroed by default: the space joins a chain of freeblocks inside the page, and the old bytes stay until a later insert reuses them. This is the most common place to find a deleted quarantine event.

Freelist pages

When a page no longer holds any live cell, SQLite puts it on the freelist for reuse. Its old content can remain until the page is handed out again.

Older page copies: main file vs WAL

With a write-ahead log, new versions of pages go into the -wal file first and reach the main file at a checkpoint. Until then the main file holds the older version of each changed page, and the WAL holds the newer one. A row deleted in the WAL version can still be live in the main file's copy, and the other way round after a checkpoint.

Superseded and uncommitted WAL frames

A WAL can contain several frames for the same page, each a later version. Superseded frames keep earlier states of the page, and frames written after the last commit were never committed at all. SQLite ignores both when it reads the database; a carver does not have to.

Rollback journal pages

In rollback-journal mode, which the observed macOS 26.6 system used (journal_mode=delete), SQLite copies the original version of each page into the -journal file before changing it. In DELETE mode the journal is removed at commit, so a -journal next to the database usually means a transaction was in progress or interrupted when you copied it; its pages can hold the rows as they were before the change. In PERSIST mode SQLite keeps the journal file and only invalidates its header, so older page images can linger there too.

secure_delete and later writes

SQLite has a secure_delete setting that overwrites deleted content. When it is on, freed cells are zeroed and carving from free space finds nothing. How Apple configures it for this database is not documented, so do not assume either way. Independently of that setting, any later insert can reuse freed space, and a busy database recycles space faster. Both reasons explain why recovery is opportunistic.

Why the carver anchors on serial types

A SQLite record starts with its payload length and row id, followed by a header that lists one serial type per column, and then the column values. When a cell is freed, SQLite writes the freeblock header (the offset of the next freeblock and the size of this one) over the first 4 bytes of the cell. That destroys the start of the cell, but the serial types survive.

For LSQuarantineEvent, those serial types form a recognisable pattern: eleven columns, mostly text, a REAL for the timestamp (or an integer when the value is whole, a storage optimisation SQLite applies), an integer for the type number and a blob or NULL for the alias. Quarantine Parser searches for that pattern in free space and decodes the column values that follow it to rebuild the row.

Partial records

A carved record can be partial: later writes may have overwritten part of the freed cell, or the bytes needed for the last columns may be gone. Treat each field as reliable only if it decodes cleanly. The identifier and a URL are often enough to join and pivot. Compare recovered records with the live ones: an older page copy can also contain a row that is still live, so check for duplicates before you count deletions.

What recovered rows prove, and what they do not

A recovered row shows that this event existed in this user's database and is no longer live. That is a lead, not a verdict. It does not tell you when the row was deleted or by whom; SQLite does not record that. It does not distinguish a deliberate DELETE from routine clearing. The Findings view words it as "possible deletion or clearing" for that reason. Context decides: one row missing out of a steady history, right after a suspicious download, reads differently from a history wiped wholesale.

And the reverse: when nothing is recovered, nothing is proven. Freed space may have been reused, or secure_delete may have zeroed it.

Preserving the evidence

  • Copy the database with its -wal and -journal files (cp -p with a trailing *, as shown in the collection guide).
  • Hash the copies before any analysis.
  • Do not open the original with sqlite3. Opening a database can checkpoint the WAL into the main file or roll back a hot journal, which overwrites exactly the pages you want to carve. Even on a copy, run sqlite3 only on a second copy.
  • Prefer raw files over parsed output: a parsed export contains only the live rows the query returned.

Quarantine Parser never writes to the files: it uses its own read-only SQLite reader in your browser, applies committed WAL frames the way SQLite does, and carves the rest.

Example: FIN-MBP-03

The synthetic case behind "Try a sample" on the home page shows the whole chain. On the Mac laptop FIN-MBP-03, user dana.whitlock, on 2026-09-14:

  • At 10:07:12 Safari downloads https://files.example/s/8f3k2/tools.zip (origin https://files.example/s/8f3k2). Archive Utility extracts tools/README.txt, tools/updater.sh and tools/com.example.updater.plist, each with the archive's quarantine value and UUID.
  • A copy of com.example.updater.plist sits in ~/Library/LaunchAgents/ and still carries that value.
  • Around 10:48 the files.example row is deleted from the database with sqlite3.

In the live table there is no longer any files.example event, and several attribute UUIDs have no database row. Quarantine Parser recovers the row from free space in the table page and joins it to the five files carrying its UUID, the LaunchAgents copy included. The timing comes from the scenario: in a real case the database would not tell you when the deletion happened, and neither would it show the Terminal session used to run sqlite3. Unified logs, TCC.db and FSEvents are the places to look for that.

For the attribute side of the join, see com.apple.quarantine flags; for the full investigation, the macOS download investigation walkthrough; for a reference card, macforensics.app.

Frequently asked questions

Can deleted QuarantineEventsV2 records be recovered?

Often, but not always. A deleted row can survive in free space inside table pages, in freelist pages, in older page copies in the WAL or the main file, and in rollback-journal pages. SQLite's secure_delete setting and later writes can destroy it.

Does a recovered record prove someone tampered with the database?

No. It proves the row existed in that database and is no longer live. Deliberate deletion with sqlite3 and routine clearing can look alike; timing, other artifacts and the files carrying the UUID help decide which it was.

If nothing is recovered, were no rows deleted?

No. Freed space is reused by later writes and secure_delete overwrites deleted content, so the absence of recovered rows proves nothing.

How do I avoid destroying deleted records during collection?

Copy the database together with its -wal and -journal files, hash the copies and work on a copy of the copy. Do not open the original with sqlite3: opening it can checkpoint a WAL or roll back a journal and overwrite free space.

Related articles