Automated Threat Modeling

Brief Summary: pgAdmin 4 CVE-2026-116241 OS Command Injection via Import/Export Data

A brief summary of CVE-2026-116241, a critical OS command injection flaw in pgAdmin 4's Import/Export Data tool that bypasses the earlier CVE-2026-17566 fix. Includes patch and detection information.

CVE Analysis

9 min read

Gaurav Sarraf
Gaurav Sarraf

2026-11-03

Brief Summary: pgAdmin 4 CVE-2026-116241 OS Command Injection via Import/Export Data
Experimental AI-Generated Content

This CVE analysis is an experimental publication that is completely AI-generated. The content may contain errors or inaccuracies and is subject to change as more information becomes available. We are continuously refining our process.

If you have feedback, questions, or notice any errors, please reach out to us.

[email protected]

Introduction

Before version 9.19, a user with nothing more than a standard database role and access to pgAdmin's Import/Export Data tool could make an operating system command run on the pgAdmin host itself. The command runs as the account running pgAdmin, before PostgreSQL even sees the statement and rejects it. A routine data export feature becomes a host compromise primitive.

pgAdmin is the open source administration, development, and monitoring tool for PostgreSQL, running in desktop and server modes across Windows, macOS, Linux, and container deployments. It is one of the most widely deployed PostgreSQL management platforms and is maintained by the community that uses it. The remote reach of this flaw matters most in server mode, where multiple people hold pgAdmin accounts.

Technical Information

For a query export, pgAdmin wraps the user's SQL in a psql \copy ( ... ) TO ... command and hands it to psql. The only thing stopping a user's ) from closing that wrapper early is a Python check, _is_query_parens_balanced(). It walks the query and skips anything inside quoted strings and identifiers. That check is only safe if pgAdmin tracks quotes exactly the way psql's \copy tokenizer (strtokx in parse_slash_copy) does.

According to the commit message, strtokx gets its escape character from the connection's standard_conforming_strings (scs) setting and applies it to whichever quote opened the token, single or double.

The earlier fix for CVE-2026-17566 (commit 1496fabe2, according to the new commit message) made the validator reject any backslash inside a single-quoted string. Double-quoted identifiers had no such rule, and pgAdmin treated the next " as closing the identifier. With scs off, psql reads \" as an escaped quote, so the identifier keeps going. At that point the two parsers disagree about what is quoted. A ) that pgAdmin thinks is inside an identifier is live code to psql, so it closes the wrapper and exposes a TO PROGRAM clause. psql popen()s that command on the pgAdmin host before the server ever sees the SQL.

The record classifies the issue under CWE-115 (Misinterpretation of Input) and CWE-78 (OS Command Injection).

Attack flow

  1. The attacker holds an ordinary database role and the tools_import_export_data permission, which non-administrators receive by default.
  2. They turn off standard_conforming_strings for their own session, which an ordinary role is allowed to do.
  3. They submit a query export containing a backslash-escaped double quote inside a double-quoted identifier, followed by a closing parenthesis, followed by a TO PROGRAM clause. The commit message's illustrative example is SELECT "a\"b"c) TO PROGRAM 'cmd' x".
  4. pgAdmin's _is_query_parens_balanced() believes the ) sits inside the identifier and passes the query as balanced.
  5. psql, reading \" as an escaped quote with scs off, keeps the identifier open past where pgAdmin closed it, treats the ) as closing the \copy wrapper, and executes the TO PROGRAM command via popen() on the pgAdmin host, under the pgAdmin process account.
  6. The PostgreSQL backend later rejects the statement as a syntax error, but only after the command has already run.

Patch Information

pgAdmin fixed CVE-2026-116241 in pgAdmin 4 9.19, released 2026-10-29. The release notes list it under tracking Issue #10663. The fix comes in two commits on web/pgadmin/tools/import_export/__init__.py. The first closes the reported bypass in the query-export path. The second blocks the same backslash problem where object names are built into the \copy command.

Fix 1: reject backslashes in double-quoted identifiers (commit 9be31a0)

The identifier branch of _is_query_parens_balanced() now uses the same fail-closed rule as the single-quoted branch:

- # 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 does not try to guess the server's scs setting and copy psql's behavior. Any database role can change scs for its own session, so pgAdmin cannot reliably know the value when it validates the query. The patch treats a backslash in any quoted token as ambiguous and refuses the query. The updated docstring states the rule: reject "any query containing a backslash inside a quoted token outright." The standard SQL way to escape a quote inside an identifier, doubling it (""), still works.

The commit adds regression cases to test_import_export_create_job_unit_test.py:

  • the scs-off backslash-quote identifier bypass is expected to be rejected
  • any identifier containing a backslash, such as "a\b", is rejected
  • an identifier with a doubled quote that hides a ) ("a""b)") is still accepted as balanced, so legitimate quoting keeps working

Fix 2: the same class of bug in schema, table and column names (commit f88596f)

The maintainers also looked for other ways a backslash could reach the same \copy command. Schema, table and column names (including the FORCE_* column lists, which are used even for query exports) go through qtIdent() before being added to the command. qtIdent() doubles embedded double quotes but leaves backslashes alone, so a name ending in \ becomes "name\", which psql reads as an identifier that never ends. Null bytes are a related risk. They can cut off the C string, so Python and psql end up checking different text. The commit adds a check that runs before any column list is formatted:

def _identifier_has_unsafe_char(data): ... names = _identifier_strings(data.get('schema')) + \ _identifier_strings(data.get('table')) for key in ('columns', NOT_NULL_COLUMNS, NULL_COLUMNS, 'force_quote_columns'): names += _identifier_strings(data.get(key)) return any('\\' in n or '\x00' in n for n in names)
if _identifier_has_unsafe_char(data): return bad_request( errormsg=_('A schema, table or column name must not contain a ' 'backslash or null byte.'))

The helper _identifier_strings() checks names in whatever JSON form they arrive: list items, dict keys, or a bare string. Without that, a backslash in a string-typed column value could slip past a list-only check, because columns_to_string() loops over a bare string one character at a time. A new test file, test_import_export_identifier_backslash.py, covers backslashes in every name field, string and dict column values, null bytes, and safe inputs such as * and names with embedded double quotes.

Design takeaway

Both commits use the same idea. Instead of building a more accurate copy of psql's tokenizer, which depends on a server setting the attacker controls, pgAdmin rejects the one character whose meaning is ambiguous. In the maintainers' words, pgAdmin never needs to name an object containing either a backslash or a null byte. The trade-off is that queries or object names containing a backslash inside identifiers can no longer be used with Import/Export Data. In exchange, the parenthesis guard and the identifier quoting now hold whatever the scs setting is.

Detection Methods

As of early November 2026, no one has published a formal detection signature for CVE-2026-116241. CVETodo lists Sigma, Suricata, YARA and Nuclei all as "None published" (checked 2 November 2026). OffSeq's radar notes that no public nuclei template is indexed and that its honeypot sensors have recorded no attack traffic. Ostorlab says plainly that no indicators of compromise have been published. What defenders can use is a set of hunting leads from Ostorlab's write-up, plus the exploit pattern that the upstream fix commit spells out. Read every match as something to investigate, not proof of compromise.

1. Find the exposed instances first

Every pgAdmin 4 release before 9.19 is affected, in server, desktop and container deployments alike. The remote attack path matters most in server mode, where other people hold pgAdmin accounts. The container logs its version at startup:

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

For pip installs:

pip show pgadmin4 | head -2

The Help, About box also shows the version. Do not trust floating Docker tags such as latest. Check the version directly. Exposure also depends on who holds the tools_import_export_data permission, which non-administrators get by default, so review which roles still have it.

2. Know what a malicious query looks like

