diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/test-nginx.py | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/scripts/test-nginx.py b/scripts/test-nginx.py index 05ff3389..7d58739e 100755 --- a/scripts/test-nginx.py +++ b/scripts/test-nginx.py @@ -67,6 +67,11 @@ class NginxTest(testlib_httpd.HttpdCommon): self.ssl_crt = "/etc/nginx/cert.pem" self.ssl_key = "/etc/nginx/cert.key" + # conf.d files are included in the http context by the default + # nginx.conf, so they are suitable for directives such as `map` + # that are only valid in the http context. + self.conf_d = "/etc/nginx/conf.d" + self.pidfile = "/var/run/nginx.pid" self.daemon = testlib.TestDaemon("nginx") @@ -81,6 +86,12 @@ class NginxTest(testlib_httpd.HttpdCommon): if os.path.exists(self.ssl_key): os.unlink(self.ssl_key) + # Remove any conf.d snippets created by individual tests so they + # don't leak into subsequent tests or the running configuration. + for filename in os.listdir(self.conf_d): + if filename.startswith("qrt-") and filename.endswith(".conf"): + os.unlink(os.path.join(self.conf_d, filename)) + testlib.config_restore(self.default_vhost) def _test_headers(self, url="https://localhost/", content="", invert=False, check_ssl=True): @@ -258,6 +269,206 @@ server { self._test_headers(url="http://localhost/", content="X-QRT-Test: headers-more-works", check_ssl=False) self._test_headers(url="http://localhost/", content="Server:", invert=True, check_ssl=False) + def _get_nginx_worker_pids(self): + '''Return the set of current nginx worker process PIDs.''' + rc, report = testlib.cmd(['pgrep', '-x', 'nginx']) + # pgrep matches both master and workers; exclude the master pid. + master_pid = None + try: + with open(self.pidfile, 'r') as fd: + master_pid = fd.readline().rstrip('\n') + except (IOError, OSError): + pass + pids = set() + if rc == 0: + for line in report.strip().split('\n'): + pid = line.strip() + if pid and pid != master_pid: + pids.add(pid) + return pids + + def test_cve_2026_42533(self): + '''Verify CVE-2026-42533 (heap buffer overflow with map regex)''' + + # CVE-2026-42533: a heap buffer overflow could occur in a worker + # process when using the `map` directive with regex matching if the + # map variable was included in a string expression after a regex + # capture affected by that same map. A similar issue could happen + # when using a non-cacheable (`volatile`) variable in a string + # expression. + # + # The nginx script engine evaluates complex values (string + # expressions with variables) in two passes: a length pass to + # calculate the buffer size, then a copy pass to fill it. When a + # map variable with regex matching is evaluated, it runs the regex + # and sets r->captures as a side effect. If a regex capture ($1, + # $named_capture) appears in the same expression BEFORE the map + # variable, the length pass uses the original captures, but by the + # copy pass the captures have been overwritten by the map's regex — + # causing the copy to write more data than the buffer can hold. + # + # The fix (nginx 1.31.3, 1.30.4) adds buffer boundary checking in + # the script engine (ngx_http_script_check_length) that logs "no + # buffer space in script copy" and returns a 500 error instead of + # overflowing. + # + # On a vulnerable build the worker process crashes (SIGSEGV); on a + # fixed build the worker survives and returns an error response. + # + # This test has two parts: + # 1. Trigger test: exercise the exact CVE-2026-42533 code path and + # verify the worker does not crash. + # 2. Functionality test: verify normal map regex and volatile map + # usage produces correct values (no regression from the fix). + + # The `map` directive is only valid in the http context, so place it + # in a conf.d snippet (included by the default nginx.conf) rather + # than in the server block. + map_conf = os.path.join(self.conf_d, "qrt-cve-2026-42533.conf") + testlib.create_fill(map_conf, ''' +# CVE-2026-42533 regression test +# +# Trigger 1: map with regex matching where the map value uses a named +# capture from that same regex. When the map variable is used in a string +# expression after a capture reference, the map's regex execution +# overwrites r->captures between the length and copy passes. +map $uri $qrt_map { + default "unknown"; + "~^(?P<qrt_capture>.+)$" "$qrt_capture"; +} + +# Trigger 2: non-cacheable (volatile) map. A volatile variable is +# re-evaluated on every access, so using it in a string expression can +# cause the length to change between the two passes. +map $request_uri $qrt_volatile { + volatile; + default "default-volatile"; + "~^/volatile" "volatile-matched"; +} +''') + + # --- Part 1: Trigger test (worker crash detection) --- + # + # The trigger uses the rewrite module's `set` directive to create a + # string expression where a capture appears before the map variable. + # `set $qrt_temp "$qrt_capture $qrt_map"` evaluates $qrt_capture + # (empty, since no location regex set it) then $qrt_map (which runs + # the map regex, overwriting r->captures). On the copy pass, + # $qrt_capture reads from the now-modified captures — writing far + # more bytes than the 0 allocated for it → heap buffer overflow. + # + # A long URI is used so the overflow is large enough to crash the + # worker process on a vulnerable build. + testlib.config_replace(self.default_vhost, ''' +server { + listen 80; + server_name localhost; + + root %s; + index index.html index.htm index.nginx-debian.html; + + location / { + set $qrt_capture ""; + set $qrt_temp "$qrt_capture $qrt_map"; + return 200 "$qrt_temp\\n"; + } + + location = /volatile { + return 200 "volatile-endpoint-ok\\n"; + } +} +''' % self.document_root, append=False) + + rc, report = self.daemon.restart() + self.assertTrue(rc, report) + self.assertTrue(testlib.check_pidfile("nginx", self.pidfile)) + + # Record worker PIDs before sending the trigger request. + pids_before = self._get_nginx_worker_pids() + self.assertTrue(len(pids_before) > 0, + "No nginx worker processes found") + + # Send a request with a long URI to trigger the overflow. On a + # vulnerable build, the worker crashes (segfault) and the master + # restarts it with a new PID. On a fixed build, the buffer overrun + # protection catches the overflow and returns a 500 error, but the + # worker stays alive. + long_uri = "/" + "A" * 4096 + testlib.cmd(['curl', '-s', '-o', '/dev/null', + 'http://localhost' + long_uri]) + + # Worker PIDs should be unchanged — the same workers are still + # running. On a vulnerable build, the worker would have crashed and + # been restarted with a different PID. + pids_after = self._get_nginx_worker_pids() + self.assertEqual(pids_before, pids_after, + "nginx worker process crashed and was restarted " + "(PIDs changed: %s -> %s). This indicates " + "CVE-2026-42533 is not fixed." % + (pids_before, pids_after)) + + # --- Part 2: Functionality test (no regression) --- + # + # Replace the vhost config with normal map usage (no trigger) to + # verify the fix didn't break legitimate map regex and volatile + # functionality. + testlib.config_replace(self.default_vhost, ''' +server { + listen 80; + server_name localhost; + + root %s; + index index.html index.htm index.nginx-debian.html; + + # Map output used in a string expression (normal functionality). + # Use `return` instead of `add_header` because add_header is not + # applied to 404 responses, and we want to verify the map value + # directly regardless of whether a file exists on disk. + location / { + return 200 "mapped:$qrt_map vol:$qrt_volatile\\n"; + } +} +''' % self.document_root, append=False) + + rc, report = self.daemon.restart() + self.assertTrue(rc, report) + self.assertTrue(testlib.check_pidfile("nginx", self.pidfile)) + + # Regex map: a request URI should be captured by the map regex. + # The volatile map matches /testpath against default (no /volatile + # prefix), so we expect "default-volatile". + rc, report = testlib.cmd(['curl', '-s', + 'http://localhost/testpath']) + expected = 0 + result = 'Got exit code %d, expected %d\n' % (rc, expected) + self.assertEqual(expected, rc, result + report) + self._word_find(report, "mapped:/testpath") + self._word_find(report, "vol:default-volatile") + # Worker must still be alive. + self.assertTrue(testlib.check_pidfile("nginx", self.pidfile)) + + # Regex map: a different path should produce a different capture. + rc, report = testlib.cmd(['curl', '-s', + 'http://localhost/another']) + expected = 0 + result = 'Got exit code %d, expected %d\n' % (rc, expected) + self.assertEqual(expected, rc, result + report) + self._word_find(report, "mapped:/another") + # Worker must still be alive. + self.assertTrue(testlib.check_pidfile("nginx", self.pidfile)) + + # Volatile map: matching request URI should produce the matched + # value, and the volatile (non-cacheable) code path should work. + rc, report = testlib.cmd(['curl', '-s', + 'http://localhost/volatile']) + expected = 0 + result = 'Got exit code %d, expected %d\n' % (rc, expected) + self.assertEqual(expected, rc, result + report) + self._word_find(report, "mapped:/volatile") + self._word_find(report, "vol:volatile-matched") + # Worker must still be alive. + self.assertTrue(testlib.check_pidfile("nginx", self.pidfile)) + def test_proxy(self): '''Test proxy''' |
