Skip to content

Race condition in Py_FinalizeEx: _PyObject_Dump and "lost sys.stderr" when SIGINT arrives during _PyImport_Cleanup #144616

@raminfp

Description

@raminfp

Bug description:

When SIGINT is delivered during interpreter shutdown (specifically during _PyImport_Cleanup in Py_FinalizeEx), CPython falls back to _PyObject_Dump and prints "lost sys.stderr". This happens because sys.stderr is set to None during module cleanup, but signals are not blocked during this critical phase.

The _PyObject_Dump fallback leaks raw heap and type object addresses to the process output:

object address  : 0x72fed827c940
object refcount : 6
object type     : 0xa3e620
object type name: KeyboardInterrupt
object repr     : KeyboardInterrupt()
lost sys.stderr

Users should never see _PyObject_Dump output under normal operation. This is a C-level internal debug function (Objects/object.c) that bypasses Python's I/O system entirely.

Python version

Python 3.12.7 (main, Jun 18 2025, 13:16:51) [GCC 14.2.0]
Linux 6.11.0-29-generic x86_64

How to reproduce

The reproducer is a single self-contained Python file. It only requires curl (available on all Linux systems).

Quick start

python3 reproduce_bug_curl.py

The bug typically reproduces on the first attempt.

Self-contained reproducer (reproduce_bug_curl.py)

Click to expand full reproducer code
#!/usr/bin/env python3
"""
Self-contained reproducer for CPython _PyObject_Dump bug during Py_FinalizeEx.

Bug: When SIGINT arrives during Py_FinalizeEx while sys.stderr is being cleared
in _PyImport_Cleanup, CPython falls back to _PyObject_Dump because
PyErr_Display sees sys.stderr=None.

The Py_FinalizeEx shutdown sequence:
  1. wait_for_thread_shutdown -> threading._shutdown -> _python_exit -> t.join()
  2. call_py_exitfuncs -> atexit handlers
  3. _PyImport_Cleanup -> sys.stderr becomes None
  4. If SIGINT arrives NOW -> PyErr_Display sees sys.stderr=None -> _PyObject_Dump

This reproducer uses only curl (no external tools needed).

Usage:
  python reproduce_bug_curl.py              # Run bug reproducer (default)
  python reproduce_bug_curl.py --worker     # Run in worker mode (called internally)
"""

import subprocess
import time
import os
import sys
import signal
import tempfile
import concurrent.futures


# ============================================================================
# IP ranges — generate enough concurrent work to create the thread-shutdown
# timing window needed for the bug
# ============================================================================

IP_RANGES = [
    "2.58.104.0/24",
]


# ============================================================================
# Worker code: curl-based HTTP scanner
# ============================================================================

def generate_ips(ip_ranges):
    """Generate list of IPs from CIDR ranges"""
    import ipaddress
    ips = []
    for cidr in ip_ranges:
        network = ipaddress.IPv4Network(cidr, strict=False)
        for ip in network.hosts():
            ips.append(str(ip))
    return ips


def scan_ip_curl(ip, timeout=5):
    """
    Scan a single IP using curl.
    Attempts HTTPS connection to the IP with a short timeout.
    The actual connection result doesn't matter — we just need blocking I/O
    in many threads to create the shutdown timing window.
    """
    try:
        result = subprocess.run(
            [
                "curl", "-s", "-o", "/dev/null",
                "-w", "%{http_code} %{time_total}",
                "--connect-timeout", str(timeout),
                "--max-time", str(timeout),
                "-k",  # allow insecure (self-signed certs)
                f"https://{ip}/"
            ],
            capture_output=True,
            text=True,
            timeout=timeout + 2,
        )
        output = result.stdout.strip()
        parts = output.split()
        if len(parts) >= 2:
            http_code = parts[0]
            time_total = float(parts[1])
            return {
                "ip": ip,
                "success": http_code not in ("000", ""),
                "time_ms": time_total * 1000,
                "message": f"HTTP {http_code} in {time_total*1000:.0f}ms",
            }
        return {"ip": ip, "success": False, "time_ms": -1, "message": f"curl: {output}"}
    except subprocess.TimeoutExpired:
        return {"ip": ip, "success": False, "time_ms": -1, "message": "Timeout"}
    except Exception as e:
        return {"ip": ip, "success": False, "time_ms": -1, "message": str(e)[:50]}


def worker_main():
    """
    Worker mode: scan IPs using curl with many threads.
    This creates the multi-threaded shutdown scenario needed for the bug.
    """
    ips = generate_ips(IP_RANGES)

    print(f"Worker started: {len(ips)} IPs, 100 workers", flush=True)

    with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
        future_to_ip = {
            executor.submit(scan_ip_curl, ip, 3): ip
            for ip in ips
        }

        for i, future in enumerate(concurrent.futures.as_completed(future_to_ip)):
            ip = future_to_ip[future]
            try:
                result = future.result()
                status = "+" if result["success"] else "x"
                print(f"[{i+1}/{len(ips)}] {status} {ip}: {result['message']}", flush=True)
            except Exception as e:
                print(f"[{i+1}/{len(ips)}] x {ip}: Error - {e}", flush=True)


# ============================================================================
# Bug reproducer: launch worker and send SIGINT burst during shutdown
# ============================================================================

def send_sigint(pid):
    try:
        os.kill(pid, signal.SIGINT)
        return True
    except ProcessLookupError:
        return False


def is_alive(pid):
    try:
        os.kill(pid, 0)
        return True
    except ProcessLookupError:
        return False


def read_output_lines(proc, count=10, timeout=120):
    """Read worker output lines until we have enough results"""
    lines = []
    deadline = time.time() + timeout
    while len(lines) < count and time.time() < deadline:
        line = proc.stdout.readline()
        if not line:
            break
        decoded = line.decode(errors='replace').strip()
        if decoded:
            lines.append(decoded)
            print(f"  {decoded}")
    return lines


def run_attempt(attempt, script_path):
    """Run worker subprocess and send continuous burst of SIGINT"""
    print(f"\n{'='*60}")
    print(f"Attempt {attempt}")
    print(f"{'='*60}")

    cmd = [sys.executable, '-u', script_path, '--worker']

    proc = subprocess.Popen(
        cmd,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )

    pid = proc.pid
    print(f"  PID: {pid}")
    print(f"  Waiting for scan output...\n")

    output_lines = read_output_lines(proc, count=20, timeout=180)

    if not output_lines:
        print("  No output, aborting")
        proc.kill()
        proc.communicate()
        return None

    if not is_alive(pid):
        _, stderr = proc.communicate()
        return stderr.decode(errors='replace')

    print(f"\n  Got {len(output_lines)} results. Sending SIGINT burst...\n")

    # Send continuous burst of SIGINT signals for ~5 seconds.
    # This covers the entire shutdown sequence:
    #   as_completed -> __exit__ -> shutdown -> atexit -> _PyImport_Cleanup
    # One of these signals MUST land during _PyImport_Cleanup when
    # sys.stderr is being cleared -> triggers _PyObject_Dump
    signal_count = 0
    start = time.time()
    duration = 5.0  # seconds of continuous SIGINT bombardment

    while time.time() - start < duration:
        if not is_alive(pid):
            break
        send_sigint(pid)
        signal_count += 1
        time.sleep(0.005)  # 200 signals/second

    elapsed = time.time() - start
    print(f"  Sent {signal_count} SIGINT signals over {elapsed:.1f}s")

    try:
        stdout, stderr = proc.communicate(timeout=15)
    except subprocess.TimeoutExpired:
        proc.kill()
        stdout, stderr = proc.communicate()

    return stderr.decode(errors='replace')


def reproducer_main():
    """Entry point for bug reproducer mode"""
    script_path = os.path.abspath(__file__)

    print(f"Python: {sys.version}")
    print(f"Platform: {sys.platform}")
    print(f"Worker: {script_path} --worker")
    print(f"Method: curl (HTTPS connect to IP ranges)")
    print(f"Workers: 100 threads")
    print(f"Target: _PyObject_Dump + 'lost sys.stderr'")
    print(f"")
    print(f"This reproducer launches a multi-threaded curl-based scanner,")
    print(f"then sends rapid SIGINT signals during Python's shutdown sequence")
    print(f"to trigger the _PyObject_Dump bug when sys.stderr becomes None.")

    max_attempts = 10

    for attempt in range(1, max_attempts + 1):
        stderr_text = run_attempt(attempt, script_path)

        if stderr_text is None:
            continue

        has_object_dump = 'object address' in stderr_text
        has_lost_stderr = 'lost sys.stderr' in stderr_text
        has_keyboard_interrupt = 'KeyboardInterrupt' in stderr_text

        print(f"\n  --- STDERR ---")
        print(stderr_text)
        print(f"  --- END ---")

        if has_object_dump:
            print(f"\n{'='*60}")
            print(f"BUG REPRODUCED on attempt {attempt}!")
            print(f"{'='*60}")
            if has_lost_stderr:
                print("Full bug with 'lost sys.stderr'!")
            return 0

        if has_keyboard_interrupt:
            print(f"  Got KeyboardInterrupt but not _PyObject_Dump. Retrying...")
        else:
            print(f"  No relevant output. Retrying...")

    print(f"\nFailed after {max_attempts} attempts.")
    return 1


# ============================================================================
# Main entry point
# ============================================================================

def main():
    if "--worker" in sys.argv:
        worker_main()
    else:
        reproducer_main()


if __name__ == "__main__":
    sys.exit(main())

How the reproducer works

The script operates in two modes:

  1. Reproducer mode (default): Launches the worker as a subprocess, waits for it to start processing, then sends ~960 SIGINT signals over 5 seconds (~200 signals/second) to cover the entire shutdown sequence window.

  2. Worker mode (--worker): Uses concurrent.futures.ThreadPoolExecutor with 100 workers to run curl HTTPS requests against IP ranges. This creates many active threads, which is essential for the shutdown timing window.

The key parameters:

  • 100 concurrent threads in ThreadPoolExecutor
  • ~7,800 curl tasks submitted
  • ~960 SIGINT signals over 5 seconds during shutdown
  • Signal interval: 5ms (~200 signals/second)

Requirements

  • Linux
  • Python 3.12 (confirmed on 3.12.7)
  • curl (pre-installed on virtually all Linux distributions)

No third-party Python packages or external tools are required.

Observed output

Bug reproduces on the first attempt:

$ python3 reproduce_bug_curl.py

Python: 3.12.7 (main, Jun 18 2025, 13:16:51) [GCC 14.2.0]
Platform: linux
Worker: /home/user/reproduce_bug_curl.py --worker
Method: curl (HTTPS connect to IP ranges)
Workers: 100 threads
Target: _PyObject_Dump + 'lost sys.stderr'
...

  Got 20 results. Sending SIGINT burst...

  Sent 956 SIGINT signals over 5.0s

  --- STDERR ---
Traceback (most recent call last):
  File "/usr/lib/python3.12/concurrent/futures/_base.py", line 243, in as_completed
  File "/usr/lib/python3.12/threading.py", line 655, in wait
  File "/usr/lib/python3.12/threading.py", line 355, in wait
    waiter.acquire()
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/reproduce_bug_curl.py", line 139, in worker_main
    for i, future in enumerate(concurrent.futures.as_completed(future_to_ip)):
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/concurrent/futures/_base.py", line 258, in as_completed
    with f._condition:
         ^^^^^^^^^^^^
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/user/reproduce_bug_curl.py", line 304, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/user/reproduce_bug_curl.py", line 298, in main
  File "/home/user/reproduce_bug_curl.py", line 133, in worker_main
    with concurrent.futures.ThreadPoolExecutor(max_workers=100) as executor:
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.12/concurrent/futures/_base.py", line 647, in __exit__
    self.shutdown(wait=True)
  File "/usr/lib/python3.12/concurrent/futures/thread.py", line 238, in shutdown
object address  : 0x72fed827c940
object refcount : 6
object type     : 0xa3e620
object type name: KeyboardInterrupt
object repr     : KeyboardInterrupt()
lost sys.stderr
Exception ignored in: <module 'threading' from '/usr/lib/python3.12/threading.py'>
Traceback (most recent call last):
  File "/usr/lib/python3.12/threading.py", line 1594, in _shutdown
    atexit_call()
  File "/usr/lib/python3.12/concurrent/futures/thread.py", line 31, in _python_exit
    t.join()
  File "/usr/lib/python3.12/threading.py", line 1149, in join
  File "/usr/lib/python3.12/threading.py", line 1169, in _wait_for_tstate_lock
    if lock.acquire(block, timeout):
       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt:
  --- END ---

============================================================
BUG REPRODUCED on attempt 1!
============================================================
Full bug with 'lost sys.stderr'!

Key observations

  1. _PyObject_Dump was invoked — This is a C-level internal function (Objects/object.c) that writes raw object information directly to fd 2, bypassing Python's I/O system. Users should never see this output.

  2. Memory addresses leakedobject address: 0x72fed827c940 (heap address) and object type: 0xa3e620 (type object address) are exposed.

  3. "lost sys.stderr" — Originates from Python/pythonrun.c in the PyErr_Display / _PyErr_WriteUnraisableMsg path. Emitted when sys.stderr is NULL or destroyed during module cleanup.

  4. Traceback truncated mid-line — The normal traceback is cut after thread.py", line 238, in shutdown and immediately followed by _PyObject_Dump output, indicating the normal exception handling path was interrupted.

Expected behavior

  • The interpreter should handle SIGINT gracefully during shutdown, or block signals during critical cleanup phases
  • _PyObject_Dump (a C-level debug fallback) should never be visible to end users
  • Internal memory addresses should not be leaked to process output

Root cause analysis

The race condition is in Py_FinalizeEx (Python/pylifecycle.c):

1. wait_for_thread_shutdown() -> threading._shutdown() -> t.join()
2. call_py_exitfuncs()        -> atexit handlers
3. _PyImport_Cleanup()        -> sys.stderr = None    <-- VULNERABLE WINDOW
4. SIGINT arrives here        -> KeyboardInterrupt raised
                              -> PyErr_Display() finds sys.stderr = NULL
                              -> falls back to _PyObject_Dump()
                              -> prints "lost sys.stderr"

Signals are not blocked during _PyImport_Cleanup, so SIGINT can trigger exception handling after sys.stderr has been cleared.

CPython versions tested on:

3.12

Operating systems tested on:

Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions