MVF-2026-0007: a SQL query the documentation called read-only could modify evidence
The affected range is the range checked by source at each tag, not inferred from a changelog: sqlitequery first appears at v2.3.0 and the confinement below is absent through v2.5.0.
| Identifier | MVF-2026-0007 |
| Internal row | M26-DAT-001 |
| Severity | S1, priority P0 |
| Affected | 2.3.0, 2.4.0, 2.5.0 |
| Not affected | 2.0.0, 2.0.1, 2.1.0, 2.2.0, and every 1.x release |
| Fixed in | 2.6.0 |
| Reported | internally; Stage B2 audit, confirmed 2026-09-25 |
| Published | 2026-10-05 |
| CVE / GHSA | none assigned |
| CVSS:3.1 | CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H |
| CVSS:4.0 | CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:A/VC:L/VI:H/VA:H/SC:N/SI:N/SA:N |
The affected range is the range checked by source at each tag, not inferred from
a changelog: sqlite_query first appears at v2.3.0 and the confinement below is
absent through v2.5.0. Both CVSS vectors are this project's own assessment and
are given so they can be argued with — local because the SQL runs on the examiner's
machine, user-interaction because somebody has to run the query, integrity and
availability high because what is damaged is the evidence and cannot be
regenerated, confidentiality low because the data a bound parameter leaks is
written to a local file rather than sent anywhere. Where the two scales
disagree it is their wording and not the judgement: 4.0 says UI:A, 3.1 says
UI:R, and both mean the examiner has to run the query.
Summary#
sqlite_query and sqlite_query_bytes copy the database they are given before
reading it, and the documentation promised the original was never modified. The
copy was real; the promise was not. The script's SQL ran — several statements at
a time — on a writable connection to that copy, and SQL names another file
with ATTACH. So a query could reach straight past the copy and open any other
path on the machine read-write, including the evidence it had just been handed.
Impact#
Everything below was reproduced against a tree built from the v2.5.0 tag, not
reasoned about:
- The evidence was modified in place.
ATTACH ?1 AS o; DELETE FROM o.temptied the original the query was given. - The write-ahead log was destroyed by a plain
SELECT. Attaching a WAL-mode database and reading one row from it checkpointed that log into the database and deleted it. A WAL holds committed-but-unmerged rows and, on a live acquisition, records of deletions — the material an examiner most often wants. Nothing warned that it had gone. - Any buffer the program held could be written to a file of the query's
choosing, through
ATTACHto a new path followed byCREATE TABLEandINSERTof a bound parameter. That includes bytes the program had just read out of the evidence. VACUUM INTOcreated files, because it attaches its output the same way.PRAGMA query_onlywas no defence.PRAGMA query_only=0; DELETE FROM tran. A pragma is a request to the connection and SQL can withdraw it.
The SQL is supplied by whoever wrote the Mutant program, so the realistic route is an analysis script, a query copied out of a writeup, or a snippet pasted from an issue — run by an examiner against an image, in the belief that the tool had guaranteed it could not write.
Why this is in scope#
A builtin that cannot write is a different thing from a builtin documented as
unable to write. Three places in the affected releases said the second:
builtin/metadata.go's sqlite_query summary, the category blurb in
cmd/gendocs/sections.go that renders into docs/CAPABILITY_REFERENCE.md, and
docs/EVIDENCE_HANDLING_POLICY.md, which gave the temp copy as "exactly why the
original is never opened by something that would modify it".
That last sentence is the defect stated as a belief. The copy was always made.
It was never what kept the original safe, because ATTACH did not go through
it. An examiner reads a policy document to decide whether a step preserves a
chain of custody, and here it told them it did.
Details#
withSQLiteCopy copied the database and its -wal/-shm sidecars to a temp
file and opened that file, which is correct and is still what happens. What
followed was the whole of the boundary: the connection was writable, and
sqlite_query runs whatever SQL the program wrote.
ATTACH 'path' AS name is SQLite's way of naming a second database file on the
same connection, and by default it opens that file read-write. Once a second
database is attached, every statement that works on a table works on it. Reading
from an attached WAL-mode database is enough to destroy its log, because SQLite
checkpoints and removes a WAL when the last connection to a database closes
cleanly — so the damage is a side effect of a SELECT rather than of anything
that looks like a write.
The fix#
The boundary is no longer a pragma, because a pragma can be withdrawn from SQL.
Four things, in builtin/sqlite_builtins.go and builtin/sqlite_driver.go:
- The copy's write-ahead log is folded into the copy and the copy is taken out
of WAL mode (
PRAGMA journal_mode=DELETEon the temp file). This is the only SQL the builtin now runs on a writable connection, and it runs it on the copy. - The copy is then reopened through a
file:URI withmode=ro. That is an open flag rather than a setting, so SQL cannot undo it. - The connection is given an attached-database limit of zero through
sqlite3_limit. That refusesATTACH, and with itVACUUM INTO, which attaches its output. A limit set this way cannot be raised again from SQL, which is what makes it a boundary. PRAGMA temp_store=MEMORYkeeps a sort or a temporary table from reaching a file on its way through a query.
A refusal says which of the two it is rather than returning SQLite's wording alone: an attach refusal explains that the query runs on a copy of the one database given, may reach no other file, and that joining two databases takes two queries; a read-only refusal says nothing can be written. A forensic tool that refuses without saying why teaches the operator to work around it.
The browser_history, browser_cookies and browser_downloads parsers read
through the same connection, so they are confined by the same change.
Fixture#
builtin/sqlite_readonly_test.go —
TestSqliteQueryCannotReachAnyFileButItsCopy, run for both sqlite_query and
sqlite_query_bytes.
It builds an acquisition of a live database: evidence.db in WAL mode with
committed rows still only in the log, its -wal and -shm beside it, and a
second database in the same directory. It digests every file in that directory,
runs eight statements that must each be refused, and then digests them again. On
the unfixed code every one of the eight ran, the digest of evidence.db moved,
its -wal and -shm were gone, and two files the queries named — leak.db and
vacuumed.db — had appeared. On the fixed code all eight are refused and every
digest is unchanged.
One limit of it is worth recording, because the number invites a wrong reading.
Its count(*) assertion fails on the unfixed code too, but not because a
live database read incorrectly: two of the eight statements ahead of it are
DELETEs, which succeeded, so the table was already empty. Asked separately,
first and alone, the unfixed and fixed builds both read every committed row.
Settling the log changes no answer; it removes the need to write a -shm index
in order to read one.
Workarounds for affected releases#
There is no setting that turns this off in 2.3.0 through 2.5.0. Until you can upgrade:
- Run only SQL you wrote yourself. Treat a query from anywhere else the way you would treat a program from anywhere else.
- Keep the evidence read-only outside the tool — a write blocker, a
read-only mount, or read-only permissions on the file. This does stop the
modification of the original, because the
ATTACHhas to open it read-write to damage it. It does not stop a bound parameter being written to a new file in a directory you can write to. - Do not point these builtins at a database inside a directory you would mind having files created in.
What changes for programs that worked before#
A query that attached a second database now fails with a refusal that says so,
and VACUUM INTO fails with it. Joining data from two databases takes two
queries and a join in Mutant. This is a deliberate incompatibility: attaching a
second file is the behaviour being removed.
Credit#
Found by an internal audit — Stage B2 of the 2.6.0 review, 2026-09-25 — and confirmed the same day by an independent verification pass. There is no external reporter for this one.
The row stands at S1/P0. It carries no re-rating, unlike MVF-2026-0001 -- but the ledger keeps only a row's current values, so that is the absence of a record and not a record of no change, and it is put that way deliberately. The reasoning kept with the row is that the damage is to the evidence itself and arrives with no warning; a write-ahead log merged into a database and deleted cannot be reconstructed from it.
References#
- The Security section of CHANGELOG.md — the entry for 2.6.0.
- SECURITY.md — what is in scope, and how an identifier is assigned.
- docs/EVIDENCE_HANDLING_POLICY.md — the policy this builtin is held to.
- The advisory index.