A 2025 survey (Lee, Park, Lee & Choi, FSI:DI 55, art. 302031) shows that the common SQLite deleted-record recovery tools emit false positives: they report rows as deleted that are in fact still live. The survey isolates two structural causes — B-tree rebalancing (a merge shuffles a live row onto a freed page, which freed-page carvers misread as deleted) and table reinsertion with the same schema (residue from a dropped table mis-attributed to a recreated same-name table).
We replicated the survey's scenario construction with the real sqlite3 engine
and measured our carver against four of the survey's tools on identical bytes:
bring2lite, Undark (v0.7.1), DC3's sqlite_dissect, and FQLite (4.22, driven
headlessly via a source tap). Two tools could not run on every scenario, and we
report the blocker rather than a fabricated number: FQLite's WAL recovery path is
GUI-coupled in the current source and was not reachable headlessly, so scenario 10
has no FQLite number; sqlite_dissect crashes on scenario 10's secure_delete'd
main image (schema format 0) before it reaches the WAL. Both blockers are
documented below.
- On the B-tree rebalancing scenario,
bring2liteproduced 13 false positives (live rows reported as deleted; precision 0.705); our carver produced 0 (precision 1.000). This reproduces the survey's Type-** finding on our replication. The difference is structural: we exclude live rowids by construction, so a row on a freed page that is still reachable from the live b-tree is never re-surfaced.FQLitelikewise produced 0 live false positives here (precision 1.000) — its freelist carve recovers fewer of the truly-deleted rows (11/50) but does not re-surface a live one. - On WAL +
secure_delete=ON, we recover all 20 deleted rows from the-wal;Undark(a main-image-only carver) recovers 0 because it does not read the-wal. This matches the survey's observation that only WAL-aware tools recover this case. - On the overwritten-same-schema scenario we are honest-but-imperfect. We recover the dropped-table residue, flag it deleted, and keep it disjoint from the live set — but we attribute it by page ownership to the recreated same-name table rather than detecting the drop-recreate explicitly.
Scope discipline. The survey's own corpus + code is "released upon publication" and is not yet public, so we did not reproduce its bytes. Every figure below is labelled by source: measured (this repo, identical bytes) — our carver and the oracles run on the same replicated files — versus reported by the paper — the survey's numbers on its corpus, which we cite but did not re-measure. We make no head-to-head claim on absolute recall across the two corpora; only the identical-bytes column is apples-to-apples.
The survey classifies SQLite deleted-record recovery into three techniques, and evaluates tools for false positives within each:
| Technique | What it reads | Survey's tool examples |
|---|---|---|
| Metadata-based | freeblocks + freelist via the page/b-tree structure | freeblock/freelist parsers |
| Carving-based | pattern/serial-type scan of unallocated space | Undark, bring2lite, FQLite |
| WAL / journal | the -wal and rollback -journal sidecars |
bring2lite, FQLite |
sqlite4n6 spans all three: metadata-aware freeblock + freelist reconstruction, a
serial-type carve of unallocated space, and WAL + rollback-journal recovery — with
a 0-false-positive discipline on the in-page tier (live rowids are excluded by
construction).
The three techniques trade off along three axes — qualitative characterizations, not measured values:
| Technique | Throughput | Coverage | Anti-forensic resilience |
|---|---|---|---|
| Metadata-based | High | High | Low |
| Carving-based | Medium | High | Medium |
| WAL-based | Low | Low | High |
This is the survey's qualitative read of the three approaches — not a scorecard
for any tool, and we don't add a self-graded sqlite4n6 row to it (the axes have
no defined cutoffs and we'd be grading ourselves). sqlite4n6 implements all three
plus the rollback journal (stated above); where it actually lands is shown by the
measured evidence below — the false-positive comparison on identical bytes and
the throughput figures (~15.6 s on a 100 MB database).
Three scenarios were built with the real sqlite3 engine following the survey's
Table 5 construction (fixtures + generator in
tests/data/paper_fp/). Each tool was run
on the same files; recovered records were scored by content against the
ground-truth live/deleted sets. precision = TP / (TP + FP),
recall = TP / deleted.
| Tool | TP | FP (live) | Precision | Recall | Source |
|---|---|---|---|---|---|
| sqlite4n6 (ours) | 33 | 0 | 1.000 | 0.660 | measured (this repo, identical bytes) |
| bring2lite | 31 | 13 | 0.705 | 0.620 | measured (this repo, identical bytes) |
| FQLite | 11 | 0 | 1.000 | 0.220 | measured (this repo, identical bytes) |
| Undark | 0 | 1 | 0.000 | 0.000 | measured (this repo, identical bytes) |
DC3 sqlite_dissect |
42 | 13 | 0.764 | 0.840 | measured (this repo, identical bytes) |
| Bring2Lite | — | ~10 | — | — | reported by the paper (its corpus) |
The headline: on identical bytes, the freed-page carvers re-surface live rows as
deleted — bring2lite 13, sqlite_dissect 13 (ids 51–63) — while our
live-rowid exclusion yields 0. sqlite_dissect recovers the most truly-deleted
rows (42 of 50) but pays for it in noise and live re-reads: its -c -f carve emits
2,543 carved records that collapse to just 55 distinct tagged rows (42
deleted + 13 live) plus 1,111 untagged fragments and ~1,377 duplicate re-reads
of the same row across page versions (precision is scored on the tagged data rows
only, as for the other tools; the untagged fragments are not scoreable as
live/deleted). This is the same aggressive-freelist-carve noise measured in the
Nemetz head-to-head (recovery-comparison.md).
FQLite recovers 11 of the 50 truly-deleted freelist rows (ids 1–9, 18, 27,
content-scored by the embedded ROW-<id>-… tag) with 0 live false positives
(its 15 output lines also include 1 sqlite_master schema row and 2 untagged
freespace fragments, neither scorable as a live/deleted data row). Undark
recovers nothing truly-deleted from the freelist pages here and emits a
single freespace fragment — whose id-tag (ROW-56-…) is a live row (id 56,
range 51..80) surfaced as recovered: 1 false positive, 0 true positives.
Undark dumps a flat CSV with no live/deleted bucketing, so its lone recovery is a
live-row fragment, exactly the survey's Type-** weakness.
| Tool | TP (OLD residue) | FP (live NEW) | Precision | Recall (/10) | Source |
|---|---|---|---|---|---|
| sqlite4n6 (ours) | 5 | 0 | 1.000 | 0.500 | measured (this repo, identical bytes) |
| bring2lite | 5 | 0 | 1.000 | 0.500 | measured (this repo, identical bytes) |
| FQLite | 5 | 0 | 1.000 | 0.500 | measured (this repo, identical bytes) |
DC3 sqlite_dissect |
5 | 0 | 1.000 | 0.500 | measured (this repo, identical bytes) |
All four recover the 5 surviving OLD residue rows (rowids 6..=10; the other 5 OLD
rows lost their cells to same-rowid reuse by the NEW rows) and none re-surface a
live NEW row, so the content false-positive count is 0 for all four on this
replication. FQLite carves the OLD residue as a flat list under the recreated
students table (its output is the 5 OLD-NAME-6..10 rows, plus the
sqlite_master schema row and one malformed fragment, neither a live NEW row), so
it shares the others' clean 0-false-positive result on this replication.
sqlite_dissect likewise surfaces the 5 OLD-NAME-6..10 residue rows with 0
live false positives, alongside 26 untagged phantom fragments from its freelist
carve; its CSV export then aborts (IndexError while writing the sqlite_master
schema commit) after the table data is fully written, so the residue scoring is
complete. Our nuance (the Type-* caveat): we attribute the OLD residue by
page ownership to the recreated recovered_students table; we do not reroute or
re-tier it. Where the recreated table is AUTOINCREMENT, we now ALSO surface a
table_instance_risk provenance flag on each residue row whose rowid exceeds the
table's sqlite_sequence high-water mark — a hint, not a predecessor proof
(the same rowid > seq is reachable by an UPDATE of the rowid or a
sqlite_sequence edit), carrying its evidence (the rowid, the seq) so the examiner
can cross-check. For a plain INTEGER PRIMARY KEY recreate the flag stays silent —
the survey's genuinely-undecidable case. bring2lite sidesteps the attribution
question entirely by emitting the residue as schema-less unallocated blobs.
| Tool | TP | FP | Precision | Recall | Source |
|---|---|---|---|---|---|
| sqlite4n6 (ours) | 20 | 0 | 1.000 | 1.000 | measured (this repo, identical bytes) |
| bring2lite | 20 | 0 | 1.000 | 1.000 | measured (this repo, identical bytes) |
| Undark | 0 | 0 | n/a | 0.000 | measured (this repo, identical bytes) |
DC3 sqlite_dissect |
— | — | — | — | did not complete — crashes on the secure_delete'd main image (see provenance) |
| FQLite | — | — | — | recover | cited (paper) — WAL path not reachable headlessly (see provenance) |
With secure_delete=ON the main image holds none of the message bodies; the only
residue is in the uncheckpointed -wal. We and bring2lite (both WAL-aware)
recover all 20; Undark (main-image only, no -wal awareness) recovers none,
confirming the survey's main-image-vs-WAL split on identical bytes.
sqlite_dissect did not complete on this scenario: because secure_delete=ON
left the main image with schema format number 0 (no schema/data in the main file —
everything lives in the uncheckpointed -wal), its header parser raises
KeyError: 0 in database_header.stringify before it reaches any WAL carving,
with or without an explicit -w wcase.db-wal. No figure is fabricated for it. FQLite is WAL-aware in its GUI, and the paper reports it
recovering this case, but its WAL recovery is structurally GUI-coupled in the
current source (the -wal reader is instantiated by a JavaFX ImportDBTask, and
the WAL table wiring in Job.processDB() sits entirely inside if (gui != null)
blocks). The headless tap drives the in-page/freelist engine (Job.run), which on
this file leaves job.wal == null and recovers nothing — so scenario 10 keeps
FQLite's cited figure rather than a fabricated measured one (blocker detailed
in the provenance note).
One honest WAL artifact. Our carve of
wcase.dbemits 21 records for 20 distinct deleted rowids: rowid 14 also appears once as a lower-confidence (0.48) all-NULL record carved from a different WAL commit generation. It is not a false positive (14 is genuinely deleted), but it shows the multi-snapshot WAL view can surface the same rowid across commit generations. The 20 clean full-content rows recover at confidence 0.90.
-
bring2lite(Python 3) — RAN on all three files. Repo. Its CLI path imports PyQt5 at module load (only used on the--guipath); a minimal import shim let the headless run proceed. It buckets recovered records into per-page logs (unalloc-parsing/,freelists/,WALs/vs liveregular-page-parsing/), which is how live-vs-deleted attribution was scored. It emitsis-with-literalSyntaxWarnings under 3.11 but runs. -
Undark (v0.7.1, Paul L. Daniels, C) — RAN on all three files. Repo. Built from the master tarball (sha256
c0a9ee7ebd180727deef52fbafe0ef0e2b7c9b43c5604761bfeb86bc9306912a) with two behavior-preserving clang/macOS patches (hoist the nestedswap64/ntohllout ofdecode_row; renamentohll→u_ntohllto dodge the macOS<sys/_endian.h>macro), thenmake. Gated onUNDARK_BIN; recipe indocs/validation.mdanddocs/corpus-catalog.md§F.1. Undark emits a flat CSV (rowid,cols…) with no live/deleted bucketing — it dumps every record it can decode, including live b-tree rows — so on 0B it surfaces the 5 live NEW rows as recovered, and on 0F its lone freespace fragment is a live row (id 56). It reads the main image only (no-wal), so it recovers nothing from scenario 10 — confirmed (0 records). -
DC3
sqlite_dissect(Python) — RAN on 0F and 0B; crashed on scenario 10. Repo; installed viapip install sqlite-dissect. Driven throughscripts/run-sqlite-dissect.sh(SQLITE_DISSECT_CMD), which runssqlite_dissect <db> -c -f -e csv(carving + freelist carving on; off by default) and normalizes the per-table CSV to column order, skipping theOperation == "Added"(live) rows so only carved records are scored. On 0F it recovers 42 of 50 deleted rows but re-surfaces 13 live rows (ids 51–63) and emits 1,111 untagged fragments + heavy duplicate re-reads across page versions; on 0B it recovers the 5 OLD residue rows with 0 live FP (the CSV export aborts on thesqlite_mastercommit after the table data is written, so scoring is complete). On scenario 10 it raisesKeyError: 0indatabase_header.stringifyon thesecure_delete'd main image (schema format number 0) before any WAL carving, with or without-w— so no figure is entered. Setup intools/README.mdanddocs/corpus-catalog.md§F.4. -
FQLite (4.22, Dirk Pawlaszczyk, Java) — RAN on 0F and 0B; its WAL path on scenario 10 was not reachable headlessly (genuine blocker, no number fabricated). Repo. FQLite's command-line mode was removed at v2.0 (the GUI installer ships no runnable CLI), so it is driven through a headless source-instrumentation tap of its carving engine (
fqlite.base.Job). Build recipe (gitignored undertools/fqlite/, gated onFQLITE_TAP):- clone
pawlaszczyk/fqliteat commit26922bd9e3cdc60c93b72dfb1fb2f5972a0af6a6; - no edits to fqlite's own source are needed: at this commit every
gui.add_table(...)call is already behind anif (gui != null)guard upstream, so the GUI-null engine path (Job.run) runs to completion — the committedfqlite.patchis intentionally empty (header/explanation only, no diff hunks); - add six compile-only stubs for the
rag/ermLLM packages so the build links without pulling langchain4j/llama; - compile the engine +
tap/HeadlessTap.javawith OpenJDK 25,--release 21, against OpenJFX 22.0.2 SDK (--module-path javafx-sdk-22.0.2/lib --add-modules javafx.base,javafx.graphics,javafx.controls) pluscommons-codec-1.17.1,jspecify-1.0.0,antlr4-runtime-4.8,sqlite-jdbc-3.51.1.0; - run:
FQLITE_JAVA=<jdk-25>/bin/java tools/fqlite/run-tap.sh <db>→ CSVrowid,col1,col2,…of recovered DELETED records (rowid-1when the header rowid is unrecoverable; scored by content). The tap boots the JavaFX toolkit headlessly (no window) because the engine'sAppLogstatic init andprocessDBcleanup fence still touch JavaFX.
On 0F it recovers 11 of 50 deleted freelist rows with 0 live false positives; on 0B it recovers the 5 surviving OLD residue rows with 0 false positives. The WAL blocker (scenario 10): the
-walreader (WALReader) is instantiated by a JavaFXImportDBTaskTask, and the WAL table registration inJob.processDB()is entirely insideif (gui != null)blocks (gui.add_table(...).thenAccept(...)→setWALPath/guiwaltab). DrivingJob.run()headlessly therefore never builds the WAL reader (job.wal == nullafter the run), and WAL-recovered rows would in any case land inWALReader's ownresultlist, notjob.resultlist. The tap was extended to (a) setreadWAL/walpathand (b) drainjob.wal.resultlist, but the GUI-coupled instantiation cannot be reached without reconstructing theImportDBTaskflow or refactoringprocessDB's WAL wiring out of itsif (gui != null)guards — out of scope for this pass. So scenario 10 keeps FQLite's cited figure; no FQLite number is fabricated. Full recipe + engine API map intools/fqlite/README.mdandtools/fqlite/ENGINE_NOTES.md(both gitignored); also indocs/validation.mdanddocs/corpus-catalog.md§F.2. - clone
Companion comparison — the Nemetz head-to-head.
bring2liteand DC3sqlite_dissectare also wired as standing, env-gated oracles into the repo's third-party-ground-truth head-to-head (forensic/tests/nemetz_tool_comparison.rs, written up inrecovery-comparison.md), through committed wrappers —scripts/run-bring2lite.sh(BRING2LITE_CMD) andscripts/run-sqlite-dissect.sh(SQLITE_DISSECT_CMD). That table scores precision/recall against the Nemetz answer key on the(col1,col2)matcher (both tools emit per-column records, so their recall is directly comparable); this page scores the survey's false-positive scenarios. Read the two together for the full picture.
The survey scores deleted-record carving. Beyond that axis, this tool recovers substrates and structures none of the four surveyed tools address — and is validated for them against independent ground truth:
- Freeblock-clobbered rows the others drop. When SQLite overwrites a freed cell's first four bytes, we rebuild the record from its surviving serial-type tail plus a same-page schema template — including deletions with a 2-byte rowid (any rowid ≥ 128, i.e. most real tables) and coalesced runs of adjacent deletions (accepted only when they tile the freed slot exactly, so a misaligned read is rejected, never emitted as a phantom).
- Dropped-table schema recovery. A deleted
sqlite_masterrow is recovered from page-1 free space, surfacing a dropped table/index/view/trigger's name andCREATEstatement (auditreportsSQLITE-DROPPED-SCHEMA-RECOVERED). - Rollback-journal + WAL recovery of the last transaction's deletes and
edits — the default
DELETE/PERSISTmode the freed-page carvers don't read. - Confirmed on a real case. On the NIST CFReDS Data Leakage Case, the
Google Drive
snapshot.dbcarved from a Volume Shadow Copy yields both deletedcloud_entryrecords NIST documents — including the freeblock-clobbered one — with the live row never re-surfaced. The independentsqlite-unhidecorpus (third-party answer keys) and NIST SFT-05 (native types + a variety of BLOB image formats) are exercised the same way. Seevalidation.md.
- Replicated corpus, not the survey's bytes. The survey's official corpus is not yet public; these are real-engine replications of its construction. When the corpus is released, swap it in and re-measure (tracked in the fixtures README).
- Throughput is now measured apples-to-apples (see "Throughput" below). Every
tool was timed on the same machine (Apple M4 Pro) and the same
locally-generated ~100 MB image; our
carve -f jsonl -o -runs in a median 15.6 s (release). The survey's numbers are on its own 100 MB DB on a different machine and are not directly comparable — they are kept only as a historical anchor, not read against the measured table. - Recall is not the headline. The identical-bytes recall numbers differ by scenario and tool, but the survey's contribution — and ours here — is about false positives and substrate coverage (WAL vs main-image), not a recall leaderboard across different corpora.
Throughput is not the headline — false positives and substrate coverage are. Lower wall-clock time only means "faster at the same job" when the tools are doing the same job; they are not (see the recovery discipline column). A flat b-tree dump that emits ~178k undifferentiated rows in 1.5 s is not "10× faster" than a deleted-only carve that emits 79,904 attributed rows in 15 s — it is doing different, lesser work. The table below records times as facts in their work-done context, not as a leaderboard.
Every tool was run on the same machine and the same locally-generated
~100 MB messages-like image (210k → 178k rows, an 80k-row contiguous middle subset
DELETEd, secure_delete=OFF, auto_vacuum=NONE; generator
tests/data/paper_fp/gen_large.py, gitignored DB). Methodology: wall-clock,
warm cache, median of 5 runs per tool, release build; harness
scripts/throughput-bench.sh. Our carve recovers 79,904 of the 80,000 deleted
rows (recall 0.9988) with 0 live false positives — correctness holds at scale,
pinned by the env-gated perf-smoke forensic/tests/perf_large_carve.rs
(SQLITE_FORENSIC_PERF_DB).
Measured here — same machine (Apple M4 Pro, macOS 15.7.8), same ~100 MB db:
| Tool / mode | Median wall-clock | Records emitted | Recovery discipline |
|---|---|---|---|
sqlite4n6 carve -f jsonl -o - |
15.6 s | 79,904 deleted | deleted-only, live-rowid excluded, 0 live FP; every row content-confirmed in the deleted range |
sqlite4n6 carve (workbook) + -f db |
44.8 s | 79,904 deleted | as above, plus it rebuilds a 45 MB queryable .carved.db + an 8.7 MB .xlsx |
undark -i |
1.5 s | 177,906 | flat-dump: whole b-tree (live + freespace), no live/deleted split — ~178k rows are almost all live |
fqlite (headless tap) |
2.4 s | 2,935 | deleted-only bucket (status flag D), but mostly freelist-page fragments — only 31 rows carry an MSG- tag, 6 of them in the true deleted range |
bring2lite |
— (did not complete) | 0 | crashes mid-run (struct.error: unpack requires a buffer of 1 bytes in _extract_trunk_page_content while parsing a freelist trunk page) before producing any recovered record |
Notes on the measurements:
bring2litedid not complete a recovery on this db — it parses the live b-tree, then aborts in the freelist trunk-page parser with a truncated-bufferstruct.error. The ~10 s it spends before crashing is not a recovery time, so no figure is entered for it. (It runs to completion on the small identical-bytes fixtures elsewhere in this doc; the failure is specific to the larger image's freelist structure.)undarkemits all 177,906 rows then SIGSEGVs at exit when its stdout is a regular file or/dev/null(it exits cleanly to a pipe); the timing pipes its output, and the row count is complete regardless of the exit-time crash.- Flags shown are the current
-f/-oform. These figures were measured before the output-flag redesign, when a single--dbrun materialized the workbook and the carved db together (the 44.8 s row); the carve work itself is unchanged.
Reported by the paper (its own 100 MB db, a different machine — NOT comparable with the table above; kept only as a historical order-of-magnitude anchor):
| Tool | Reported time |
|---|---|
| Undark | 2.94 s |
| FQLite | 13.62 s |
| Bring2Lite | 21.89 s |
These survey numbers were measured on a different machine and a different corpus; they are explicitly not an apples-to-apples comparison with the measured table and must not be read against it. The apples-to-apples evidence is the first table: same machine, same bytes, with the records-emitted and recovery-discipline columns making clear that a smaller time often buys a smaller — or undifferentiated — result.