From 166e27eb7689e0675209b6b48a143f5a8b3911d8 Mon Sep 17 00:00:00 2001 From: Greg Burd Date: Sun, 6 Sep 2026 16:51:36 -0400 Subject: [PATCH 1/3] fix(lock): exclude non-write locks from the commit lock list `__lock_vec`'s `DB_LOCK_PUT_READ` / `DB_LOCK_UPGRADE_WRITE` handling sized the temporary DBT descriptor array -- the array that becomes the replication commit lock list -- from `sh_locker->nwrites`, while the population loop skipped only the modes it named by hand (`DB_LOCK_READ`, `DB_LOCK_READ_UNCOMMITTED`). `DB_LOCK_SIREAD` matched neither of those names nor `IS_WRITELOCK`, so a retained SSI read marker fell through to the populate branch and consumed a descriptor slot the sizing never allocated: - a heap-buffer-overflow WRITE of sizeof(DBT) past the allocation. The only bounds check was a `DB_ASSERT`, which compiles out of every non-DIAGNOSTIC build, so release builds corrupted the heap silently; and - `__lock_fix_list` was handed `nwrites` rather than the number of descriptors actually written, truncating the serialized list. Because newly granted locks go to the HEAD of the locker's `heldby` list, the SIREAD object was visited first and could displace the modified page's write-lock object. `__rep_process_txn` reacquires only the listed objects as write locks, so apply could change a page a separate client transaction still read-locked -- an isolation violation on the replication apply path. Fix, in three parts that make the invariant hold by construction: - Size the array by counting `IS_WRITELOCK` locks on `heldby`, the same predicate the populate branch now uses, so sizing and population cannot disagree for any present or future mode. - Gate the populate branch on `IS_WRITELOCK(lp->mode)`. The list exists so apply can reacquire WRITE locks; a read marker has no business in it. The set of modes this path *releases* is deliberately unchanged: SIREAD markers must stay on `heldby` for `__lock_sicommit` at `__txn_end`. - Pass `__lock_fix_list` the count actually populated, not a separately maintained counter. The `DB_ASSERT` bounds check is promoted to a real runtime guard that fails the operation via `__env_panic` in every build, since a future sizing/population skew would be a memory-safety bug precisely where `DB_ASSERT` is absent. Also fixes three SIREAD omissions of the same class found by the audit: `__lock_printlock` and `__db_lockmode_to_string` printed SIREAD as `UNKNOWN`, and `__lock_dump_object` walked only `holders`/`waiters`, so an object pinned solely by SIREAD markers printed as empty. Regression test: `test/c/test_lock_sireads.c` + `test/c/chk.locksireads`, which asserts both halves under ASan -- no heap overflow, and that the serialized commit lock list names the page the transaction modified. Fixes #140 --- src/db/db_pr.c | 2 + src/lock/lock.c | 85 ++++++++++++-- src/lock/lock_stat.c | 10 ++ test/c/chk.locksireads | 136 ++++++++++++++++++++++ test/c/test_lock_sireads.c | 226 +++++++++++++++++++++++++++++++++++++ 5 files changed, 450 insertions(+), 9 deletions(-) create mode 100755 test/c/chk.locksireads create mode 100644 test/c/test_lock_sireads.c diff --git a/src/db/db_pr.c b/src/db/db_pr.c index 4eba08809..a898f1493 100644 --- a/src/db/db_pr.c +++ b/src/db/db_pr.c @@ -357,6 +357,8 @@ __db_lockmode_to_string(mode) return ("Read uncommitted"); case DB_LOCK_WWRITE: return ("Was written"); + case DB_LOCK_SIREAD: + return ("Snapshot isolation read"); default: break; } diff --git a/src/lock/lock.c b/src/lock/lock.c index 595283896..1e7d2005b 100644 --- a/src/lock/lock.c +++ b/src/lock/lock.c @@ -333,7 +333,7 @@ __lock_vec(env, sh_locker, flags, list, nlist, elistp) DB_LOCKREGION *region; DB_LOCKTAB *lt; DBT *objlist, *np; - u_int32_t ndx; + u_int32_t ndx, nobj; int did_abort, i, ret, run_dd, upgrade, writes; /* Check if locks have been globally turned off. */ @@ -398,9 +398,27 @@ __lock_vec(env, sh_locker, flags, list, nlist, elistp) * We know these should be ilocks, * but they could be something else, * so allocate room for the size too. + * + * Size from the SAME predicate the populate + * branch below uses -- one slot per retained + * write lock -- so sizing and population agree + * by construction. Do NOT size from + * sh_locker->nwrites: that counter only counts + * write locks whose status is DB_LSTAT_HELD, + * and it says nothing about the non-write + * modes this loop retains (DB_LOCK_SIREAD, + * DB_LOCK_IREAD, DB_LOCK_WAIT, ...), which used + * to fall through and consume uncounted + * slots -- a heap overflow (issue #140). */ - objlist->size = - sh_locker->nwrites * sizeof(DBT); + nobj = 0; + if (writes != 1) + SH_LIST_FOREACH(lp, + &sh_locker->heldby, + locker_links, __db_lock) + if (IS_WRITELOCK(lp->mode)) + nobj++; + objlist->size = nobj * sizeof(DBT); if ((ret = __os_malloc(env, objlist->size, &objlist->data)) != 0) goto up_done; @@ -450,10 +468,51 @@ __lock_vec(env, sh_locker, flags, list, nlist, elistp) break; continue; } - if (objlist != NULL) { - DB_ASSERT(env, (u_int8_t *)np < - (u_int8_t *)objlist->data + - objlist->size); + /* + * MODE ENUMERATION (keep in sync with the + * sizing pass above, and with IS_WRITELOCK in + * dbinc/lock.h): + * + * The replication commit lock list is consumed + * by __rep_process_txn, which reacquires every + * listed object as DB_LOCK_WRITE before apply. + * It must therefore contain EXACTLY the write + * locks this transaction retains. A retained + * non-write mode (DB_LOCK_SIREAD -- an SSI read + * marker, DB_LOCK_IREAD, DB_LOCK_WAIT, ...) is + * not a write lock and must not enter the list: + * before this test existed such a lock consumed + * a descriptor slot that the sizing never + * allocated (heap overflow) and, because newly + * granted locks go to the head of heldby, could + * displace a modified page's write-lock object + * from the truncated list -- letting apply + * change a page a client still read-locks + * (issue #140). + * + * Non-write locks retained here are released + * later by the DB_LOCK_PUT_ALL in __txn_end; + * SIREAD markers are handled just before it by + * __lock_sicommit, which is why they must be + * RETAINED (not released) on this path. + */ + if (objlist != NULL && IS_WRITELOCK(lp->mode)) { + /* + * Runtime bounds check, not a + * DB_ASSERT: DB_ASSERT compiles out of + * every non-DIAGNOSTIC build, which is + * precisely where a sizing/population + * skew would corrupt the heap in + * silence. Fail loudly instead. + */ + if ((u_int8_t *)(np + 1) > + (u_int8_t *)objlist->data + + objlist->size) { + __db_errx(env, DB_STR("2056", + "Lock list overflow")); + ret = __env_panic(env, EINVAL); + break; + } np->data = SH_DBT_PTR(&sh_obj->lockobj); np->size = sh_obj->lockobj.size; np++; @@ -462,10 +521,18 @@ __lock_vec(env, sh_locker, flags, list, nlist, elistp) if (ret != 0) goto up_done; - if (objlist != NULL) + /* + * Serialize exactly the descriptors populated above -- + * not sh_locker->nwrites, which is an independently + * maintained counter and so could truncate the list or + * read uninitialized slots. + */ + if (objlist != NULL) { + nobj = (u_int32_t)(np - (DBT *)objlist->data); if ((ret = __lock_fix_list(env, - objlist, sh_locker->nwrites)) != 0) + objlist, nobj)) != 0) goto up_done; + } switch (list[i].op) { case DB_LOCK_UPGRADE_WRITE: /* diff --git a/src/lock/lock_stat.c b/src/lock/lock_stat.c index de5987f0b..ecabc9ad8 100644 --- a/src/lock/lock_stat.c +++ b/src/lock/lock_stat.c @@ -606,6 +606,13 @@ __lock_dump_object(lt, mbp, op) __lock_printlock(lt, mbp, lp, 1); SH_TAILQ_FOREACH(lp, &op->waiters, links, __db_lock) __lock_printlock(lt, mbp, lp, 1); + /* + * SSI SIREAD markers live on their own list, not on holders: a mode + * enumeration that walks only holders/waiters reports the object as + * having no locks while markers still pin it (and pin their lockers). + */ + SH_TAILQ_FOREACH(lp, &op->sireaders, links, __db_lock) + __lock_printlock(lt, mbp, lp, 1); return (0); } @@ -678,6 +685,9 @@ __lock_printlock(lt, mbp, lp, ispgno) case DB_LOCK_WAIT: mode = "WAIT"; break; + case DB_LOCK_SIREAD: + mode = "SIREAD"; + break; default: mode = "UNKNOWN"; break; diff --git a/test/c/chk.locksireads b/test/c/chk.locksireads new file mode 100755 index 000000000..21b7b7c99 --- /dev/null +++ b/test/c/chk.locksireads @@ -0,0 +1,136 @@ +#!/bin/sh +# chk.locksireads -- ASan regression gate for GitHub issue #140. +# +# Builds an AddressSanitizer-instrumented libdb (reusing the fuzz gate's +# build_asan_gate/ mechanism -- see test/fuzz/check-crashes.sh) and runs +# test/c/test_lock_sireads.c twice: +# +# --trigger DB_TXN_SNAPSHOT txn on a DB_MULTIVERSION btree holding BOTH a +# write lock and a DB_LOCK_SIREAD marker, committed on a +# replication master (the only path that builds a commit lock +# list). Before the fix this was a heap-buffer-overflow WRITE in +# __lock_vec (the SIREAD lock consumed a DBT descriptor slot that +# the nwrites-based sizing never allocated) AND produced a commit +# lock list that omitted the modified page. Must be clean. +# +# --control The same transaction with the read removed: one write lock, no +# SIREAD marker, no overflow. Clean before AND after the fix; it +# proves the SIREAD lock is what triggers the bug. +# +# We assert BOTH: no ASan fault, and -- for the trigger -- that the commit lock +# list actually names the page the transaction modified (the isolation half of +# #140: a SIREAD object must never displace a write-lock object, because the +# replication apply path reacquires only the listed objects as write locks). +# +# Run from test/c/ inside a `nix develop` shell (or any shell with clang). +# Usage: sh test/c/chk.locksireads +# Env: CC (default clang), LIBDB_ASAN_BUILD (reuse an existing ASan tree) + +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ROOT=$(CDPATH= cd -- "$HERE/../.." && pwd) +CC=${CC:-clang} +GATE=${LIBDB_ASAN_BUILD:-$ROOT/build_asan_gate} +WORK=$(mktemp -d) +trap 'rm -f -r "$WORK" 2>/dev/null || true' EXIT + +# ---- 1. ASan libdb (same recipe as test/fuzz/check-crashes.sh) ------------- +if [ ! -f "$GATE/libdb.a" ]; then + echo "building ASan libdb in $GATE (this takes a few minutes)..." + mkdir -p "$GATE" + ( cd "$GATE" && + ../dist/configure --enable-debug \ + CC="$CC" CFLAGS="-fsanitize=address -g -O1" >configure.log 2>&1 && + make -j"$(nproc 2>/dev/null || echo 4)" >build.log 2>&1 ) || { + echo "FAIL: could not build an ASan libdb (see $GATE/build.log)" >&2 + exit 1 + } +fi + +# liburing is linked in when the ASan tree autodetected io_uring. +URING= +grep -q '^#define[ ]*HAVE_IO_URING' "$GATE/db_config.h" 2>/dev/null && + URING=-luring + +echo "compiling test_lock_sireads against $GATE" +"$CC" -g -O1 -fsanitize=address -fno-omit-frame-pointer -I "$GATE" \ + "$HERE/test_lock_sireads.c" "$GATE/libdb.a" $URING -lpthread -ldl \ + -o "$WORK/test_lock_sireads" + +# ---- 2. run both variants ------------------------------------------------- +rc=0 +run() { # run