Skip to content

Use-after-free in _curses from unsynchronized access to shared ncurses SCREEN state #154139

Description

@jacobggman

Crash report

What happened?

_curses releases the GIL around ncurses calls (refresh, getch, resizeterm, …) with no lock, while stock ncurses (built without --enable-reentrant, the Ubuntu/Debian default) is not thread-safe and keeps all screen state in global curscr/newscr row buffers. refresh()→doupdate uses those rows while resizeterm()→wresize frees them (wresize.c:227-240). Two Python threads therefore race into a use-after-free read+write of the shared screen → heap corruption → crash.

---ncurses version:
/usr/local/lib/libncursesw.so
/usr/local/lib/libncursesw.so.6
/usr/local/lib/libncursesw.so.6.4
ncurses 6.4.20240113
---OS:
PRETTY_NAME="Ubuntu 24.04.4 LTS"
#!/usr/bin/env python3
"""
Reliable use-after-free crash in CPython's `curses` module.

Root cause (Modules/_cursesmodule.c): several ncurses calls release the GIL
with no lock of their own -- notably window.refresh() -> wrefresh()->doupdate()
(_cursesmodule.c:4447/4459) and curses.resizeterm(). Stock ncurses (built
without --enable-reentrant, as on Ubuntu/Debian) is NOT thread-safe and keeps
all screen state in process-global structures.

doupdate()'s TransformLine() walks and writes the global curscr/newscr row
buffers (ncurses/tty/tty_update.c:1307-1308,1494,1625), while
resizeterm()->wresize() FREES those same buffers (ncurses/base/wresize.c:227-240).
Two Python threads therefore execute ncurses C concurrently on the shared,
unlocked SCREEN -> use-after-free read and write -> heap corruption ->
SIGSEGV / SIGABRT ("corrupted size vs. prev_size", "double free or
corruption").

Run (Unix, any CPython with _curses + non-reentrant ncurses):
    python3 poc_uaf_curses.py
Crashes reliably (usually within a couple of seconds). Re-run if it survives
one pass; raising NTHREADS/DURATION increases the chance.
"""
import curses
import sys
import threading
import time

NTHREADS = 8        # refresh threads racing on the shared SCREEN
DURATION = 15       # seconds to race before giving up


def main():
    stop = threading.Event()

    def hammer(win, tag):
        win.nodelay(True)
        i = 0
        while not stop.is_set():
            i += 1
            try:
                # dirty the window so doupdate has real work, then refresh
                # (refresh releases the GIL inside wrefresh()->doupdate()).
                win.addstr(0, 0, f"{tag}{i:06d}" + "x" * 40)
                win.refresh()
            except curses.error:
                pass

    def resizer():
        # Frees + reallocates curscr/newscr/stdscr row buffers via wresize()
        # while the refresh threads are inside doupdate() (GIL released).
        flip = False
        while not stop.is_set():
            flip = not flip
            try:
                curses.resizeterm(24 if flip else 25, 80 if flip else 81)
            except curses.error:
                pass

    def run(stdscr):
        curses.curs_set(0)
        stdscr.nodelay(True)
        wins = [curses.newwin(3, 60, (i % 20), 0) for i in range(NTHREADS)]
        threads = [threading.Thread(target=hammer, args=(w, chr(65 + i)),
                                    daemon=True)
                   for i, w in enumerate(wins)]
        threads += [threading.Thread(target=resizer, daemon=True) for _ in range(2)]
        for t in threads:
            t.start()
        t0 = time.monotonic()
        while time.monotonic() - t0 < DURATION and not stop.is_set():
            try:
                stdscr.noutrefresh()
                curses.doupdate()          # another GIL-releasing racer
            except curses.error:
                pass
            time.sleep(0.01)
        stop.set()

    curses.wrapper(run)
    print("Survived this run -- re-run, or raise NTHREADS/DURATION.",
          file=sys.stderr)


if __name__ == "__main__":
    main()

I would like to fix this myself by adding locking to the curses module.

CPython versions tested on:

3.16

Operating systems tested on:

Linux

Output from running 'python -VV' on the command line:

Python 3.16.0a0 (heads/main:c08c89a, Jul 19 2026, 02:39:49) [GCC 13.3.0]

Linked PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    docsDocumentation in the Doc dir

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Bugs
    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions