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

    Projects

    Status
    Done
    Status
    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions