Skip to content

bugfix: SIGSEGV in receiveuntil __gc on aborted multipart upload.#2504

Merged
zhuizhuhaomeng merged 2 commits into
openresty:masterfrom
climagabriel:fix-receiveuntil-gc-uaf
May 26, 2026
Merged

bugfix: SIGSEGV in receiveuntil __gc on aborted multipart upload.#2504
zhuizhuhaomeng merged 2 commits into
openresty:masterfrom
climagabriel:fix-receiveuntil-gc-uaf

Conversation

@climagabriel
Copy link
Copy Markdown
Contributor

read_error_retval_handler calls finalize_read_part directly when the receiveuntil iterator's recv errors. That clears u->buf_in but leaves cp->upstream live with cp->state > 0. Later GC fires cleanup_compiled_pattern -> read_prepare, which derefs the now-NULL u->buf_in.

Mirror tcp_finalize's cp->upstream = NULL detach so __gc's existing if (u != NULL) guard short-circuits.

Backtrace:

ngx_http_lua_socket_tcp_read_prepare
ngx_http_lua_socket_cleanup_compiled_pattern
lj_BC_FUNCC
gc_call_finalizer
gc_finalize
gc_onestep
lj_gc_fullgc
lua_gc
lj_cf_collectgarbage
lj_BC_FUNCC
ngx_http_lua_run_thread
ngx_http_lua_socket_tcp_resume_helper
ngx_http_lua_access_handler
ngx_http_core_access_phase
ngx_http_core_run_phases
ngx_http_lua_socket_tcp_read
ngx_http_request_handler
ngx_epoll_process_events
ngx_process_events_and_timers
ngx_worker_process_cycle
ngx_spawn_process
ngx_start_worker_processes
ngx_master_process_cycle
main

I hereby granted the copyright of the changes in this pull request
to the authors of this lua-nginx-module project.

read_error_retval_handler calls finalize_read_part directly when
the receiveuntil iterator's recv errors. That clears u->buf_in
but leaves cp->upstream live with cp->state > 0. Later GC fires
cleanup_compiled_pattern -> read_prepare, which derefs the
now-NULL u->buf_in.

Mirror tcp_finalize's cp->upstream = NULL detach so __gc's
existing `if (u != NULL)` guard short-circuits.

Backtrace:

  ngx_http_lua_socket_tcp_read_prepare
  ngx_http_lua_socket_cleanup_compiled_pattern
  lj_BC_FUNCC
  gc_call_finalizer
  gc_finalize
  gc_onestep
  lj_gc_fullgc
  lua_gc
  lj_cf_collectgarbage
  lj_BC_FUNCC
  ngx_http_lua_run_thread
  ngx_http_lua_socket_tcp_resume_helper
  ngx_http_lua_access_handler
  ngx_http_core_access_phase
  ngx_http_core_run_phases
  ngx_http_lua_socket_tcp_read
  ngx_http_request_handler
  ngx_epoll_process_events
  ngx_process_events_and_timers
  ngx_worker_process_cycle
  ngx_spawn_process
  ngx_start_worker_processes
  ngx_master_process_cycle
  main
@climagabriel
Copy link
Copy Markdown
Contributor Author

Standalone pytest + nginx config that reliably reproduces the crash on the unpatched module (20/20) and passes with this PR applied. Useful until/unless this gets folded into the upstream t/ suite.

Drop both files in the same directory and run:

NGINX_BIN=/path/to/nginx python3 -m pytest test_receiveuntil_gc_uaf.py

The client sends a multipart/form-data POST whose body starts with six dashes against a 12-dash boundary leader, advancing the receiveuntil DFA into a mid-pattern state with no fallback, then RSTs the connection. iter() errors, read_error_retval_handler calls finalize_read_part directly, and cp->upstream is left pointing at a half-finalized u. The conf forces the GC synchronously via collectgarbage("collect") so the crash is deterministic per request; in production the same fault fires from lj_gc_step on JIT trace exit.

Backtrace (production, vendored nginx + lua-nginx-module 0.10.26):

ngx_http_lua_socket_tcp_read_prepare
ngx_http_lua_socket_cleanup_compiled_pattern
lj_BC_FUNCC
gc_call_finalizer
gc_finalize
gc_onestep
lj_gc_fullgc
lua_gc
lj_cf_collectgarbage
lj_BC_FUNCC
ngx_http_lua_run_thread
ngx_http_lua_socket_tcp_resume_helper
ngx_http_lua_access_handler
ngx_http_core_access_phase
ngx_http_core_run_phases
ngx_http_lua_socket_tcp_read
ngx_http_request_handler
ngx_epoll_process_events
ngx_process_events_and_timers
ngx_worker_process_cycle
ngx_spawn_process
ngx_start_worker_processes
ngx_master_process_cycle
main

receiveuntil_gc_uaf.conf

# Reproducer for the receiveuntil __gc UAF this PR fixes.
#
# An aborted multipart POST causes iter() inside resty.upload's
# form:read() to error.  read_error_retval_handler calls
# finalize_read_part directly, which clears u->buf_in but leaves
# cp->upstream pointing at u with cp->state > 0.  After
# parse_upload() returns, the compiled-pattern userdata goes out
# of scope; its __gc handler then dereferences the now-NULL
# u->buf_in.
#
# collectgarbage("collect") is a determinism knob -- under
# realistic load the same fault fires from lj_gc_step on JIT
# trace exit.

daemon off;
master_process on;
worker_processes 1;
error_log stderr info;
pid /tmp/receiveuntil_gc_uaf.pid;

events {}

http {
    server {
        listen 18291;

        location / {
            access_by_lua_block {
                local function parse_upload()
                    local upload = require("resty.upload")
                    local form = upload:new(4096)
                    if not form then return end
                    while true do
                        local typ = form:read()
                        if not typ or typ == "eof" then return end
                    end
                end

                pcall(parse_upload)
                collectgarbage("collect")
            }
        }
    }
}

test_receiveuntil_gc_uaf.py

"""
Reproducer for the receiveuntil __gc UAF this PR fixes.

Mechanism:
    1. Client sends a multipart POST and RSTs mid-pattern.
    2. iter() inside resty.upload's form:read() errors.
       read_error_retval_handler calls finalize_read_part directly,
       which clears u->buf_in but leaves cp->upstream pointing at u
       with cp->state > 0.
    3. parse_upload() returns; the resty.upload `form` table -- which
       owns the compiled-pattern userdata via form.read2boundary --
       goes out of scope.
    4. LuaJIT's incremental GC finalizes cp.  Its __gc handler
       ngx_http_lua_socket_cleanup_compiled_pattern calls
       read_prepare(r, u, NULL), which derefs u->buf_in == NULL.

The accompanying .conf forces the GC step synchronously with
collectgarbage("collect") so the crash is deterministic per request;
in production the same fault fires from lj_gc_step on JIT trace exit.

Usage:
    NGINX_BIN=/path/to/nginx python3 -m pytest test_receiveuntil_gc_uaf.py
"""

import os
import signal
import socket
import struct
import subprocess
import time

NGINX_BIN   = os.environ.get('NGINX_BIN', 'nginx')
CONF_SRC    = os.path.join(os.path.dirname(__file__), 'receiveuntil_gc_uaf.conf')
PID_FILE    = '/tmp/receiveuntil_gc_uaf.pid'
STDERR_LOG  = '/tmp/receiveuntil_gc_uaf.stderr'
LISTEN_PORT = 18291
ABORTS      = 8
BOUNDARY    = b'----------exampleboundary12'


def _wait_for_listen(port, timeout=5.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            with socket.create_connection(('127.0.0.1', port), timeout=0.2):
                return True
        except OSError:
            time.sleep(0.05)
    return False


def _abort_post():
    s = socket.socket()
    s.settimeout(2.0)
    s.connect(('127.0.0.1', LISTEN_PORT))

    # Six leading dashes against the 12-dash boundary leader: the DFA
    # advances to state 6 with no fallback, then RST mid-pattern
    # makes iter() error with cp->state > 0.
    req = (
        b'POST / HTTP/1.1\r\n'
        b'Host: 127.0.0.1\r\n'
        b'Content-Type: multipart/form-data; boundary=' + BOUNDARY + b'\r\n'
        b'Content-Length: 1048576\r\n'
        b'Connection: close\r\n'
        b'\r\n'
        b'------'
    )
    try:
        s.sendall(req)
    except OSError:
        pass

    time.sleep(0.05)

    try:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER,
                     struct.pack('ii', 1, 0))
    except OSError:
        pass
    s.close()


def _stderr_has_crash(path):
    try:
        with open(path, 'rb') as f:
            blob = f.read()
    except FileNotFoundError:
        return False, b''
    return (b'exited on signal 11' in blob
            or b'SIGSEGV' in blob
            or b'core dumped' in blob), blob


def test_receiveuntil_gc_uaf_does_not_crash_worker():
    open(STDERR_LOG, 'wb').close()

    with open(STDERR_LOG, 'wb') as errf:
        nginx = subprocess.Popen(
            [NGINX_BIN, '-c', CONF_SRC],
            stdout=subprocess.DEVNULL,
            stderr=errf,
        )

    try:
        assert _wait_for_listen(LISTEN_PORT, timeout=5.0), \
            'nginx did not start listening on 127.0.0.1:18291'

        for _ in range(ABORTS):
            _abort_post()
            time.sleep(0.05)

        time.sleep(0.5)

        crashed, blob = _stderr_has_crash(STDERR_LOG)
        assert not crashed, (
            'worker crashed during aborted-multipart POST.\n'
            'nginx stderr tail:\n'
            + blob[-4000:].decode('latin-1', errors='replace')
        )

        assert nginx.poll() is None, 'nginx master exited unexpectedly'

    finally:
        try:
            nginx.send_signal(signal.SIGTERM)
            nginx.wait(timeout=10)
        except subprocess.TimeoutExpired:
            nginx.kill()
        try:
            os.unlink(PID_FILE)
        except FileNotFoundError:
            pass

@zhuizhuhaomeng zhuizhuhaomeng merged commit 48d5628 into openresty:master May 26, 2026
6 of 7 checks passed
@climagabriel climagabriel deleted the fix-receiveuntil-gc-uaf branch May 27, 2026 05:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants