All advisories
Draft

Proxy Protocol v2 Header Length Undercount Enables Smuggling

envoyproxy/envoy

Affected packages

envoy other
Affected versions= c33d01624d5b173b5d6e5c0c0474d480cab69b7b
Patched versionsNot specified

Description

Proxy Protocol v2 Header Length Undercount Enables Smuggling

Affected commit: c33d01624d
Sink: source/extensions/common/proxy_protocol/proxy_protocol_header.cc:230

Summary

With proxy_protocol_remove_too_long_tlvs disabled, generateV2Header() calculates the advertised frame length from only the fitting TLVs but serializes all TLVs including over-limit ones. Extra bytes after the declared frame boundary are parsed by the receiver as the start of the next protocol message.

Reproduce

#!/bin/bash
set -e

# Clone and identify HEAD
git clone --depth 1 https://github.com/envoyproxy/envoy.git /tmp/envoy-repro
cd /tmp/envoy-repro
echo "HEAD: $(git rev-parse HEAD)"

# Verify length is computed from fitting TLVs only
echo "--- extension_length accumulates only fitting TLVs ---"
sed -n '215,225p' source/extensions/common/proxy_protocol/proxy_protocol_header.cc

# Verify serialization uses combined_tlv_vector when remove_too_long_tlvs is false
echo "--- Serialization uses all TLVs when not removing ---"
sed -n '227,237p' source/extensions/common/proxy_protocol/proxy_protocol_header.cc

The vulnerable code at proxy_protocol_header.cc:215-237:

// Phase 1: compute extension_length from only fitting TLVs
for (auto&& tlv : combined_tlv_vector) {
  uint64_t new_size = extension_length + PROXY_PROTO_V2_TLV_TYPE_LENGTH_LEN + tlv.value.size();
  if (new_size > max_extension_length) {
    skipped_tlvs = true;
    continue;  // skipped in length calculation
  }
  extension_length = new_size;
  final_tlvs.push_back(tlv);
}

// Phase 2: write header with extension_length (undercounted)
generateV2Header(..., static_cast<uint16_t>(extension_length), out);

// Phase 3: serialize ALL TLVs when remove_too_long_tlvs is false
const std::vector<...>& really_final_tlvs =
    remove_too_long_tlvs ? final_tlvs : combined_tlv_vector;
for (auto&& tlv : really_final_tlvs) {
  out.add(&tlv.type, 1);
  ...
}

The proxy protocol v2 header advertises extension_length computed from only the fitting TLVs, but when remove_too_long_tlvs is false, the serialization loop writes combined_tlv_vector — including the over-limit TLVs. The receiver reads extension_length bytes as TLV data, then interprets the remaining over-limit TLV bytes as the start of the next protocol message. This enables request smuggling or parser confusion on the upstream receiver.

Expected output:

HEAD: <resolved-commit>
--- extension_length accumulates only fitting TLVs ---
  for (auto&& tlv : combined_tlv_vector) {
    uint64_t new_size = extension_length + PROXY_PROTO_V2_TLV_TYPE_LENGTH_LEN + tlv.value.size();
    if (new_size > max_extension_length) {
      ENVOY_LOG_MISC(warn, "Skipping TLV type {} because adding it would exceed the 65535 limit.",
                     tlv.type);
      skipped_tlvs = true;
      continue;
    }
    extension_length = new_size;
    final_tlvs.push_back(tlv);
  }
--- Serialization uses all TLVs when not removing ---
  generateV2Header(src.addressAsString(), dst.addressAsString(), src.port(), dst.port(),
                   src.version(), static_cast<uint16_t>(extension_length), out);

  const std::vector<Envoy::Network::ProxyProtocolTLV>& really_final_tlvs =
      remove_too_long_tlvs ? final_tlvs : combined_tlv_vector;
  for (auto&& tlv : really_final_tlvs) {
    out.add(&tlv.type, 1);
    uint16_t size = htons(static_cast<uint16_t>(tlv.value.size()));
    out.add(&size, sizeof(uint16_t));
    out.add(&tlv.value.front(), tlv.value.size());
  }

Credit

Zheng Yu @ Depthfirst