Skip to content

Grav sandbox-escape, privilege-validation, and host/origin trust boundaries

Source: GitHub Security Advisories unreviewed feed, 2026-08-25 (all first published 2026-08-25): GHSA-3625-697m-q29v (Email plugin unsandboxed Twig → OS command, high 8.8), GHSA-crrc-vpp2-f5x7 and GHSA-mw85-cjh9-8hp7 (Twig sandbox config-secret read, high 7.5 / 6.5), GHSA-8vp7-8q4w-vv7m (offsetGet/offsetexists User-field read, high 6.5), GHSA-qw9m-fc79-372g (login unlock handler missing privilege check, critical 9.8), GHSA-92vc-67q9-382j and GHSA-px9v-979x-qmh9 (non-constant-time token/nonce compare, high 7.5 / medium 3.7), GHSA-55hc-4r2f-h6wr (email enumeration, critical 5.3), GHSA-qh7h-6c7g-x8m6 (unanchored Referer prefix origin bypass, critical 5.4), GHSA-6c2c-797q-5r9x (sendInvitationEmail untrusted Host, high 7.5), GHSA-896w-cw95-xq7w (deleteFile path traversal, high 8.1), GHSA-cpcf-vm8j-257x (scheduler lock-file symlink, high 8.4), GHSA-h2w4-jr6m-7c52 (webhook DNS-rebinding SSRF, medium 5.3), and GHSA-f549-r4pw-jw3m (Flex Objects shortcode authorization bypass, high 7.7). Core Grav affects 2.0.16/3.9.2, Login plugin 3.9.1/1.0.16, Email plugin 4.2.2, API plugin 1.0.16, Flex Objects 1.4.0–1.4.7.

Grav is a flat-file CMS whose page model lets content editors render Twig. This wave is durable because it concentrates a reusable set of editor-to-authority boundaries: page-editor content crossing into unsandboxed Twig evaluation, a sandbox denylist that misses config/user fields, an API row-action that omits a privilege check, and host/origin validation that trusts untrusted headers. The "editor can render a template but the template is not sandboxed" chain (Email plugin) and the "sandbox allowlist method leaks a sensitive object" chain (offsetGet) are the same trust-confusion family that recurs in any CMS that exposes a template engine to non-admin editors.

Canaries only

Run these checks in a disposable Grav install with synthetic users, marker files, and owned no-content peers. Use denied command/file/network sinks. Never execute a real OS command, read or delete real files, enumerate real user accounts, exfiltrate config secrets, or reach internal services.

Boundary map

Surface Caller-controlled value Privileged transition Safe positive
Email plugin form field Twig expression in email.body page-editor content evaluated unsandboxed as OS command expression is sandboxed or not evaluated; canary command is denied
Twig sandbox config.get/config.toArray, offsetGet, dot-notation arrays page-editor reads config/user secrets denied paths + filtered methods block the read
login unlock row-action api.users.write session clears lockout on admin.super without privilege check target-account privilege validated
Referer/Host header attacker Referer/Host prefixing the site origin/redirect validation treated as same-origin anchored + scheme/port-anchored validation
deleteFile ../-shaped media filename authenticated delete escapes media root path confined to media root
lock-file create pre-placed symlink at predictable temp path scheduler overwrites an arbitrary writable file target validated / symlink rejected
webhook hostname attacker-controlled DNS rebinding passes public validation, reaches private delivery re-validate final resolved peer

Email plugin unsandboxed-Twig command execution

The most severe item. The Email plugin renders page-editor-controlled form process.email.body as a non-sandboxed Twig template. A user with only api.access + api.pages.write can place a Twig OS-command expression, publish the page, and submit the form to run a command as the PHP account.

  1. In a disposable Grav with the Email plugin, create a page with a form whose process.email.body is a harmless Twig marker (e.g. one that would print a canary).
  2. Publish and submit as a low-priv editor.
  3. Record whether the expression is evaluated in a sandbox or as arbitrary Twig with full function access.
Input Expected secure result
ordinary text body rendered, no evaluation
{{ ... }} expression sandboxed to a denied allowlist; no OS/system/process access

A bounded positive is the expression reaching an unsandboxed evaluation path that would reach a process sink. Do not run a real command. Prove the sandbox is absent with the recorded evaluation path and a denied command sink; execution is the downstream risk, reported as such without a live payload.

Sandbox allowlist leaks: config and user fields

Three related leaks show an incomplete sandbox denylist:

  • Config secrets — config.get() / config.toArray() (and dot-notation array access) return system.cache.redis.password-style values when config_access is enabled; config_denied_paths is bypassed by dot notation.
  • User fields — allow-listed offsetGet()/offsetexists() on User objects lack field filtering, exposing hashed passwords and 2FA secrets to any page editor.

Test each with a synthetic user and a config containing only fake canary values:

Probe Expected secure result
config.get('system.cache.redis.password') denied path; no value returned
config.toArray() filtered arrays, no secrets
user.offsetGet('password') / offsetGet('two_factor') field filtered out

A positive is a canary config value or a synthetic user's marker password/2FA field appearing in rendered output. Report the specific allow-listed method and the missing field filter; never capture a real credential.

Login unlock handler privilege gap

onApiUserListRowAction (unlock) with api.users.write clears login lockout counters on admin.super accounts without validating the target account's privilege. In a lab: as a low-priv user, clear the lockout on a synthetic admin account and record whether the highest-privilege account's brute-force protection is removed. A positive is the admin.super lockout cleared by a lower-privilege principal. Report the missing target-privilege check; do not target real accounts.

Host/origin validation trust confusion

  • Unanchored Referer prefix match — Uri::referrer()/Pages::referrerRoute() use str_starts_with($referrer, $base) with no trailing delimiter, so https://example.com.attacker.tld is treated as same-origin. Test with an owned domain that prefixes the victim origin and record the origin decision. A positive is the attacker origin passing the same-origin check.
  • sendInvitationEmail untrusted Host — invitation links are built from the unvalidated Host header; require_trusted_host only guards password reset. Manipulate Host and record the resulting token-bearing link target. A positive is the invitation link pointing to the attacker-controlled host.
  • Email enumeration — register() throws a distinct EMAIL_NOT_AVAILABLE for existing addresses with no rate limit. Note this as an enumeration primitive; do not enumerate real accounts, only confirm the differential response on synthetic data.
  • MediaUploadTrait::deleteFile traversal — only the basename is validated; ../ in the directory portion reaches unlink(). Use a sibling marker file in a scratch root and record whether it is deleted.
  • Scheduler createLockFile symlink — a pre-placed symlink at the predictable, world-writable temp lock path can be followed to overwrite an arbitrary writable file. Place a symlink to a scratch marker and record whether the scheduler writes through it. Stop at the denied write/unlink/ownership syscall against the sibling canary; do not target real files.

Webhook DNS-rebinding SSRF

The Grav API plugin validates the configured webhook hostname with one lookup and delivers with another. Use two owned DNS answers (public for validation, private for delivery) and record the validation peer vs the delivery peer. A bounded positive is validation on an owned public peer while delivery reaches an owned private/loopback peer. Never reach cloud metadata, loopback admin routes, or internal services.

Reporting heuristics

  • Frame the Email item as "Grav Email plugin evaluates page-editor form fields as unsandboxed Twig," citing the low-priv capability set and the process sink.
  • Keep the sandbox-leak items scoped to the specific allow-listed method and the missing field/denied-path filter, with a synthetic canary.
  • For the host/origin items, cite the exact comparison (str_starts_with without delimiter, unvalidated Host) and the route it gates.
  • Cite the per-plugin version bounds; the core CMS fixes land in 2.0.16/3.9.2, plugins in their own lines.

Safety

  • Authorized, in-scope targets only; Grav sites are commonly shared hosting where "editor" may mean any content author.
  • Synthetic users, fake config canaries, marker files, and owned no-content peers; denied command/file/network sinks.
  • No real command execution, no real file read/delete, no real account enumeration, no config-secret exfiltration, no internal/metadata reach.
  • Report the primitive at each boundary without performing the high-impact action on a live host.

September 4 follow-up: 2FA secret rotation, sandbox exfil, decompression, and blueprint callable

