Safeguard
Vulnerability Analysis•

PgBouncer CVE-2026-107350: How a Second Startup Message Let One Password Log In as Any SCRAM User

PgBouncer 1.26.1 fixes an authentication bypass present since 1.17.0: anyone who knows one auth_file user's password could log in as any other SCRAM user, including admins and database superusers.

Safeguard Security Team
Security Research
9 min read

PgBouncer 1.26.1, released on 14 October 2026, fixes an authentication bypass that has been in every release since 1.17.0 in March 2022. CVE-2026-107350 lets anyone who knows the password of one user in PgBouncer's auth_file log in as any other user that authenticates with SCRAM, including PgBouncer admin users and database superusers. The trick is a second StartupMessage, sent in the middle of the SCRAM exchange, aimed at a database that doesn't exist.

PgBouncer sits in front of a great many PostgreSQL deployments, often shared by several applications with different database users. This post is for teams that run it. It explains the bug from the fix commit, who is exposed, the two denial-of-service bugs fixed in the same release, and a script that checks a configuration against the conditions for all three.

The short answer

Upgrade to PgBouncer 1.26.1. If you can't straight away, CVE-2026-107350 needs two things: an attacker who knows the password of a user in auth_file, and SCRAM authentication for both that user and the one being impersonated. The highest-risk setups have several users with different privileges, including an admin user, reachable over the network. Debian's tracker showed bookworm, trixie and forky still vulnerable on 15 October, with the fix in unstable.

CVEWhat it isAffectedConditions
CVE-2026-107350Log in as another SCRAM user, including admins and superusers (CVSS 3.1 8.8)1.17.0 to 1.26.0Knows one auth_file user's password; SCRAM for both users
CVE-2026-107349Unauthenticated CPU spin that stops PgBouncer serving all clients (CVSS 3.1 7.5)1.20.0 to 1.26.0ignore_startup_parameters set but not including options
CVE-2026-107348Integer overflow that hangs PgBouncer1.21.0 to 1.26.0max_packet_size set to 2147483649 or higher (default unaffected)

All three are fixed in 1.26.1. The PgBouncer changelog credits Dave Aitel of OpenAI for CVE-2026-107350.

How a second StartupMessage switched users

A PostgreSQL connection begins with a StartupMessage naming the user and database. PgBouncer then runs the authentication exchange, SCRAM-SHA-256 in this case, using that user's stored keys. A client should only ever send one StartupMessage, and PgBouncer had a check to reject a second one.

The fix commit (55233b2) explains why the check didn't always fire. It only ran once the client had a connection pool assigned, and a client connecting to a database that doesn't exist never gets a pool. So an attacker could:

  1. send a StartupMessage as a user whose password they know, for a non-existent database,
  2. start the SCRAM exchange, which loads that user's keys into the SCRAM state,
  3. send a second StartupMessage naming a different user,
  4. finish SCRAM with the first user's password.

PgBouncer processed the second message, which "switched the user being logged in as", but completed SCRAM against the keys of the first user, which were already loaded. The result was a session authenticated as the second user. The fix is one line in src/client.c. The check now rejects any second StartupMessage once one has been received, whether or not a pool exists:

-		if (client->pool && !sending_auth_query(client)) {
+		if (client->startup_message_received) {
 			disconnect_client(client, true, "client re-sent startup pkt");

The commit adds a regression test that sends a second StartupMessage for both a real and a non-existent database and expects the "client re-sent startup pkt" error.

Two details narrow who is exposed. Because PgBouncer doesn't run auth_query for a non-existent database, the user whose password the attacker knows has to be in auth_file. The user being impersonated can come from auth_file or auth_query. And both have to authenticate with SCRAM, which happens when an auth_file entry holds a SCRAM secret, or holds a plain-text password with auth_type = scram-sha-256.

Who should worry most

The bug turns "I know one password" into "I am anyone", so it matters most where those are different people:

  • Shared poolers. One PgBouncer in front of several applications, each with its own database user. A compromised low-privilege application credential becomes a superuser session.
  • Admin users. Users listed in admin_users can run any console command, including pausing or reconfiguring the pooler. They're exactly the users an attacker would choose.
  • Network-reachable PgBouncer. An attacker has to reach the PgBouncer port. Setups listening only on a local socket or loopback, used by a single application, are much less exposed.

A single-application setup where PgBouncer has one user is effectively unaffected: there's nobody else to become.

The two denial-of-service fixes

CVE-2026-107349 lets an unauthenticated client make PgBouncer "spin forever on a CPU core" by sending a startup options parameter with an empty option name, such as -c=. It only applies when ignore_startup_parameters is set and doesn't include options. Because PgBouncer is single-threaded, one spinning connection stops it serving everyone.

CVE-2026-107348 is an integer overflow in packet buffer growth that can hang PgBouncer on a packet header declaring a length above 2^31. It only applies when max_packet_size has been raised to 2147483649 or more, so the default of 2147483647 is safe. The changelog notes it's the same class of bug as CVE-2026-6668, fixed in 1.26.0 three weeks earlier in a different buffer.

Check your configuration

This script reads pgbouncer.ini and the auth_file it points to, and reports which of the three CVEs' conditions your configuration meets. It prints user names and secret types, never the secrets themselves. It uses only the standard library. It doesn't cover auth_type = hba, where SCRAM depends on your HBA file, so check that case by hand.

#!/usr/bin/env python3
"""Check a PgBouncer configuration against the conditions for the CVEs fixed in 1.26.1
(14 October 2026). Reads pgbouncer.ini and its auth_file; prints user names and secret
types only, never secrets. Standard library only.
Usage: python3 pgbouncer_oct2026_check.py /etc/pgbouncer/pgbouncer.ini [--version 1.25.1]"""
import configparser, os, re, shlex, sys

def version_tuple(s):
    return tuple(int(x) for x in re.findall(r"\d+", s)[:3])

ini_path = sys.argv[1]
version = sys.argv[sys.argv.index("--version") + 1] if "--version" in sys.argv else None
cp = configparser.ConfigParser(strict=False, interpolation=None, inline_comment_prefixes=(";", "#"))
cp.read(ini_path)
pb = cp["pgbouncer"] if cp.has_section("pgbouncer") else {}
get = lambda k, d="": (pb.get(k, d) or d).strip()

auth_type = get("auth_type", "md5")
users = {}                                   # user -> secret kind
auth_file = get("auth_file")
if auth_file:
    path = auth_file if os.path.isabs(auth_file) else os.path.join(os.path.dirname(ini_path), auth_file)
    for line in open(path):
        parts = shlex.split(line.strip(), comments=True)
        if len(parts) >= 2:
            secret = parts[1]
            users[parts[0]] = ("scram" if secret.startswith("SCRAM-SHA-256$") else
                               "md5" if re.fullmatch(r"md5[0-9a-f]{32}", secret) else "plain")

def uses_scram(kind):
    # PgBouncer uses SCRAM for SCRAM secrets under md5/scram-sha-256/hba, and for plain-text
    # passwords when auth_type is scram-sha-256 (see the auth_type documentation).
    return kind == "scram" or (kind == "plain" and auth_type == "scram-sha-256")

scram_users = sorted(u for u, k in users.items() if uses_scram(k))
admins = {u.strip() for u in get("admin_users").split(",") if u.strip()}
findings = []

if version and not (version_tuple("1.17.0") <= version_tuple(version) < version_tuple("1.26.1")):
    print(f"PgBouncer {version}: outside 1.17.0-1.26.0, CVE-2026-107350 does not apply.")
elif auth_type in ("trust", "any", "cert"):
    print(f"auth_type={auth_type}: no password-based SCRAM logins, CVE-2026-107350 conditions not met.")
elif scram_users and (len(scram_users) > 1 or get("auth_query")):
    target = sorted(set(scram_users) & admins)
    findings.append(("HIGH" if target else "REVIEW", "CVE-2026-107350",
        f"{len(scram_users)} auth_file user(s) log in with SCRAM ({', '.join(scram_users)})"
        + (f"; admin user(s) {', '.join(target)} could be impersonated" if target else "")
        + ("; auth_query users can be impersonated too" if get("auth_query") else "")))

isp = [p.strip() for p in get("ignore_startup_parameters").split(",") if p.strip()]
if isp and "options" not in isp:
    findings.append(("REVIEW", "CVE-2026-107349",
        f"ignore_startup_parameters={','.join(isp)} without 'options': unauthenticated CPU spin (1.20.0+)"))

if int(get("max_packet_size", "2147483647")) >= 2147483649:
    findings.append(("REVIEW", "CVE-2026-107348", f"max_packet_size={get('max_packet_size')}: overflow hang (1.21.0+)"))

listen = get("listen_addr", "")
exposed = listen not in ("", "127.0.0.1", "localhost", "::1")
for level, cve, msg in findings:
    print(f"{level:<7} {cve}  {msg}")
print(f"\nlisten_addr={listen or '(unset, unix socket only)'}"
      + (" is reachable from the network: patch first." if exposed else "")
      + f"\n{len(findings)} finding(s). Fixed in PgBouncer 1.26.1.")
sys.exit(1 if findings else 0)

We tested it on two fixture configurations. The first is a shared pooler with three SCRAM-capable users, an admin user, ignore_startup_parameters without options, and a network listener:

[databases]
app = host=10.0.0.5 dbname=app

[pgbouncer]
listen_addr = 0.0.0.0
listen_port = 6432
auth_type = scram-sha-256
auth_file = userlist.txt
admin_users = pgb_admin
ignore_startup_parameters = extra_float_digits

Here are the results for that configuration and for a second one that listens on loopback, uses MD5 secrets and includes options in ignore_startup_parameters:

$ python3 pgbouncer_oct2026_check.py fx1/pgbouncer.ini --version 1.25.1
HIGH    CVE-2026-107350  3 auth_file user(s) log in with SCRAM (app_user, pgb_admin, reporting); admin user(s) pgb_admin could be impersonated
REVIEW  CVE-2026-107349  ignore_startup_parameters=extra_float_digits without 'options': unauthenticated CPU spin (1.20.0+)

listen_addr=0.0.0.0 is reachable from the network: patch first.
2 finding(s). Fixed in PgBouncer 1.26.1.
$ echo $?
1
$ python3 pgbouncer_oct2026_check.py fx2/pgbouncer.ini --version 1.25.1

listen_addr=127.0.0.1
0 finding(s). Fixed in PgBouncer 1.26.1.
$ echo $?
0

What to do

  1. Upgrade to 1.26.1. Distribution packages lag, so check your vendor or use the PostgreSQL community's packages where your policies allow.
  2. Until then, reduce who can reach PgBouncer. Restrict the listener to the application hosts that need it.
  3. Rotate and review auth_file passwords. Anyone who has seen one of them could have used this bug. If application credentials have leaked through logs, CI or config repositories, treat the other users as exposed too.
  4. Check the logs. After upgrading, "client re-sent startup pkt" in PgBouncer's log shows attempts. Before the upgrade, attempts against non-existent databases wouldn't have produced that message, so look for login failures for unknown databases from unexpected hosts.
  5. Keep admin users separate. Don't give application users console access, and keep admin_users to a short list of operators.

The fair view

CVE-2026-107350 needs a valid password to start with, which is why it scores 8.8 rather than higher. Plenty of PgBouncer deployments have one application user, or listen only locally, and for them the practical risk is small. The fix is a one-line change with a test, and the maintainers shipped it with a clear account of the conditions.

The bug is still a good example of a pattern worth checking for in any authentication code: state that should be fixed for a whole connection, here the identity being authenticated, could change partway through. Checks that depend on something else being set up first, like a pool, tend to miss the paths where it never is.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.