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.
Bug Overview
When I serve any request with an nginx built
--with-debugand-fsanitize=undefined -fno-sanitize-recover=undefined, witherror_log ... debug;configured, the worker dies with a UBSan nonnull trap inngx_sprintf_str()(src/core/ngx_string.c:586) instead of serving the request and logging it.%V(and%v) forwarddata/lentongx_sprintf_str(), which unconditionally callsngx_cpymem(buf, src, len); an emptyngx_str_tis{ 0, NULL }by convention throughout nginx (r->argsandr->extenon any URI without a query string or extension are the ubiquitous examples), andmemcpywith 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-debugnginx 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
Also reproducible on current
master--src/core/ngx_string.cis unchanged. A stronger demonstration: the nginx-tests harness writeserror_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
NGINX version and build configuration options
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
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: ""):Additional Context
The fix is a three-line guard that is exactly semantics-preserving (
ngx_cpymemwith zero length already returnsbufunchanged); happy to submit it as a PR:The hexadecimal branches are unaffected (their loops never dereference
srcat length zero), and%s's length--1path requires a NUL-terminated string by contract.Test-suite evidence with the patch applied:
--with-debugbuild: full nginx-tests suite clean -- 493 files, 2519 tests, PASS (no behavior change).ngx_http_proxy_module.c:1398(nonnull memcpy, URI-lessproxy_pass),ngx_http_upstream_sticky_module.c:684(nonnull memcpy, nopath=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), andngx_resolver.c:2359(buf[i] << 24int-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.