Four later Grav advisories extend the 2026-08-25 editor-to-authority and sandbox work on this page. They belong here because each is a Grav-specific trust boundary in the same families already mapped above.

  • GHSA-7mgc-c7pq-3rr3 / CVE-2026-62669 — 2FA bypass via login.regenerate2FASecret secret rotation. The regenerate2FASecret row-action re-derives the TOTP secret and, in the vulnerable state, can be driven by a caller who should not control secret rotation, letting an attacker replace a target's 2FA secret with one they can compute. Reusable check: for any 2FA/TOTP row-action that rotates or regenerates the secret, confirm the actor is bound to the target account and the rotation is not callable by a different principal or by a self-service path the account owner does not own.
  • GHSA-mc5q-6hpj-rp7j / CVE-2026-61842 — Twig sandbox config exfiltration via grav.offsetGet. A second, distinct sandbox-leak path to config: offsetGet (offset-access) reaches a config object the sandbox denylist does not cover. This is the same family as the existing offsetGet/offsetexists User-field read item, extending it to the config namespace. Reusable check: enumerate every object-accessor method the Twig sandbox allows and test each against config, user, and process objects, not just the originally-reported method.
  • GHSA-928x-9mpw-8h56 / CVE-2026-61690 — decompression bomb via ZipArchiver missing extraction limits. An attacker-supplied archive reaches ZipArchiver with no size/entry/entropy bounds, so a high-compression-ratio archive expands to exhaustion during extraction. Reusable check: for any archive-unpack path that handles user content (media uploads, backup import, theme/plugin install), verify explicit bounds on uncompressed total size, entry count, and per-entry expansion, and test with a benign high-ratio canary archive that stops at the limit decision.
  • GHSA-fj2p-qj2f-74v5 / CVE-2026-64850 — RCE via unrestricted callable in Blueprint. A Blueprint (plugin/config schema) field is passed to a callable without allowlisting, so a value that reaches call_user_func/dynamic invocation executes arbitrary code. Reusable check: for any CMS blueprint/config that stores a value later passed to a PHP callable, confirm the callable name is allowlisted or the value is treated as data; test with a marker callable that would be invoked if the allowlist were absent.

All four are canary-only: synthetic users, marker config values, a benign high-ratio archive, a marker callable, and a lab TOTP secret. No real secret rotation, no real config exfiltration, no real decompression, and no real command execution.

Five later Grav records published 2026-09-05 extend the editor-to-authority and host-trust axes already mapped on this page. All five are canary-only: synthetic editors/admins, marker assets, owned no-content peers, marker invitation payloads, and denied write/exec sinks.

  • GHSA-5pmv-xr2f-mrf8 — Twig sandbox asset-method XSS (Grav core < 2.0.20). The sandbox allowlists addJs/addCss on Grav\Common\Assets without output escaping, so a page editor can register a malicious asset (script src or injected attributes) that renders unescaped into the document head and executes for all visitors, including administrators. Reusable check: for every sandbox-allowlisted object/method, test not only what the method reads but what it emits into HTML — allow-listing a method is not an escaping guarantee. Proof is a marker asset whose tag appears unescaped in the rendered head on a second user's page.
  • GHSA-gg9g-7x93-5gq2 — forgot-password reset links built from untrusted Host (grav-plugin-api < 1.0.20). The forgot-password endpoint assembles the reset URL from the request Host header, so an attacker can request a reset for any account with a malicious Host, capture the token from the victim email, and complete account takeover including super-admin. This extends the existing sendInvitationEmail untrusted-Host item: the require_trusted_host guard covers only the invitation/reset paths it was written for, not the API plugin's own reset link builder. Reusable check: for every token-bearing email link, map the exact URL-construction call site and confirm the base URL comes from server configuration, not the request.
  • GHSA-99h4-pmfq-pv63 — Form plugin cross-page form-name resolution without page authorization (Form < 9.1.22). The plugin resolves a form by name across all pages and does not re-check page access for the page that owns the form, so an anonymous visitor can POST to any public page with a restricted form's name and trigger its save/upload/email/call actions. Reusable check: when an action is resolved by an attacker-chosen name/ID that spans multiple objects, the authorization must check the resolved object's access tier, not the entry page's.
  • GHSA-rg66-gwrp-wj75 — invitation super-flag stripping misses dot-keyed variants (grav-plugin-api < 1.0.20). InvitationsController::stripSuperFlags() removes nested super flags but not dot-keyed equivalents such as api.super; a non-super user manager with api.access + api.users.write can embed the dot-keyed flag in an invitation's access payload, and accepting the public invitation endpoint mints a super-admin account with a valid JWT with no real invitee interaction. Reusable check: whenever a guard strips forbidden keys from a user-supplied structure, test every notational variant (nested array key, dot-notation string, URL-encoded, case variants) against the same payload — key-stripping is a canonicalization problem, not a key-list problem.
  • GHSA-6rq7-cjmj-rc58 — group-inherited super permissions not validated in user-management guards (grav-plugin-api < 1.0.20). The user-management guards check the target account's own flags but not group-inherited super status, so a non-super manager with api.users.write can patch password fields on group-super accounts to gain full administrative control. Reusable check: for any guard that decides "is this target privileged?", confirm it evaluates effective privilege (account flags + group + role inheritance), matching how the authorization engine resolves it elsewhere — a guard that re-derives privilege from a single field is the drift.

Durable axes this follow-up adds to the Grav map: sandbox allow-list vs. output-escaping is two different controls (a method can be safely callable yet still emit attacker HTML); token-link URL builders are per-plugin, so a trusted-host guard in core does not cover plugin-built links; and privilege guards fail on canonicalization — both the dot-keyed flag variant and the group-inheritance gap are the same class: the guard's key/privilege model is a simplified copy of the real one.

September 16 late-night follow-up: pre-render validation bypass via Twig concatenation, and media-action CSS injection (2 GHSAs)

Two later Grav records published 2026-09-16T22:13Z extend the editor-to-rendered-content axis:

  • GHSA-2c4f-86xc-cr74 / CVE-2026-61453 — XSS blueprint validator bypass via Twig string concatenation (Grav = 2.0.0, fixed 2.0.1). Security::detectXss() scans the raw page content before Twig processing. {% set %} is an allowed tag and the ~ concatenation operator is a core operator never gated by the sandbox, so {% set x = "on" ~ "error" %}<img src=1 {{ x }}=alert(1)> passes validation (the validator's on_events regex sees no on*=, and { is outside its boundary character class) and then renders <img ... onerror=...> into {{ content|raw }}. The same technique reconstructs any blocked token: <s{{"c"~"r"~"i"~"p"~"t"}}> defeats the dangerous_tags blocklist, and href="{{"java"~"script"}}:..." defeats invalid_protocols. Reachable with twig_content.process_enabled: true plus api.pages.write. Reusable check: any validator that runs on pre-template-engine input is bypassable by any construct that defers literal assembly to render time — test the bypass with concatenated fragments for every blocked pattern family (event handlers, tag names, schemes), not just the originally reported vector. This is the exact dual of the Sept 5 lesson: guards fail both on canonicalization downward (dot-keys) and on deferred assembly (render-time reconstruction).
  • GHSA-ffmg-hfvg-jhg9 / CVE-2026-58657 — stored CSS injection via Markdown image resize() media action (Grav = 2.0.0-rc.9). Prior media hardening rejects direct ?style= payloads and unsafe attribute() fallbacks, but the adjacent resize() action writes caller-controlled values straight into styleAttributes: ![logo](image.png?resize=100;position:fixed;top:0;left:0;width:100vw;height:100vh;background:white;z-index:9999,200) renders a full-viewport overlay in the reviewer/admin view — no JavaScript needed. Reusable check: enumerate every media-action/parser hook that emits into the same style/attribute sink the sanitizer was written for; the sanitizer covers the named parameter, not the sink. Same sibling-miss family as the @nuxtjs/mdc xlink:href gap on the Sept 16 sanitizer-parity page.

Tracked adjacent without promotion: GHSA-2vcx-h8p2-9pg9 / CVE-2026-59193 — Installer::unZip() calls ZipArchive::extractTo() with no uncompressed-size/entry-count/depth limits (zip bomb + disk exhaustion, admin-authenticated Direct Install path); availability-only, no new privilege boundary.

September 17 follow-up: unauthenticated Clockwork profiler history and a sandbox guard that checks the wrong flag (2 GHSAs)

Two Grav records published 2026-09-17T12:32Z extend the debug-surface and sandbox families on this page.

  • GHSA-q3g2-wjhw-4rmj / CVE-2026-92916 — unauthenticated Clockwork profiler endpoint leaks sessions, plaintext passwords, and config secrets (Grav 1.7.0–1.7.53.2, 2.0.0–2.0.21, fixed 1.7.53.4 / 2.0.22). With system.debugger.enabled: true (non-default), InitializeProcessor::handleDebuggerRequest() intercepts any path containing /__clockwork/ during bootstrap and dispatches to Debugger::debuggerRequest() with no user lookup, no IP restriction, no Clockwork authenticator check, plus anonymous pagination over the entire stored history. With the shipped censored: false default, each stored record contains raw request cookies (including Grav's session cookie — the value is the PHP session id, so one read resumes any user's session, admin included), the full parsed request body (login form posts data[username]/data[password], stored plaintext because Clockwork's password filter only inspects top-level keys), and the whole system/plugin config including SMTP credentials, API keys, and licence keys. Authorization and X-API-Token headers are stored even when censored: true. On Grav 2, setting provider: debugbar is not an avoidance — Grav forces the Clockwork provider for requests preferring a JSON response. Fixes restrict /__clockwork/ to server-local requests or requests presenting a new system.debugger.token.
  • Reusable checks: profiler/debug history endpoints (Clockwork, Telescope, Debugbar, /__profiler/, /__clockwork/, /debugbar/) are feature-named recon targets — path-substring routing means try anything/__clockwork/1 if the direct path is filtered. Test the Accept: application/json differential because provider selection can differ by content negotiation. A censoring filter that walks only top-level keys is a key-shape gap, same family as the dot-keyed flag-strip miss above. When triaging any CMS instance, probe the documented debug endpoints even if "non-default" — operators enable debuggers on production far more often than they remember.
  • GHSA-rv7g-323x-h2q2 / CVE-2026-92917 — Twig content sandbox fails open for dump/serialize filters because the guard checks the global flag (Grav 2.0.0-rc.1–2.0.21, fixed 2.0.22). GravExtension::assertSandboxDumpSafe() calls SandboxExtension::isSandboxed() without a Source argument, which returns only the global sandbox flag that Grav never enables — so the guard added for GHSA-mc5q-6hpj-rp7j above never executes. A page editor with Twig processing enabled can render {{ config|print_r }} and dump the entire merged configuration: print_r reflects the real Config object held in a private property of the SandboxConfig facade, bypassing the facade's path redaction — plugin SMTP creds, API tokens, webhook secrets, cache backend passwords. Grav 1.7 is unaffected (it ships no Twig content sandbox at all — an older-version target can be the easier sandbox story or the harder one, check per line). Fixed by registering the filters with Twig's needs_is_sandboxed flag.
  • Reusable checks: a sandbox-state guard must be evaluated per-source, not globally — grep any isSandboxed()-style call for a missing source/context argument; if the caller-supplied flag defaults to "off", the guard is dead code, and "a guard was added for the previous CVE" is not evidence it runs. Reflection-style output filters (print_r, var_dump, json_encode, yaml_encode) reach an object's private state and therefore bypass any facade that censors only its public surface — enumerate every filter/function registered in the sandbox and test each against the config/user objects, exactly the lesson from the Sep 4 grav.offsetGet item, now with a second distinct mechanism.

Both proofs are canary-only: a lab Grav with the debugger enabled and fake config secrets, synthetic users/cookies, and a lab page with a marker config key; never harvest real session ids, real request bodies, or real credentials.

September 17 late follow-up: the patched callable validator routed around by a registered handler (GHSA-c4wf-2xxc-68qm)

GHSA-c4wf-2xxc-68qm (published 2026-09-17T17:15Z) is an incomplete-fix bypass of GHSA-fj2p-qj2f-74v5 above: the 2.0.7 fix added isSafeDynamicCall() validation to Blueprint::dynamicData(), but FlexDirectory::getBlueprint() registers a dynamic handler for the data action on every Flex directory, and Blueprint::init() prefers registered handlers over the built-in method — so FlexDirectory::dynamicDataField() runs instead, calling call_user_func_array($function, $params) on blueprint-supplied input gated only by is_callable(). exec/system/passthru/shell_exec are all valid PHP callables. An account holding only create/update on any Flex directory triggers it whenever a blueprint field carries a data-*@: directive.

  • Reusable check: when a prior CVE was fixed by adding validation inside a built-in dispatcher method, enumerate every registered-handler/alternate-dispatcher path that reaches the same sink — the patch covers one call site, not the sink. For any Grav 2 target, treat Flex blueprint fields as a pre-auth-tier-to-RCE candidate ladder and test with an inert marker callable, never a command payload.
  • Same sibling-miss family as the resize() media-action CSS injection and the xlink:href sanitizer gap: fix lands on the named path, adjacent path keeps the primitive.
  • Same window: GHSA-w3f4-8pj2-599w — ImageMedium::watermark() passes its $image argument unsanitized into UniformResourceLocator::findResource(), which only lexically collapses .. segments (no realpath/containment re-check against the stream root), so an editor-authored Markdown watermark(image.png?watermark=../../../path) resolves outside the media sandbox; if the target is a decodable image, its pixels are composited into the carrier and cached/served from a public unauthenticated URL — anonymous disclosure of any image-class file on disk. Same lesson as resize(): enumerate every allow-listed media action, not just the patched one; and lexical .. collapsing is not containment — prove only with synthetic lab images and never read real config/secret files. Adjacent re-surfacing records GHSA-7pgq-cr25-xvc8 + duplicates GHSA-mmwh-j75q-gxp8/GHSA-vj8j-973f-r65j reference this and the Flex callable items and add nothing new.

September 17 17:31Z follow-up: the media-hardening fix scoped to the style attribute, missing the rawHtml <source> sink (GHSA-6qw9-4vv5-jr97 / CVE-2026-75831)

GHSA-6qw9-4vv5-jr97 / CVE-2026-75831 (published 2026-09-17T17:31Z, verified on 2.0.13 and develop HEAD) is the fourth sibling miss in the media-parameter injection class — and the sharpest one yet, because the maintainer's audit commit (aba291a5) explicitly scoped itself to "sinks reaching the style attribute." The audio/video player builds its inner <source> element as Parsedown rawHtml (emitted verbatim, unescaped) by concatenating the media URL straight into a double-quoted attribute (AudioMediaTrait::sourceParsedownElement(), identical in VideoMediaTrait), so the URL fragment is reflected without encoding: a Markdown embed like ![x](song.mp3#"><svg/onload=alert(1)>) breaks out of <source src="…"> and injects arbitrary HTML — full script execution, not CSS injection — in every viewer's session, including a logged-in admin viewing the page (Admin session → same-origin attacker script). Unlike the three prior siblings (attribute(), style(), resize()), this sink escalates past CSS to stored XSS. Duplicate record GHSA-q8cg-5m48-5c25 adds nothing new.

  • Reusable check: when a sanitizer fix names its scope by attribute (style, href) rather than by sink class (rawHtml/attribute-context emission), enumerate every emitter into any HTML context the same untrusted value reaches. For Grav media, the full sweep is now style() / attribute() / resize() / watermark() / audio-video <source> — test each media action per file type (audio and video carry their own traits) with a quote-break + inert <svg onload> marker canary in the URL fragment, on a lab page you own.
  • Same family as the resize() sibling-miss and the @nuxtjs/mdc xlink:href gap: the fix covers the named parameter, the adjacent sink keeps the primitive — and this time the audit itself declared its narrow scope, which is a signal to expand the matrix rather than trust the patch series.

September 17 20:23–20:59Z follow-up: the sandbox read primitives complete, the dynamic-data ladder closes to RCE, and the XSS gate fails at the regex-engine level (10 GHSAs)

Ten Grav records published 2026-09-17T20:23–20:59Z (reviewed feed) extend three families already on this page. Together they complete the sandbox read-primitive map (four distinct bypasses of the same "editor reads config/user secrets" goal), turn the dynamic-data blueprints family into a login-to-RCE ladder, and add two engine-level fail-open lessons to the detectXss() family that no amount of regex-logic hardening will fix.

Sandbox read primitives (20:23–20:27Z) — four bypasses, one lesson: the sandbox policy only governs object members, so anything that reaches config/user data as a plain array or an unfiltered accessor escapes it:

  • GHSA-p597-crqc-m349 / CVE-2026-72698 — system, site, theme Twig variables bypass the content sandbox entirely. Twig::init() puts the raw config arrays into twig_vars; the sandboxed page-content render copies them untouched, replacing only the config key with the filtered SandboxConfig facade. Twig's SecurityPolicy gates method/property access on objects — dot/subscript access on plain arrays is always allowed, and config_denied_paths filters only the facade, never the arrays. So {{ system.cache.redis.password }} renders in sandboxed editor content even with the documented default config_access: false.
  • GHSA-xjw5-q542-3vmr / CVE-2026-76846 — when operators do enable config_access: true, the shipped config_denied_paths default (plugins, streams, security, backups, scheduler) omits the system prefix, exposing the same Redis/cache passwords via config.get()/config.toArray() — an incomplete denylist in the CVE-2026-xxxx fix chain's own facade.
  • GHSA-3jhr-mxmx-38cx / CVE-2026-76839 — offsetget/offsetexists allow-listed for UserInterface, and User::offsetGet() redacts nothing (only jsonSerialize() strips hashed_password/secret/twofa_secret). The redaction lives on one accessor; the sandbox allow-list exposes a sibling accessor that lacks it. Third recurrence of the raw-data-path-bypasses-intended-redaction shape in this file.
  • GHSA-47ch-6w46-6xm7 / CVE-2026-72697 — allow-listed media_directory() Twig function treats any non-stream-looking string as a raw filesystem path, so {{ media_directory('/etc') }} + the also-allow-listed Medium.filepath accessor (against a code comment saying it "stays blocked") enumerate and read any readable directory's files whose extension matches a media type (default list includes txt/json/xml/pdf, not just images).
  • Unified reusable check: against any template sandbox, enumerate the variables by type first — every plain array/dict handed to the template is policy-invisible; then enumerate allow-listed methods for unfiltered sibling accessors (offsetGet vs jsonSerialize, filepath vs the comment's intent); then test allow-listed functions for raw-path legs that skip their own stream/prefix check. Prove only with lab installs, fake config canaries, and synthetic user records.

Dynamic-data ladder to RCE + engine-level XSS fail-open (20:43–20:45Z):

  • GHSA-r94f-hx44-8jqf / CVE-2026-72819 — unauthenticated-sibling RCE via dual-form callable + config-file-as-own-blueprint (Grav < 2.0.13, CVSS 8.8, published 20:45Z). The isSafeDynamicCall() gate added by the earlier fixes has two branches; the Class::method allowlist and the component denylist are keyed on the callable name arriving as one string. The same routine can be named as an array pair [class, method] — the pair form matches neither branch, skips both lists, and is returned as "safe", then call_user_func_array runs it with attacker-supplied arguments from the same blueprint field. The chosen gadget is Grav\Common\GPM\Installer::unZip(user/media/evil.zip, <docroot>): ZIP is an accepted media upload type and archive contents are never examined, so a PHP file sits at a predictable URL, and unZip checks internal names against ../ but writes to the destination exactly as given — naming the web root drops shell.php into the public folder. The setup needs no file edit on the server: Flex Objects treats each entry of its own directories list as the address of a blueprint file, so pointing that list at the very settings file being saved (user/config/plugins/flex-objects.yaml) makes one YAML act as both config and blueprint — the malicious data-default@ field is created through a normal settings PATCH. Full PoC (two requests: PATCH /api/v1/config/plugins/flex-objects, then POST /api/v1/flex-objects/flex-objects) is in the advisory. Duplicate GHSA-wvxr-6v52-gfmh adds nothing new.
  • Reusable checks: a name-based validator must canonicalize the callable before gating it — test every language-legal spelling of the same invocation (single string, [class, method] pair, [object, method], function('name'), __invoke holder) against the same gate; array-form callables are the classic bypass of string-equality checks. Second reusable check: when a component loads its own configuration as blueprints/schemas, "settings write" and "blueprint write" are the same capability — enumerate any config key whose value is interpreted as a path to a schema file the same config then feeds. Third: archive-upload → extract-to-caller-chosen-destination is an RCE chain even when the extractor blocks internal traversal, because destination confinement is the missing half (same lesson as the Sept 17 watermark/tar-slip items: traversal checks cover the name, not the write target).
  • GHSA-f8wv-xp27-6gq7 / CVE-2026-75827 (critical) — bare-function branch of the same gate is a denylist, and error_log is not on it (published 20:44Z). The CVE-2026-64850 fix allowlisted the Class::method half of isSafeDynamicCall() but left the bare-function half on Utils::isDangerousFunction() (a ~250-name denylist). error_log($msg, 3, $dest) appends an attacker string to an attacker path — arbitrary file append, i.e. data-options@: ['error_log','<?php …?>',3,'user/data/x.php'] writes PHP into the site; stream_socket_client, dl, mb_send_mail are likewise absent (SSRF and other primitives). paramsContainDangerousCallable() only scans params for callable-looking strings, so payload strings pass. Twin sink in FlexDirectory.php:936.
  • Reusable check: whenever two code paths guard the same sink with different control types, the weakest guard is the real control — diff the fix for sibling-branch asymmetry (allowlist here, denylist there), then probe the denylist branch for its missing members. Denylist enumeration is mechanical: build a list of PHP functions with write/network side effects that take path/URL strings and check membership; error_log is the perennial omission because it looks inert.
  • GHSA-q2j8-x8hf-63ch / CVE-2026-75834 — one invalid UTF-8 byte disables every rule in Security::detectXss() (CVSS 8.0, published 20:44Z). All six $patterns use the PCRE u modifier; PHP's documented behavior is that preg_match() with u returns false for the entire call — not "no match" — when the subject contains any invalid UTF-8 sequence. detectXss() checks truthiness, so false ≡ "clean", and a single stray byte anywhere in the field (nowhere near the payload) silences all six checks. The browser does not fail open: it substitutes U+FFFD and renders <img … onerror=…> normally. This is the fifth detectXss() patch in the family and the first that regex-logic hardening cannot fix — prior fixes (GHSA-9695/GHSA-269c/GHSA-2c4f above) all patched the regexes; this defect is in how the engine's error channel is collapsed into a boolean.
  • Reusable check: on any PCRE-with-u or similar "strict parse" validator, prepend a single invalid byte (e.g. \xC3\x28) to the payload field and re-test every previously blocked pattern family — validator-vs-browser divergence on malformed encodings is a bypass class of its own (same family as the Sept 17 parser-differential pages, now at the regex-engine layer). Also applies to preg_match return-code handling audits generally: false and 0 must not share a branch.
  • GHSA-vfmf-q6x9-cw96 / CVE-2026-75828 (critical) — unpaired quote inside an unquoted attribute value blinds the quote-aware tag scanner (published 20:43Z). The GHSA-269c fix made the on_events regex quote-aware so > inside a paired quoted value is data — but the regex treats any "/' as a delimiter, while HTML only enters quoted state when the quote follows =. So <img src=x" onerror=alert(1)> — browser tokenizes src as unquoted value x", onerror as a live attribute, fires on the 404 — is, to the regex, an unterminated string it can never scan past: the fix itself introduced the gap (quote-strictness in the sanitizer vs quote-lenience in HTML5 parsing).
  • Reusable check: when a sanitizer is made quote-aware, test the quote-strictness differential directly: attr=x" + onevent=, single-quote twin, and the paired-quote baseline — for any attribute, not just src. Same family as the Sept 17 innerHTML→textContent fake sanitizer: character-class tokenizers must be diffed against the consumer's real tokenizer, not against the previous bypass list.
  • GHSA-xhfv-7758-r9hx / CVE-2026-75837 — CVE-2026-42613's security@: admin.super guard added to the account blueprint but not the sibling group blueprint; duplicate GHSA-m97h-2qj3-5773. A delegated admin.users operator saves their own group with data[access][admin][super]=1 and the permission map persists to groups.yaml, granting super to every member (→ scheduler/Twig RCE). Two sharp lessons: blueprint fixes must be applied to every sibling blueprint declaring the same field — the account/group.yaml pair is the exact family-drift this page keeps documenting (same pattern as the Flex dynamicDataField handler miss and the media <source> sink miss); and check_authorize: false in the blueprint has ZERO PHP consumers — a declared option flag with no enforcement code is an illusion control, so grep any flag name you rely on (or that a target's config exposes) for actual code consumers, not just YAML presence.
  • GHSA-4v9q-p283-qc2m / CVE-2026-74907 — pre-boot static asset server in index.php uses str_starts_with(realFile, realBase) containment (published 20:43Z). A plugin-provided user/config/plugin-asset-map.php route table is served before Grav's routing/security stack (zero auth where present); realpath() resolves .. correctly, but the containment test is a raw string prefix — /srv/site/user/evil.md passes base /srv/site/user — the sibling-prefix class already on the Sept 17 page (decap-server, Jupyter root_dir) showing up inside Grav's front controller. Precondition is opt-in (plugin-asset-map.php must exist), so treat it as a presence check in recon: probe for the file's routes and record the 200/404 differential rather than assuming stock behavior.

Durable axes this wave adds: dual-form callable bypass (validators key on string shape, languages accept pair form), sibling-branch guard asymmetry (allowlist vs denylist on the same sink), engine-error fail-open (preg_match false ≡ clean; invalid-UTF-8 silences all /u patterns), fix-introduced tokenizers (quote-aware regex stricter than HTML5 = new bypass), sibling-blueprint fix drift + zero-consumer config flags (illusion controls), and front-controller fast paths that bypass the framework's security stack entirely.

Tracked without publication this wave: GHSA-jq29-c7v8-rg55 / CVE-2026-72695 is the duplicate re-publication of the MediaUploadTrait::deleteFile() traversal already mapped in the file/symlink sinks section above (duplicate GHSA-896w-cw95-xq7w updated in the same window adds nothing). Soup Sieve ReDoS pair, HAPI FHIR SHCParser DEFLATE DoS pair, react/http chunked-body DoS, Plate parsing item, and CoreDNS item are handled on their own pages this run.

Reviewed but not promoted here

All six remaining Grav records in this wave (the second timing/nonce item, the two duplicate config-secret records, and the Flex Objects shortcode authorization bypass) are covered by the boundary map above and tracked in the source index.