CVE-2024-12254 — asyncio writelines() missing flow control (DoS)¶
Verified reproduction
This page documents a confirmed reproduction of CVE-2024-12254. The exploit drives the vulnerable code path deterministically on Python 3.12.8, without exhausting real host RAM.
| Field | Detail |
|---|---|
| Project | CPython |
| Affected component | asyncio standard library module |
| Severity | 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 (Uncontrolled Resource Consumption), CWE-770 (Allocation of Resources Without Limits or Throttling) |
| Affected versions | Python 3.12.0 – 3.12.8 and 3.13.0 – 3.13.1 (Linux and macOS only) |
| Fixed versions | Python 3.12.9 and 3.13.2 (both released 2025-02-04) |
| GHSA | GHSA-ph84-rcj2-fxxm |
1. Vulnerability overview¶
CPython's asyncio module provides a non-blocking networking layer built on the event loop. On Linux and macOS, the low-level transport that sends data over sockets is _SelectorSocketTransport, found in Lib/asyncio/selector_events.py. Applications that use the Protocol API send data with transport.write() or, starting in Python 3.12.0, with the newer transport.writelines().
Both methods queue data into an internal write buffer. When that buffer exceeds the configured high-water mark, asyncio is supposed to call protocol.pause_writing(). That call is the standard backpressure signal that tells the application to stop producing new data. If pause_writing() is never called, a remote peer that stops reading will cause the server's in-process buffer to grow without limit, consuming all available memory and crashing the process. The result is a network-accessible denial-of-service.
Root cause. The existing write() method correctly calls self._maybe_pause_protocol() after appending data to the buffer. The writelines() method, introduced in 3.12.0, queues data and registers a write handler but never calls _maybe_pause_protocol():
# write() — correct, has flow control
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
The fix (CPython PR #127656, commit e991ac8f2037d78140e417cc9a9486223eb3e786 on main; cherry-picked as 71e8429a for 3.13, 9aa0deb2 for 3.12) adds exactly one line:
--- 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 get_write_buffer_size() >= self._high_water and, if true, calls protocol.pause_writing(). Without it, a protocol using writelines() never receives pause_writing(), so it keeps feeding data into the transport indefinitely.
The bug only affects the SelectorEventLoop path (Linux and macOS). Windows uses ProactorEventLoop, which is not affected. The bug was disclosed on 2024-12-06 and fixed the same day.
2. Vulnerable environment¶
The reproduction uses a single Docker container running the pinned Python 3.12.8 interpreter on Debian Linux. That is the last release before the fix and sits inside the affected range 3.12.0 – 3.12.8. No third-party packages are needed because the bug lives entirely in CPython's standard library. No network port is published to the host. The exploit drives a loopback transport and reads flow-control state inside the container via docker exec.
Download all environment files as a bundle: env.zip
Individual files:
Bring up the environment:
Verify you have the vulnerable interpreter. The smoke test below confirms Python 3.12.8 is running, that asyncio.new_event_loop() returns a _UnixSelectorEventLoop (the affected path), and that writelines() does not yet contain the _maybe_pause_protocol call:
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 vulnerable, unpatched source is present.
3. How to exploit¶
The proof-of-concept script (exploit/poc.py) constructs a real _SelectorSocketTransport over a loopback socket pair whose peer never reads. It sets the high-water mark to 1024 bytes (matching the upstream regression test), then calls transport.writelines() with 64 chunks of 64 KiB each. Because the peer never reads, the kernel send buffer fills quickly and the transport's Python-side write buffer accumulates. The buffer exceeds the 1024-byte high-water mark by a wide margin, but on Python 3.12.8 writelines() never calls _maybe_pause_protocol(), so pause_writing() is never invoked on the protocol. The workload is bounded and completes in a fraction of a second. No real memory exhaustion is needed to observe the bug.
Download all exploit files as a bundle: exploit.zip
Individual files:
Step 1 — Start the environment¶
Step 2 — Copy the PoC into the container¶
Step 3 — Run the PoC¶
The four arguments are: high_water (1024 bytes), low_water (256 bytes), chunk_size (65536 bytes per chunk), num_chunks (64 chunks). All have matching built-in defaults, so running the script with no arguments produces the same result.
| Argument | 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 |
PoC output on Python 3.12.8:
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's stdout is not the load-bearing evidence; it is a convenience display only. The definitive proof comes from an independent instrumentation harness that the verifier ran inside the container separately from the exploit script. That harness drove the identical workload and read the flow-control state from objects the verifier controlled directly, including a spy on the transport's internal _maybe_pause_protocol method. Its recorded output:
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
The key observations are:
VULN pause_writing_calls 0: the protocol'spause_writing()hook, owned by the verifier's harness, was never called even though the write buffer (4,186,240 bytes) far exceeded the high-water mark (1,024 bytes).VULN maybe_pause_protocol_reached 0: the verifier's spy on the transport's own_maybe_pause_protocolmethod confirmed it was never reached by the unpatchedwritelines().VULN buffer_nonempty_when_writer_registered True:_add_writerwas called withself._buffernon-empty, so execution took theif self._buffer:branch that the fix augments with the single_maybe_pause_protocol()line. The omission is therefore tied to the specific vulnerable code path the patch changes, not to any unrelated cause.CTRL pause_writing_calls 1: running the same workload with the patchedwritelines()body (same interpreter, same buffer crossing) firespause_writing()exactly once. The flip from 0 to 1 is caused by precisely the one line the CVE fix adds, which confirms the signal is attributable to this vulnerability and nothing else.
Environment teardown¶
Once you are done, stop and remove the container and its associated volumes:
4. Security advice¶
Remediation¶
Upgrade to Python 3.12.9 or Python 3.13.2 (both released 2025-02-04). These releases include the one-line fix that adds the missing _maybe_pause_protocol() call inside the if self._buffer: branch of _SelectorSocketTransport.writelines(). Older 3.12.x and 3.13.x patch releases (3.12.0 – 3.12.8 and 3.13.0 – 3.13.1) are vulnerable. Python 3.11 and earlier are not affected because writelines() did not exist on that code path before 3.12.0.
Mitigations and workarounds¶
If an immediate interpreter upgrade is not possible:
- Replace
writelines()withwrite()in application code. The existingwrite()method on_SelectorSocketTransportcalls_maybe_pause_protocol()and is not affected. - Implement
pause_writing()/resume_writing()in everyProtocoland treat the absence of apause_writing()call as the bug indicator. This does not fix the root cause, but it limits the damage by making the application logic aware that backpressure should have fired. - The bug only affects the
SelectorEventLooppath (Linux and macOS). Applications running on Windows (which usesProactorEventLoop) are not affected.
References¶
- NVD — CVE-2024-12254
- GHSA-ph84-rcj2-fxxm
- CPython issue #127655 — original bug report
- CPython PR #127656 — the one-line fix
- Fix commit on main — e991ac8f
- 3.13 backport commit — 71e8429a
- 3.12 backport commit — 9aa0deb2
- Python security announcement
- Openwall oss-security disclosure
- NetApp security advisory NTAP-20250404-0010