Repository navigation
[FUZZER/LOW] aes128cbc in legacy mode writes a database that fails to reopen with its own key, after a specific CREATE TABLE/VACUUM sequence #278
Description
This is one of a handful of bugs identified via fuzzing, as described in #277 and as requested there I am opening them as issues. This one specifically is rather low priority, as I would classify this as a cursed bug that is extremely unluckily to occur in any real settings.
A database keyed once with aes128cbc, legacy=1, and kept on that same cipher and the same passphrase the whole time, can end up unreadable with that same passphrase after closing and reopening, following an ordinary sequence of CREATE TABLE and VACUUM statements that never errors.
#include <stdio.h>
#include <string.h>
#include "sqlite3mc_amalgamation.h"
static const unsigned char PASSWORD[] = {
184, 187, 48, 27, 93, 14, 220, 136, 142, 193, 210, 183,
247, 186, 18, 175, 35, 94, 33, 203, 202, 237, 93
};
static void run_sql(sqlite3 *db, const char *sql) {
char *err = NULL;
int rc = sqlite3_exec(db, sql, NULL, NULL, &err);
if (rc != SQLITE_OK) {
fprintf(stderr, "SQL FAILED (%d): %s -- %s\n", rc, sql, err ? err : "?");
sqlite3_free(err);
}
}
static void configure(sqlite3 *db) {
int cipher = sqlite3mc_cipher_index("aes128cbc");
sqlite3mc_config(db, "cipher", cipher);
sqlite3mc_config_cipher(db, "aes128cbc", "legacy", 1);
sqlite3_key(db, PASSWORD, (int) sizeof(PASSWORD));
}
int main(void) {
remove("aes128_repro.db");
sqlite3 *db;
sqlite3_open("aes128_repro.db", &db);
configure(db);
run_sql(db, "CREATE TABLE IF NOT EXISTS t1(i INTEGER, r REAL, t TEXT, b BLOB)");
run_sql(db, "VACUUM"); run_sql(db, "VACUUM"); run_sql(db, "VACUUM");
run_sql(db, "CREATE TABLE IF NOT EXISTS t2(i INTEGER, r REAL, t TEXT, b BLOB)");
run_sql(db, "VACUUM"); run_sql(db, "VACUUM"); run_sql(db, "VACUUM");
run_sql(db, "VACUUM"); run_sql(db, "VACUUM"); run_sql(db, "VACUUM");
run_sql(db, "CREATE TABLE IF NOT EXISTS t0(i INTEGER, r REAL, t TEXT, b BLOB)");
sqlite3_close(db);
sqlite3 *db2;
sqlite3_open("aes128_repro.db", &db2);
configure(db2);
char *err = NULL;
int rc = sqlite3_exec(db2, "SELECT type, name FROM sqlite_schema", NULL, NULL, &err);
printf("reopen schema query: rc=%d (%s) err=%s\n", rc, sqlite3_errstr(rc), err ? err : "none");
sqlite3_free(err);
sqlite3_close(db2);
return 0;
}Built with the same defines the project's own build uses:
$ cc -O0 -g -DSQLITE_TEMP_STORE=2 -DSQLITE_ENABLE_DBPAGE_VTAB -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_CORE \
-DHAVE_CIPHER_AES_128_CBC -DHAVE_CIPHER_AES_256_CBC -DHAVE_CIPHER_CHACHA20 -DHAVE_CIPHER_SQLCIPHER \
-DHAVE_CIPHER_RC4 -DHAVE_CIPHER_ASCON128 -DHAVE_CIPHER_AEGIS \
-o repro repro.c sqlite3mc_amalgamation.c -lpthread -ldl -lm
$ ./repro
reopen schema query: rc=26 (file is not a database) err=file is not a database
The first connection runs every statement without error, including the final CREATE TABLE t0, and closes cleanly. The second connection, with the identical cipher configuration and the identical passphrase, cannot read page 1 back at all (SQLITE_NOTADB), not just a single corrupted row.
Some things observed while narrowing this down.
- Needs
legacy=1specifically. The same sequence withlegacy=0never fails. - Needs
aes128cbcspecifically. The same sequence does not reproduce underaes256cbc,rc4, orchacha20. - Needs a passphrase, not a raw key, and depends on the exact passphrase bytes. Several other passphrases tried did not reproduce. I have absolutely no idea why.
- Needs exactly two distinct tables created across the vacuums, and exactly 9 total
VACUUMstatements between the first and the lastCREATE TABLE, split anywhere among them (3+6, 6+3, 9+0 all reproduce). 8 or 10 vacuums do not. - No
PRAGMA rekey, no cipher change, no passphrase change anywhere in the sequence.
I hope that exorcists are not needed for this one.
Best,
Luca
Activity
utelle commentedon Oct 3, 2026
As always the question is whether an "identified bug" is really a bug.
Thanks. This allows to classify and discuss a potential issue, before taking action.
Yes, you are absolutely right to call it a cursed bug. Chances to hit this "bug" are 8 out of 65536 (or less than 0.0122 percent). For example, change the first byte of the
PASSWORDfrom184to185and you won't see any bug. All will work as expected.This has to do with the actual content of the database header on closing the database.
The reason is simple: you used a cursed password. Just kidding. The reason is the legacy mode. In legacy mode the 8 bytes at offset 16 of the database file are encrypted. However, SQLite reads these bytes before the cipher scheme is set up. The first 2 bytes are interpreted as the page size of the database. If SQLite finds a valid page size, it will use it. If the page size is invalid, the default page size will be used.
In your test application the 2 bytes contain the value
x8000corresponding to 32768 = 32 kB. And that looks like a valid page size, BUT it is not correct. The correct page size is 4 kB.legacy=0never fails because SQLite always reads the correct page size from the database header.Pure luck. Each cipher will generate a different encryption of the header bytes. Most of the time you are lucky to not hit a valid page size.
I guess, by now you understand what's going on. At least partially.
The content of the relevant header bytes change in the course of the various actions. You won the jackpot by finding a combination that left a valid page size value in the database header.
Just not using cipher schemes in legacy mode is the best way to avoid this kind of problem.
For the cipher schemes
aes128cbcandaes256cbcthat were created by myself almost 20 years ago, I modified the implementation about 12+ years ago to not encrypt the critical database header bytes. Back then I didn't call the prior mode legacy, instead opening and modifying a database changed the cipher scheme automatically to the new format.Unfortunately, the problem persists for other cipher schemes that chose to encrypt the whole database header, like
sqlcipherorsqleet(=chacha20in legacy mode). This is particularly problematic for such widely used cipher schemes assqlcipher. The risk to hit the problem is small, but not zero.P.S.:
I will work through your list of issues, but that will take time - I'm rather busy right now. I'm pretty sure that most issues are not critical, because otherwise I would have received bug reports already in the past.
LucaCappelletti94 commentedon Oct 3, 2026
Absolutely no rush, and I agree none of them are critical (otherwise I would not have opened them in this fashion but submitted privately). Other the ones I opened, I have found a handful of even more cursed ones which I cannot even reproduce deterministically, or really excessively unlikely to ever happen. The only thing fixing these bugs will get us is likely being able to find more, so as I said elsewhere it is somewhat circular.
That said, I hope this effort was appreciated. These harnesses will be used in the ClusterFuzzLite CI downstream, so any other discovery should be (once everything is properly setup) automatically found and reported.
utelle commentedon Oct 3, 2026
Yes, critical vulnerabilities should be reported following the security policiy of this project (see Security Policy).
Well, if the configuration parameter
legacyunequal to zero is involved you now know that there is always a small chance that SQLite fails to open the database.Sure, if real issues are revealed, it is a good thing. And if there are real bugs, they will be fixed.
LucaCappelletti94 commentedon Oct 3, 2026
I suspect then that #282 might be of the same legacy family, and as such should best be closed. I'll leave to you the choice of how to proceed.
utelle commentedon Oct 3, 2026
Maybe, maybe not. depends on the actual circumstances.
I will look into issue #282 and comment there.
utelle commentedon Oct 5, 2026
The root cause is definitely a different one. For this issue the solution would be to explicitly configure the parameter
legacy_page_sizeto the value4096. For the legacy mode of the ciphersaes128cbcandaes256cbcthis is not done automatically, because the default pagesize is not unique, because SQLite changed the default pagesize to 4096 - in 2016 IIRC. Before that date the pagesize was 1024 AFAICR.I commented in the issue today. I would need a test database exhibiting the problem to be able to analyze for what reason SQLite3MC fails to open that database.