Skip to content

transaction, eckey: fix use-after-free on registry idx collision in add_*_locked - #30

Merged
edtubbs merged 2 commits into
edtubbs:0.1.5-dev-statelessfrom
xanimo:fix/tx-registry-replace-uaf
Jul 21, 2026
Merged

transaction, eckey: fix use-after-free on registry idx collision in add_*_locked#30
edtubbs merged 2 commits into
edtubbs:0.1.5-dev-statelessfrom
xanimo:fix/tx-registry-replace-uaf

Conversation

@xanimo

@xanimo xanimo commented Jul 20, 2026

Copy link
Copy Markdown

What

Fixes the same use-after-free in both registry add_*_locked() paths on the #330 stateless refactor branch — first the transaction registry, then its eckey twin found by auditing for the same shape.

Registry ids are minted as HASH_COUNT() + 1, so ids recycle after any removal. When a fresh start_transaction_ts() / start_key_ts() collides with an id still held by a find_*_ts() caller, the collision path did HASH_REPLACE_INT and then unconditionally dogecoin_free()'d the displaced entry — bypassing the retain / deferred-delete model that #330 itself introduced (1b144b0b for transactions, b6567a95 for eckey).

Consequences (both registries):

  • use-after-free: the retained entry is freed out from under a legitimate holder;
  • double free: on the holder's contract-mandated release_*_ts();
  • leak: the wrapper / lifetime record is stranded.

The eckey case is additionally a CWE-226 key-material leak: the raw free bypassed destroy_eckey_locked(), so the displaced key's private/public bytes were never cleansed — left in freed heap.

Both are reachable single-threaded — no concurrency required.

Fix

Per registry, extract a lifetime-aware dispose_unlinked_*_locked() helper (the defer-or-free logic remove_*_locked() already used) and route both the collision path and the explicit-removal path through it. A still-referenced entry is marked pending_delete and freed by the last release (cleansed, for eckey); an unreferenced one is freed immediately.

  • src/transaction.cdispose_unlinked_transaction_locked()
  • src/eckey.cdispose_unlinked_eckey_locked()

Tests

Two deterministic single-threaded regressions, each colliding a fresh mint against a retained entry:

  • test_transaction_ts_replace_retained()
  • test_eckey_ts_replace_retained() (also asserts the retained key's WIF is unmodified)

Verified for both:

  • ASan deterministic repro: heap-use-after-free at add_*_locked before → clean after.
  • Negative control: each new test traps the UAF with its src fix reverted, passes with it applied.
  • TSan concurrent transaction find/clear/mint harness: 12 warnings → 0.
  • Plain full suite: 76/76 (baseline 74 + the two new tests).

Scope & related PRs (cross-checked)

Separate pre-existing issue (not addressed here)

A concurrent eckey harness aborts on assert(secp256k1_ctx) in src/ecc.c:85 during parallel start_key_ts() — a global-ECC-context race, reproduces identically on stock dogecoinfoundation#330 without this change. Flagging it as its own follow-up; out of scope for this PR.

xanimo added 2 commits July 20, 2026 15:20
…saction_locked

The stateless refactor mints working_transaction ids as HASH_COUNT()+1, so ids
recycle after any removal. When a fresh start_transaction_ts() lands on an id
still held by a find_transaction_ts() caller, add_transaction_locked() resolved
the collision with HASH_REPLACE_INT and then unconditionally dogecoin_free()'d
the displaced entry.

That raw free bypassed the retain/deferred-delete model introduced for
find-then-use: it ignored refcount/pending_delete, freeing the wrapper out from
under a legitimate holder (use-after-free, and a double free on the holder's
paired release_transaction_ts()), and it freed the wrapper without freeing
tx->transaction (leak).

Route the displaced entry through the same deferred-delete path as an explicit
removal by extracting dispose_unlinked_transaction_locked(), used by both the
collision path and remove_transaction_locked(). An entry that is still
referenced is marked pending_delete and freed by the last release; an
unreferenced one is freed immediately, transaction included.

Add test_transaction_ts_replace_retained(), a deterministic single-threaded
regression that reproduces the collision against a retained entry. It traps the
use-after-free under ASan/TSan before this change and passes after.
The eckey registry has the same collision bug just fixed for transactions.
Key ids are minted as HASH_COUNT()+1, so they recycle after a removal; a fresh
start_key_ts() can then collide with an id still held by a find_eckey_ts()
caller. add_eckey_locked() resolved the collision with HASH_REPLACE_INT and then
unconditionally dogecoin_free()'d the displaced key.

That raw free was doubly wrong here:
  - it ignored the eckey_lifetime side table (refcount/pending_delete) that
    find_eckey_ts() populates, freeing the key out from under a legitimate
    holder -> use-after-free, plus a stranded/dangling lifetime record; and
  - it bypassed destroy_eckey_locked(), so the displaced key's private-key and
    public-key material was never cleansed -- left in freed heap (CWE-226).

Extract dispose_unlinked_eckey_locked() -- the lifetime-aware defer-or-destroy
logic that remove_eckey_locked() already used -- and route the collision path
through it too. A still-referenced key is marked pending_delete and cleansed+freed
by the last release; an unreferenced one is cleansed+freed immediately.

Add test_eckey_ts_replace_retained(), a deterministic single-threaded regression
mirroring test_transaction_ts_replace_retained(): it collides a fresh mint against
a retained key and asserts the retained key stays valid and unmodified. Traps the
use-after-free under ASan/TSan before this change; passes after.
@xanimo xanimo changed the title transaction: fix use-after-free on registry idx collision in add_transaction_locked transaction, eckey: fix use-after-free on registry idx collision in add_*_locked Jul 20, 2026

@edtubbs edtubbs left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ACK, good catch from the sanitizer. Regression confirms the use-after-free fix now goes through the deferred-delete path instead of raw-freeing a still-retained entry.

@edtubbs
edtubbs merged commit 090c233 into edtubbs:0.1.5-dev-stateless Jul 21, 2026
30 checks passed
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.

2 participants