Skip to content

Free-threaded: concurrent proxy.__wrapped__ = ... double-frees (unprotected Py_XSETREF) #347

Description

@espressolee

Following the free-threading support in #269/#271: the C ObjectProxy.__wrapped__
setter updates self->wrapped with Py_XSETREF and no critical section, so two
threads assigning __wrapped__ on the same proxy double-free the old value.

Site (current develop, src/wrapt/_wrappers.c)

static int WraptObjectProxy_set_wrapped(WraptObjectProxyObject *self, PyObject *value) {
    ...
    Py_INCREF(value);
    Py_XSETREF(self->wrapped, value);      // 2977 — no critical section

Py_XSETREF(self->wrapped, value) is tmp = self->wrapped; self->wrapped = value; Py_XDECREF(tmp). Two threads running the __wrapped__ setter on one shared proxy
can both read the same tmp (the old wrapped) and both Py_XDECREF it — the old
object is released twice → double-free / use-after-free. The WraptObjectProxy_inplace_*
operators (proxy += x, …) update self->wrapped the same way and are very likely
the same bug via a different entry (I reproduced the __wrapped__ setter).

This is live as shipped: develop declares {Py_mod_gil, Py_MOD_GIL_NOT_USED}
(_wrappers.c:4970), so on a free-threaded interpreter the GIL stays off and no
override is needed. In pure Python x.attr = y from two threads is a lost update,
not a crash — the C proxy turns it into a memory-safety fault under free-threading.

Reproducer

python3.14.0rc1t, wrapt 2.3.0 built from source (C _wrappers). 8 threads assign
shared.__wrapped__ = object() on one shared proxy. 10 rounds per arm:

arm result
multi-setter, GIL off (default on 3.14t) 10/10 SIGSEGV
control — single setter clean 10/10
control — each thread sets its own proxy clean 10/10
control — multi-setter, GIL on (PYTHON_GIL=1) clean 10/10

The two controls isolate it: a second concurrent setter on the same proxy is
required, and the GIL-on control staying clean makes it a free-threading fault. The
fresh object() values are uniquely held, so the double-decref actually frees them.

ft_wrapt.py
"""wrapt 2.3.0 C ObjectProxy — C4: unprotected Py_XSETREF(self->wrapped) (double-free).

_wrappers.c:2977 WraptObjectProxy_set_wrapped (the __wrapped__ setter):
    Py_INCREF(value);
    Py_XSETREF(self->wrapped, value);     // no critical section

Py_XSETREF expands to: tmp = self->wrapped; self->wrapped = value; Py_XDECREF(tmp).
Two threads setting `proxy.__wrapped__` on the SAME shared proxy both read the same
`tmp` (the old wrapped) and both Py_XDECREF it → the old object is released twice →
double-free / use-after-free. Distinct from the container-argument classes: the raced
state is the extension's OWN instance field.

Live-by-default: wrapt declares {Py_mod_gil, Py_MOD_GIL_NOT_USED}.
"""
import os, sys, threading, time
from wrapt._wrappers import ObjectProxy    # the C proxy

N = int(os.environ.get("N", 8))
SECONDS = float(os.environ.get("SECONDS", 5))
MUTATE = os.environ.get("MUTATE", "1") == "1"   # 0 => single setter (no second writer)
DECOY = os.environ.get("DECOY", "0") == "1"      # each thread sets its OWN proxy

def main():
    print(f"py={sys.version.split()[0]} gil={sys._is_gil_enabled()} setters={N if MUTATE else 1} decoy={DECOY}", flush=True)
    shared = ObjectProxy(object())
    stop = threading.Event()
    nset = N if MUTATE else 1
    barrier = threading.Barrier(nset + 1)
    done = [0]*nset

    def setter(t):
        own = ObjectProxy(object()) if DECOY else None
        barrier.wait()
        target = own if DECOY else shared
        i = 0
        while not stop.is_set():
            target.__wrapped__ = object()   # fresh, uniquely-held → double-decref frees it
            i += 1
        done[t] = i

    threads = [threading.Thread(target=setter, args=(t,)) for t in range(nset)]
    for th in threads: th.start()
    barrier.wait()
    time.sleep(SECONDS)
    stop.set()
    for th in threads: th.join()
    print(f"clean: {sum(done)} sets survived", flush=True)
    return 0

if __name__ == "__main__":
    raise SystemExit(main())

A possible fix

Wrap the swap in Py_BEGIN_CRITICAL_SECTION(self) (the same mitigation used elsewhere
in the free-threading migration), around Py_XSETREF(self->wrapped, value) in the
__wrapped__ setter and in the inplace_* operators.

Not claimed

No severity — it needs the caller's own code to assign __wrapped__ on a shared proxy
from two threads. Reported so it can be fixed, not rated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions