CVE-2024-12254 — asyncio writelines() missing flow control (unbounded buffer growth)¶
Availability impact — no authentication required
A remote client that stops reading data can cause an asyncio server's write buffer to grow without bound, exhausting process memory. No credentials or special privileges are needed.
| Field | Detail |
|---|---|
| Project | CPython standard library — asyncio module |
| Affected versions | Python 3.12.0 – 3.12.8, Python 3.13.0 – 3.13.1 (Linux and macOS only) |
| Fixed versions | Python 3.12.9 (2025-02-04), Python 3.13.2 (2025-02-04) |
| CWE | CWE-400 (Uncontrolled Resource Consumption), CWE-770 (Allocation of Resources Without Limits or Throttling) |
| 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 |
| GHSA | GHSA-ph84-rcj2-fxxm |
1. Vulnerability overview¶
CPython's asyncio module provides a high-level, event-loop-driven networking API. When building servers or clients with the low-level Protocol interface, application code writes data through a Transport object. If the remote peer stops consuming data, the transport's internal write buffer fills up; the correct behaviour is for the transport to call protocol.pause_writing() to signal the application to stop producing data — a standard backpressure mechanism.
Python 3.12.0 introduced a new, zero-copy writelines() method on _SelectorSocketTransport (the transport used by SelectorEventLoop on Linux and macOS). Unlike the existing write() method, writelines() omitted the call to _maybe_pause_protocol() after queuing data. The result: any application using transport.writelines() will never receive a pause_writing() signal, no matter how large the buffer grows. A remote client that simply stops reading can force the server process to buffer data indefinitely until it exhausts system memory. The bug does not affect Windows, which uses a different event loop (ProactorEventLoop).
Root cause¶
File: Lib/asyncio/selector_events.py
Method: _SelectorSocketTransport.writelines()
The write() method correctly calls _maybe_pause_protocol() after appending to the internal buffer:
# write() — correct, has flow control
self._buffer.append(data)
self._maybe_pause_protocol() # signals protocol to pause if buffer >= high-water
The writelines() method, added in 3.12.0, queues data and registers a write handler but never makes that call:
# 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
The fix (CPython PR #127656, main-branch commit e991ac8f, cherry-picked as 9aa0deb2 for 3.12 and 71e8429a for 3.13) adds a single line inside the if self._buffer: block:
--- 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() checks whether get_write_buffer_size() >= self._high_water and, if so, calls protocol.pause_writing(). Without this call inside writelines(), the protocol is never told to pause, and the buffer grows without bound for as long as the peer withholds reads.
2. Vulnerable environment¶
The reproduction uses a single Docker container running the pinned vulnerable interpreter — no application server, no published port, and no additional dependencies beyond the CPython standard library. The bug lives entirely in the asyncio source distributed with the interpreter.
Stack:
| Component | Detail |
|---|---|
| Container image | python:3.12.8-slim-bookworm (Debian Linux) |
| Container name | cve-2024-12254-python |
| Python version | 3.12.8 — last release before the 3.12.9 fix; in the affected range 3.12.0–3.12.8 |
| Event loop | _UnixSelectorEventLoop — the affected code path on Linux |
| Host port | None published — the exploit runs entirely inside the container via docker exec |
The environment is defined by two files that sit beside this page: env/Dockerfile and env/docker-compose.yml. Bring it up with:
The compose healthcheck asserts sys.version_info[:3] == (3, 12, 8) at startup, so the container will only report healthy when the exact pinned interpreter is running.
Verifying the environment is the vulnerable one¶
Once the container is healthy, a one-liner confirms the three required conditions — interpreter version, event-loop type, and the absent _maybe_pause_protocol call in writelines():
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)
"
Expected output:
writelines_has_pause False confirms the unpatched source is present — _maybe_pause_protocol does not appear in writelines(), exactly the omission this CVE describes.
3. How to exploit¶
The PoC driver (exploit/poc.py) constructs a real _SelectorSocketTransport over a loopback socket pair whose peer never reads. It sets a low high-water mark (1024 bytes) to trigger the crossing deterministically with a bounded workload, then issues a single transport.writelines() of 64 × 64 KiB chunks. Because the peer never reads, the kernel send buffer fills and data accumulates in Python's internal buffer; writelines() takes the if self._buffer: self._loop._add_writer(...) branch — and on 3.12.8, never calls _maybe_pause_protocol().
Step 1 — Bring up the environment¶
Step 2 — Copy the PoC into the container¶
Step 3 — Run the PoC¶
The four positional arguments are:
| Position | Name | Value | Meaning |
|---|---|---|---|
| 1 | high_water |
1024 |
Transport high-water mark in bytes |
| 2 | low_water |
256 |
Transport low-water mark in bytes |
| 3 | chunk_size |
65536 |
Size of each chunk passed to writelines() |
| 4 | num_chunks |
64 |
Number of chunks in the single writelines() call |
All four have the same built-in defaults, so invoking poc.py with no arguments is equivalent.
PoC output on the vulnerable interpreter¶
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
What proves it worked¶
The PoC reports the flow-control state but does not assert its own success — it only drives the transport. The proof comes from an independent observation, separate from the exploit itself.
An independent instrumented harness (run inside the container, distinct from poc.py) drove the identical workload and read the flow-control state directly from the asyncio objects it controlled — using its own Protocol subclass whose pause_writing/resume_writing hooks it owned, plus a spy on the transport's internal _maybe_pause_protocol method. The harness also ran a differential control: the same workload with a patched writelines() (the fix's single added line spliced in). Results:
VULN write_buffer_size 4186240
VULN high_water 1024
VULN buffer_exceeds_high_water True
VULN pause_writing_calls 0 # pause_writing NEVER fired — backpressure absent
VULN maybe_pause_protocol_reached 0 # _maybe_pause_protocol never reached by stock writelines()
VULN add_writer_called 1
VULN buffer_nonempty_when_writer_registered True # execution is in the exact branch the fix augments
---
CTRL write_buffer_size 4186240 # same workload, same transport
CTRL high_water 1024
CTRL buffer_exceeds_high_water True
CTRL pause_writing_calls 1 # patched writelines() fires pause_writing exactly once
The key signal is the combination of buffer_exceeds_high_water True and pause_writing_calls 0, read by the independent harness from objects it owns — not from the PoC's stdout. The maybe_pause_protocol_reached 0 line anchors this to the specific vulnerable code path: _maybe_pause_protocol was never reached inside writelines(), while _add_writer was called once with self._buffer non-empty — confirming execution entered the precise if self._buffer: branch that the fix augments. The differential (CTRL) result shows that adding the single _maybe_pause_protocol() line flips pause_writing_calls from 0 to 1 for the identical workload, attributing the omission directly to this CVE.
Step 4 — Teardown¶
4. Security advice¶
Remediation¶
Upgrade to Python 3.12.9 or 3.13.2. Both were released on 2025-02-04 and contain the one-line fix. The patch is a cherry-pick with no API changes — upgrading is a drop-in replacement.
If an immediate upgrade is not possible:
- Avoid
transport.writelines()on the SelectorEventLoop. Replace calls totransport.writelines(list_of_data)with a loop oftransport.write(chunk)calls — thewrite()method has always called_maybe_pause_protocol()and is not affected. - Implement
pause_writing/resume_writingin yourProtocol. Even on the vulnerable interpreter, a protocol that implements these callbacks and stops producing data whenpause_writing()fires will not accumulate unbounded memory — but this only helps once the fix is applied; on 3.12.8 those callbacks are never called bywritelines(). - Apply connection-level limits. Accepting a bounded number of concurrent connections and enforcing per-connection write timeouts limits the exposure surface.
The bug affects only Linux and macOS (SelectorEventLoop). Windows (ProactorEventLoop) is not affected.
References¶
- NVD — CVE-2024-12254
- GHSA-ph84-rcj2-fxxm — authoritative advisory with affected/fixed version ranges
- CPython issue #127655 — original bug report and scope discussion
- CPython PR #127656 — primary fix merged to
main2024-12-06 - Fix commit on main — e991ac8f
- 3.13 backport commit — 71e8429a
- 3.12 backport commit — 9aa0deb2
- Python security announcement
- Openwall oss-security disclosure
- NetApp advisory NTAP-20250404-0010 — downstream product impact