Neutron, our AI engine, scored 97.1% on UC Berkeley's CyberGym benchmark. Learn more

Deep Dive

Deep Dive

pgAdmin 4 Import/Export RCE, Fixed in 9.19 (CVE-2026-116241)

pgAdmin 4 before 9.19 lets any Import/Export user run a psql TO PROGRAM command on the host, and lockout bypasses open admin guessing; fixed in 9.19.

TL;DR

  • pgAdmin 4's Import/Export Data tool runs an attacker's operating-system command on the pgAdmin host when a crafted query export slips past its parenthesis guard: CVE-2026-116241, CVSS v3.1 9.9 Critical from the PostgreSQL CNA. The tool's permission is granted to non-administrators by default.
  • The same release fixes a second critical, CVE-2026-116238: four bypasses of pgAdmin's failed-login limit let an unauthenticated attacker guess the administrator's password without limit in server mode. Yonghwa Lee of Xint by Theori reported the injection.
  • All 14 flaws in the batch, published October 30, 2026, are fixed in pgAdmin 4 9.19, released October 29, 2026. Every earlier version, back to the tool's first releases, is affected.
  • No public exploit or exploitation has been reported, and neither critical is in CISA's Known Exploited Vulnerabilities (KEV) catalog as of October 31, 2026. Upgrade to 9.19 now; the server-mode lockdown changes in it need planning, listed under "How do I fix it?".

Am I affected?

For the command injection, CVE-2026-116241, all of these must hold:

  • You run pgAdmin 4 at any version before 9.19, in any deployment: server mode, desktop mode or a container.
  • A user you do not fully trust can use the Import/Export Data tool on some server connection. The tools_import_export_data permission is granted to non-administrators by default.
  • That user's database role on the target server can run SET standard_conforming_strings = off for itself, which an ordinary role can.

The code runs in both modes, but the remote-attacker path is server mode, where other people hold pgAdmin accounts. Desktop mode exposes it to whoever can already drive the desktop.

For the lockout bypass, CVE-2026-116238, all of these must hold:

  • You run pgAdmin 4 in server mode, at any version before 9.19. Desktop mode is not affected.
  • The instance is reachable by anyone you do not fully trust.
  • An internal account exists, which is the default: the initial administrator is one, and MAX_LOGIN_ATTEMPTS is 3 by default.
Branch Affected versions Fixed version
pgAdmin 4 every release before 9.19 9.19 (2026-10-29)

The batch is large: the PostgreSQL CVE Numbering Authority (CNA) published fourteen records for it, two critical, ten high, one medium and one low, all fixed in the same release.

The pgAdmin 4 9.19 security batch by severity: two critical, ten high, one medium, one low
Figure 1: Fourteen CVEs fixed in one release. Source: PostgreSQL CNA records, checked October 31, 2026.

Check what you run. The container logs its version at startup:

docker logs pgadmin 2>&1 | grep -m1 "Starting pgAdmin"

For a pip install:

pip show pgadmin4 | head -2

In the interface, the About box on the Help menu shows the version. For the Docker image, the tag also carries it, but floating tags such as latest need one of the version checks in this section.

How do I fix it?

Upgrade every deployment to 9.19, the only fixed version:

  1. Back up the pgAdmin configuration database, which holds users, roles and server definitions.
  2. Before upgrading or restarting, review the four server-mode changes below and configure the required allowlists. Password-command migration can permanently clear unmatched commands.
  3. Upgrade the deployment. For pip:
pip install --upgrade pgadmin4==9.19
  1. For Docker Compose, first set the pgadmin service image to dpage/pgadmin4:9.19 in your Compose file, then pull and recreate that service:
docker compose pull pgadmin
docker compose up -d pgadmin

Server mode in 9.19 locks down four things, each with a cost to plan for:

  • Free-text Password Exec Commands no longer run. ENABLE_SERVER_PASS_EXEC_CMD is deprecated and ignored. Administrators now list the permitted commands by name in SERVER_PASSEXEC_COMMANDS; on the first start after upgrading, each existing command that exactly matches a listed entry is converted to its name and every other command is cleared, which cannot be undone. If SERVER_PASSEXEC_COMMANDS is empty, conversion is postponed and a warning is logged at each start.
  • Connection services are restricted. SERVER_MODE_ALLOWED_SERVICES is empty by default, so servers configured with a connection service stop connecting until the administrator lists it. Listing a service shares its credentials with every pgAdmin user.
  • SSH tunnels no longer read the service account's own SSH identity. In server mode they no longer use its ~/.ssh keys, its configuration file or its agent, so a tunnel relying on them must be given an identity file in the user's storage or a password on the server.
  • On Python 3.10 and later, pgAdmin requires Flask-Security-Too 5.8.3 or later, which fixes an open redirect, GHSA-ccgp-pv8r-95wm. This matters to anyone installing the wheel into an existing environment.

For deployments that cannot upgrade yet, one workaround closes the injection path for CVE-2026-116241:

  1. Revoke the Import/Export Data permission from every role you do not trust with host commands. In server mode, an administrator removes the tools_import_export_data permission from roles in the GUI's role management.
  2. The workaround covers CVE-2026-116241 only. It does nothing for the lockout bypass, CVE-2026-116238, or the other twelve flaws, and it takes the export tool away from users who need it.

For the lockout bypass without an upgrade, restrict who can reach the pgAdmin login page: a VPN, an IP allowlist on the reverse proxy, or an authentication source that fronts pgAdmin entirely. None of these change the four bypasses; each removes the unauthenticated reachability they need.

What is the root cause?

The Import/Export Data tool wraps a user's SQL query in a psql \copy (...) TO command and validates it with a hand-written parenthesis-balance check whose quoting rules differ from psql's. The route, POST /import_export/job/<sid>, is gated only by the ordinary tools_import_export_data permission, and it passes the rendered \copy line to psql.

The guard, _is_query_parens_balanced() in web/pgadmin/tools/import_export/__init__.py, walked the query to be sure nothing could close the wrapping parentheses early. Before 9.19, its handling of double-quoted identifiers was:

        # Double-quoted identifier: skip to closing quote
        # (handles "" escape sequences — matches strtokx behavior)
        if ch == '"':
            i += 1
            while i < length:
                if query[i] == '"':

The comment claims a match with psql's tokenizer, strtokx, but the two disagree on the backslash. strtokx takes its escape character from the connection's standard_conforming_strings setting and applies it to whichever quote opened the token. With that setting off, a backslash before a double quote is an escaped quote to psql, while pgAdmin's guard treats it as closing the identifier. The commit message for the fix states the consequence: in a query export such as SELECT "a\"b"c) TO PROGRAM 'cmd' x", the closing parenthesis hidden from the check ends the \copy (...) wrapper, and the following TO PROGRAM clause runs through popen() on the pgAdmin host before the database server rejects the statement.

The lockout bypass, CVE-2026-116238, has four separate holes in one design. pgAdmin counts failed logins and locks an internal account after MAX_LOGIN_ATTEMPTS, but Flask-Security-Too registers its own views next to pgAdmin's, and submissions to those views skipped pgAdmin's counting and checks entirely: /login logged in without the failed-login count, /verify checked a signed-in user's password without limit, /change changed a password without pgAdmin's permission, and /reset applied resets for accounts that are not internal or are locked. A username typed in another case was neither counted nor checked against the lock. With LDAP or webserver authentication also configured, a wrong internal password was never counted at all.

How is it exploited?

No proof-of-concept code is public, and no exploitation has been reported. The steps come from the CVE records and the fix commit:

  1. The attacker needs a pgAdmin account with the Import/Export Data permission, which non-administrators hold by default, and an ordinary role on a target database server.
  2. On that connection, the attacker runs SET standard_conforming_strings = off for themselves, which an ordinary role can do.
  3. They submit a query-based export whose query hides a closing parenthesis inside a double-quoted identifier that contains a backslash, followed by a TO PROGRAM clause. The CVE record's example shape is SELECT "a\"b"c) TO PROGRAM 'cmd' x".
  4. pgAdmin's balance check, reading the backslash differently from psql, believes the parentheses balance and accepts the job.
  5. The rendered \copy (...) TO PROGRAM 'cmd' runs in psql. Because psql applies the connection's escape rule, the hidden parenthesis closes the wrapper. psql then executes the TO PROGRAM command through popen() on the pgAdmin host, as the operating-system account running pgAdmin, before the database server rejects the statement.

For the lockout bypass, the steps are simpler:

  1. An unauthenticated attacker reaches the pgAdmin login page of a server-mode deployment.
  2. They submit password guesses against the internal administrator account through one of the uncounted paths. Those paths are Flask-Security's own /login view, a username typed in another case, and a wrong internal password while LDAP or webserver authentication is also configured.
  3. No lock ever engages, so the guesses continue without limit until the password falls.

How does the attack flow?

An ordinary pgAdmin account turns a query export into an operating-system command by desynchronizing pgAdmin's quoting check from psql's tokenizer. The check that fails is the parenthesis guard's reading of the backslash.

flowchart LR A["Attacker with Import/Export
permission and an ordinary role"] --> B["SET standard_conforming_strings=off
on the connection"] B --> C["Export query hides ) inside
backslash-quoted identifier + TO PROGRAM"] C --> D["check fails: balance guard reads
the backslash differently from psql"] D --> E["\\copy (...) TO PROGRAM
runs in psql via popen"] E --> F["Arbitrary command executed on
the pgAdmin host"]

What can an attacker do?

The injection, CVE-2026-116241, executes an arbitrary operating-system command on the host running pgAdmin, as the account running pgAdmin. In the official container that account is normally the non-root pgadmin user (UID 5050); in a pip or package install it is whoever runs the web server. The command runs before the database server rejects the statement, so the attack needs no privileges on the database beyond an ordinary role. The PostgreSQL CNA scores it 9.9 Critical, CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, and 9.4 Critical on CVSS v4.0, CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H.

The lockout bypass, CVE-2026-116238, gives an unauthenticated attacker unlimited password guesses against any internal account in the default configuration, including the administrator's. The same CVE records that a password reset could be sent for, or applied to, an account from another authentication source where accounts share an email address. Its scores are 8.1 High on CVSS v3.1, CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, and 9.2 Critical on CVSS v4.0, CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N.

Neither flaw has a public exploit, an exploitation report, or a KEV listing as of October 31, 2026. The injection is the third round in one family. CVE-2025-13780 ran commands through PLAIN-format dump restores in versions up to 9.10. CVE-2026-17566, fixed in 9.17 in July 2026, closed the same export guard's single-quoted-string gap. This one reopens it through double-quoted identifiers.

Timeline of the guard-bypass family: CVE-2025-13780 in December 2025, the 9.17 fix of CVE-2026-17566 in July 2026, the 9.19 fix on October 29 and the 14 CVE records on October 30
Figure 2: Third round of the same guard bypass in ten months. Source: CVE records and pgAdmin release notes, checked October 31, 2026.

Why does the fix work?

Commit 9be31a019f78, "Reject a backslash in a double-quoted identifier in the Import/Export Data query export", changes the guard to refuse the ambiguous case instead of guessing a reading:

-        # Double-quoted identifier: skip to closing quote
-        # (handles "" escape sequences — matches strtokx behavior)
+        # Double-quoted identifier: skip to closing quote.
+        # Handles the "" escape sequence to match psql's strtokx.
+        # A bare backslash is rejected outright for the same reason
+        # as in a single-quoted string (see docstring): strtokx
+        # applies its escape character to whichever quote opened the
+        # token, so under standard_conforming_strings=off a \" is an
+        # escaped quote to psql but a closing quote to us.
         if ch == '"':
             i += 1
             while i < length:
