Skip to content

Don't fuzz a module's own mutex: fusil was releasing it - #276

Merged
devdanzin merged 1 commit into
mainfrom
module-own-mutex-fuzzed-and-released
Sep 6, 2026
Merged

Don't fuzz a module's own mutex: fusil was releasing it#276
devdanzin merged 1 commit into
mainfrom
module-own-mutex-fuzzed-and-released

Conversation

@devdanzin

Copy link
Copy Markdown
Owner

The bug

PyPy implements grp in Python over cffi with a module-level mutex, and getgrall() holds it across its loop over libc's static group buffer:

_lock = _thread.allocate_lock()

def getgrall():
    with _lock:
        lib.setgrent()
        while 1:
            p = lib.getgrent()                    # -> libc's STATIC buffer
            if not p: break
            lst.append(_group_from_gstruct(p))    # walks p.gr_mem[i] until NULL

That lock is the only thing making the iteration safe. fusil selected grp._lock as a module-level object — it's just an attribute — and fuzzed it:

--- Fuzzing instance: fuzz_target_module._lock (type hint: lock, prefix: obj0m) ---
[obj0m11] lock.release_lock()
[obj0m12] lock.release_lock()

Release it mid-getgrall(), a second thread re-enters and calls setgrent()/getgrnam(), libc reuses the buffer under the first thread's feet, and while res.gr_mem[i]: walks freed memory.

result
concurrent getgrall() + _lock.release_lock() 6/6 SIGSEGV in _group_from_gstruct
same concurrency, lock intact 6/6 clean

Necessary and sufficient. This is the grp crash open since the fleet_08 triage, left INCONCLUSIVE there because 22 of 24 replays timed out — releasing a lock hangs the harness as readily as it crashes it, which is why replay never worked.

Why METHOD_BLACKLIST didn't stop it

The shape of the gap is worth recording:

take a lock drop a lock
acquire BLOCKED release allowed
acquire_lock BLOCKED release_lock allowed
_acquire_lock BLOCKED _release_lock allowed
_acquire_restore BLOCKED _release_save allowed
wait BLOCKED _at_fork_reinit allowed

fusil had blocked the methods that hang it and left the ones that corrupt the target.

The fix

Filter on the object's type, at selection time, and only for module attributes. A lock fusil instantiated itself is nobody's mutex, so fuzzing threading still covers Lock/RLock/Condition through the class path. A name-based ban on release would instead have cost memoryview.release() — the surface PYPY-FUZZ-011 came from.

It has to hold on both discovery paths: in --discover-in-target mode the runner only sees a proxy carrying the type's name, so the target subprocess filters too — mirroring how TRIVIAL_TYPES is already split across the two.

Verified against the real module. Introspecting PyPy's grp now yields ['__loader__', '__spec__', 'ffi'] — no _lock.

Tests

Five, using real stdlib locks (tempfile._once_lock, logging._lock) on both paths, plus over-filtering guards: a lock fusil made itself is still a fuzz target, the class isn't skipped, and ordinary module objects survive. They set test_private=True as the PyPy fleets do — otherwise the underscore-prefixed names are filtered before the type is looked at and the test proves nothing. Four of five fail without the fix.

1308 tests pass; ruff check + ruff format --check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WPBSmN87d2BqUnrDbojUT1

PyPy implements `grp` in Python over cffi with `_lock = _thread.allocate_lock()`,
and `getgrall()` holds that lock across its loop over libc's STATIC group buffer.
The lock is the only thing making the iteration safe.

fusil selected `grp._lock` as a module-level object -- it is just an attribute --
and fuzzed it:

    --- Fuzzing instance: fuzz_target_module._lock (type hint: lock, prefix: obj0m) ---
    [obj0m11] lock.release_lock()
    [obj0m12] lock.release_lock()

Releasing it mid-`getgrall()` drops the mutual exclusion, a second thread re-enters
and calls setgrent()/getgrnam(), libc reuses the static buffer under the first
thread's feet, and `while res.gr_mem[i]:` walks freed memory. Measured: 6/6 SIGSEGV
with the release thread, 6/6 clean without it -- necessary and sufficient. That is
the `grp` crash open since the fleet_08 triage, where it was left INCONCLUSIVE
because 22 of 24 replays timed out; releasing a lock hangs the harness as readily
as it crashes it, which is why replay never worked.

METHOD_BLACKLIST was no defence, and the shape of the gap is worth recording: it
blocks every way to TAKE a lock (acquire, acquire_lock, _acquire_lock,
_acquire_restore, wait) and none of the ways to DROP one (release, release_lock,
_release_lock, _release_save, _at_fork_reinit). fusil had blocked the methods that
hang IT and left the ones that corrupt the TARGET.

So the filter is on the object's TYPE, at selection time, and only for module
attributes: a lock fusil instantiated itself is nobody's mutex, so fuzzing
`threading` still covers Lock/RLock/Condition through the class path. A name-based
ban on "release" would instead have cost `memoryview.release()`, which is the
surface PYPY-FUZZ-011 came from.

It has to hold on both discovery paths. In `--discover-in-target` mode the runner
only ever sees a proxy carrying the type's NAME, so the target subprocess filters
too, mirroring how TRIVIAL_TYPES is already split across the two.

Verified against the real module: introspecting PyPy's `grp` now yields
['__loader__', '__spec__', 'ffi'] -- no `_lock`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WPBSmN87d2BqUnrDbojUT1
@devdanzin
devdanzin merged commit a7aa4e9 into main Sep 6, 2026
2 checks passed
@devdanzin
devdanzin deleted the module-own-mutex-fuzzed-and-released branch September 6, 2026 05:50
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