-
-
Notifications
You must be signed in to change notification settings - Fork 100
Description
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;
}