This blog post is about an AI Engine plugin vulnerability. If you're an AI Engine user, please update the plugin to at least version 1.9.99.
✌️ Our users are protected from this vulnerability. Are yours?
Automatically mitigate vulnerabilities in real-time without changing code.
See pricingIdentify vulnerabilities in your plugins and get recommendations for fixes.
Request auditProtect your users, improve server health and earn additional revenue.
Patchstack for hostsAbout the AI Engine Plugin
The plugin AI Engine (free version), which has over 50,000 active installations, is known as the more popular AI-related plugin in WordPress.

This plugin enables us to create chatbot, craft content, coordinate AI-related work using templates, play with AI Copilot in the editor for faster work, track statistics, and more. The AI Playground offers a range of AI tools, including translation, correction, SEO, suggestions, WooCommerce product fields, and others. There is also an internal API so other plugins can tap into its capabilities.
The security vulnerability
This plugin suffers from an unauthenticated arbitrary file upload vulnerability. This vulnerability allows any unauthenticated user to upload arbitrary files, including php files, that could lead to remote code execution. The described vulnerability was fixed in version 1.9.99 and assigned CVE-2023-51409.
Unauthenticated Arbitrary File Upload
The underlying vulnerable code exists in the rest_upload
function:
public function rest_upload() {
require_once( ABSPATH . 'wp-admin/includes/image.php' );
require_once( ABSPATH . 'wp-admin/includes/file.php' );
require_once( ABSPATH . 'wp-admin/includes/media.php' );
$file = $_FILES['file'];
$error = null;
if ( empty( $file ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided.' ], 400 );
}
$local_upload = $this->core->get_option( 'image_local_upload' );
$image_expires_seconds = $this->core->get_option( 'image_expires' );
$expires = ( empty( $image_expires_seconds ) || $image_expires_seconds === 'never' ) ? null :
date( 'Y-m-d H:i:s', time() + $image_expires_seconds );
$fileId = null;
$url = null;
if ( $local_upload === 'uploads' ) {
if ( !$this->check_db() ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not create database table.' ], 500 );
}
$upload_dir = wp_upload_dir();
$filename = wp_unique_filename( $upload_dir['path'], $file['name'] );
$path = $upload_dir['path'] . '/' . $filename;
if ( !move_uploaded_file( $file['tmp_name'], $path ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not move the file.' ], 500 );
}
$url = $upload_dir['url'] . '/' . $filename;
$fileId = md5( $url );
$this->wpdb->insert( $this->table_files, [
'fileId' => $fileId,
'type' => 'image',
'status' => 'uploaded',
'created' => date( 'Y-m-d H:i:s' ),
'updated' => date( 'Y-m-d H:i:s' ),
'expires' => $expires,
'path' => $path,
'url' => $url
]);
}
else if ( $local_upload === 'library' ) {
$id = media_handle_upload( 'file', 0 );
if ( is_wp_error( $id ) ) {
$error = $id->get_error_message();
return new WP_REST_Response([ 'success' => false, 'message' => $error ], 500);
}
$url = wp_get_attachment_url( $id );
$fileId = md5( $url );
update_post_meta( $id, '_mwai_file_id', $fileId );
update_post_meta( $id, '_mwai_file_expires', $expires );
}
return new WP_REST_Response( [
'success' => true,
'data' => [ 'id' => $fileId, 'url' => $url ]
], 200 );
}
This function handles requests to the mwai-ui/v1/files/upload
REST API endpoint:
public function rest_api_init() {
register_rest_route( $this->namespace, '/files/upload', array(
'methods' => 'POST',
'callback' => array( $this, 'rest_upload' ),
'permission_callback' => '__return_true'
) );
register_rest_route( $this->namespace, '/files/delete', array(
'methods' => 'POST',
'callback' => array( $this, 'rest_delete' ),
'permission_callback' => '__return_true'
) );
}
Note that the permission_callback
parameter of the REST endpoint is set to __return_true
which allows any unauthenticated user to trigger the rest_upload
function.
Back to the rest_upload
function, notice that in one of the conditions, the code will try to save the uploaded files with $path
value using the move_uploaded_file
function. The $path
itself is constructed from $filename
variable and it contains the $file['name']
value which we have full control over. Since there is no proper file type and extension validation on the function, we can simply upload an arbitrary file such as .php file to achieve RCE.
Note that this vulnerability can be reproduced from by an unauthenticated user on a default installation of the plugin without any additional conditions or requirements.
The patch
The team decided to apply a permission check to the custom REST API endpoint and also apply a check on the file type and extension using the wp_check_filetype_and_ext
function. The patch can be seen below:

Conclusion
Always check every process of $_FILES parameter in the plugin or theme code. Make sure to apply a check on the filename and extension before uploading the file. Also, pay extra attention to the permission checks on the custom REST API endpoints.
Timeline
🤝 You can help us make the Internet a safer place
Streamline your disclosure process to fix vulnerabilities faster and comply with CRA.
Get started for freeProtect your users too! Improve server health and earn added revenue with proactive security.
Patchstack for hostsReport vulnerabilities to our gamified bug bounty program to earn monthly cash rewards.
Learn more