-                if query[i] == '"':
+                if query[i] == '\\':
+                    return False  # ambiguous escape: reject
+                elif query[i] == '"':
                     if i + 1 < length and query[i + 1] == '"':
                         i += 2  # escaped quote
                     else:

The fix also rejects a backslash or a null byte in the schema, table or column names of an export or import. Those names reach the same \copy command through qtIdent(). The commit's regression tests pin both directions of the desynchronization. Each carries a TO PROGRAM 'echo pwned' payload that must now be refused: the single-quote case from the July fix, and the double-quote case of this one.

The lockout bypass is fixed by four changes:

  • submissions to Flask-Security's own views are refused with HTTP 405
  • every failed internal login is counted, however the username is typed and whatever other authentication sources are configured
  • a login that posts a password no longer falls back to a source that does not check it
  • only internal authentication is locked, so a locked internal account no longer blocks other sources

The fix for the injection is complete for this guard. Every quoted token a \copy command can contain, single-quoted strings and double-quoted identifiers alike, now rejects backslashes outright. The parameter names are covered by the null-byte and backslash rejection in qtIdent(). The lockout fix closes all four bypass paths the CVE record describes.

How do I check if I was compromised?

For the injection, no indicators of compromise are published. Review the records your deployment keeps of Import/Export jobs: the Processes panel in the pgAdmin interface lists them, and their history is stored in pgAdmin's configuration database. Look for exports whose query contains TO PROGRAM:

# The Processes panel or the job history in the pgAdmin SQLite database.
sqlite3 -readonly /var/lib/pgadmin/pgadmin4.db \
  "SELECT pid, start_time, command, arguments FROM process WHERE arguments LIKE '%TO PROGRAM%';"

A hit means a job tried to run a program through psql and is a lead, not a proof, because a legitimate export can mention TO PROGRAM in a comment. Then check the host for what a popen() command could have left: unfamiliar processes, cron entries and files under the pgAdmin user's home directory.

A session-level SET standard_conforming_strings does not persist as database configuration, but statement or audit logs may record it. Review any retained logs; absent job history does not rule out an attempt.

For the lockout bypass, look in the pgAdmin logs for a volume of failed logins that never triggered a lock, which is the bypass's own signature. No victim, indicator or campaign has been reported anywhere as of October 31, 2026.

Timeline

  • 2026-07-27: pgAdmin commits 1496fabe, the fix for CVE-2026-17566, closing the single-quoted-string backslash gap in the same guard.
  • 2026-07-31: pgAdmin 9.17 ships that fix, and the CVE-2026-17566 record is published the same day.
  • 2026-10-23: pgAdmin commits 9be31a019f78, the double-quoted-identifier fix for CVE-2026-116241, authored by hiteshjambhale with Dave Page, reported by Yonghwa Lee of Xint by Theori.
  • 2026-10-25: pgAdmin commits the lockout-bypass fixes for CVE-2026-116238, including 58c531d83117.
  • 2026-10-29: pgAdmin 4 9.19 is released, published to PyPI at 15:36 UTC, with release notes covering all 14 security fixes.
  • 2026-10-30: the PostgreSQL CNA publishes the batch of 14 CVE records at 19:55 UTC.

FAQ

Does 9.18 protect me?

No. 9.18 carries the July fix for CVE-2026-17566, which closed the single-quoted-string backslash case, but the double-quoted-identifier case of CVE-2026-116241 defeats that fix. The lockout bypasses of CVE-2026-116238 are also present in 9.18. Only 9.19 fixes both.

Is desktop mode affected?

The injection code runs in both modes, but exploiting it remotely requires server mode, where other people hold accounts and can reach the tool. Desktop mode exposes the same flaw only to whoever can already operate that desktop. The lockout bypass is server mode only; desktop mode is not affected.

What breaks when I upgrade a server-mode deployment to 9.19?

Server mode changes four things, all listed in "How do I fix it?". The costliest two to plan are the password-command allowlist, which converts or clears existing commands on the first start, and SERVER_MODE_ALLOWED_SERVICES, which every server configured with a connection service needs before it connects again.

Are the other twelve CVEs fixed in 9.19 as well?

Yes. All 14 records say "before 9.19", and the 9.19 release notes carry a fix line for each. They range from the File Manager path races of CVE-2026-116240 to the service-account credential uses of CVE-2026-116242 through CVE-2026-116247. Upgrading once fixes the batch.

Who found these flaws?

Yonghwa Lee of Xint by Theori reported the injection, CVE-2026-116241. Yves Soete of Blacksight LLC reported the cross-source login confusion of CVE-2026-116239, and Harsh Singh reported the binary-path escape of CVE-2026-116243. The lockout bypass, CVE-2026-116238, lists no external reporter in the CVE record, which credits Ashesh Vashi of EnterpriseDB for the remediation development.

Inventory your exposed pgAdmin servers with Ostorlab

Ostorlab's attack surface discovery inventories your internet-facing servers and services. It shows which of your deployments run a pgAdmin endpoint exposed to the network. You then know which ones to move to 9.19 first, and which to pull behind a VPN.

References

  • CVE-2026-116241 record, published by the PostgreSQL CNA with CVSS v3.1 9.9 and CVSS v4.0 9.4, crediting Yonghwa Lee: https://www.cve.org/CVERecord?id=CVE-2026-116241 (checked October 31, 2026)
  • CVE-2026-116238 record, the four lockout bypasses, with CVSS v3.1 8.1 and CVSS v4.0 9.2: https://www.cve.org/CVERecord?id=CVE-2026-116238 (checked October 31, 2026)
  • pgAdmin 4 9.19 release notes, dated October 29, 2026, with the security fix lines for issues #10663, #10676, #10674 and the rest of the batch, and the four server-mode changes: https://www.pgadmin.org/docs/pgadmin4/9.19/release_notes_9_19.html (checked October 31, 2026)
  • Fix commit 9be31a019f78 for CVE-2026-116241, whose message documents the strtokx desynchronization and the example query, reported by Yonghwa Lee of Xint by Theori: https://github.com/pgadmin-org/pgadmin4/commit/9be31a019f8780647fb689c458408c8803263c812 (checked October 31, 2026)
  • Fix commit 58c531d83117 for CVE-2026-116238, "Act only on the internal account when accounts from other sources share its email or username": https://github.com/pgadmin-org/pgadmin4/commit/58c531d831179dc9b0a5bf873ccb80c71c8d387a (checked October 31, 2026)
  • CVE-2026-17566 record, the July fix of the single-quoted-string case in the same guard, with fix commit 1496fabe: https://www.cve.org/CVERecord?id=CVE-2026-17566 (checked October 31, 2026)
  • CVE-2025-13780 record, the first round of the family, command execution through PLAIN-format dump restores in versions up to 9.10: https://www.cve.org/CVERecord?id=CVE-2025-13780 (checked October 31, 2026)
  • pgAdmin 4 9.19 on PyPI, uploaded October 29, 2026 at 15:36 UTC: https://pypi.org/project/pgadmin4/ (checked October 31, 2026)
  • The dpage/pgadmin4 Docker image, the standard server-mode deployment channel, with 453,158,170 pulls: https://hub.docker.com/r/dpage/pgadmin4 (checked October 31, 2026)
  • CISA Known Exploited Vulnerabilities catalog, JSON feed, searched for both criticals: https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json (checked October 31, 2026)
  • Official container documentation, default non-root user and deployment configuration: https://www.pgadmin.org/docs/pgadmin4/9.19/container_deployment.html