fix(registry): emergency wipe could zero a different live buffer without holding its lock - #56
Closed
deadpoets wants to merge 1 commit into
Closed
fix(registry): emergency wipe could zero a different live buffer without holding its lock#56deadpoets wants to merge 1 commit into
deadpoets wants to merge 1 commit into
Conversation
… address
The janitor keyed each registration by its mapping's base address. That is
unique for a mapping's lifetime, which the old comment said, but not across
lifetimes, which it did not: free a region and the OS may hand the same base to
the next allocation, which then registers under the identical key.
wipeInPlace resolves a key, drops the janitor lock to wait on that region's
lock, then resolves the key AGAIN. Those two resolutions were assumed to name
the same buffer. They need not. A wipe blocked on a buffer destroyed during the
wait could wake, resolve the key to whatever buffer now occupies that address,
and wipe it with lockHeld=true — that is, with no lock on it at all. The stray
wipe races the new buffer's accessors and its Seal, which flips the pages to
PAGE_NOACCESS mid-write, so the worst case is an access violation rather than
merely a lost secret.
Reachable from the exact combination the package documents as concurrency-safe:
WipeAllSecrets in flight while one buffer is destroyed and another allocated.
tryWipeInPlace has the same shape with a much narrower window, since tryLock
does not wait, and is fixed identically.
Two changes, deliberately overlapping:
- Keys now come from a counter, so a key cannot be reused and the aliasing is
unreachable in production.
- The re-resolution matches on the lock pointer the caller is actually
holding, via takeAnyIf. That makes the wipe safe LOCALLY rather than by
depending on the key scheme, and it is the property a test can assert. The
lock pointer is sound identity: the caller holds a live reference across the
comparison, so the GC cannot recycle the address underneath.
takeAny had no remaining callers and is removed rather than left to rot.
The regression test forces the aliased state directly under the janitor lock
instead of trying to race the allocator into reusing an address, because the
property under test is that the wipe is safe when handed a key resolving to a
region it never locked — independent of how keys are minted. Verified: 3/3 fail
against the unfixed registry.go with the bystander's secret replaced by zeros,
5/5 pass with the fix.
Found by an external multi-agent review. I had audited this path during the
windows/amd64 corruption hunt and cleared it, having only considered the case
where the re-resolution finds NOTHING — not the case where it finds a DIFFERENT
region. The reporter flags it as a candidate mechanism for that open crash.
This was referenced Aug 18, 2026
Closed
Closed
Owner
Author
|
Landed on Rebased locally rather than squash-merged so the commit carries your SSH signature ( Still the highest-severity item from the review, and still only a candidate mechanism for the windows/amd64 runtime corruption, which has since had a second sighting ( |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the HIGH finding from the external review. Confirmed against the source,
with a deterministic regression test.
The bug
The janitor keyed each registration by its mapping's base address:
That comment is true and insufficient. A base address is unique for a mapping's
lifetime; it is not unique across lifetimes. Free a region and the OS may
hand the same base to the next allocation, which registers under the identical
key.
wipeInPlaceresolves a key, drops the janitor lock to wait on that region'slock, then resolves the key again:
If A is destroyed during the wait and a new buffer B lands on the same base,
step 3 returns B and step 4 zeroes B with no lock held on it. That races B's
accessors and its
Seal, which flips the pages toPAGE_NOACCESSmid-write —so the worst case is a hardware access violation, not merely a lost secret.
Reachable from exactly the combination the package documents as concurrency
safe:
WipeAllSecretsin flight while one buffer is destroyed and anotherallocated.
tryWipeInPlacehas the same shape with a far narrower window(
tryLockdoes not wait) and is fixed identically.The fix — two overlapping changes
unreachable in production.
takeAnyIf).This makes the wipe safe locally rather than by depending on the key
scheme, and it is the property a test can actually assert. The lock pointer
is sound identity — the caller holds a live reference across the comparison,
so the GC cannot recycle the address underneath.
takeAnyhad no remaining callers and is removed rather than left to rot.The regression test
It forces the aliased state directly under the janitor lock rather than trying
to race the allocator into reusing an address, because the property under test
is that the wipe stays safe when handed a key that resolves to a region it never
locked — independent of how keys are minted.
3/3 fail against the unfixed
registry.go, with the bystander's secretreplaced by zeros:
5/5 pass with the fix. Full core suite green under
-race.Relevance to the open crash
The reporter flags this as a candidate mechanism for the unexplained one-off
windows/amd64runtime corruption (fatal error: stack not a power of 2). Theshape fits: a stray write into address space the allocator has already re-handed
lands in whatever now owns that range — which on a Go process can be the
runtime's own heap or goroutine metadata. I am not claiming this closes that
investigation; the soak stays in place and the release train stays held.
Disclosure
I audited this path during the corruption hunt and cleared it. I considered only
the case where the re-resolution finds nothing, and never the case where it
finds a different region. The review caught what I missed.