Skip to content

sqlite3mc_cipher_name is not thread safe #228

@Dzejkop

Description

@Dzejkop

AI Disclosure

I used an LLM to investigate this issue and generate the repro code.

Description

sqlite3mc_cipher_name() returns a pointer to a process-global static char cipherName[32] buffer. When multiple threads call sqlite3_open_v2 concurrently, each triggers:

sqlite3_open_v2 → openDatabase → sqlite3mcHandleMainKey
  → sqlite3mcConfigureFromUri → sqlite3mc_cipher_name(defaultCipherIndex)

sqlite3mcConfigureFromUri stores the returned pointer and later calls sqlite3_stricmp() against it — but between those two points, another thread can call sqlite3mc_cipher_name() and execute the cipherName[0] = '\0' line, effectively zeroing the shared buffer. The stricmp sees "", no cipher matches, and the connection fails with "unknown cipher 'chacha20'" — despite the cipher name being valid.

This breaks SQLite's own thread-safety contract: in serialized mode, concurrent sqlite3_open_v2 calls are explicitly guaranteed to be safe. sqlite3mc's static buffer violates that guarantee.

Confirmed with ThreadSanitizer on macOS arm64 (v2.3.1):

WARNING: ThreadSanitizer: data race (pid=29436)
  Write of size 1 at 0x0001031827f8 by thread T10:
    #0 sqlite3mc_cipher_name     (libsqlite3mc.dylib:arm64+0x3f5ec)
    #1 sqlite3mcConfigureFromUri (libsqlite3mc.dylib:arm64+0x40b5c)
    #2 sqlite3mcHandleMainKey    (libsqlite3mc.dylib:arm64+0x1d3f8c)
    #3 openDatabase              (libsqlite3mc.dylib:arm64+0x2d8dc)
    #4 sqlite3_open_v2           (libsqlite3mc.dylib:arm64+0x2d980)
    #5 worker                    (repro_tsan:arm64+0x100000a50)

  Previous write of size 1 at 0x0001031827f8 by thread T20:
    #0 sqlite3mc_cipher_name     (libsqlite3mc.dylib:arm64+0x3f5ec)
    #1 sqlite3mcConfigureFromUri (libsqlite3mc.dylib:arm64+0x40b5c)
    #2 sqlite3mcHandleMainKey    (libsqlite3mc.dylib:arm64+0x1d3f8c)
    #3 openDatabase              (libsqlite3mc.dylib:arm64+0x2d8dc)
    #4 sqlite3_open_v2           (libsqlite3mc.dylib:arm64+0x2d980)
    #5 worker                    (repro_tsan:arm64+0x100000a50)

  Location is global 'sqlite3mc_cipher_name.cipherName'
    at 0x0001031827f8 (libsqlite3mc.dylib+0x5727f8)

On Linux x86 the race also produces observable runtime failures ("unknown cipher 'chacha20'" from sqlite3_open_v2) at ~15–30% rate with 8 concurrent threads.

Suggested fix

Return a pointer directly to the stable globalCodecDescriptorTable[j].m_name entry, which is written once during sqlite3mc_initialize() and never mutated after that. Concurrent reads of immutable memory are safe with no locking required.

Reproduction

I've run a different version of this script to get the results above - this one should run on a Linux machine.

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdatomic.h>

/* Include the SQLite3MC header */
#include "SQLite3MultipleCiphers/src/sqlite3mc.h"

#define NUM_THREADS       8
#define ITERATIONS        200

/* Shared barrier so all threads hammer the open at the same moment */
static pthread_barrier_t start_barrier;

/* Count of "unknown cipher" errors seen across all threads/iterations */
static atomic_int unknown_cipher_errors = 0;
static atomic_int total_opens           = 0;
static atomic_int other_errors          = 0;

/*
 * URI for an in-memory database with the chacha20 cipher and a key.
 * We deliberately do NOT pass a URI ?cipher= parameter on some threads
 * so that sqlite3mcConfigureFromUri() must call sqlite3mc_cipher_name()
 * to resolve the default cipher index — this is the vulnerable code path.
 *
 * Concretely:
 *   - "cipher=chacha20" threads race with threads that rely on the default,
 *     or we can just flood with the default-cipher path.
 *
 * We set chacha20 as the process-wide default before spawning threads so
 * that every open goes through the sqlite3mc_cipher_name() call.
 */
#define DB_URI_WITH_CIPHER    "file::memory:?mode=memory&cache=shared&cipher=chacha20&key=testkey1234"
#define DB_URI_NO_CIPHER_PARAM "file::memory:?mode=memory&cache=shared&key=testkey1234"

