CVE-2026-76839: Grav CMS Twig Sandbox Credential Leak via offsetGet()
A page editor in Grav CMS can call offsetGet() on a User object inside a sandboxed Twig template to read the bcrypt password hash and 2FA secrets directly, bypassing every redaction Grav applies…
The problem
Grav's Twig sandbox policy in system/config/security.yaml allow-lists offsetget and offsetexists for Grav\Common\User\Interfaces\UserInterface. The concrete User class does not filter which fields offsetGet() returns, so any sandboxed template with access to a User object can read hashed_password, secret, and twofa_secret freely.
The impact is concrete: an attacker with page-edit permission can extract a bcrypt hash for offline cracking, or steal the TOTP seed to generate valid 2FA codes without the user's device. If the user variable resolves to an arbitrary account (not just the current session user), the scope widens to full site-wide credential disclosure.
Proof of concept
A working proof-of-concept for CVE-2026-76839 in getgrav/grav, with the exact payload below.
{{- user.offsetGet('hashed_password') }}
{{- user.offsetGet('secret') }}
{{- user.offsetGet('twofa_secret') }}Twig routes user.offsetGet('x') through checkMethodAllowed, which clears because offsetget is in the UserInterface allowed-methods list. The subscript form user['x'] is correctly blocked because UserInterface has no allowed_properties entry at all.
Only the explicit method-call form leaks.
User::offsetGet() only special-cases the authorized field; it applies no redaction for hashed_password, secret, or twofa_secret. By contrast, User::jsonSerialize() already strips those three fields, proving the intent to hide them exists in the codebase but was never wired into the ArrayAccess read path.
The root cause is CWE-522 (Insufficiently Protected Credentials) compounded by CWE-284 (Improper Access Control in the sandbox policy).
An additional depth: User extends Data, and a separate Grav\Common\Data\Data entry in allowed_methods independently grants get, value, and offsetGet. Removing only the UserInterface entry is not a complete fix; the parent-class entry must also be addressed for those three fields.
The fix
Upgrade getgrav/grav to 2.0.16 (commit 804731d7becab374a49dfb784435aa8cd6480547). The robust fix mirrors the SandboxConfig pattern already used for Config: introduce a redacting User facade that filters hashed_password, secret, and twofa_secret on every read path, and allow-list that facade instead of the raw User or Data class.
A narrower alternative is to override User::get(), value(), and offsetGet() to apply the same redaction that jsonSerialize() already performs.
Reported by Alham Rizvi.
Related research
- high · 5.9CVE-2026-74907CVE-2026-74907: Grav CMS Unauthenticated Path Traversal via plugin-asset-map.php
- critical · 8.8CVE-2026-75827CVE-2026-75827: Grav CMS Arbitrary File Write via Blueprint error_log Injection
- high · 9.1CVE-2026-75837CVE-2026-75837: Grav Group Blueprint Missing Authorization Allows Privilege Escalation to Super-Admin
- high · 8.8CVE-2026-72819CVE-2026-72819: Grav CMS Remote Code Execution via ZIP Upload