Skip to content

[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

Closed

Description

@LucaCappelletti94

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=1 specifically. The same sequence with legacy=0 never fails.
  • Needs aes128cbc specifically. The same sequence does not reproduce under aes256cbc, rc4, or chacha20.
  • 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 VACUUM statements between the first and the last CREATE 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

  1. utelle commented on Oct 3, 2026

    @utelle
    Owner

    This is one of a handful of bugs identified via fuzzing,

    As always the question is whether an "identified bug" is really a bug.

    as described in #277 and as requested there I am opening them as issues.

    Thanks. This allows to classify and discuss a potential issue, before taking action.

    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.

    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 PASSWORD from 184 to 185 and you won't see any bug. All will work as expected.

    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.

    This has to do with the actual content of the database header on closing the 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.

    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 x8000 corresponding to 32768 = 32 kB. And that looks like a valid page size, BUT it is not correct. The correct page size is 4 kB.

    • Needs legacy=1 specifically. The same sequence with legacy=0 never fails.

    legacy=0 never fails because SQLite always reads the correct page size from the database header.

    • Needs aes128cbc specifically. The same sequence does not reproduce under aes256cbc, rc4, or chacha20.

    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.

    • 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.

    I guess, by now you understand what's going on. At least partially.

    • Needs exactly two distinct tables created across the vacuums, and exactly 9 total VACUUM statements between the first and the last CREATE TABLE, split anywhere among them (3+6, 6+3, 9+0 all reproduce). 8 or 10 vacuums do not.

    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.

    I hope that exorcists are not needed for this one.

    Just not using cipher schemes in legacy mode is the best way to avoid this kind of problem.

    For the cipher schemes aes128cbc and aes256cbc that 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 sqlcipher or sqleet (= chacha20 in legacy mode). This is particularly problematic for such widely used cipher schemes as sqlcipher. 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.

  2. LucaCappelletti94 commented on Oct 3, 2026

    @LucaCappelletti94
    ContributorAuthor

    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.

    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.

  3. utelle commented on Oct 3, 2026

    @utelle
    Owner

    Absolutely no rush, and I agree none of them are critical (otherwise I would not have opened them in this fashion but submitted privately).

    Yes, critical vulnerabilities should be reported following the security policiy of this project (see Security Policy).

    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.

    Well, if the configuration parameter legacy unequal to zero is involved you now know that there is always a small chance that SQLite fails to open the database.

    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.

    Sure, if real issues are revealed, it is a good thing. And if there are real bugs, they will be fixed.

  4. LucaCappelletti94 commented on Oct 3, 2026

    @LucaCappelletti94
    ContributorAuthor

    Well, if the configuration parameter legacy unequal to zero is involved you now know that there is always a small chance that SQLite fails to open the database.

    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.

  5. utelle commented on Oct 3, 2026

    @utelle
    Owner

    I suspect then that #282 might be of the same legacy family,

    Maybe, maybe not. depends on the actual circumstances.

    and as such should best be closed. I'll leave to you the choice of how to proceed.

    I will look into issue #282 and comment there.

  6. utelle commented on Oct 5, 2026

    @utelle
    Owner

    I suspect then that #282 might be of the same legacy family,

    The root cause is definitely a different one. For this issue the solution would be to explicitly configure the parameter legacy_page_size to the value 4096. For the legacy mode of the ciphers aes128cbc and aes256cbc this 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 will look into issue #282 and comment there.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions