CVE-2024-12254 — asyncio writelines() 缺少流量控制(DoS)¶
已验证复现
本页面记录了 CVE-2024-12254 的已确认复现。该漏洞利用程序在 Python 3.12.8 上以确定性方式触发易受攻击的代码路径,无需耗尽主机实际内存。
| 字段 | 详情 |
|---|---|
| 项目 | CPython |
| 受影响组件 | asyncio 标准库模块 |
| 严重级别 | HIGH |
| CVSS 3.1 | 7.5 HIGH (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| CVSS 4.0 | 8.7 HIGH |
| CWE | CWE-400(不受控资源消耗),CWE-770(无限制或节流的资源分配) |
| 受影响版本 | Python 3.12.0 – 3.12.8 和 3.13.0 – 3.13.1(仅限 Linux 和 macOS) |
| 修复版本 | Python 3.12.9 和 3.13.2(均于 2025-02-04 发布) |
| GHSA | GHSA-ph84-rcj2-fxxm |
1. 漏洞概述¶
CPython 的 asyncio 模块提供了基于事件循环的非阻塞网络层。在 Linux 和 macOS 上,真正负责通过套接字发送数据的底层传输对象是 _SelectorSocketTransport,位于 Lib/asyncio/selector_events.py。使用 Protocol API 的应用程序可以通过 transport.write() 或(自 Python 3.12.0 起)较新的 transport.writelines() 来发送数据。
这两个方法都会将数据排入内部写缓冲区。当缓冲区超过配置的高水位线时,asyncio 应调用 protocol.pause_writing(),即标准的反压信号,通知应用程序停止生产新数据。如果 pause_writing() 从未被调用,一个停止读取数据的远端对端会导致服务器的进程内缓冲区无限增长,耗尽所有可用内存并使进程崩溃(可通过网络触发的拒绝服务)。
根本原因。 现有的 write() 方法在将数据追加到缓冲区后会正确调用 self._maybe_pause_protocol()。在 3.12.0 中引入的 writelines() 方法会将数据排入缓冲区并注册写处理程序,但从未调用 _maybe_pause_protocol():
# write() — 正确,具有流量控制
self._buffer.append(data)
self._maybe_pause_protocol() # signals protocol to pause if buffer >= high-water
# writelines() BEFORE fix — missing flow control
def writelines(self, list_of_data):
if self._eof:
raise RuntimeError('Cannot call writelines() after write_eof()')
if self._empty_waiter is not None:
raise RuntimeError('unable to writelines; sendfile is in progress')
if not list_of_data:
return
self._buffer.extend([memoryview(data) for data in list_of_data])
self._write_ready()
# If the entire buffer couldn't be written, register a write handler
if self._buffer:
self._loop._add_writer(self._sock_fd, self._write_ready)
# BUG: _maybe_pause_protocol() never called here
修复方案(CPython PR #127656,main 分支提交 e991ac8f2037d78140e417cc9a9486223eb3e786;3.13 版本的 cherry-pick 为 71e8429a,3.12 版本为 9aa0deb2)仅增加了一行代码:
--- a/Lib/asyncio/selector_events.py
+++ b/Lib/asyncio/selector_events.py
@@ -1175,6 +1175,7 @@ def writelines(self, list_of_data):
# If the entire buffer couldn't be written, register a write handler
if self._buffer:
self._loop._add_writer(self._sock_fd, self._write_ready)
+ self._maybe_pause_protocol()
_maybe_pause_protocol() 会检查 get_write_buffer_size() >= self._high_water,若为真则调用 protocol.pause_writing()。缺少这一调用,使用 writelines() 的协议将永远收不到 pause_writing(),从而不断向传输层输送数据。
该漏洞仅影响 SelectorEventLoop 路径(Linux 和 macOS)。Windows 使用 ProactorEventLoop,不受影响。漏洞于 2024-12-06 披露,并于同日修复。
2. 漏洞环境¶
本次复现使用单个 Docker 容器,基于 Debian Linux,运行固定版本的 Python 3.12.8 解释器。该版本是修复前的最后一个发行版,处于受影响范围 3.12.0 – 3.12.8 之内。漏洞完全位于 CPython 标准库中,因此无需任何第三方软件包。容器不向主机发布任何网络端口;漏洞利用程序通过 docker exec 在容器内部驱动回环传输并读取流量控制状态。
下载全部环境文件(打包):env.zip
单独文件:
启动环境:
验证易受攻击的解释器。 以下冒烟测试确认 Python 3.12.8 正在运行,asyncio.new_event_loop() 返回 _UnixSelectorEventLoop(受影响的路径),且 writelines() 尚不包含 _maybe_pause_protocol 调用:
docker exec cve-2024-12254-python python3 -c "import sys,asyncio,inspect; from asyncio.selector_events import _SelectorSocketTransport as S; v=sys.version_info[:3]; src=inspect.getsource(S.writelines); print('version', '.'.join(map(str,v))); print('loop', type(asyncio.new_event_loop()).__name__); print('writelines_has_pause', '_maybe_pause_protocol' in src)"
预期输出:
writelines_has_pause False 确认存在未打补丁的易受攻击源代码。
3. 如何利用¶
概念验证脚本(exploit/poc.py)在一个对端从不读取数据的回环套接字对上构造真实的 _SelectorSocketTransport。它将高水位线设置为 1024 字节(与上游回归测试一致),然后以 64 个 64 KiB 的数据块调用 transport.writelines()。由于对端从不读取,内核发送缓冲区迅速填满,传输层的 Python 端写缓冲区持续积累。缓冲区远超 1024 字节的高水位线,但在 Python 3.12.8 上 writelines() 从未调用 _maybe_pause_protocol(),因此协议的 pause_writing() 永远不会被触发。整个工作负载有界,在不到一秒内完成,无需真正耗尽内存即可观察到该漏洞。
下载全部漏洞利用文件(打包):exploit.zip
单独文件:
第一步 — 启动环境¶
第二步 — 将 PoC 复制到容器中¶
第三步 — 运行 PoC¶
四个参数分别为:high_water(1024 字节)、low_water(256 字节)、chunk_size(每块 65536 字节)、num_chunks(64 块)。所有参数均有相同的内置默认值,因此不带参数运行脚本结果相同。
| 参数位置 | 名称 | 值 | 含义 |
|---|---|---|---|
| 1 | high_water |
1024 |
传输层高水位线(字节) |
| 2 | low_water |
256 |
传输层低水位线(字节) |
| 3 | chunk_size |
65536 |
每块传递给 writelines() 的大小 |
| 4 | num_chunks |
64 |
单次 writelines() 调用中的块数 |
Python 3.12.8 上的 PoC 输出:
python_version 3.12.8 ; loop_type _UnixSelectorEventLoop ; transport_type _SelectorSocketTransport
high_water 1024 ; write_buffer_size 4186240 ; pause_writing_calls 0 ;
resume_writing_calls 0 ; buffer_exceeds_high_water True
漏洞证明¶
PoC 的标准输出并非决定性证据,仅作为便于查看的展示。决定性证明来自验证者在容器内独立于漏洞利用脚本运行的检测工具。该工具驱动完全相同的工作负载,并从验证者直接控制的对象中读取流量控制状态,其中包括对传输层内部 _maybe_pause_protocol 方法的探针。其记录的输出如下:
VULN write_buffer_size 4186240
VULN high_water 1024
VULN buffer_exceeds_high_water True
VULN pause_writing_calls 0 # verifier-owned Protocol hook
VULN maybe_pause_protocol_reached 0 # spy on transport._maybe_pause_protocol: NEVER called by stock writelines()
VULN add_writer_called 1
VULN buffer_nonempty_when_writer_registered True # anchors crossing to the patched branch
---
CTRL write_buffer_size 4186240 # identical workload
CTRL high_water 1024
CTRL buffer_exceeds_high_water True
CTRL pause_writing_calls 1 # stock + the single added line -> fires exactly once
关键观察结果:
VULN pause_writing_calls 0:验证者工具所控制的协议pause_writing()钩子从未被调用,尽管写缓冲区(4,186,240 字节)远超高水位线(1,024 字节)。VULN maybe_pause_protocol_reached 0:验证者对传输层自身_maybe_pause_protocol方法的探针确认,未打补丁的writelines()从未触达该方法。VULN buffer_nonempty_when_writer_registered True:_add_writer在self._buffer非空时被调用,说明执行走的正是修复补丁所增补的if self._buffer:分支。这将该省略与补丁所移除的具体易受攻击代码路径直接关联起来。CTRL pause_writing_calls 1:以打补丁的writelines()函数体(相同解释器、相同缓冲区穿越)运行相同工作负载时,pause_writing()恰好触发一次。从 0 到 1 的翻转由 CVE 修复所添加的那一行代码引起,确认该信号可归因于此漏洞。
环境清理¶
完成测试后,停止并删除容器及其关联卷:
4. 安全建议¶
修复措施¶
升级至 Python 3.12.9 或 Python 3.13.2(均于 2025-02-04 发布)。这些版本包含了单行修复,在 _SelectorSocketTransport.writelines() 的 if self._buffer: 分支中补充了缺失的 _maybe_pause_protocol() 调用。较旧的 3.12.x 和 3.13.x 补丁版本(3.12.0 – 3.12.8 和 3.13.0 – 3.13.1)均受影响。Python 3.11 及更早版本不受影响,因为该代码路径上的 writelines() 在 3.12.0 之前并不存在。
缓解措施与临时方案¶
如果无法立即升级解释器:
- 将应用代码中的
writelines()替换为write()。_SelectorSocketTransport上现有的write()方法会正确调用_maybe_pause_protocol(),不受此漏洞影响。 - 在每个
Protocol中实现pause_writing()/resume_writing(),并将pause_writing()未被调用视为漏洞指示器。这不修复根本原因,但能让应用逻辑感知到本应触发的反压信号,从而限制损害。 - 该漏洞仅影响
SelectorEventLoop路径(Linux 和 macOS)。在 Windows 上运行的应用程序(使用ProactorEventLoop)不受影响。