TL;DR
Pluck CMS 4.7.20 and earlier lets an authenticated administrator write PHP files anywhere the web server can reach, not just into the pages folder where they belong. The page editor's sub_page field is read straight from $_POST, never sanitised, and concatenated into a file path. Feed it a traversal sequence like ../../../ and the saved file lands in the webroot instead of the pages directory. That's a High-severity path traversal (CVSS 7.2, CWE-22) that gives an attacker a persistent, partly attacker-controlled file on a web-accessible path.
What the page editor is supposed to do
Pluck is a flat-file CMS, which means pages aren't rows in a database. They're actual files on disk. When you edit a page in the admin panel and hit Save, Pluck writes a .php file into its pages directory. That's the whole design, and it's a perfectly reasonable one for a lightweight CMS.
The code that does this lives in save_page() inside data/inc/functions.admin.php. It builds a destination path by gluing together the pages directory constant and the details of the page you're saving:
save_file(PAGE_DIR.'/'.$subpage.$number.'.'.$seo_title.'.php', $data);
The quiet assumption here is that $subpage is a tame little string: a sub-page name, nothing more. If it were, the file would always land inside PAGE_DIR, exactly where it should.
Where the trust breaks
$subpage comes directly from the sub_page POST parameter, and nobody checks what's in it.
You might expect Pluck's security module to catch this. It has one: data/inc/security.php. But here's the gap that makes the whole thing work. That module filters $_GET values. It does not touch $_POST. The sub_page parameter arrives via POST, so it sails straight past the guard untouched.
So the attacker-supplied string gets concatenated right into the path handed to save_file(). Put ../../../ in there and the resolved destination climbs out of the pages directory and up into the webroot, or anywhere else the www-data process can write.
Under the bonnet
The attack needs admin credentials, which is why this is rated PR:H. With a valid admin session, the request is almost mundane:
POST /admin.php?action=editpage HTTP/1.1
Host: <target>
Cookie: PHPSESSID=<admin_session>
Content-Type: application/x-www-form-urlencoded
Referer: http://<target>/admin.php
title=poc&seo_name=poc&content=BLOCK8_CVE_POC&sub_page=../../../&hidden=no&save=Save
With sub_page=../../../, the file (named something like 1.poc.php) is created in the webroot rather than the pages folder. You can then fetch it directly:
curl http://<target>/1.poc.php
The response is an HTTP 200 serving the PHP file, which contains the marker BLOCK8_CVE_POC drawn from the page content. The page title, SEO name, and body are all partly attacker-controlled, so the written file's contents aren't arbitrary, but they're far from fixed either.
Why it matters
This is rated CVSS 7.2 (High) with the vector AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H, classified as CWE-22, Improper Limitation of a Pathname to a Restricted Directory. High confidentiality, integrity, and availability impact, gated behind needing an admin account.
The write primitive on its own is already serious. An attacker can drop or overwrite web-accessible PHP files, and can corrupt CMS configuration, for example by clobbering data/settings/pass.php to break authentication for everyone. That's persistence and sabotage in one move.
Full, clean arbitrary code execution does take one more step: the written file's content is only partly attacker-controlled, so turning it into a reliable shell means chaining with the CSRF bypass that also ships in 4.7.20 (where requestedByTheSameDomain() returns true whenever the HTTP_REFERER header is simply absent). We're calling that honestly. The traversal write is demonstrated and dangerous by itself; the clean-RCE escalation is a known chain, not a hypothetical hand-wave.
How to fix it
The fix is to stop trusting sub_page and to stop filtering only half of your input sources:
- Sanitise
sub_pagebefore it touches a path. Strip or reject traversal sequences, and ideally restrict the value to a known-safe character set (letters, numbers, hyphens). Resolve the final path and confirm it still sits insidePAGE_DIRbefore writing. - Filter POST, not just GET. The security module guarding
$_GETwhile leaving$_POSTwide open is the structural flaw underneath this bug. Any input that reaches a filesystem path, a query, or an include needs the same scrutiny regardless of HTTP method. - Run the web process with least privilege. If
www-datacan't write to the webroot, the blast radius of a traversal write shrinks considerably.
Closing thought
A flat-file CMS lives and dies by how carefully it turns user input into file paths. The Block8.ai Security Research Team found that Pluck's page editor trusted a POST field to stay inside its lane, and a security module that only watched one door while the other stood open. Put those two together and "Save Page" quietly becomes "Save anywhere www-data can reach".