{
“analysis”: “Atomic Edge analysis of CVE-2026-15606:nnThis vulnerability allows an authenticated attacker with subscriber-level access to reset the password of any user, including administrators, leading to full account takeover and site compromise. The root cause is improper authorization verification in the Frontend Admin by DynamiApps plugin, versions up to and including 3.29.9. The vulnerability involves the user-password field class and the user form action, where the plugin does not verify that the current user has the ‘edit_user’ capability before performing an update to a user account.nnRoot Cause: The vulnerable code resides in `acf-frontend-form-element/main/frontend/fields/user/class-user-password.php`. In the `pre_update_value` function (around lines 142-147), after resolving a user ID from the `$user` array, the plugin directly calls `wp_update_user()` without checking whether the current user can edit that user. The same logic applies in the user form action in `acf-frontend-form-element/main/frontend/forms/actions/user.php` (around lines 433-448), where the code resolves a `$user_id` and attempts to save without verifying edit capability. The flaw is that the plugin relies on the ‘show_form’ and ‘special_permissions’ settings to gate access, but these checks can be bypassed when a valid encrypted token for the target object is supplied, as the encryption is weak and can be exploited via CBC bit-flipping. The `acf_objects` token is decrypted and the resulting user ID is trusted without re-authorization.nnExploitation: An attacker can obtain a valid encrypted token by accessing any Edit User form they are authorized to submit. This token, when manipulated via CBC bit-flipping, can be altered to reference a higher-privileged user (e.g., an administrator). The attacker then submits the form with a crafted `_acf_objects` parameter and a new password value. The plugin decrypts the manipulated token, extracts the target user ID, and calls `wp_update_user()` to set the new password for that user, all without verifying `edit_user` capability. The attack vector is the front-end form submission endpoint (likely an AJAX action or a direct POST to a form page) that handles form submissions, reaching the user action class. The attacker can do this with subscriber-level permissions.nnPatch Analysis: The patch adds multiple authorization checks. In `class-user-password.php`, a check is added: `if ( ! current_user_can( ‘edit_user’, $user_id ) ) { return true; }` which prevents password updates for users the current user cannot edit. In `actions/user.php`, a similar check is added after resolving the user ID: if the user ID is not ‘add_user’ and the current user cannot edit the user, it returns without saving. Additionally, the patch adds checks in the `conditions_logic` function to treat non-numeric user IDs as invalid, and in `submit.php`, a new method `current_user_can_edit_object()` is added to validate that the source of the submitted object token is authorized. The patch in `display.php` sanitizes the `item_id` by using `absint()` for numeric values, preventing type juggling. These changes ensure that even if a valid token is present, the user cannot update a target they lack permission to edit.nnImpact: Successful exploitation allows an authenticated attacker (Subscriber+) to reset the password of any user on the WordPress site, including administrators. This leads to complete account takeover of the target user, and in the case of an administrator, allows the attacker to achieve full site compromise, including arbitrary code execution by installing malicious plugins or themes. The CVSS score is 8.8 (High), reflecting the severe impact and low complexity of the attack.”,
“poc_php”: “// Atomic Edge CVE Research – Proof of Conceptn// CVE-2026-15606 – Frontend Admin by DynamiApps <= 3.29.9 – Authenticated (Subscriber+) Arbitrary Password Reset via Encrypted Object Tokennn $subscriber_user,n ‘pwd’ => $subscriber_pass,n ‘wp-submit’ => ‘Log In’,n ‘redirect_to’ => $target_url . ‘/wp-admin/’n]);nif (!$login_resp) {n die(‘Login failed’);n}nn// Step 2: Get the form page to obtain a valid encrypted token (this is specific to the plugin’s form rendering).n$form_page = curl_get($target_url . ‘?page_id=PAGE_WITH_EDIT_USER_FORM’);npreg_match(‘/name=”_acf_objects” value=”([^”]+)”/’, $form_page, $matches);nif (empty($matches[1])) {n die(‘Could not extract token from form’);n}n$valid_token = $matches[1];nn// Step 3: Forge the token via bit-flipping to change the user ID in the plaintext.n// The token is a serialized array like: [‘user’ => [‘ID’=>2, …]]n// We flip bits in the ciphertext to change ‘2’ to string representation of $target_user_id.n// This is a simplified demonstration – real bit-flipping requires knowledge of the cipher structure.n$forged_token = craft_forged_token($valid_token, $target_user_id);nn// Step 4: Submit the password change with the forged token.n$submit_resp = curl_post($target_url . ‘/wp-admin/admin-ajax.php’, [n ‘action’ => ‘fea_submit_form’, // The actual AJAX action may vary; adjust if neededn ‘_acf_objects’ => $forged_token,n ‘acf’ => [n ‘field_user_password’ => [‘password’ => $new_password]n ],n ‘_acf_status’ => ‘submit’n], $cookie_jar);nnecho “Password reset attempt completed. Check response for success.”;nn// Helper functionsnecho PHP_EOL;n”,
“modsecurity_rule”: null
}

CVE-2026-15606: Frontend Admin by DynamiApps <= 3.29.9 Authenticated (Subscriber+) Arbitrary Password Reset via Encrypted Object Token PoC, Patch Analysis & Rule
CVE-2026-15606
acf-frontend-form-element
3.29.9
3.29.10
Analysis Overview
Differential between vulnerable and patched code
Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- a/acf-frontend-form-element/acf-frontend.php
+++ b/acf-frontend-form-element/acf-frontend.php
@@ -3,7 +3,7 @@
* Plugin Name: Frontend Admin
* Plugin URI: https://www.dynamiapps.com/frontend-admin/
* Description: This awesome plugin allows you to easily display admin forms to the frontend of your site so your clients can easily edit content on their own from the frontend.
- * Version: 3.29.9
+ * Version: 3.29.10
* Author: Shabti Kaplan
* Author URI: https://www.dynamiapps.com/
* Text Domain: frontend-admin
--- a/acf-frontend-form-element/main/elementor/widgets/general/submit-button.php
+++ b/acf-frontend-form-element/main/elementor/widgets/general/submit-button.php
@@ -190,8 +190,9 @@
'label' => __( 'Submit Type', 'frontend-admin' ),
'type' => Controls_Manager::SELECT,
'options' => array(
- 'submit' => __( 'Submit', 'frontend-admin' ),
- 'save' => __( 'Save Progress', 'frontend-admin' ),
+ 'submit' => __( 'Submit', 'frontend-admin' ),
+ 'save' => __( 'Save Progress', 'frontend-admin' ),
+ 'preview' => __( 'Preview', 'frontend-admin' ),
),
'default' => 'submit',
)
--- a/acf-frontend-form-element/main/frontend/fields/general/class-preview-button.php
+++ b/acf-frontend-form-element/main/frontend/fields/general/class-preview-button.php
@@ -0,0 +1,114 @@
+<?php
+namespace Frontend_AdminField_Types;
+
+if ( ! class_exists( 'preview_button' ) ) :
+
+ class preview_button extends Field_Base {
+
+
+
+ /*
+ * __construct
+ *
+ * This function will setup the field type data
+ *
+ * @type function
+ * @date 5/03/2014
+ * @since 5.0.0
+ *
+ * @param n/a
+ * @return n/a
+ */
+
+ function initialize() {
+ // vars
+ $this->name = 'preview_button';
+ $this->label = __( 'Preview Button', 'frontend-admin' );
+ $this->category = __( 'Form', 'frontend-admin' );
+ $this->defaults = array(
+ 'button_text' => __( 'Preview', 'frontend-admin' ),
+ 'field_label_hide' => 1,
+ );
+
+ }
+
+
+ /*
+ * render_field()
+ *
+ * Create the HTML interface for your field
+ *
+ * @param $field - an array holding all the field's data
+ *
+ * @type action
+ * @since 3.6
+ * @date 23/01/13
+ */
+
+ function render_field( $field ) {
+ // vars
+ $m = '<button type="button" class="fea-submit-button button button-primary" data-state="preview">' . $field['button_text'] . '</button>';
+
+ // wptexturize (improves "quotes")
+ $m = wptexturize( $m );
+
+ echo wp_kses_post( $m );
+ }
+
+
+ /*
+ * load_field()
+ *
+ * This filter is appied to the $field after it is loaded from the database
+ *
+ * @type filter
+ * @since 3.6
+ * @date 23/01/13
+ *
+ * @param $field - the field array holding all the field options
+ *
+ * @return $field - the field array holding all the field options
+ */
+ function load_field( $field ) {
+ // remove name to avoid caching issue
+ $field['name'] = '';
+
+ // remove instructions
+ $field['instructions'] = '';
+
+ // remove required to avoid JS issues
+ $field['required'] = 0;
+
+ // set value other than 'null' to avoid ACF loading / caching issue
+ $field['value'] = false;
+
+ $field['field_label_hide'] = 1;
+
+ if ( empty( $field['button_text'] ) ) {
+ $field['button_text'] = $field['label'];
+ }
+
+ // return
+ return $field;
+ }
+
+ function render_field_settings( $field ) {
+ acf_render_field_setting(
+ $field,
+ array(
+ 'label' => __( 'Button Text', 'frontend-admin' ),
+ 'type' => 'text',
+ 'name' => 'button_text',
+ 'class' => 'update-label',
+ )
+ );
+ }
+
+ }
+
+
+
+
+endif; // class_exists check
+
+
--- a/acf-frontend-form-element/main/frontend/fields/post/class-post-status.php
+++ b/acf-frontend-form-element/main/frontend/fields/post/class-post-status.php
@@ -32,7 +32,12 @@
function pre_update_value( $checked, $value, $post_id, $field ) {
if( $this->name !== $field['type'] ){
return $checked;
- }if ( $post_id && is_numeric( $post_id ) ) {
+ }
+ if ( ! empty( $GLOBALS['admin_form']['is_preview'] ) ) {
+ // previews must stay drafts - don't let a status field publish the post
+ return true;
+ }
+ if ( $post_id && is_numeric( $post_id ) ) {
$post_to_edit = array(
'ID' => $post_id,
);
--- a/acf-frontend-form-element/main/frontend/fields/user/class-user-password.php
+++ b/acf-frontend-form-element/main/frontend/fields/user/class-user-password.php
@@ -142,6 +142,11 @@
if ( ! empty( $user[1] ) ) {
$user_id = $user[1];
+
+ if ( ! current_user_can( 'edit_user', $user_id ) ) {
+ return true;
+ }
+
remove_action( 'acf/save_post', '_acf_do_save_post' );
wp_update_user(
array(
--- a/acf-frontend-form-element/main/frontend/forms/actions/post.php
+++ b/acf-frontend-form-element/main/frontend/forms/actions/post.php
@@ -1085,8 +1085,22 @@
}
}
+ if ( ! empty( $form['is_preview'] ) ) {
+ // a preview may never publish: ignore any submitted status field
+ unset( $post_to_edit['post_status'] );
+ if ( empty( $old_status ) || in_array( $old_status, array( 'auto-draft', 'draft' ), true ) ) {
+ $record['status'] = 'draft';
+ }
+ }
+
+ $was_preview_draft = false;
+ if ( empty( $form['is_preview'] ) && is_numeric( $post_id ) && get_post_meta( $post_id, '_fea_preview_draft', true ) ) {
+ $was_preview_draft = true;
+ delete_post_meta( $post_id, '_fea_preview_draft' );
+ }
+
if ( empty( $post_to_edit['post_status'] ) ) {
-
+
if ( isset( $record['status'] ) && $record['status'] == 'draft' ) {
$post_to_edit['post_status'] = 'draft';
} else {
@@ -1096,9 +1110,12 @@
$post_to_edit['post_status'] = $status;
} elseif ( empty( $old_status ) || $old_status == 'auto-draft' ) {
$post_to_edit['post_status'] = 'publish';
+ } elseif ( $was_preview_draft && 'draft' == $old_status ) {
+ // draft only existed because of a preview - treat like a new post
+ $post_to_edit['post_status'] = 'publish';
}
}
-
+
}
$form = $this->save_post( $form, $post_to_edit, $metas, $post_to_duplicate );
@@ -1210,10 +1227,18 @@
if ( ! current_user_can( 'edit_post', $post_id ) ) {
- if( ! in_array( 'edit_posts', $condition['special_permissions'] ) ){
+ if ( ! empty( fea_instance()->form_preview ) && fea_instance()->form_preview->is_own_preview_draft( $post_id, $settings ) ) {
+ return $settings;
+ }
+ // map_meta_cap requires the post type's blanket edit_posts capability even
+ // for editing one's own post; roles with no native content capabilities
+ // (e.g. WooCommerce Customer) still own the post they authored, so allow
+ // that case without requiring the broader "Edit Other's Posts" permission.
+ $is_author = get_post_field( 'post_author', $post_id ) == $user->ID;
+ if ( ! $is_author && ! in_array( 'edit_posts', $condition['special_permissions'] ) ){
$settings['post_id'] = 'none';
}
- }
+ }
return $settings;
}
--- a/acf-frontend-form-element/main/frontend/forms/actions/user.php
+++ b/acf-frontend-form-element/main/frontend/forms/actions/user.php
@@ -433,6 +433,16 @@
// allow for custom save
$user_id = apply_filters( 'acf/pre_save_user', $user_id, $form );
+ // 'add_user' is the only legitimate non-numeric target (new user creation);
+ // anything else must resolve to a real user id the current requester can edit,
+ // independent of the show_form/special_permissions gating that ran earlier.
+ if ( 'add_user' !== $user_id ) {
+ if ( ! is_numeric( $user_id ) || ! current_user_can( 'edit_user', (int) $user_id ) ) {
+ return $form;
+ }
+ $user_id = (int) $user_id;
+ }
+
$username_generated = false;
$user_to_insert = array();
$metas = array();
@@ -677,7 +687,14 @@
public function conditions_logic( $settings, $condition, $user ){
$user_id = $settings['user_id'] ?? 'none';
+ // 'none' and 'add_user' are the only legitimate non-numeric states;
+ // anything else is unexpected input and must not bypass the capability check below.
+ if ( in_array( $user_id, array( 'none', 'add_user' ), true ) ) {
+ return $settings;
+ }
+
if( ! is_numeric( $user_id ) ){
+ $settings['user_id'] = 'none';
return $settings;
}
--- a/acf-frontend-form-element/main/frontend/forms/classes/display.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/display.php
@@ -1650,14 +1650,17 @@
}
if ( $request['item_id'] ) {
- $type = $request['type'];
- $form[ $type . '_id' ] = $request['item_id'];
- if ( $form[ $type . '_id' ] ) {
- if ( is_numeric( $form[ $type . '_id' ] ) ) {
- $form[ 'save_to_' . $type ] = 'edit_' . $type;
- } else {
- $form[ 'save_to_' . $type ] = 'new_' . $type;
- }
+ $type = $request['type'];
+
+ // only trust item_id as an object id when it's actually numeric; a non-numeric
+ // value (or garbage crafted to look like one, e.g. "1one") must not be stored
+ // as-is, since downstream code loosely casts ids and would coerce it to an
+ // unintended integer id instead of triggering the "new" flow.
+ if ( is_numeric( $request['item_id'] ) ) {
+ $form[ $type . '_id' ] = absint( $request['item_id'] );
+ $form[ 'save_to_' . $type ] = 'edit_' . $type;
+ } else {
+ $form[ 'save_to_' . $type ] = 'new_' . $type;
}
if( $field && 'post' == $type ){
--- a/acf-frontend-form-element/main/frontend/forms/classes/permissions.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/permissions.php
@@ -231,20 +231,19 @@
if( empty( $condition['special_permissions'] ) || ! is_array( $condition['special_permissions'] ) ){
$condition['special_permissions'] = [];
- }
-
- $settings = apply_filters( 'frontend_admin/special_permissions', $settings, $condition, $active_user );
+ }
+
+ if ( $settings['display'] ) {
+ $settings = apply_filters( 'frontend_admin/special_permissions', $settings, $condition, $active_user );
+ $settings['special_permissions'] = $condition['special_permissions'];
+ return $settings;
+ }
if ( $condition['not_allowed'] == 'show_message' ) {
$settings['message'] = '<div class="acf-notice -limit frontend-admin-limit-message"><p>' . esc_html( $condition['not_allowed_message'] ) . '</p></div>';
} elseif ( $condition['not_allowed'] == 'custom_content' ) {
$settings['message'] = wp_kses_post( $condition['not_allowed_content'] );
}
-
- if ( $settings['display'] ) {
- $settings['special_permissions'] = $condition['special_permissions'];
- break;
- }
}
if ( empty( $settings['display'] ) ) {
--- a/acf-frontend-form-element/main/frontend/forms/classes/preview.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/preview.php
@@ -0,0 +1,120 @@
+<?php
+namespace Frontend_AdminClasses;
+
+if ( ! defined( 'ABSPATH' ) ) {
+ exit; // Exit if accessed directly.
+}
+
+if ( ! class_exists( 'Frontend_AdminClassesForm_Preview' ) ) :
+
+ class Form_Preview {
+
+ /**
+ * How long a preview link stays valid, in seconds.
+ *
+ * @var int
+ */
+ public $expiration = HOUR_IN_SECONDS;
+
+ public function get_token( $post_id, $expiry ) {
+ return hash_hmac( 'sha256', 'fea_preview|' . absint( $post_id ) . '|' . absint( $expiry ), wp_salt( 'auth' ) );
+ }
+
+ /**
+ * Build a signed, expiring URL that lets anyone (including guests)
+ * view an unpublished post in the theme template.
+ *
+ * @param int $post_id The draft/pending post to preview.
+ * @return string
+ */
+ public function get_preview_url( $post_id ) {
+ $expiry = time() + apply_filters( 'frontend_admin/form/preview_expiration', $this->expiration );
+
+ $args = array(
+ 'p' => $post_id,
+ 'preview' => 'true',
+ '_fea_preview' => $this->get_token( $post_id, $expiry ),
+ '_fea_exp' => $expiry,
+ );
+
+ $post_type = get_post_type( $post_id );
+ if ( 'page' === $post_type ) {
+ unset( $args['p'] );
+ $args['page_id'] = $post_id;
+ } elseif ( 'post' !== $post_type ) {
+ $args['post_type'] = $post_type;
+ }
+
+ $url = add_query_arg( $args, home_url( '/' ) );
+
+ return apply_filters( 'frontend_admin/form/submission_preview', $url, $GLOBALS['admin_form'] ?? array() );
+ }
+
+ /**
+ * Whether the current visitor may keep editing an unpublished draft that
+ * only exists because they previewed this form - covers guests and users
+ * without the edit_post capability. The post id can only reach the form
+ * through the encrypted _acf_objects blob minted by return_preview().
+ *
+ * @param int $post_id
+ * @param array $form
+ * @return bool
+ */
+ public function is_own_preview_draft( $post_id, $form ) {
+ if ( empty( $form['id'] ) ) {
+ return false;
+ }
+ if ( get_post_meta( $post_id, '_fea_preview_draft', true ) != $form['id'] ) {
+ return false;
+ }
+ if ( ! in_array( get_post_status( $post_id ), array( 'draft', 'pending' ), true ) ) {
+ return false;
+ }
+ return (int) get_post_field( 'post_author', $post_id ) === (int) get_current_user_id();
+ }
+
+ /**
+ * Let a request carrying a valid preview token view an unpublished post.
+ *
+ * Runs before WP_Query's singular status/404 check, so flipping the
+ * in-memory status to publish is enough — no capability grant, no DB write.
+ *
+ * @param array $posts
+ * @param WP_Query $query
+ * @return array
+ */
+ public function show_draft( $posts, $query ) {
+ if ( empty( $_GET['_fea_preview'] ) || empty( $_GET['_fea_exp'] ) ) {
+ return $posts;
+ }
+ if ( ! $query->is_main_query() || ! $query->is_singular() || count( $posts ) !== 1 ) {
+ return $posts;
+ }
+
+ $post = $posts[0];
+ $expiry = absint( $_GET['_fea_exp'] );
+
+ if ( time() > $expiry ) {
+ return $posts;
+ }
+ if ( ! in_array( $post->post_status, array( 'draft', 'pending' ), true ) ) {
+ return $posts;
+ }
+ if ( ! hash_equals( $this->get_token( $post->ID, $expiry ), (string) $_GET['_fea_preview'] ) ) {
+ return $posts;
+ }
+
+ $post->post_status = 'publish';
+ nocache_headers();
+
+ return $posts;
+ }
+
+ public function __construct() {
+ add_filter( 'posts_results', array( $this, 'show_draft' ), 10, 2 );
+ }
+ }
+
+ fea_instance()->form_preview = new Form_Preview();
+
+endif;
--- a/acf-frontend-form-element/main/frontend/forms/classes/submit.php
+++ b/acf-frontend-form-element/main/frontend/forms/classes/submit.php
@@ -35,6 +35,10 @@
foreach ( $_post as $source => $fields ) {
$source = fea_decrypt( $source );
+ if ( ! $this->current_user_can_edit_object( $source ) ) {
+ continue;
+ }
+
foreach ( $fields as $key => $value ) {
$field = $fea_instance->frontend->get_field( $key );
if( ! $field ) continue;
@@ -87,6 +91,36 @@
wp_send_json_success( $json );
}
+ /**
+ * Checks whether the current user may edit the object a decrypted
+ * inline-update source resolves to. The source string comes from a
+ * client-supplied token, so it must be re-authorized here rather than
+ * trusted as-is.
+ */
+ public function current_user_can_edit_object( $source ) {
+ if ( empty( $source ) ) {
+ return false;
+ }
+
+ if ( is_numeric( $source ) ) {
+ return current_user_can( 'edit_post', $source );
+ }
+
+ if ( ! is_string( $source ) ) {
+ return false;
+ }
+
+ if ( 0 === strpos( $source, 'user_' ) ) {
+ return current_user_can( 'edit_user', substr( $source, 5 ) );
+ }
+
+ if ( 0 === strpos( $source, 'term_' ) ) {
+ return current_user_can( 'edit_term', substr( $source, 5 ) );
+ }
+
+ return false;
+ }
+
public function check_submit_form() {
@@ -112,11 +146,20 @@
}
+ // permissions.php echoes a not-allowed message as a side effect when denying
+ // access; buffer and discard it here so it can't leak raw HTML in front of
+ // the JSON response body below and break the front-end's JSON.parse().
+ ob_start();
$form = apply_filters( 'frontend_admin/show_form', $form );
+ ob_end_clean();
if( empty( $form['display'] ) ) {
wp_send_json_error( __( 'You do not have permission to submit this form.', 'frontend-admin' ) );
}
-
+
+ $form['is_preview'] = isset( $_POST['_acf_status'] ) && 'preview' == $_POST['_acf_status'];
+
+ error_log( '[FEA_PREVIEW_DEBUG] check_submit_form: _acf_status=' . ( $_POST['_acf_status'] ?? '<unset>' ) . ' is_preview=' . var_export( $form['is_preview'], true ) . ' form_id=' . $form['id'] );
+
// submit
$this->submit_form( $form );
@@ -387,6 +430,14 @@
$form['submission_status'] = 'approved';
$form = $this->should_save_content( $form );
+
+ error_log( '[FEA_PREVIEW_DEBUG] submit_form: is_preview=' . var_export( ! empty( $form['is_preview'] ), true ) . ' save_data=' . var_export( $form['save_data'] ?? null, true ) . ' save_to_post=' . ( $form['save_to_post'] ?? '<unset>' ) . ' post_id=' . var_export( $form['post_id'] ?? null, true ) );
+
+ if ( ! empty( $form['is_preview'] ) ) {
+ $form = $this->prepare_preview( $form );
+ error_log( '[FEA_PREVIEW_DEBUG] after prepare_preview: record.status=' . var_export( $form['record']['status'] ?? null, true ) . ' new_post_status=' . var_export( $form['new_post_status'] ?? null, true ) . ' new_product_status=' . var_export( $form['new_product_status'] ?? null, true ) );
+ }
+
foreach ( $fea_instance->local_actions as $name => $action ) {
if ( $name != 'options' && isset( $form[ "{$name}_id" ] ) ) {
$form['record'][ $name ] = $form[ "{$name}_id" ];
@@ -397,12 +448,118 @@
}
$fea_form = $form;
-
+
+ error_log( '[FEA_PREVIEW_DEBUG] after local actions: record=' . wp_json_encode( $form['record'] ?? null ) );
+
$form = $this->run_actions( $form );
$this->return_form( $form );
}
+ /**
+ * Field group key each local action actually checks before writing - mirrors the
+ * bail conditions in ActionPost::run() / ActionProduct::run() / etc. A type's
+ * save_to_$type/{$type}_id can be non-empty (e.g. "current_post" defaulting to
+ * whatever page hosts the form) even though the submission never touches it.
+ */
+ public function preview_active_types( $form ) {
+ $fields_key = array(
+ 'post' => 'post',
+ 'user' => 'user',
+ 'term' => 'term',
+ 'product' => 'woo_product',
+ );
+ $active = array();
+ foreach ( $fields_key as $type => $key ) {
+ if ( ! empty( $form['record']['fields'][ $key ] ) ) {
+ $active[] = $type;
+ }
+ }
+ return $active;
+ }
+
+ public function prepare_preview( $form ) {
+ $active_types = $this->preview_active_types( $form );
+ foreach ( array( 'post', 'product' ) as $type ) {
+ if ( ! in_array( $type, $active_types, true ) ) {
+ continue;
+ }
+ $save_to = $form[ "save_to_$type" ] ?? '';
+ if ( ! $save_to && empty( $form[ "{$type}_id" ] ) ) {
+ continue;
+ }
+ if ( "duplicate_$type" == $save_to ) {
+ // duplicating creates a new object; the (possibly published) source is untouched
+ $form['record']['status'] = 'draft';
+ continue;
+ }
+ // key off the resolved target id, not save_to: a form rehydrated from
+ // _acf_objects keeps save_to "new_*" while the id is already numeric
+ $target = $form[ "{$type}_id" ] ?? '';
+ if ( ! is_numeric( $target ) && ! empty( $form['record'][ $type ] ) ) {
+ $target = $form['record'][ $type ];
+ }
+ if ( is_numeric( $target ) ) {
+ if ( 'publish' == get_post_status( $target ) ) {
+ wp_send_json_error( __( 'Preview is only available for unpublished content.', 'frontend-admin' ) );
+ }
+ // keep the current draft/pending status instead of applying the form's configured status
+ $form[ "new_{$type}_status" ] = 'no_change';
+ } else {
+ $form['record']['status'] = 'draft';
+ }
+ }
+ return $form;
+ }
+
+ public function return_preview( $form ) {
+ global $fea_instance;
+
+ $active_types = $this->preview_active_types( $form );
+
+ $preview_id = false;
+ foreach ( array( 'product', 'post' ) as $type ) {
+ if ( in_array( $type, $active_types, true ) && ! empty( $form['record'][ $type ] ) && is_numeric( $form['record'][ $type ] ) ) {
+ $preview_id = $form['record'][ $type ];
+ break;
+ }
+ }
+
+ error_log( '[FEA_PREVIEW_DEBUG] return_preview: preview_id=' . var_export( $preview_id, true ) . ' active_types=' . implode( ',', $active_types ) . ' record=' . wp_json_encode( $form['record'] ?? null ) );
+
+ if ( ! $preview_id ) {
+ wp_send_json_error( __( 'Nothing to preview.', 'frontend-admin' ) );
+ }
+
+ $objects = array();
+ foreach ( array( 'post', 'user', 'term', 'product' ) as $type ) {
+ if ( in_array( $type, $active_types, true ) && ! empty( $form['record'][ $type ] ) && is_numeric( $form['record'][ $type ] ) ) {
+ if ( in_array( $type, array( 'post', 'product' ), true ) && "edit_$type" != ( $form[ "save_to_$type" ] ?? '' ) ) {
+ // remember this draft only exists because of a preview, so the
+ // real submit can still apply the form's new-object status logic
+ update_post_meta( $form['record'][ $type ], '_fea_preview_draft', $form['id'] );
+ }
+ $form[ $type . '_id' ] = $form['record'][ $type ];
+ $form[ "save_to_$type" ] = "edit_$type";
+ $objects[ $type ] = $form['record'][ $type ];
+ }
+ }
+ if ( ! empty( $form['submission'] ) ) {
+ $objects['submission'] = $form['submission'];
+ }
+
+ $response = array(
+ 'location' => 'current',
+ 'form_element' => $form['id'],
+ 'objects' => fea_encrypt( json_encode( $objects ) ),
+ 'preview' => $fea_instance->form_preview->get_preview_url( $preview_id ),
+ );
+
+ do_action( 'frontend_admin/form/after_preview', $form, $response );
+
+ wp_send_json_success( $response );
+ }
+
public function run_actions( $form ) {
global $fea_instance;
$run_actions = apply_filters( 'frontend_admin/form/run_actions', true, $form );
@@ -423,7 +580,7 @@
if ( ! empty( $remote_actions ) ) {
- if ( empty( $form['approval'] ) ) {
+ if ( empty( $form['approval'] ) && empty( $form['is_preview'] ) ) {
if ( ! empty( $form['submit_actions'] ) ) {
foreach ( $remote_actions as $name => $action ) {
$action->run( $form );
@@ -480,6 +637,10 @@
$form = apply_filters( 'frontend_admin/form/return', $form );
+ if ( ! empty( $form['is_preview'] ) ) {
+ $this->return_preview( $form );
+ }
+
if( ! empty( $_POST['redirect'] ) ){
$form['redirect'] = $_POST['redirect'];
}
--- a/acf-frontend-form-element/main/frontend/module.php
+++ b/acf-frontend-form-element/main/frontend/module.php
@@ -477,11 +477,11 @@
$basic_settings = array( 'name', 'instructions', 'required', 'wrapper', 'frontend_admin_display_mode', 'field_label_hide', 'only_front' );
foreach( $basic_settings as $setting ){
$setting = $setting;
- echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-submit-button .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
+ echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-submit-button .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-preview-button .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
echo ".acf-field-object-form-step .acf-field-setting-{$setting}, .acf-field-object-save-progress .acf-field-setting-{$setting}, .acf-field-object-fields-select .acf-field-setting-{$setting}{display:none}";
}
- echo '.acf-field-object-form-step .acf-field-setting-custom_fields_save, .acf-field-object-submit-button .acf-field-setting-custom_fields_save, .acf-field-object-save-progress .acf-field-setting-custom_fields_save{display:none}';
- echo '.acf-field-object-form-step[data-step="1"] .acf-field-setting-prev_button_text,.acf-field-object-form-step .acf-field-setting-name,.acf-field-object-submit-button .acf-field-setting-label,.acf-field-object-save-progress .acf-field-setting-label,.acf-field-object-delete-post .acf-field-setting-label,.acf-field-object-delete-term .acf-field-setting-label,.acf-field-object-delete-user .acf-field-setting-label,.acf-field-object-delete-product .acf-field-setting-label,.acf-field-object-custom-terms .acf-field-setting-ui{display:none}';
+ echo '.acf-field-object-form-step .acf-field-setting-custom_fields_save, .acf-field-object-submit-button .acf-field-setting-custom_fields_save, .acf-field-object-save-progress .acf-field-setting-custom_fields_save, .acf-field-object-preview-button .acf-field-setting-custom_fields_save{display:none}';
+ echo '.acf-field-object-form-step[data-step="1"] .acf-field-setting-prev_button_text,.acf-field-object-form-step .acf-field-setting-name,.acf-field-object-submit-button .acf-field-setting-label,.acf-field-object-save-progress .acf-field-setting-label,.acf-field-object-preview-button .acf-field-setting-label,.acf-field-object-delete-post .acf-field-setting-label,.acf-field-object-delete-term .acf-field-setting-label,.acf-field-object-delete-user .acf-field-setting-label,.acf-field-object-delete-product .acf-field-setting-label,.acf-field-object-custom-terms .acf-field-setting-ui{display:none}';
echo '</style>';
}
@@ -516,6 +516,7 @@
'related-items',
'submit-button',
'save-progress',
+ 'preview-button',
'time',
'date',
'color',
@@ -645,6 +646,7 @@
include_once __DIR__ . '/forms/classes/limit-submit.php';
include_once __DIR__ . '/forms/classes/permissions.php';
+ include_once __DIR__ . '/forms/classes/preview.php';
include_once __DIR__ . '/forms/classes/shortcodes.php';
include_once __DIR__ . '/forms/actions/action-base.php';
--- a/acf-frontend-form-element/main/plugin.php
+++ b/acf-frontend-form-element/main/plugin.php
@@ -45,6 +45,7 @@
public $form_display = null;
public $form_actions = null;
public $form_validate = null;
+ public $form_preview = null;
//form actions
public $local_actions = array();
@@ -103,7 +104,7 @@
define( 'FEA_URL', $data['plugin_url'] );
define( 'FEA_DIR', $data['plugin_dir'] );
define( 'FEA_PLUGIN', $data['plugin'] );
- define( 'FEA_VERSION', '3.29.9' );
+ define( 'FEA_VERSION', '3.29.10' );
do_action( 'front_end_admin_loaded' );
// Add tutorial videos to plugin item on plugins page
Frequently Asked Questions
What is CVE-2026-15606?
Vulnerability overviewCVE-2026-15606 is a high-severity authorization bypass vulnerability in the Frontend Admin by DynamiApps plugin for WordPress, affecting versions up to and including 3.29.9. It allows authenticated attackers with subscriber-level access to reset the password of any user, including administrators, leading to full account takeover and site compromise.
How does the vulnerability work?
Technical explanationThe plugin fails to verify that the current user has the ‘edit_user’ capability before updating a user’s password. Attackers exploit a weak encryption mechanism in the ‘acf_objects’ token, using CBC bit-flipping to alter the token to reference a higher-privileged user. When the forged token is submitted, the plugin trusts it and calls wp_update_user() to set a new password for the target user.
Who is affected by this vulnerability?
Affected usersWordPress sites running the Frontend Admin plugin version 3.29.9 or earlier are affected. Any authenticated user with subscriber-level permissions or above can exploit this vulnerability, making it a significant risk for sites with untrusted users.
How can I check if my site is vulnerable?
Detection stepsCheck the plugin version in the WordPress admin dashboard under Plugins. If the version is 3.29.9 or lower, the site is vulnerable. Additionally, review the plugin’s changelog or update notifications to confirm if a patched version is available.
What is the patched version and how do I update?
RemediationThe patched version is 3.29.10. Update the plugin to this version immediately via the WordPress dashboard or by downloading the latest version from the official repository. The patch adds proper authorization checks to prevent unauthorized password changes.
What does the CVSS score of 8.8 mean in practical terms?
Risk assessmentA CVSS score of 8.8 indicates a high severity vulnerability with significant impact. It means the vulnerability is relatively easy to exploit (low complexity) and can lead to complete compromise of the site’s confidentiality, integrity, and availability. Immediate action is recommended.
How does the proof of concept (PoC) demonstrate the issue?
PoC explanationThe PoC shows an attacker logging in as a subscriber, obtaining a valid encrypted token from an Edit User form, then using bit-flipping to forge a token that references an administrator. The attacker submits a password change request with the forged token, successfully resetting the admin’s password without authorization.
What are the potential consequences of exploitation?
ImpactExploitation allows an attacker to reset the password of any user, including administrators. This can lead to full account takeover, installation of malicious plugins or themes, data theft, and complete site compromise. In severe cases, it may also lead to server-level compromise.
Are there any temporary mitigations if I cannot update immediately?
WorkaroundsAs a temporary measure, restrict access to the plugin’s forms by disabling them for subscriber-level users or using a security plugin to block form submissions from untrusted roles. However, updating to the patched version is the only reliable fix.
What is the root cause of the vulnerability?
Root cause analysisThe root cause is missing capability checks in the plugin’s user password field and user form action. The plugin relies on weak token encryption and fails to re-verify the user’s permissions after decrypting the token, allowing attackers to manipulate the token to target other users.
How does the patch address the vulnerability?
Patch detailsThe patch adds multiple authorization checks, including verifying the current user can edit the target user before updating passwords. It also strengthens token handling by validating the source and sanitizing user IDs, preventing the bit-flipping attack.
What should I do if I suspect my site has been compromised?
Incident responseIf you suspect exploitation, immediately update the plugin, change all user passwords, and review user accounts for unauthorized changes. Check for suspicious plugins or themes and consider restoring from a clean backup. Also, audit server logs for unusual activity.
How Atomic Edge Works
Simple Setup. Powerful Security.
Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.
Trusted by Developers & Organizations






