How an empty reason phrase crashes ngx.fetch() callers in njs
A NULL pointer dereference in ngx.fetch(). How an empty HTTP reason phrase leaves an invalid string in Response.statusText, and why reading it crashes the NGINX worker.
CVE-2026-78222 is a NULL pointer dereference in NGINX JavaScript (njs) reported to F5 by Sujal Tuladhar. F5 published advisory K000162603 on September 2, 2026, and credits Sujal together with YuuLuo. If a script uses ngx.fetch() and reads Response.statusText, a server that answers with a slightly malformed status line can crash the NGINX worker. The bug has been in njs since ngx.fetch() was added in 0.5.1 in early 2021, and it is fixed in njs 1.0.1.
This post traces the crash to a single missing assignment in the HTTP status line parser, and explains why one line ending works fine while the other kills the worker.
What F5's advisory says
The advisory sets three conditions. The response received by ngx.fetch() has to be malformed. The script has to read Response.statusText. And the attacker needs control over, or influence on, the fetched response. F5 classifies the result as a NULL pointer dereference that makes the worker terminate unexpectedly, which means denial of service on the data plane with no control plane exposure.
The second condition is the interesting one. If parsing the bad response were the problem, the worker would die as soon as the response arrived, whether or not anyone read statusText. The crash waiting for a property read suggests the parser stores something bad and the getter is what trips over it.
Where the fix landed
To follow along in the source, diff the last vulnerable tag against the fixed one, filtered to the shared HTTP client code. Three commits come back:
git clone https://github.com/nginx/njs.git && cd njs
git log --oneline 1.0.0..1.0.1 -- nginx/ngx_js_http.cTwo of them deal with header handling. The third, a62feb48, is titled "Fetch: fix empty response status text handling". Its message explains that a status line with an empty reason phrase, ended by a bare LF, left the start of the status text unset, and that reading Response.statusText then crashed the worker on the resulting invalid string. The code change is five lines.
The status line parser
ngx_js_http_parse_status_line() in nginx/ngx_js_http.c is a byte-by-byte state machine in the same style as the parsers in NGINX core. After it reads the three-digit status code, it expects a space (or a ., which some IIS versions send for sub-status codes like 403.1), then the reason phrase, then the end of the line. This is the part that matters, as it was in 1.0.0:
/* space or end of line */
case sw_space_after_status:
switch (ch) {
case ' ':
state = sw_status_text;
break;
case '.': /* IIS may send 403.1, 403.2, etc */
state = sw_status_text;
break;
case CR:
break;
case LF:
goto done;
default:
return NGX_ERROR;
}
break;
/* any text until end of line */
case sw_status_text:
switch (ch) {
case CR:
hp->status_text_end = p;
state = sw_almost_done;
break;
case LF:
hp->status_text_end = p;
goto done;
}
if (hp->status_text == NULL) {
hp->status_text = p;
}
break;The parser records two pointers, status_text for where the reason phrase starts and status_text_end for where it stops. The start pointer is set lazily by the if at the bottom of sw_status_text, on the first byte seen in that state.
Look at the two line-ending branches. On CR, the code sets the end pointer and breaks out of the inner switch, so execution falls through to the if and the start pointer gets set too. On LF, the code sets the end pointer and jumps straight to done, skipping the if. As long as the reason phrase has at least one character, the start pointer was already set by an earlier byte, so this does not matter. When the reason phrase is empty, the very first byte in sw_status_text is the line ending, and the two branches behave differently:
| Status line shape | status_text | status_text_end | Reading statusText |
|---|---|---|---|
| code, space, reason, CRLF | start of reason | position of CR | works |
| code, space, reason, LF | start of reason | position of LF | works |
| code, space, CRLF | position of CR | position of CR | empty string |
| code, space, bare LF | NULL | position of LF | worker crashes |
code, ., bare LF | NULL | position of LF | worker crashes |
| code, bare LF | NULL | NULL | empty string, via NULL - NULL |
The last row does not crash in practice, but subtracting two NULL pointers is undefined behavior in C, and the fix covers it as well.
From the parser to the crash
Once the status line is parsed, ngx_js_http.c copies the two pointers into the response object as a pointer and a length:
http->response.status_text.data = hp->status_text;
http->response.status_text.len = hp->status_text_end - hp->status_text;With status_text set to NULL and status_text_end pointing into the receive buffer, len becomes the numeric value of a heap address, while data points at address zero. Nothing reads that field during normal request processing. The response headers and body are handled, the promise resolves, and everything looks fine.
The damage happens later, when JavaScript asks for the property. Both engines build a string straight from that pair. The njs engine getter calls njs_vm_value_string_create(vm, retval, response->status_text.data, response->status_text.len), and the QuickJS getter calls qjs_string_create() with the same arguments. Building a string from a NULL pointer with a length equal to a memory address brings the worker down. That is why the advisory makes reading statusText a condition: the parser plants the bad value, and the getter dereferences it.
The crash takes every in-flight request on that worker with it. The NGINX master process starts a new worker, but every request that reaches the same script and the same bad upstream crashes it again.
Who can send that response
The status line comes from whatever ngx.fetch() talks to, so the attacker has to be that server or sit between it and NGINX. In practice that means a script that builds its fetch URL from request data (a user-supplied webhook or callback URL, for example), an upstream the attacker runs or has compromised, or an upstream reached over plain HTTP where someone on the network path can rewrite the response.
The response itself is small. An empty reason phrase is legal in HTTP/1.1, and the njs parser accepts a bare LF as a line ending, as many HTTP parsers do for robustness. A server only has to combine the two.
What the fix changes
Commit a62feb48 adds one block at the done label, which every successful path through the parser reaches:
done:
if (hp->status_text == NULL) {
hp->status_text = p;
hp->status_text_end = p;
}If no reason phrase start was recorded, both pointers are set to the current position, giving a valid empty string. That covers the bare LF case and the no-space case, and removes the NULL pointer subtraction.
The regression tests in nginx/t/js_fetch.t add a small test server that returns four status line shapes: an empty reason phrase followed by LF, the IIS-style . followed by LF, an empty reason phrase followed by CRLF, and a status code followed directly by LF. Each test reads statusText through ngx.fetch() and expects an empty string instead of a dead worker.
Severity
F5 scores this 7.5 under CVSS v3.1 and 8.7 under CVSS v4.0, both High, with network attack vector, low complexity, no privileges, no user interaction, and availability impact only. The njs security page on nginx.org lists it as medium. Whichever rating you go by, the deciding factor is the precondition: an attacker has to influence the fetched response first. If your scripts fetch anything an outsider can steer, treat it as High.
Are you exposed?
Check the njs version first. Anything from 0.5.1 up to and including 1.0.0 has the bug:
# Debian / Ubuntu
dpkg -l | grep -iE 'njs|mod-js'
# RHEL / Fedora / Amazon Linux
rpm -qa | grep -iE 'njs|mod-js'
# if the njs CLI is installed
njs -vThen look for scripts that both fetch and read the status text:
grep -rn "ngx.fetch" /etc/nginx/
grep -rn "statusText" /etc/nginx/A script that never touches statusText does not trigger the crash, even on a vulnerable version, because the bad pointer is never used. Scripts that log statusText, put it into error messages, or pass whole response objects to code that reads it are the ones to look at. Also check where each fetch URL comes from, and whether that upstream is reached over plain HTTP.
Fixing it
Upgrade njs to 1.0.1 or later. There is no fixed release on the 0.x branch, so 0.x users need to move to 1.x. njs 1.0.2 (October 14, 2026) is the current release and also tightens ngx.fetch() response handling: it rejects responses with conflicting Content-Length fields, with both Transfer-Encoding and Content-Length, or with 1xx status codes.
If you cannot upgrade right away, F5's mitigation is to restrict ngx.fetch() to trusted servers and stop reading Response.statusText for responses from endpoints an attacker can control or influence. In most scripts the numeric Response.status is what the logic actually depends on, and reading it is not affected, so switching to it is usually a one-line change. For upstreams you do trust, using HTTPS with certificate verification left on removes the on-path attacker from the picture, though it does nothing against a malicious or compromised upstream.
Timeline
| Date | Event |
|---|---|
| 2021-01-21 | Commit 73c75e41 adds ngx.fetch() to njs (first released in 0.5.1) |
| 2026-08-19 | Fix committed to njs (a62feb48) |
| 2026-09-02 | njs 1.0.1 released; F5 publishes K000162603 and the out-of-band notification K000162872 |
| 2026-10-14 | njs 1.0.2 released with further ngx.fetch() hardening |
Credits
F5 acknowledges Sujal Tuladhar (EvilGenius) and YuuLuo for reporting this issue through coordinated disclosure. The fix was written by Dmitry Volyntsev of the njs team.
References
- F5 advisory K000162603: https://my.f5.com/manage/s/article/K000162603
- F5 out-of-band security notification K000162872: https://my.f5.com/manage/s/article/K000162872
- njs 1.0.1 release: https://github.com/nginx/njs/releases/tag/1.0.1
- Fix commit: https://github.com/nginx/njs/commit/a62feb4831e75c75c446298ca4a23862dcfcbab4
- njs changelog: https://nginx.org/en/docs/njs/changes.html
- njs security advisories: https://nginx.org/en/docs/njs/security.html
- CVE record: https://www.cve.org/CVERecord?id=CVE-2026-78222
The PenCore teamMore posts