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.t emptied 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 ATTACH to a new path followed by CREATE TABLE and INSERT of a bound parameter. That includes bytes the program had just read out of the evidence.
  • VACUUM INTO created files, because it attaches its output the same way.
  • PRAGMA query_only was no defence. PRAGMA query_only=0; DELETE FROM t ran. 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=DELETE on 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 with mode=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 refuses ATTACH, and with it VACUUM 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=MEMORY keeps 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 ATTACH has 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#

Related reading