Skip to content

Fix use-after-free on Node's cached mutation iterator in write_state - #49

Open
kaspermunch wants to merge 1 commit into
popgenmethods:mainfrom
munch-group:fix/node-write-state-use-after-free
Open

Fix use-after-free on Node's cached mutation iterator in write_state#49
kaspermunch wants to merge 1 commit into
popgenmethods:mainfrom
munch-group:fix/node-write-state-use-after-free

Conversation

@kaspermunch

Copy link
Copy Markdown

Node caches an iterator it into its mutation_sites map to speed up repeated positional lookups (get_state/move_iterator). write_state(pos, 0) erased the element by key and only then tested it->first == pos to decide whether to reset the cached iterator:

mutation_sites.erase(pos);      // invalidates `it` if it pointed at pos
if (it->first == pos) {         // <-- reads the just-freed tree node
    it = mutation_sites.begin();
}

std::map::erase invalidates iterators to the erased element, so when it pointed at pos the subsequent it->first dereferenced a freed red-black tree node (use-after-free). The stale read usually did not equal pos, so it was left dangling; a later get_state -> move_iterator -> next(it)/prev(it) then walked a wild pointer and crashed with EXC_BAD_ACCESS / SIGSEGV.

Because the crash only surfaces once the freed slot is reused, it manifested as intermittent heap corruption during MCMC sampling that scaled with ARG size x iterations and moved around with parameters/seed, rather than at the erase itself.

Fix: decide before erasing. If it points at the element being removed, use the iterator-returning map::erase(it), which erases and repositions it to the following element in one step so it is never left dangling; otherwise erase by key and leave it untouched. The successor is always valid and interior (the INT_MAX sentinel is never erased), matching the invariant that move_iterator relies on.

Node caches an iterator `it` into its `mutation_sites` map to speed up
repeated positional lookups (get_state/move_iterator). write_state(pos, 0)
erased the element by key and only *then* tested `it->first == pos` to decide
whether to reset the cached iterator:

    mutation_sites.erase(pos);      // invalidates `it` if it pointed at pos
    if (it->first == pos) {         // <-- reads the just-freed tree node
        it = mutation_sites.begin();
    }

std::map::erase invalidates iterators to the erased element, so when `it`
pointed at `pos` the subsequent `it->first` dereferenced a freed red-black
tree node (use-after-free). The stale read usually did not equal `pos`, so
`it` was left dangling; a later get_state -> move_iterator -> next(it)/prev(it)
then walked a wild pointer and crashed with EXC_BAD_ACCESS / SIGSEGV.

Because the crash only surfaces once the freed slot is reused, it manifested
as intermittent heap corruption during MCMC sampling that scaled with ARG
size x iterations and moved around with parameters/seed, rather than at the
erase itself.

Fix: decide before erasing. If `it` points at the element being removed, use
the iterator-returning map::erase(it), which erases and repositions `it` to
the following element in one step so it is never left dangling; otherwise
erase by key and leave `it` untouched. The successor is always valid and
interior (the INT_MAX sentinel is never erased), matching the invariant that
move_iterator relies on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant