Skip to content

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

Description

@mreiden

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    In Review

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions