py-libp2p SDP端点内存放大漏洞修复实战

原文:https://dev.to/yashksaini/the-sdp-endpoint-that-trusted-its-callers-fixing-a-memory-amplification-dos-in-py-libp2p-3g2e(作者 @yashksaini)

项目概述

py-libp2p 是 libp2p 的 Python 实现——libp2p 是支撑 IPFS、Filecoin 和以太坊节点的点对点网络协议栈。我一直在为其 WebRTC-Direct 传输层工作,该传输层允许两个对等节点在无需证书颁发机构的情况下建立连接:对等节点的 multiaddr 包含其 TLS 证书的哈希值,DTLS 握手会据此进行验证。

在该加密传输层就绪之前,双方需要交换 SDP offer/answer 数据块。在基于 STUN 的监听器合并(#1352)之前,py-libp2p 提供了一个用于此目的的最小化开发工具:一个轻量的手写 HTTP 服务器(无 aiohttp 依赖),它通过 POST /sdp 接受一个 SDP offer,并将请求体传递给 offer 处理器。本文将介绍我在该工具中发现并修复的一个内存放大 DoS 漏洞。

缺陷修复或性能改进

POST /sdp 处理器会读取调用方的 Content-Length,并在握手发生前,无上限地缓冲该数量的字节。以下是修复前 _aiortc_helpers.py 中的读取路径:

# Read HTTP request line (consumed but not used) + headers
await asyncio.wait_for(reader.readline(), timeout=_SDP_HTTP_TIMEOUT)
headers: dict[str, str] = {}
while True:
    line = await asyncio.wait_for(reader.readline(), timeout=_SDP_HTTP_TIMEOUT)
    if line in (b"\r\n", b"\n", b""):
        break
    key, _, value = line.decode().partition(":")
    headers[key.strip().lower()] = value.strip()

content_length = int(headers.get("content-length", "0"))
body = b""
if content_length > 0:
    body = await asyncio.wait_for(
        reader.readexactly(content_length),
        timeout=_SDP_HTTP_TIMEOUT,
    )


有两个因素受攻击者控制且无上限:

  1. 请求体。 content_length 直接来自请求头。reader.readexactly(content_length) 会将指定数量的字节累积到内存中——而 readexactly 绕过了 StreamReader 默认的 64 KiB 流控制限制,因此没有任何机制对其进行节流。body.decode() 会创建第二份副本,处理器字符串会创建第三份。最终放大倍数约为 线上数据量的 2 倍,且无上限。
  2. 请求头。 使用仅有逐行超时的 while True 循环:攻击者可以无限发送头部行——循环从不限制行数。

这是握手前、未认证的输入。任何能访问该端口的人都可以触发此漏洞。

📌 /sdp 内存放大 DoS 攻击原理剖析——修复前 vs 修复后(图,点击查看)

发现过程: 一位审查者在我的 WebRTC PR(#1309)中指出了这一点——一条单行注释写着“将整个请求体读入内存且无上限,存在可用性风险”。这很容易被当作代码风格挑剔;但它并非如此。握手前的输入本质上受攻击者控制,“无上限读取整个请求体”正是漏洞的全部。

相关数据。 我构建了一个复现工具,分别导入修复前父提交(9506041)和合并后的修复提交(759c75b)中的 实际 run_signaling_server,发送恶意请求,并以 20 毫秒为间隔采样 RSS(常驻内存集):

| 指标 | 修复前 | 修复后(#1396) |

|---|---|---|

| 峰值 RSS(512 MiB 恶意请求体) | 1,113 MB(+1,052) | 61 MB(+0) |

| 峰值 RSS(1 GiB 恶意请求体) | 2,137 MB(+2,076) | 拒绝,空闲 |

| 峰值 RSS(4 个并发 × 300 MiB) | 1,575 MB(+1,514) | 88 MB(+27) |

| 拒绝超大请求的时间 | 永不(缓冲至 OOM) | 0.3 ms(仅靠头部即可返回 413) |

| 接受的请求体大小 | 无上限 | 32 KiB(超过则返回 413)|

📌 恶意 POST 请求下的峰值 RSS——修复前 vs #1396 修复后(图,点击查看)

(在 Python 3.11 / aiortc 1.15 沙箱中使用单个 asyncio 循环测量——请在您自己的硬件上重新运行该工具以获取本地数据;约 2 倍的比例关系成立。)

代码

PR: [libp2p/py-libp2p#1396 — fix(webrtc): harden /sdp HTTP server against memory-amplification DoS](https://github.com/libp2p/py-libp2p/pull/1396) · 于 2026年8月14日 合并 · 关联 [#1354](https://github.com/libp2p/py-libp2p/issues/1354) · 提交 `759c75b`

修复方案新增了三个有界常量,并在触及任何主体缓冲区之前就拒绝请求:

# 为 HTTP /sdp 开发工具设置的上限——在工具存在期间(直到基于 STUN 的监听器上线,参见 #1352)防御内存放大 DoS 攻击。
_MAX_SDP_BODY_SIZE = 32 * 1024  # 32 KiB;SDP offer 通常为 1–4 KiB
_MAX_HEADER_LINES = 64
_MAX_HEADER_BYTES = 8 * 1024    # 所有头行总计 8 KiB


请求头通过行数和累计字节数进行限制(如果终止符始终未出现,for/else 块将触发 400 错误):

headers: dict[str, str] = {}
header_bytes = 0
for _ in range(_MAX_HEADER_LINES + 1):
    line = await asyncio.wait_for(reader.readline(), timeout=_SDP_HTTP_TIMEOUT)
    if line in (b"\r\n", b"\n", b""):
        break
    header_bytes += len(line)
    if header_bytes > _MAX_HEADER_BYTES:
        writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
        await writer.drain(); return
    key, _, value = line.decode().partition(":")
    headers[key.strip().lower()] = value.strip()
else:
    writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
    await writer.drain(); return


并且 Content-Length 在分配任何主体缓冲区之前就会被验证——格式错误/负值 → 400,超出大小 → 413

raw_cl = headers.get("content-length", "0")
try:
    content_length = int(raw_cl)
except ValueError:
    writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
    await writer.drain(); return
if content_length < 0:
    writer.write(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n")
    await writer.drain(); return
if content_length > _MAX_SDP_BODY_SIZE:
    writer.write(b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n")
    await writer.drain(); return


该 PR 还包含了一个 228 行的回归测试(`test_aiortc_helpers.py`),其关键断言检查的是在收到 413 响应后,RSS 没有增长——而不仅仅是状态码:

async def _too_large(self) -> None:
    rss_before = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    server, port = await _start()
    line = await _status(port, b"POST /sdp HTTP/1.1\r\nContent-Length: 999999999\r\n\r\n")
    assert b"413" in line
    rss_after = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
    assert (rss_after - rss_before) < one_mib   # 从未分配主体缓冲区


我的改进

我特意选择了硬性上限而非“带上限的流式读取”。实践中 SDP offer 为 1–4 KiB;没有合法的调用方需要在此 POST 兆字节数据,因此一个固定的 32 KiB 上限并提前拒绝,比读取一个有限制的流更简单更安全。仅凭 Content-Length 头就拒绝请求,意味着超大请求根本不会分配缓冲区——这就是为什么表格的“之后”列能稳定在空闲基准线,且拒绝响应能在 0.3 毫秒内发出。

📌 修复方案:在主体字节被缓冲前设置三道关卡(图,点击查看)

在上下文中的影响: py-libp2p 是 Python 实现的 IPFS/Filecoin 级节点的底层依赖。一个在未认证、握手前输入就能导致整个进程因 OOM 被杀的信令端点,是一个廉价的远程崩溃漏洞——无需凭证,无需握手,仅需一次连接。将其限制在 32 KiB,将“攻击者决定我的内存上限”变成了“攻击者收到一个 413 错误”。

我学到了什么: 审阅者关于“可用性风险”的那句简短批注,实际上是一个可工作的漏洞,而我差点把它归为代码风格问题。握手前的输入本质上是受攻击者控制的——“信任 Content-Length”永远不是一个安全的默认设置。而且,测试与修复同等重要:断言 413 证明的是状态码;断言 RSS 未增长则证明了属性——即没有分配缓冲区。

复现步骤:

git clone https://github.com/libp2p/py-libp2p && cd py-libp2p
pip install aiortc
git checkout 9506041   # 修复前:RSS 攀升至约 1.1 GB,返回 200 OK
python3 exploit.py PREFIX body 512
git checkout 759c75b   # 修复后(#1396):0.3 ms 内返回 413,RSS 平稳
python3 exploit.py POSTFIX bodyreject 512


Sentry 的最佳实践

Sentry 并没有提供针对 trio 的一级集成(其 Python 集成主要面向 asyncio / Django / Flask / FastAPI / AIOHTTP 等),因此我手动进行了接入——而且它契合得很好,因为这些组件确实适用于当前场景:

  • `/sdp` 服务器基于纯 asyncio,因此将处理程序包裹在 sentry_sdk.start_transaction(op="sdp.post", name="/sdp handler") 中,为我提供了真实的追踪瀑布图——可以看到 reader.readexactly() 所在的 span 明显就是问题根源。
  • `AsyncioIntegration` 实际上很适用 —— 来自 #1309 的 WebRTC 桥接在守护线程中运行着一个真实的 asyncio 事件循环,因此该集成并非装饰性的。
  • `SocketIntegration` 免费添加了 DNS/连接 span,对于 P2P 库来说主题相关。
  • 我将 RSS 作为自定义度量指标输出,这样修改前后的对比数据就能在 Sentry 仪表盘中显示出来:
import resource, sentry_sdk
from sentry_sdk.integrations.asyncio import AsyncioIntegration
from sentry_sdk.integrations.socket import SocketIntegration

sentry_sdk.init(dsn="<YOUR_DSN>", traces_sample_rate=1.0, profiles_sample_rate=1.0,
                integrations=[AsyncioIntegration(), SocketIntegration()])

def rss_mb():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024  # Linux: KB→MB

with sentry_sdk.start_transaction(op="sdp.post", name="/sdp handler") as tx:
    tx.set_measurement("rss_before_mb", rss_mb(), "megabyte")
    # ... 处理请求 ...
    tx.set_measurement("rss_after_mb", rss_mb(), "megabyte")


以下是捕获到的追踪记录——sdp.read_body span 中执行的 reader.readexactly(157286400) 操作说明了一切,而 rss_before_mb / rss_after_mb 这两个度量指标也随事务一同被记录:

📌 Sentry 追踪瀑布图 —— readexactly 这个 span 就是罪魁祸首(图,点击查看)

Seer 的根因分析 —— 这是最亮眼的部分。我对捕获到的事件运行了 Seer 分析,在它完全不知道我已经发布了修复方案的情况下,它准确指出了根本原因,并提出了几乎相同的补丁:

📌 Sentry Seer RCA —— 独立提出了正文大小限制和头部限制方案(图,点击查看)

引用其分析:Seer 标记出了 “单次请求内存增加 +196 MB” 的尖峰,发现 handle() 函数 “直接从攻击者可控的 HTTP 头部读取 Content-Length,并将其原样传递给 reader.readexactly(content_length) …… 没有进行任何上限检查”,并且还单独捕获到头部处理循环是一个 “无界的 while 循环”。它建议的修复方案——设置 MAX_SDP_BODY_SIZE 限制并在读取前返回 413 Payload Too Large,加上每行/总计的头部大小限制——与 #1396 中的修复方案属于同一类型。唯一的真正区别是:Seer 建议设置 64 KiB 的限制;而我实际发布的是 32 KiB。诚实评价:结论正确,且具体到足以直接行动 —— 这是少见的情况,AI 根因分析给出了实际的修复方案,而不是模糊的 “添加验证”。

关于 Session Replay(会话重放): 不适用——这个路径中没有浏览器环境,因此我没有模拟截图。对维护者来说,一个诚实的 “不适用” 比一个生造的更有价值。

Google AI 的最佳实践

我将修复前的读取路径代码粘贴到了 Google AI Studio (Gemini) 中,并要求它针对无界内存增长问题,列出三个具体的假设,按可能性排序,并为每个假设提供一行代码的修复建议。

📌 Gemini 将“不受信任的 Content-Length”列为最可能的原因(图,点击查看)

它的排名第一、标记为“最可能”的假设完全正确“服务器信任客户端提供的 `content-length` 头部,并试图将全部内容缓冲到 RAM 中……攻击者可以发送一个很大的值(例如 `10^9`),然后慢慢传输数据,迫使服务器增长其内部缓冲区以匹配。” 它甚至独立指出了我测量到的约 2 倍内存放大效应——“Python 的对象复制行为正充当内存放大器。” 它提出的防护措施(if content_length > 1_048_576: raise …)与我在 #1396 中发布的修复方案属于同一类型——唯一的区别是限制值的大小(Gemini 选择了 1 MiB;而我使用了 32 KiB,因为真实的 SDP offer 大小通常在 1-4 KiB 之间)。诚实评价:对于一个范围精确的提示词,Gemini 的第一排序假设在第一次尝试时就命中了真正的 bug。

原文:https://dev.to/yashksaini/the-sdp-endpoint-that-trusted-its-callers-fixing-a-memory-amplification-dos-in-py-libp2p-3g2e(作者 @yashksaini)

发布评论
全部评论(0)