nginx / nginx/nginx

UBSAN (nonnull) trap in ngx_sprintf_str() on every debug-logged request: %V passes NULL to memcpy for empty strings

Open
#1,671 4 comments 0 reactions 1 assignee View on GitHub

@vinaykumar-1591 is already working on this.

Since Aug 24, 2026.

bug in-analysis
Dominant language
C
Stars
31.7k
Forks
8.3k
Avg merge
1d 12h
Merged PRs (30d)
13

Description

Bug Overview

When I serve any request with an nginx built --with-debug and -fsanitize=undefined -fno-sanitize-recover=undefined, with error_log ... debug; configured, the worker dies with a UBSan nonnull trap in ngx_sprintf_str() (src/core/ngx_string.c:586) instead of serving the request and logging it. %V (and %v) forward data/len to ngx_sprintf_str(), which unconditionally calls ngx_cpymem(buf, src, len); an empty ngx_str_t is { 0, NULL } by convention throughout nginx (r->args and r->exten on any URI without a query string or extension are the ubiquitous examples), and memcpy with a null source is undefined behavior even at zero length. Debug lines that format such strings sit on the hot path ("http args:" / "http exten:" in request-line processing, "http output filter \"%V?%V\"", ...), so the very first request kills the worker -- which effectively makes UBSAN and debug logging mutually exclusive. (With sanitize-recover instead, every request produces report spam.)

We hit this in a module CI pipeline running an ASAN+UBSAN nginx: it stayed invisible for months because nothing logged at debug level under the sanitized binary; the first tool that did died on its first request.

Expected Behavior

A sanitized --with-debug nginx with debug-level logging serves requests normally and writes the debug log, with no sanitizer reports -- debug logging of empty strings is well-formed.

Steps to Reproduce the Bug
curl -O https://nginx.org/download/nginx-1.31.4.tar.gz
tar xzf nginx-1.31.4.tar.gz && cd nginx-1.31.4
./configure --with-debug \
  --with-cc-opt="-fsanitize=undefined -fno-sanitize-recover=undefined -O1 -g" \
  --with-ld-opt="-fsanitize=undefined"
make -j"$(nproc)"

mkdir -p /tmp/r/logs /tmp/r/html && echo hello > /tmp/r/html/index.html
# nginx.conf as in the "NGINX Configuration" field, saved to /tmp/r/nginx.conf
objs/nginx -p /tmp/r -c /tmp/r/nginx.conf -g "daemon off; master_process off;" &
sleep 1
curl -s http://127.0.0.1:18130/     # empty reply (curl exit 52); nginx has exited
grep -m1 "runtime error" /tmp/r/logs/error.log

Also reproducible on current master -- src/core/ngx_string.c is unchanged. A stronger demonstration: the nginx-tests harness writes error_log ... debug; unconditionally, so against this build every one of its 493 test files fails the same way -- the test suite cannot run under UBSAN at all.

NGINX Configuration
error_log /tmp/r/logs/error.log debug;
pid /tmp/r/nginx.pid;
events { }
http {
    server {
        listen 127.0.0.1:18130;
        root /tmp/r/html;
    }
}
NGINX version and build configuration options
nginx version: nginx/1.31.4
built by gcc 15.3.1 20260722 (Red Hat 15.3.1-1) (GCC)
configure arguments: --with-debug --with-cc-opt='-fsanitize=undefined -fno-sanitize-recover=undefined -O1 -g' --with-ld-opt=-fsanitize=undefined
Environment where NGINX is being built and/or deployed

Built and reproduced on Fedora 43 (WSL2), gcc 15.3.1, glibc 2.42; first observed on Ubuntu-based CI builders (gcc 15.2). Not deployment-specific -- sanitizer builds are a CI/development configuration, and the trap follows the build flags to any Linux target.

Architecture where NGINX is being built and/or deployed
Linux Mr-Computer 6.6.87.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun  5 18:30:46 UTC 2025 x86_64 GNU/Linux
NGINX Debug Log

The log is the crash site -- it ends at the exact moment the trap fires (the next line would have been http args: ""):

2026/08/21 07:08:49 [debug] 495#0: *1 http process request line
2026/08/21 07:08:49 [debug] 495#0: *1 http request line: "GET / HTTP/1.1"
2026/08/21 07:08:49 [debug] 495#0: *1 http uri: "/"
src/core/ngx_string.c:586:19: runtime error: null pointer passed as argument 2, which is declared to never be null
    #0 ngx_sprintf_str src/core/ngx_string.c:586
    #1 ngx_vslprintf src/core/ngx_string.c:255
    #2 ngx_log_error_core src/core/ngx_log.c:135
    #3 ngx_http_process_request_uri src/http/ngx_http_request.c:1389
Additional Context

The fix is a three-line guard that is exactly semantics-preserving (ngx_cpymem with zero length already returns buf unchanged); happy to submit it as a PR:

--- a/src/core/ngx_string.c
+++ b/src/core/ngx_string.c
@@ -583,7 +583,10 @@ ngx_sprintf_str(u_char *buf, u_char *last, u_char *src, size_t len,

         } else {
             len = ngx_min((size_t) (last - buf), len);
-            buf = ngx_cpymem(buf, src, len);
+
+            if (len) {
+                buf = ngx_cpymem(buf, src, len);
+            }
         }

     } else if (hexadecimal == 1) {

The hexadecimal branches are unaffected (their loops never dereference src at length zero), and %s's length--1 path requires a NUL-terminated string by contract.

Test-suite evidence with the patch applied:

  • Plain --with-debug build: full nginx-tests suite clean -- 493 files, 2519 tests, PASS (no behavior change).
  • The UBSAN build can then run the suite for the first time --- and doing so immediately surfaced four further UB instances in unrelated code, each killing its test file's workers: ngx_http_proxy_module.c:1398 (nonnull memcpy, URI-less proxy_pass), ngx_http_upstream_sticky_module.c:684 (nonnull memcpy, no path= configured), ngx_http_upstream_least_time_module.c:434 (unclamped decay shift, observed exponent 1787288950 -- the x86 mod-64 result also mis-ranks long-idle peers in non-sanitized builds), and ngx_resolver.c:2359 (buf[i] << 24 int-promotion signed overflow on network-supplied DNS bytes, first octet >= 128). I'll file those separately unless bundling is preferred. Everything else in the suite passes under the sanitized binary.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.