All advisories
Draft

HTTP/3 QPACK Blocked Streams Exhaust Process Memory

envoyproxy/envoy

Affected packages

envoy other
Affected versions= c33d01624d5b173b5d6e5c0c0474d480cab69b7b
Patched versionsNot specified

Description

HTTP/3 QPACK Blocked Streams Exhaust Process Memory

Affected commit: c33d01624d
Sink: source/common/quic/envoy_quic_server_stream.cc:265

Summary

An unauthenticated HTTP/3 client sends header blocks referencing unavailable QPACK dynamic-table entries. Blocked payload is retained while flow-control credit is returned. Repeated streams grow the heap without bound at the Envoy layer, potentially killing the process. Quiche may enforce max_blocked_streams internally, but Envoy sets no additional cap and the code has a TODO noting per-stream buffer limits are not checked.

Detail

At envoy_quic_server_session.cc:252-256, QPACK dynamic table capacity is set to zero only when http3_options_->disable_qpack() is true. With QPACK enabled (default), the quiche session retains blocked header payloads for streams referencing unavailable dynamic-table entries. In OnBodyAvailable() (envoy_quic_server_stream.cc:287-296), a TODO comment explicitly notes the missing limit:

// TODO(danzh): check Envoy per stream buffer limit.
// Currently read out all the data.
while (HasBytesToRead()) {
  iovec iov;
  int num_regions = GetReadableRegions(&iov, 1);
  ASSERT(num_regions > 0);
  size_t bytes_read = iov.iov_len;
  buffer->add(iov.iov_base, bytes_read);
  MarkConsumed(bytes_read);
}

Envoy returns flow-control credit as data is consumed, but the QPACK-blocked header state is retained separately. An attacker opens many streams referencing a dynamic-table entry that is never inserted, causing each stream's blocked payload to accumulate in quiche's internal state while Envoy keeps accepting new streams.

Reproduce

#!/bin/bash
set -e

git clone --depth 1 https://github.com/envoyproxy/envoy.git /tmp/envoy-qpack
cd /tmp/envoy-qpack
echo "HEAD: $(git rev-parse HEAD)"

# Verify QPACK dynamic table is non-zero by default (only disabled when disable_qpack is true)
echo "=== QPACK capacity only zeroed when disable_qpack is true ==="
grep -n -A4 'disable_qpack' source/common/quic/envoy_quic_server_session.cc

# Verify no per-stream buffer limit in OnBodyAvailable
echo ""
echo "=== Missing per-stream buffer limit (TODO still present) ==="
grep -n -A6 'TODO(danzh): check Envoy per stream buffer limit' source/common/quic/envoy_quic_server_stream.cc

# Verify OnStreamFrame delegates directly to quiche without Envoy-side memory cap
echo ""
echo "=== OnStreamFrame passes directly to quiche ==="
sed -n '259,266p' source/common/quic/envoy_quic_server_stream.cc

rm -rf /tmp/envoy-qpack

Expected output (line numbers may shift):

HEAD: <resolved-HEAD>
=== QPACK capacity only zeroed when disable_qpack is true ===
252:  if (http3_options_->disable_qpack()) {
253-    DisableHuffmanEncoding();
254-    DisableCookieCrumbling();
255-    set_qpack_maximum_dynamic_table_capacity(0);
256-  }

=== Missing per-stream buffer limit (TODO still present) ===
287:  // TODO(danzh): check Envoy per stream buffer limit.
288-  // Currently read out all the data.
289-  while (HasBytesToRead()) {
290-    iovec iov;
291-    int num_regions = GetReadableRegions(&iov, 1);
292-    ASSERT(num_regions > 0);
293-    size_t bytes_read = iov.iov_len;

=== OnStreamFrame passes directly to quiche ===
void EnvoyQuicServerStream::OnStreamFrame(const quic::QuicStreamFrame& frame) {
  ...
  quic::QuicSpdyServerStreamBase::OnStreamFrame(frame);
}

An unauthenticated HTTP/3 client that references unavailable QPACK dynamic-table entries causes each stream's blocked header payload to accumulate in quiche's internal state. Envoy imposes no aggregate memory cap on this retained state, and the TODO at line 287 confirms per-stream buffer limits are not checked.

Credit

Zheng Yu @ Depthfirst