Skip to content

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:

docker compose -f env/docker-compose.yml up -d --wait

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:

version 3.12.8
loop _UnixSelectorEventLoop
writelines_has_pause False

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

docker compose -f env/docker-compose.yml up -d --wait

Step 2 — Copy the PoC into the container

docker cp exploit/poc.py cve-2024-12254-python:/work/poc.py

Step 3 — Run the PoC

docker exec cve-2024-12254-python python3 /work/poc.py 1024 256 65536 64

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's pause_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_protocol method confirmed it was never reached by the unpatched writelines().
  • VULN buffer_nonempty_when_writer_registered True: _add_writer was called with self._buffer non-empty, so execution took the if 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 patched writelines() body (same interpreter, same buffer crossing) fires pause_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:

docker compose -f env/docker-compose.yml down -v

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() with write() in application code. The existing write() method on _SelectorSocketTransport calls _maybe_pause_protocol() and is not affected.
  • Implement pause_writing() / resume_writing() in every Protocol and treat the absence of a pause_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 SelectorEventLoop path (Linux and macOS). Applications running on Windows (which uses ProactorEventLoop) are not affected.

References