The fix commit (9be31a0) describes the bug class exactly. The attacker first turns off standard_conforming_strings for their own session, then submits a query export with a backslash-escaped double quote inside a double-quoted identifier. A closing parenthesis follows, then a TO PROGRAM clause. The commit message's example is SELECT "a\"b"c) TO PROGRAM 'cmd' x". The regression test added for this CVE uses the payload TO PROGRAM 'echo pwned'. In practice, look for three things together in any query export:

  • a \ inside a "..." identifier
  • a ) that appears to sit inside that identifier
  • the string TO PROGRAM

A legitimate export has little reason to contain TO PROGRAM, but Ostorlab notes it can appear harmlessly, for example in a SQL comment. Treat matches as leads to triage.

3. Search pgAdmin's own job history

This is the most direct hunt. Every Import/Export job shows in the pgAdmin Processes panel, and its history is stored in pgAdmin's configuration database. Ostorlab gives this read-only query for the default SQLite store:

# 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. Use its start_time and pid to line it up with host and database logs. Ostorlab also warns that an empty job history does not rule out an attempt, so keep checking the other sources below.

4. Look for the database-side setup step

The exploit only works with standard_conforming_strings off. An ordinary role can change that for its own session, and the change is not saved as database configuration. It can still show up in PostgreSQL statement or audit logs, if you were collecting them. Search retained logs for SET standard_conforming_strings from roles that use pgAdmin. If you find it close in time to an Import/Export job from the same user, that pairing is worth a close look. The commit also explains that psql rejects the statement as a syntax error, but only after the command has already run. A syntax error near TO PROGRAM in the logs from a pgAdmin-connected role is therefore a related clue. It suggests an attempt was made, not that it was blocked.

5. Check the host for what the command left behind

The fix commit explains that psql calls popen() for a client-side \copy ... TO PROGRAM before the backend sees the statement. The command runs on the pgAdmin host, as the OS account running pgAdmin, not on the database server. That account is usually the non-root pgadmin user (UID 5050) in the official container, and whichever account runs the web server in pip or package installs. Ostorlab recommends checking for unfamiliar processes, cron entries and files under that user's home directory. If you have process telemetry (EDR or auditd, for example), watch for shells or other unexpected programs whose parent is a psql process started by pgAdmin. The import/export tool normally makes psql write data to a file, not start other programs.

6. Use web logs for attribution

The vulnerable route is POST /import_export/job/<sid>. Reverse-proxy or access logs usually will not record the request body, so they cannot show the payload. They can still tell you which accounts submitted import/export jobs and when, which helps you scope suspects after a hit from steps 3 through 5.

As of the sources' check dates, no public exploit, in-the-wild exploitation, campaign IoCs or KEV listing has been reported. Hunting therefore relies on spotting the exploit's behavior rather than matching known indicators.

Affected Systems and Versions

pgAdmin 4 before version 9.19 is affected across server, desktop, and container deployments. The fix is included in pgAdmin 4 9.19 and later, released 2026-10-29. Exposure is highest in server mode, where multiple accounts exist, and depends on which roles hold the tools_import_export_data permission, granted to non-administrators by default.

Vendor Security History

This issue is a bypass of the fix for CVE-2026-17566, which had rejected backslashes inside single-quoted strings but left double-quoted identifiers unguarded. Version 9.19 included fixes for 15 security vulnerabilities, including this one. The project operates a private vulnerability reporting process and publishes advisories sourced from the Open Source Vulnerabilities database. The recurrence shows why a security-sensitive input check has to match the precise behavior of every parser involved. That observation is specific to this case and does not by itself establish a broader security trend for the project.

References

Detect & fix
what others miss

Works with
  • GitHub
  • GitLab
  • Bitbucket
  • Azure DevOps Services
  • Gerrit
  • CVS
  • Jira
  • Linear
  • Slack
  • Security Compass
Explore integrations
Security magnifying glass visualization