In short
F5 published K000162607 today for CVE-2026-93479, a heap buffer overflow in nginx’s script engine. We reported it on July 25. Fixed in nginx 1.31.7 and 1.30.6.
It is a one-byte NUL write past a heap allocation, reachable without authentication through the
access_logpath in both HTTP and Stream. In Stream, a bare TLS ClientHello is enough.It is an incomplete-fix variant of CVE-2026-42533, the “major” map and regex overflow patched in June. The hardening capped copies at the end of the buffer, but the end of the buffer included the caller’s NUL reserve.
nginx wrote the fix in four days. The advisory shipped 81 days after our report. The advisory credits a second researcher, so the bug was found at least twice while it sat in the queue.
Vulnerability discovery now runs at machine speed. Disclosure and patching still run at calendar speed. The second part of this post is about closing that gap.
Part 1: the bug
nginx evaluates variable-bearing strings, including the access_log file path, with a two-pass script engine. Pass one runs length codes and adds up how many bytes the result needs. Pass two runs copy codes and writes the bytes into a buffer sized exactly by pass one. The caller asks for one extra byte on top, the len argument, so it can append a NUL terminator.
n = len; /* len = 1, the caller's NUL reserve */
while (*(uintptr_t *) e.ip) { /* pass 1: length codes */
lcode = *(ngx_http_script_len_code_pt *) e.ip;
n += lcode(&e);
}
value->data = ngx_pnalloc(r->pool, n); /* exact allocation */
e.pos = value->data;
e.end = value->data + n; /* <- includes the reserve */
while (*(uintptr_t *) e.ip) { /* pass 2: copy codes */
code = *(ngx_http_script_code_pt *) e.ip;
code((ngx_http_script_engine_t *) &e);
}
value->len = e.pos + len - value->data;The log module then terminates the string:
log.data[log.len - 1] = '\0';This is only safe if pass two writes exactly what pass one counted. The June fix for CVE-2026-42533 added ngx_http_script_check_length() so a copy can never run past e.end. But e.end was set to value->data + n, and n already contained the reserve. So the guard let pass two fill the reserve byte, and the caller then wrote its terminator one byte past the allocation.
Making the two passes disagree
Named regex captures are the lever. A capture variable like $c that has not been assigned yet resolves through not_found and contributes zero length in pass one. A map whose regular expression assigns$c runs its regex lazily the first time the map’s output variable is evaluated.
Put $c before the map output in the same path, and the two passes diverge:
Pass one reaches
$c. Unset, length zero.Pass one reaches
$logname. The map runs its regex, which sets$c.Pass two reaches
$c. It now has a value, and copies one byte that was never budgeted. The copy is in bounds only because it eats the reserve.The log module writes
'\0'atdata[n], one byte past the end.
The overshoot is hard-capped at exactly one byte. A longer capture does not scale it into a linear overwrite; the length check skips the trailing copies and nginx logs an alert instead.
Why Stream matters more than HTTP
In HTTP, the path is allocated from the request pool, and in the default configuration the stray NUL lands on the first byte of the next pool allocation, which the next allocation fully reinitialises. It self-heals and a stock build never notices.
Stream is different. The connection pool is 256 bytes, so the log path nearly always goes through ngx_palloc_large() and lands on a real malloc() chunk. The NUL then hits the allocator’s chunk boundary, and glibc’s integrity checks turn that into an abort of the worker when the chunk is freed microseconds later. The trigger is a TLS ClientHello against a server with ssl_preread on. No certificate, no application data. Our assessment put the practical ceiling at a worker crash; F5’s advisory notes code execution is possible with ASLR disabled.
The fix
nginx’s Roman Arutyunyan authored the fix on July 29, four days after our report, and it merged on October 1 as 6aa4b3f. It keeps the reserve out of the length pass entirely and adds it back only at allocation time:
- n = len;
+ n = 0;
while (*(uintptr_t *) e.ip) { ... n += lcode(&e); }
- value->len = n;
- value->data = ngx_pnalloc(r->pool, n);
+ value->len = n + len;
+ value->data = ngx_pnalloc(r->pool, n + len);The same change lands in the Stream twin. From the commit message:
Considering a possible spillover to the reserved area, the final string size could become larger than the allocated buffer size. This could later result in writing beyond the buffer boundaries.
We had proposed the equivalent e.end = value->data + n - len. Either way, pass two can no longer touch the byte the caller reserved.
How it was found
A 24-agent parallel audit of nginx 1.31.4 mainline, on a tree that already contained every CVE-2026-42533 fix. The agents were pointed at the script engine because the June series had hardened log linerendering but not log path evaluation, and incomplete fixes are where the next bug usually lives. Discovery on July 24, both variants confirmed under AddressSanitizer and the patch verified on July 25, report sent the same day.
Part 2: the 90-day window is a relic
DateEventDays from reportJuly 24Found by a 24-agent auditJuly 25Confirmed, patched, reported to nginx0July 29Fix authored upstream4October 1Fix merged to mainline68October 14Advisory, CVE, fixed releases81
The fix took four days. The other 77 were process. That process was designed for a world where a critical bug in a core piece of internet infrastructure was a rare event, found by one person, and the main risk during the embargo was that person leaking it.
That world is gone. Specialised models and tooling for security work are now good enough that a day of agent time finds what used to take a quarter of expert time. Guardrails on general commercial models slowed defenders more than attackers, who were never going to ask permission. The result is that critical bugs went from scarce to abundant, and nginx’s own advisory page tells the story: 26 advisories in 2026 so far, against 12 for 2021 through 2025 combined.

The 90-day model is flawed at both ends.
During the embargo, the bug gets found again. When discovery is cheap, collisions are the norm. This advisory credits two researchers. Over the last few months we have watched the same pattern across multiple products: the bug we reported shows up in someone else’s advisory, or someone else’s bug shows up in ours. If two defenders can find it in the same quarter, so can an attacker.
After the advisory, the patch is the exploit. The diff above is six lines. When we rebuilt the MikroTik chain from the public advisory and patch diff in three hours, that was already the rule, not the exception. An n-day is now a few hours of automated work away from every published fix, and the fleet takes weeks to catch up. A world where applying a patch was not urgent is distant now.
Discovery compressed from months to hours. Patching and remediation have to compress with it, or the embargo just becomes a longer window in which everyone but the defender knows.
What we do about it
Three things, in order of how long they take to pay off.
Report upstream, fast, with the fix. nginx fronts a large share of the internet. We sent a verified patch with the report, and the upstream fix is functionally the same change. The faster a vendor can go from report to commit, the less the embargo length matters.
Treat every n-day as a live risk. Once the advisory ships, the patch itself is a map to the bug: an n-day is a few hours of automated work away from every published fix, while fleets take weeks to catch up. That gap is where our customers are exposed, and closing it means securing more than the code they write. Our agents review their applications, and they also secure the stack those applications run on: checking their nginx fleet for the vulnerable version range and the configuration pattern, and proposing the mitigation or the upgrade.
Invest in high-assurance security engineering. Memory-safe languages are the right foundation for software with nginx’s blast radius, and models are now capable of doing that rewrite. Until that lands, defence in depth is what turns a one-byte write into a worker restart instead of an incident. That work starts with knowing where it matters most: Tolmo’s Attack Surface visualization maps every exposed service and the paths to it, so our customers can see exactly which at-risk services to harden, isolate, or rewrite first. This is the long-term win for our customers, because it is the only posture that holds when the next advisory arrives before the last patch is deployed.
If you run nginx: upgrade to 1.31.7 or 1.30.6, or NGINX Plus 37.1.0.1 or 37.0.5.1. If you want to know whether your fleet matched the vulnerable pattern before you upgraded, talk to us.

