Repository navigation
Description
Expected Behavior
After SAVEPOINT s; DROP TRIGGER t; ROLLBACK TO s; RELEASE s; on an encrypted database, the
schema is restored and the connection keeps working. This is what happens with the same
sequence on an unencrypted database, and with a full BEGIN … ROLLBACK on an encrypted one.
Actual Behavior
Roughly once in every 1000 runs, a statement executed after the ROLLBACK TO fails with
database disk image is malformed (SQLITE_CORRUPT, 11). The failing statement is either the
next DROP TRIGGER inside a new savepoint or a plain SELECT … FROM sqlite_schema. It never
fails before the first ROLLBACK TO has run.
The database file is not damaged. After closing the connection and reopening the file with
the same key, PRAGMA integrity_check returns ok and all triggers are present. This held for
every failure we observed. It looks like stale in-memory state (page cache or schema) after
the partial rollback.
The failure depends on the encrypted content. Each page is encrypted with a random IV, so the
same script fails or passes from run to run, and we could not make it deterministic. Results
with the script below (shell built from the release tags, Linux x86_64, OpenSSL 3.5.5):
| SQLCipher (SQLite) | Variant | Iterations | Failures |
|---|---|---|---|
| 4.19.0 (3.53.4) | SAVEPOINT; DROP TRIGGER; SELECT sqlite_schema; ROLLBACK TO; RELEASE |
20000 | 21 |
| 4.19.0 (3.53.4) | same without the SELECT |
20000 | 7 |
| 4.19.0 (3.53.4) | BEGIN; DROP TRIGGER; SELECT; ROLLBACK (control) |
10000 | 0 |
| 4.19.0 (3.53.4) | savepoint sequence without a key (control) | 20000 | 0 |
| 4.13.0 (3.51.2) | savepoint sequence | 10000 | 7 |
| 4.12.0 (3.51.1) | savepoint sequence | 10000 | 6 |
We first saw it through the Python binding sqlcipher3-wheels 0.5.7, which bundles 4.12.0
with OpenSSL 3.6.0. It shows the same rate on Linux and on Windows 11. With that binding, a
ROLLBACK TO that only undoes data changes (UPDATE, PRAGMA user_version) did not fail in
5000 runs: the schema change seems to be required.
Steps to Reproduce
-
Build the shell from a release tag, as in the README:
git clone --depth 1 --branch v4.19.0 https://github.com/sqlcipher/sqlcipher.git && cd sqlcipher ./configure --with-tempstore=yes CFLAGS="-DSQLITE_HAS_CODEC -DSQLITE_EXTRA_INIT=sqlcipher_extra_init -DSQLITE_EXTRA_SHUTDOWN=sqlcipher_extra_shutdown" LDFLAGS="-lcrypto" make sqlite3 -
Run
bash repro.sh ./sqlite3 20000 savepoint. The script is below, released under CC0,
so feel free to use it in your tests. -
Each iteration:
- creates a fresh database with a random raw key (
PRAGMA key = "x'…'"); - creates 10 tables and 13 triggers in one transaction;
- runs
SAVEPOINT s; DROP TRIGGER dN; SELECT count(*) FROM sqlite_schema WHERE type = 'trigger'; ROLLBACK TO s; RELEASE s;for 4 triggers; - reads
sqlite_schemaonce more.
Failures print the error and the result of reopening the file:
[303] Parse error near line 31: database disk image is malformed (11) | reopened: integrity_check, trigger count = ok 13 - creates a fresh database with a random raw key (
repro.sh (CC0)
#!/usr/bin/env bash
# Intermittent "database disk image is malformed" after ROLLBACK TO undoes a DROP TRIGGER
# on an encrypted database. Uses only the SQLCipher command-line shell and bash.
#
# Usage: repro.sh <path to the sqlcipher shell> [iterations] [variant]
# variant: savepoint (default) | no_read | transaction (control) |
# plaintext (control: the savepoint sequence with no key, i.e. not encrypted)
#
# Each iteration creates a fresh database with a random 256-bit raw key, creates 10 tables
# and 13 triggers in one transaction, then drops and restores 4 triggers. The shell runs
# with -bail, so the first error stops it and is printed. On failure the database is
# reopened with the same key and checked with PRAGMA integrity_check.
#
# License: CC0 1.0 Universal (public domain dedication),
# https://creativecommons.org/publicdomain/zero/1.0/ - use it freely, e.g. in your tests.
set -euo pipefail
SQL_SHELL="${1:?path to the sqlcipher shell}"
N="${2:-10000}"
VARIANT="${3:-savepoint}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "${WORKDIR:?}"' EXIT
READ="SELECT count(*) FROM sqlite_schema WHERE type = 'trigger';"
schema() {
echo "BEGIN;"
for i in $(seq 0 9); do
echo "CREATE TABLE t$i (id INTEGER PRIMARY KEY, a TEXT NOT NULL, b BLOB, c INTEGER);"
done
local events=(UPDATE DELETE INSERT INSERT)
for n in 0 1 2 3; do
echo "CREATE TRIGGER d$n BEFORE ${events[$n]} ON t0 WHEN $n >= 0 BEGIN SELECT RAISE(ABORT, 'trigger $n'); END;"
done
for i in $(seq 1 9); do
echo "CREATE TRIGGER e$i BEFORE UPDATE OF a ON t$i BEGIN SELECT RAISE(ABORT, 'table $i'); END;"
done
echo "COMMIT;"
}
script() {
[ "$VARIANT" = plaintext ] || echo "PRAGMA key = \"x'$1'\";"
schema
for n in 0 1 2 3; do
case "$VARIANT" in
transaction) echo "BEGIN; DROP TRIGGER d$n; $READ ROLLBACK;" ;;
no_read) echo "SAVEPOINT s; DROP TRIGGER d$n; ROLLBACK TO s; RELEASE s;" ;;
*) echo "SAVEPOINT s; DROP TRIGGER d$n; $READ ROLLBACK TO s; RELEASE s;" ;;
esac
done
echo "$READ"
}
"$SQL_SHELL" :memory: <<< "PRAGMA key = \"x'00'\"; SELECT 'sqlite ' || sqlite_version(); PRAGMA cipher_version; PRAGMA cipher_provider_version;" | tail -n +2 | tr '\n' ' '
echo "| $(uname -sr)"
failures=0
for i in $(seq 1 "$N"); do
db="${WORKDIR:?}/test-$i.db"
key="$(od -An -tx1 -N32 /dev/urandom | tr -d ' \n')"
if ! err="$(script "$key" | "$SQL_SHELL" -bail "$db" 2>&1 >/dev/null)"; then
failures=$((failures + 1))
if [ "$VARIANT" = plaintext ]; then
reopened="$(printf 'PRAGMA integrity_check;\n%s\n' "$READ" | "$SQL_SHELL" "$db" 2>&1 | tr '\n' ' ')"
else
reopened="$(printf 'PRAGMA key = "x%s";\nPRAGMA integrity_check;\n%s\n' "'$key'" "$READ" \
| "$SQL_SHELL" "$db" 2>&1 | tail -n +2 | tr '\n' ' ')"
fi
echo "[$i] ${err//$'\n'/ } | reopened: integrity_check, trigger count = $reopened"
fi
rm -f "${db:?}"
done
echo "$VARIANT: $N iterations, $failures failures"SQLCipher version: 4.19.0 community (also 4.12.0 and 4.13.0); SQLite 3.53.4; OpenSSL 3.5.5;
Linux x86_64 (WSL2, kernel 6.18). Default settings apart from the raw key.
Activity
sjlombardo commentedon Nov 10, 2026
Hello @Alpes71 - thanks for the clear report and reproduction steps. We're investigating.
sjlombardo commentedon Nov 13, 2026
Hello @Alpes71 we've identified and resolve the issue here and the fix will be included in the next release
Alpes71 commentedon Nov 13, 2026
Hi @sjlombardo Thanks a lot for the quick fix! We’ll re-run our reproducer once the next release is out and report back here.