Competitive Landscape — the 2025 SQLite false-positive survey, and where we sit¶
Executive summary¶
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's three-technique framework¶
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).
Measured false-positive / recall table (identical bytes)¶
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.
0F — B-tree rebalancing (deleted denom = 50; 30 live rows present)¶
| 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.
0B — overwritten table, same schema (residue denom = 10 OLD rows; 5 live NEW rows)¶
| 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.
10 — WAL + secure_delete=ON (deleted denom = 20; residue only in -wal)¶
| 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.
Oracle provenance — which oracles ran, which were cited¶
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.
- clone
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 -wal reader (WALReader) is instantiated by a
JavaFX ImportDBTask Task, and the WAL table registration in Job.processDB()
is entirely inside if (gui != null) blocks (gui.add_table(...).thenAccept(...)
→ setWALPath/guiwaltab). Driving Job.run() headlessly therefore never
builds the WAL reader (job.wal == null after the run), and WAL-recovered rows
would in any case land in WALReader's own resultlist, not job.resultlist.
The tap was extended to (a) set readWAL/walpath and (b) drain
job.wal.resultlist, but the GUI-coupled instantiation cannot be reached without
reconstructing the ImportDBTask flow or refactoring processDB's WAL wiring out
of its if (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 in tools/fqlite/README.md and tools/fqlite/ENGINE_NOTES.md
(both gitignored); also in docs/validation.md and docs/corpus-catalog.md
§F.2.
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.
Capabilities the surveyed tools don't have¶
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.
Limits of this comparison¶
- 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¶
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.