GitHub Advisoryadvisory · Packagist

GHSA-p597-crqc-m349 Grav: The system, site, and theme Twig variables bypass the content sandbox entirely and are never covered by config_denied_paths

HIGH Published 2026-09-17 Modified 2026-09-17 Official page ↗
Aliases
CVE-2026-72698
Ecosystem
Packagist
Packages
getgrav/grav
Severity
High
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
Weaknesses
CWE-200
Reviewed
2026-09-17

Affected packages

• Packagist getgrav/grav: < 2.0.16 → fixed in 2.0.16

Description

Summary `Grav\Common\Twig\Twig::init()` unconditionally puts the raw `system`, `site`, and `theme` config arrays into `$this->twig_vars`. `Twig::processPage()` builds the variables for the sandboxed, editor-authored page-content render by copying that same base array (`$sandbox_vars = $twig_vars;`) and replacing only the `config` key with a filtered `SandboxConfig` facade. The `system`, `site`, and `theme` keys are carried into the sandboxed render completely untouched. Because these are plain PHP arrays, not objects, Twig's sandbox `SecurityPolicy` (the `allowed_classes`/`allowed_methods`/`allowed_properties` lists in `system/config/security.yaml`) has no jurisdiction over them at all. The sandbox only gates method calls and property access on objects. Dot notation or subscript access on an array is always allowed by Twig regardless of any sandbox policy. So `{{ system.cache.redis.password }}` in page content renders the value directly, with the sandbox doing nothing to stop it, and with `security.twig_sandbox.config_denied_paths` never even being consulted, since that list only filters the separate `config` facade object, not the `system` array. This means: even on a default install where `twig_content.config_access` is `false` (its documented default) so the `config` Twig variable is empty inside sandboxed renders, an attacker with page-content edit access (or a stored-XSS-style Twig injection into page content, if `twig_content.process_enabled` is on) can still read `system.*`, `site.*`, and `theme.*` in full, including any admin-configured secret nested under those trees. Affected product and version Product: Grav CMS, getgrav/grav Confirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3 Affected code `system/src/Grav/Common/Twig/Twig.php`, in `init()`, the base variable set (around line 300): ```php $this->twig_vars += [ 'config' => $config, 'system' => $config->get('system'), 'theme' => $config->get('theme'), 'site' => $config->get('site'), 'uri' => $this->grav['uri'], ... ]; ``` `system/src/Grav/Common/Twig/Twig.php`, in `processPage()`, where the sandboxed render variables are built (around line 419-429): ```php if ($item->shouldProcess('twig') || $item->isModule()) { $name = '@Page:' . $item->path(); $this->setTemplate($name, $content); // Replace `config` with a denied-path-filtered facade for the // sandboxed render so editors can't exfiltrate plugin secrets // via `config.toArray()` (GHSA-j274-39qw-32c9). The modular // theme render below is unsandboxed and keeps the raw Config. $sandbox_vars = $twig_vars; $sandbox_vars['config'] = $this->buildSandboxConfig(); try { $output = $content = $local_twig->render($name, $sandbox_vars); ... ``` Only `$sandbox_vars['config']` is replaced. `$sandbox_vars['system']`, `$sandbox_vars['site']`, and `$sandbox_vars['theme']` still point at the exact same raw arrays that were assigned in `init()`. `system/config/system.yaml` shows a concrete real secret field that lives under `system`: ```yaml cache: redis: socket: false password: # Optional password database: ``` Root cause Two separate things have to both be true for this to be reachable, and they both are: 1. The sandbox's `SecurityPolicy` only checks object method calls and object property access (`checkMethodAllowed`, `checkPropertyAllowed` in Twig's `Sandbox\SecurityPolicy`). It has no concept of restricting array key access, because Twig's own design does not treat plain array reads as something a sandbox policy needs to arbitrate. `config_denied_paths` is implemented entirely inside `SandboxConfig`, a wrapper object with its own `get()`/`offsetGet()` that consults the denied list, that facade is what makes `config` safe. `system`/`site`/`theme` never get wrapped in anything like it, they are passed straight through as arrays. 2. `processPage()`'s sandboxed variable set is built by copying the entire pre-existing `$twig_vars` array and only patching the one key (`config`) that the GHSA-j274-39qw-32c9 fix was scoped to. `system`, `site`, and `theme` were already sitting in that array before the sandboxed path was ever reached, and nothing removes or filters them for that specific render. Proof of concept, verified, real output I verified this at two levels: first that the raw Grav source really does copy `system` into the sandboxed variables unfiltered (shown above via direct file reading of `system/src/Grav/Common/Twig/Twig.php`, not a paraphrase), and second, since I do not have a fully bootstrapped live Grav site available in this sandbox (composer install needs packagist.org, unreachable here), I verified the actual mechanism, that Twig's sandbox cannot restrict array access no matter how strict the policy is, by running it against the exact, real Twig source Grav has pinned. Step 1, get the exact Twig commit Grav's composer.lock points at: ``` $ python3 -c " import json d = json.load(open('composer.lock')) for pkg in d['packages']: if pkg['name'] == 'twig/twig': print(pkg['source']) " {'type': 'git', 'url': 'https://github.com/getgrav/Twig.git', 'reference': '24d7a0e821cf573496d99e05d6bd9d1a42f822c7'} ``` Step 2, clone that exact commit: ``` $ git clone https://github.com/getgrav/Twig.git twig-src $ cd twig-src && git checkout 24d7a0e821cf573496d99e05d6bd9d1a42f822c7 HEAD is now at 24d7a0e8 Merge branch 'twigphp:3.x' into 3.x ``` Step 3, PoC script. This builds a `SecurityPolicy` with an empty `allowed_classes`, `allowed_methods`, and `allowed_properties` list, deliberately stricter than Grav's real policy, to show that even a maximally locked down object policy still cannot stop array key access, then renders `{{ system.cache.redis.password }}` against a `system` variable shaped exactly like what `$config->get('system')` returns in real Grav: ```php '{{ system.cache.redis.password }}', ])); $twig->addExtension(new SandboxExtension($policy, true)); // Exactly what $config->get('system') returns as a plain PHP array in real // Grav, and exactly what Twig::init() assigns to $twig_vars['system']. $system_config_array = [ 'cache' => [ 'driver' => 'redis', 'redis' => [ 'socket' => false, 'password' => 'REDACTED-REAL-SECRET-VALUE-abc123', 'database' => 2, ], ], ]; try { $output = $twig->render('page_content', ['system' => $system_config_array]); echo "Template : {{ system.cache.redis.password }}\n"; echo "Rendered output : " . $output . "\n"; echo "Sandbox blocked it : " . ($output === '' ? 'YES' : 'NO, the secret was rendered in plain text') . "\n"; } catch (\Twig\Sandbox\SecurityError $e) { echo "Sandbox threw a SecurityError (blocked): " . $e->getMessage() . "\n"; } ``` Step 4, …

Related

Alias · CVE (1)

  • CVE-2026-72698 Grav CMS before 2.0.16 fails to filter system, site, and theme configuration arrays in sandboxed Twig renders, allowing content editors to… vulnerability

Weakness · CWE (1)

  • CWE-200 Exposure of Sensitive Information to an Unauthorized Actor weakness

References