Skip to content

fix(lock): heap OOB write in __lock_vec + eliminate the lock-mode enumeration class (#140) - #145

Merged
gburd merged 3 commits into
masterfrom
work/fix140-audit
Sep 6, 2026
Merged

fix(lock): heap OOB write in __lock_vec + eliminate the lock-mode enumeration class (#140)#145
gburd merged 3 commits into
masterfrom
work/fix140-audit

Conversation

@gburd

@gburd gburd commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #140: a heap out-of-bounds write in __lock_vec and the incomplete
replication commit lock list that follows from it. Also eliminates the
root-cause class — adding a DB_LOCK_* mode without auditing the
pre-existing sites that enumerate lock modes — with a CI-enforced guard.

The bug reproduces exactly as reported. Verified before/after under ASan:

BEFORE: WRITE of size 8 at 0x7c3ff701bef8 thread T0
          #0 __lock_vec  src/lock/lock.c:457:15
          #1 __txn_commit src/txn/txn.c:871:11
        0x7c3ff701bef8 is located 0 bytes after 40-byte region
        SUMMARY: AddressSanitizer: heap-buffer-overflow src/lock/lock.c:457 in __lock_vec
AFTER:  clean (both trigger and control)

And the isolation half — the commit lock list content:

reporter (pre-fix) this PR
page the txn modified 179 179
page in the commit lock list 140 (the SIREAD object) 179

Part 1 — the fix, and why this one

__lock_vec's DB_LOCK_PUT_READ path sized the descriptor array 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 those names nor IS_WRITELOCK, so it fell through and consumed a slot
the sizing never allocated.

The brief listed two candidate fixes. I took (b) — exclude SIREAD from the
list — and rejected (a) sizing by nlocks
, because (a) fixes only the
overflow and leaves the isolation bug in place: the list would then be
correctly sized but would still contain SIREAD objects, and
__rep_process_txn reacquires every listed object as DB_LOCK_WRITE. Handing
apply a write lock on a page nobody wrote is at best spurious blocking, at
worst a new correctness problem. A SIREAD marker is an SSI read marker; it has
no business in a list whose purpose is "reacquire the write locks".

Three changes, so that sizing and population agree by construction rather
than by coincidence:

  1. Size from the same predicate the loop uses — count IS_WRITELOCK locks
    on heldby. Not nwrites: that counter only counts write locks whose
    status is DB_LSTAT_HELD, and says nothing about the non-write modes the
    loop retains.
  2. Gate the populate branch on IS_WRITELOCK(lp->mode) rather than adding
    DB_LOCK_SIREAD to the skip list. Naming one more mode would have fixed
    this bug and left the next one; IS_WRITELOCK covers every future mode.
  3. Pass __lock_fix_list the count actually populated (np - data), not a
    separately maintained counter.

What I deliberately did NOT change: the set of modes this path releases.
SIREAD markers must stay on heldby past DB_LOCK_PUT_READ so
__lock_sicommit can persist-or-drop them at __txn_end. Releasing them here
would have been a smaller diff and would have broken SSI. The bug was never
"SIREAD is retained" — it was that a retained non-write lock silently entered a
list sized only for write locks.

The DB_ASSERT bounds check is promoted to a real runtime guard
(__env_panic), since a future sizing/population skew is a memory-safety bug
precisely in the builds where DB_ASSERT is absent.

Part 2 — the audit (19 sites)

Site Modes handled / mechanism Verdict
lock.c __lock_vec objlist sizing, population, fix_list count FIXED — all three now keyed on IS_WRITELOCK
lock_stat.c __lock_printlock switch over all modes FIXED — SIREAD printed as UNKNOWN
db_pr.c __db_lockmode_to_string switch over all modes FIXED — SIREAD printed as UNKNOWN LOCK MODE
lock_stat.c __lock_dump_object walked holders+waiters only FIXED — an object pinned solely by SIREAD markers printed as empty; now walks sireaders
lock.c __lock_get_internal nwrites++ under IS_WRITELOCK; SIREAD via safe_si arms correct
lock.c __lock_freelock nlocks/nwrites under IS_WRITELOCK correct
lock.c __lock_downgrade nwrites-- only on write→non-write correct
lock.c __lock_inherit_locks parent counts under IS_WRITELOCK correct
lock.c __lock_trade new-locker counts under IS_WRITELOCK correct
lock.c __lock_put_internal SIREAD→sireaders, else holders correct — mode-specific by design
lock.c __lock_sicommit walks heldby for SIREAD only correct
lock.c __lock_siclean_obj walks sireaders (all SIREAD) correct
lock_deadlock.c __dd_build switches on detector atype, not on lock mode correct — not a mode enumeration
lock_failchk.c __lock_failchk nlocks == nwrites ⇒ "no non-write locks" correct — SIREAD counts in nlocks only, so a marker-holding locker is not skipped
lock_region.c __lock_region_init db_riw_conflicts 10×10 incl. SI row+column correct — SI row/col all-zero; lock_mode >= nmodes rejects an unmatrixed mode
lock_list.c __lock_fix_list DBT objects, mode-agnostic correct
lock_list.c __lock_get_list reacquires in caller's mode correct
db_meta.c __db_lget converts READSIREAD; coupling tests name single modes correct — this is where SIREAD is produced
db_meta.c __db_lput couple/downgrade per named mode correct — SIREAD intentionally excluded, must be held to txn end
txn_util.c __txn_doevents IS_WRITELOCK on handle locks correct — handle locks are never SIREAD

Also checked and found not to be mode enumerations: lock_util.c (hashing
only), lock_id.c (nlocks/nwrites init + the si_ref deferral),
__lock_promote (conflict matrix), lang/tcl/tcl_lock.c (exposes only the 6
classic modes to Tcl by design). Every allocation in src/lock/ was inspected;
__lock_vec was the only one keyed on a mode-dependent count.

Each verdict, with its reason, is committed in
dist/cocci/lockmode_inventory.txt so it can be re-checked rather than
re-derived.

Part 3 — the permanent guard

Coccinelle (dist/cocci/rule_lock_mode_enum.cocci), wired into the existing
baseline gate so NEW matches fail CI:

Honest limitation: Coccinelle cannot express "a switch over
db_lockmode_t that is missing a case" in this spatch build — ... when != case X: inside a switch is a parse error (spatch 1.3.1). I tried several
formulations. So the switch check lives in the alternative deliverable:

Checked inventory (dist/cocci/lockmode_inventory.{sh,txt}) — blocking and
deliberately not baselined. Three hard checks:

  1. db_lockmode_t in db.in must exactly match the recorded mode set.
  2. Every inventoried site must still exist (catches silent renames).
  3. Every switch marked exhaustive must have a case arm for every mode.

Each check is verified to fail on a synthetic regression and pass when
restored:

TEST 1 (add DB_LOCK_FROB=10):   FAIL: db_lockmode_t differs from inventory
                                FAIL: __lock_printlock ... has no 'case DB_LOCK_FROB:'
                                FAIL: __db_lockmode_to_string ... no 'case DB_LOCK_FROB:'
TEST 2 (delete SIREAD arm):     FAIL: __lock_printlock ... has no 'case DB_LOCK_SIREAD:'
TEST 3 (rename a site):         FAIL: inventory site function gone: __lock_dump_object
restored:                       OK (10 modes, 19 sites, 2 exhaustive switches)

ASan job (lock-mode-asan) — the empirical half: builds an ASan libdb and
runs test/c/chk.locksireads, asserting no heap overflow and that the
serialized commit lock list names the modified page.

rfc/0003/lock-mode-audit.md — the rule ("a write-lock counter must never
size a buffer that a mode-enumerating loop fills"), the shape to write, a
7-item checklist for adding a mode, and what each guard enforces.

Validation

Check Result
--enable-debug --enable-test clean
--enable-debug --enable-diagnostic clean
release (CFLAGS=-O2) clean
ASan (-fsanitize=address) clean
test/c/chk.locksireads FAILS before the fix, PASSES after (source-only revert, test kept)
ssi001–ssi009 9/9 PASS
txn001/002/003, lock001/002/003 6/6 PASS
test001 btree/hash/queue/recno 4/4 PASS
test/fuzz/check-crashes.sh 9/9 PASS
rep001 btree PASS
Coccinelle new violations 0
lock-mode inventory OK
dist/s_include header drift none

Message ID 2056 is the next free in the lock/mutex range.

Fixes #140

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

ABI diff produced no report (build skipped or no base tag).


Advisory: libabigail/nm is the authoritative binary-ABI check; Coccinelle is complementary source-level early warning. See dist/cocci/README.md.

@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown

Coccinelle convention checks

No new violations. ✅

Resolved since baseline (2) -- update dist/cocci/baseline.txt to lock these in.
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/crypto/mersenne/mt19937db.c|return (ret);
rule_mutex_unbalanced|MUTEX_UNBALANCED|src/mp/mp_register.c|return (ret);

`__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
Issue #140 was not an SSI bug.  It was the consequence of adding a lock MODE
(`DB_LOCK_SIREAD`) to a 30-year-old enum without revisiting every pre-existing
site that enumerates lock modes exhaustively.  Nothing in the tree prevented a
recurrence, so add three guards -- two static, one empirical -- and write the
discipline down.

`dist/cocci/lockmode_inventory.{sh,txt}` -- the AUTHORITATIVE guard, blocking
and deliberately NOT baselined.  Three hard checks:

  1. The `db_lockmode_t` members in `src/dbinc/db.in` must exactly match the
     recorded mode set.  Adding a mode fails CI until every `site` line has a
     recorded verdict.
  2. Every inventoried enumeration site must still exist (a rename would
     otherwise silently drop it out of review).
  3. Every switch marked `exhaustive` must have a `case` arm for EVERY mode.
     This is the check #140's class cannot evade.

The inventory records all 19 mode-enumeration sites across `src/lock/`,
`src/db/`, and `src/txn/` with a per-site verdict and the reason, so the
judgement can be re-checked rather than re-derived.

`dist/cocci/rule_lock_mode_enum.cocci` -- two expression-level shapes, wired
into the existing baseline gate so NEW matches fail:

  - `LOCK_MODE_SIZING`: an allocation sized from `->nwrites`.  Verified to
    match the #140 bug exactly on the pre-fix source, and at zero after it.
  - `LOCK_MODE_READTEST`: a hand-enumerated read-mode test, which is silently
    incomplete the moment another non-write mode exists.  The five existing
    sites are deliberately mode-specific and are baselined.

Coccinelle CANNOT express "a switch over `db_lockmode_t` missing a case" in this
spatch build (`... when != case X:` inside a switch is a parse error, spatch
1.3.1).  That is why the exhaustive-switch check lives in the inventory script
rather than in SmPL; the limitation is documented in both files.

`lock-mode-asan` job -- the empirical half: builds an ASan libdb and runs
`test/c/chk.locksireads`, which asserts no heap overflow AND that the
serialized commit lock list names the modified page.

`rfc/0003/lock-mode-audit.md` -- the rule ("a write-lock counter must never
size a buffer that a mode-enumerating loop fills"), the shape to write, the
seven-item checklist for adding a mode, and what each guard enforces.

Verified: each of the three inventory checks fails on a synthetic regression
(new mode, removed case arm, renamed site) and passes when restored.
@gburd
gburd force-pushed the work/fix140-audit branch from b7daf37 to 22ed648 Compare September 6, 2026 22:41
test/tiers/meson.build compiled the isolation/soak/lock-matrix drivers with
include_directories: inc alone, but db.h/db_int.h are custom_target outputs whose
directory meson only wires in when they are listed as SOURCES of the target
(test/pbt/meson.build already does exactly this). Entered from the root
meson.build, 'inc's relative '.' does not resolve to dist/'s build dir, so the
drivers failed with 'fatal error: db.h: No such file or directory' -- which broke
the hegel/PBT CI job, since it builds the whole meson tree.

Pass db_h/db_int_h/db_int_def_h as sources, matching test/pbt. Verified: all three
tier drivers configure, compile and link under meson (282/282).
@gburd
gburd merged commit ff18fb5 into master Sep 6, 2026
52 of 55 checks passed
@gburd
gburd deleted the work/fix140-audit branch September 6, 2026 23:02
gburd added a commit that referenced this pull request Sep 7, 2026
The tiers were deliberately advisory while the bugs they reproduce were open,
with their own comments saying to flip them in the fixing PR. Both conditions are
now met:

- B3 (lock-mode matrix under ASan) reproduced the #140 heap overflow in
  __lock_vec; #145 fixed the lock-list sizing and the matrix passes, so any ASan
  fault in the lock list is now a real regression.
- B2 (resource-accounting soak) reproduced #137/#138; this PR fixes them and the
  tier reports 5 workloads / 0 unexpected outcomes.

Dropping continue-on-error from both. B1 was already a hard gate. Verified on this
PR: B1 pass, B3 pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

libdb 5.3.34: incomplete replication commit lock lists can violate client transaction isolation

1 participant