The Exploit
An unauthenticated attacker can execute arbitrary PHP code by posting a malicious form definition to the plugin's form rendering endpoint.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.local
Content-Type: application/x-www-form-urlencoded
action=acfe_form_render&form[render]=system&form[render_args][0]=id
The server executes the supplied callback (system) with the supplied arguments (id), returning command output in the response body. An attacker observing the HTTP response sees the output of id printed directly, confirming code execution. From this primitive, an attacker chains to RCE by calling functions like eval(), passthru(), or WordPress functions like wp_create_user() to establish persistence.
What the Patch Did
Before
}elseif(is_callable($form['render'])){
ob_start();
call_user_func_array($form['render'], array($form));
$html = ob_get_clean();
// assign new render
$form['render'] = $html;
}
After
}
The patch removed the entire unsafe callback-execution block. The vulnerable code performed an is_callable() type check on user-supplied form data but then passed the result directly to call_user_func_array() without any further validation. The is_callable() check is a false security boundary—it returns true for any string matching a PHP function name, allowing an attacker to invoke built-in functions like system(), exec(), eval(), or custom functions. The fix eliminates the attack surface entirely by deleting the code path; the form render functionality is now handled by other, properly-guarded mechanisms.
Root Cause
CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code ('Eval Injection')
The $form['render'] parameter originates from user-controlled POST data in the AJAX request. The plugin accepts this data without sanitization or capability checks, passes it through a superficial is_callable() gate (which only verifies the string names a valid function, not whether it is safe to call), and then executes it via call_user_func_array($form['render'], array($form)). The trust boundary crossed is the entry point from the HTTP request into the WordPress AJAX handler—no nonce validation, no role check, and no function whitelist exist to gate which callbacks are permissible.
Why It Works
The load-bearing line of the patch is the deletion of the call_user_func_array() invocation itself. If that line remained—even with additional checks bolted on—the vulnerability persists because the attacker can still pollute the $form['render'] parameter with a callable string. The is_callable() check is not a defence; it is a red herring. An engineer attempting to "fix" this in place might add a whitelist of safe callbacks (if (!in_array($form['render'], $SAFE_CALLBACKS))), or a capability check (current_user_can('manage_options')), or a nonce verify. All of these would raise the bar—but the deepest fix is removal. By deleting the entire callback-execution path, the patch sidesteps the cognitive load of maintaining a safe callback registry and eliminates the risk that a future maintainer will relax the whitelist or forget to validate a new parameter.
Hardening Checklist
-
Audit all
call_user_func*(),eval(),create_function(), andassert()calls: Grep the plugin codebase for these functions and verify each call operates only on hardcoded or database-stored function names, never on user input. Use a static analysis tool like PHPSTAN with strict mode to flag callable sinks. -
Require nonce validation on every AJAX handler: Wrap all
add_action('wp_ajax_*')handlers incheck_ajax_referer('action_name')and regenerate the nonce freshly on every state-changing form render, stored in a hidden field. -
Implement a capability whitelist for form rendering: If form definitions must be user-customizable, store allowed render functions in an option or constant and enforce
in_array($form['render'], $ALLOWED_RENDERS, true)before any invocation. -
Use
wp_remote_post()or PHP'sfilter_var()for callback validation: Instead ofis_callable(), use a strict regex or an explicit enum of approved function names. For example:preg_match('/^[a-z_][a-z0-9_]*$/i', $callback) && function_exists($callback)still requires additional whitelisting, but it at least prevents common injection vectors. -
Disable direct AJAX form handling and require form post to REST API: Migrate form submission to the WordPress REST API with full nonce and capability enforcement via
register_rest_route(..., ['permission_callback' => fn() => current_user_can('publish_posts')]).
References
- https://nvd.nist.gov/vuln/detail/CVE-2025-13486