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.
Following the free-threading support in #269/#271: the C
ObjectProxy.__wrapped__setter updates
self->wrappedwithPy_XSETREFand no critical section, so twothreads assigning
__wrapped__on the same proxy double-free the old value.Site (current
develop,src/wrapt/_wrappers.c)Py_XSETREF(self->wrapped, value)istmp = self->wrapped; self->wrapped = value; Py_XDECREF(tmp). Two threads running the__wrapped__setter on one shared proxycan both read the same
tmp(the old wrapped) and bothPy_XDECREFit — the oldobject is released twice → double-free / use-after-free. The
WraptObjectProxy_inplace_*operators (
proxy += x, …) updateself->wrappedthe same way and are very likelythe same bug via a different entry (I reproduced the
__wrapped__setter).This is live as shipped:
developdeclares{Py_mod_gil, Py_MOD_GIL_NOT_USED}(
_wrappers.c:4970), so on a free-threaded interpreter the GIL stays off and nooverride is needed. In pure Python
x.attr = yfrom 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 assignshared.__wrapped__ = object()on one shared proxy. 10 rounds per arm:PYTHON_GIL=1)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
A possible fix
Wrap the swap in
Py_BEGIN_CRITICAL_SECTION(self)(the same mitigation used elsewherein the free-threading migration), around
Py_XSETREF(self->wrapped, value)in the__wrapped__setter and in theinplace_*operators.Not claimed
No severity — it needs the caller's own code to assign
__wrapped__on a shared proxyfrom two threads. Reported so it can be fixed, not rated.