/* thread argument */
typedef struct {
    int thread_id;
    int use_uri_cipher;   /* 1 = include ?cipher= in URI, 0 = rely on default */
} thread_arg_t;

static void *worker(void *arg)
{
    thread_arg_t *ta = (thread_arg_t *) arg;
    const char   *uri = ta->use_uri_cipher ? DB_URI_WITH_CIPHER
                                           : DB_URI_NO_CIPHER_PARAM;
    int           flags = SQLITE_OPEN_READWRITE
                        | SQLITE_OPEN_CREATE
                        | SQLITE_OPEN_URI
                        | SQLITE_OPEN_MEMORY
                        | SQLITE_OPEN_NOMUTEX;   /* per-connection, no shared mutex */

    pthread_barrier_wait(&start_barrier);        /* synchronised start */

    for (int i = 0; i < ITERATIONS; i++) {
        sqlite3 *db = NULL;
        int rc = sqlite3_open_v2(uri, &db, flags, NULL);

        atomic_fetch_add(&total_opens, 1);

        if (rc != SQLITE_OK) {
            const char *errmsg = db ? sqlite3_errmsg(db) : sqlite3_errstr(rc);
            if (strstr(errmsg, "unknown cipher")) {
                atomic_fetch_add(&unknown_cipher_errors, 1);
                /* Print first few to keep output readable */
                if (atomic_load(&unknown_cipher_errors) <= 10) {
                    fprintf(stderr,
                        "[thread %d iter %d] rc=%d err=\"%s\"\n",
                        ta->thread_id, i, rc, errmsg);
                }
            } else {
                atomic_fetch_add(&other_errors, 1);
                if (atomic_load(&other_errors) <= 5) {
                    fprintf(stderr,
                        "[thread %d iter %d] OTHER rc=%d err=\"%s\"\n",
                        ta->thread_id, i, rc, errmsg);
                }
            }
        }

        if (db) sqlite3_close(db);
    }
    return NULL;
}

int main(void)
{
    printf("SQLite3MC version: %s\n", sqlite3_libversion());
    printf("Threads: %d   Iterations each: %d\n\n", NUM_THREADS, ITERATIONS);

    /* Initialise the library */
    sqlite3_initialize();

    /*
     * Set the process-wide default cipher to chacha20.
     * This means threads that open without ?cipher= in the URI will exercise
     * the sqlite3mc_cipher_name(defaultCipherIndex) path in
     * sqlite3mcConfigureFromUri() — the vulnerable code path.
     */
    {
        sqlite3 *cfg_db = NULL;
        sqlite3_open_v2(":memory:", &cfg_db,
                        SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
        if (cfg_db) {
            int idx = sqlite3mc_cipher_index("chacha20");
            if (idx > 0)
                sqlite3mc_config(cfg_db, "default:cipher", idx);
            else
                fprintf(stderr, "WARNING: chacha20 cipher not found (idx=%d)\n", idx);
            sqlite3_close(cfg_db);
        }
    }

    pthread_barrier_init(&start_barrier, NULL, NUM_THREADS);

    pthread_t threads[NUM_THREADS];
    thread_arg_t args[NUM_THREADS];

    for (int i = 0; i < NUM_THREADS; i++) {
        args[i].thread_id      = i;
        /*
         * Even-numbered threads use the URI cipher param (no static-buffer risk
         * for that path).  Odd-numbered threads omit the cipher param, forcing
         * the code to call sqlite3mc_cipher_name() and use its static return.
         * This maximises contention on the static buffer.
         */
        args[i].use_uri_cipher = (i % 2 == 0);
        pthread_create(&threads[i], NULL, worker, &args[i]);
    }

    for (int i = 0; i < NUM_THREADS; i++)
        pthread_join(threads[i], NULL);

    pthread_barrier_destroy(&start_barrier);

    printf("\n=== Results ===\n");
    printf("Total opens:            %d\n", atomic_load(&total_opens));
    printf("unknown cipher errors:  %d\n", atomic_load(&unknown_cipher_errors));
    printf("other errors:           %d\n", atomic_load(&other_errors));

    if (atomic_load(&unknown_cipher_errors) > 0) {
        printf("\n[RACE CONFIRMED] 'unknown cipher' errors observed — "
               "static buffer in sqlite3mc_cipher_name() is being clobbered "
               "concurrently.\n");
    } else {
        printf("\n[No race observed in this run — try increasing ITERATIONS "
               "or NUM_THREADS, or run under TSan.]\n");
    }

    sqlite3_shutdown();
    return (atomic_load(&unknown_cipher_errors) > 0) ? 1 : 0;
}

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