This blog post is about the Avada theme and plugin vulnerability. If you’re a Avada user, please update the Avada builder plugin to at least version 3.11.2 and Avada theme to at least version 7.11.2.
Patchstack Developer and Business users are protected from the vulnerability. You can also sign up for the Patchstack Community plan to be notified about vulnerabilities as soon as they become disclosed.
For plugin developers, we have security audit services and Threat Intelligence Feed API for hosting companies.
About the Avada Theme and Plugin
The theme Avada (versions 7.11.1 and below, premium version), which has over 900,000 sales is the best selling theme in ThemeForest. This theme is bundled with a required Avada Builder plugin (versions 3.11.1 and below, premium version). The Avada theme is known as the more popular premium theme in WordPress. This plugin is developed by ThemeFusion.
This theme is a premium builder focused premium theme which claims to be “The Complete WordPress Website Building Toolkit”. We can build everything from one-page business websites to an online marketplace without ever having to write a single line of code.
The security vulnerabilities
The Avada Builder plugin suffers from multiple vulnerabilities. The first vulnerability is an authenticated SQL Injection which could result in unauthorized access to sensitive data, such as passwords or personal user information. Also, it could be leveraged to remote code execution on certain cases. Another vulnerability is a reflected XSS vulnerability and could possibly allow an unauthenticated user to steal sensitive information to, in this case, privilege escalation on the WordPress site by tricking a higher privileged user to visit a certain crafted URL. The described vulnerability was fixed in version 3.11.2 and assigned CVE-2023-39309 and CVE-2023-39306.
The Avada theme itself also suffers from multiple vulnerabilities. Two of the vulnerabilities are a Contributor+ Arbitrary File Upload and Author+ Unrestricted Zip Extraction which allows the respective role to upload arbitrary files including PHP files to achieve remote code execution on the WordPress site. Another vulnerability is Contributor+ Server-Side Request Forgery which could result in unauthorized actions or access to data within the organization, either in the vulnerable application itself or on other back-end systems that the application can communicate to. The described vulnerabilities were fixed in version 7.11.2 and assigned CVE-2023-39307 , CVE-2023-39312 and CVE-2023-39313.
Authenticated SQL Injection
The first vulnerable code exists in the ajax_regenerate_css
function:
public function ajax_regenerate_css() {
if ( wp_verify_nonce( 'fusion-page-options-nonce', 'fusion_po_nonce' ) ) {
wp_send_json_error( __( 'Security check failed.', 'fusion-builder' ) );
}
if ( empty( $_GET['awb_critical_id'] ) ) {
wp_send_json_error( __( 'No ID specified.', 'fusion-builder' ) );
}
$urls = [];
$id = sanitize_text_field( wp_unslash( $_GET['awb_critical_id'] ) );
$entry = AWB_Critical_CSS()->get(
[
'where' => [
'id' => '"' . $id . '"',
],
]
);
------------------------- CUT HERE -------------------------
The function can be reached from the wp_ajax_awb_regenerate_critical_css
ajax action and the nonce check applied is not proper. The code will pass the $id
variable to the AWB_Critical_CSS()->get()
function. The sanitize_text_field
function itself is not enough to prevent SQL Injection, since it doesn’t escape quotes. Let’s take a look at the AWB_Critical_CSS()->get()
function:
public function get( $args = [] ) {
global $wpdb;
$defaults = [
'what' => '*',
'where' => [],
'order_by' => '',
'order' => 'ASC',
'limit' => '',
'offset' => 0,
];
$args = wp_parse_args( $args, $defaults );
// The table name.
$table_name = $wpdb->prefix . $this->table_name;
// The query basics.
$query = 'SELECT ' . $args['what'] . " FROM `$table_name`";
// Build the WHERE fragment of the query.
if ( ! empty( $args['where'] ) ) {
$where = [];
foreach ( $args['where'] as $where_fragment_key => $where_fragment_val ) {
if ( false === strpos( $where_fragment_val, 'LIKE' ) ) {
$where[] = "$where_fragment_key = $where_fragment_val";
} else {
$where[] = "$where_fragment_key $where_fragment_val";
}
}
$query .= ' WHERE ' . implode( ' AND ', $where );
}
------------------------- CUT HERE -------------------------
Notice that the $args['where']
variable will be constructed to the SQL query also without escaping or sanitization of the $where_fragment_val
value.
Other vulnerable code that call AWB_Critical_CSS()->get()
function without proper escaping is the ajax_save_css
function:
public function ajax_save_css() {
if ( wp_verify_nonce( 'fusion-page-options-nonce', 'security' ) ) {
wp_send_json_error( __( 'Security check failed.', 'fusion-builder' ) );
}
$css_key = sanitize_text_field( wp_unslash( $_POST['post_id'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
$css = wp_unslash( $_POST['css'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( empty( $css ) ) {
wp_send_json_error( __( 'No compiled CSS available for page.', 'fusion-builder' ) );
}
}
------------------------- CUT HERE -------------------------
// Check if we have critical CSS already.
$already_found = AWB_Critical_CSS()->get(
[
'where' => [
'css_key' => '"' . $css_key . '"',
],
]
);
------------------------- CUT HERE -------------------------
Same pattern as the ajax_regenerate_css
, this function can be called from wp_ajax_awb_critical_css
ajax action without a proper nonce check. The code will pass the $css_key
variable to the AWB_Critical_CSS()->get()
function without proper escaping.
Reflected Cross Site Scripting
The underlying vulnerable code exists in the render_register
function:
public function render_register( $args, $content = '' ) {
global $fusion_settings;
// Compatibility fix for versions prior to FB 1.5.2.
if ( ! isset( $args['register_note'] ) ) {
$args['register_note'] = esc_attr__( 'Registration confirmation will be emailed to you.', 'fusion-builder' );
}
------------------------- CUT HERE -------------------------
$html = '';
if ( ! is_user_logged_in() ) {
$html .= '<div ' . FusionBuilder::attributes( 'login-shortcode' ) . '>';
$html .= ( $heading ) ? '<h3 class="fusion-login-heading">' . $heading . '</h3>' : '';
$html .= ( $caption ) ? '<div class="fusion-login-caption">' . $caption . '</div>' : '';
$html .= '<' . $main_container . ' ' . FusionBuilder::attributes( 'login-shortcode-form' ) . '>';
// Get the success/error notices.
$html .= $this->render_notices( $action );
// Values from failed attempt.
$username = isset( $_GET['user_login'] ) ? sanitize_text_field( wp_unslash( $_GET['user_login'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
$email = isset( $_GET['user_email'] ) ? sanitize_text_field( wp_unslash( $_GET['user_email'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
$html .= '<div class="fusion-login-fields">';
$html .= '<div class="fusion-login-input-wrapper">';
$html .= '<label class="' . $label_class . '" for="user_login-' . $this->login_counter . '">' . esc_html__( 'Username', 'fusion-builder' ) . '</label>';
$placeholder = ( 'yes' === $show_placeholders ) ? ' placeholder="' . esc_attr__( 'Username', 'fusion-builder' ) . '"' : '';
$html .= '<input type="text" name="user_login"' . $placeholder . ' value="' . $username . '" size="20" class="fusion-login-username input-text" id="user_login-' . $this->login_counter . '" />';
$html .= '</div>';
$html .= '<div class="fusion-login-input-wrapper">';
$html .= '<label class="' . $label_class . '" for="user_email-' . $this->login_counter . '">' . esc_html__( 'Email', 'fusion-builder' ) . '</label>';
$placeholder = ( 'yes' === $show_placeholders ) ? ' placeholder="' . esc_attr__( 'Email', 'fusion-builder' ) . '"' : '';
$html .= '<input type="email" name="user_email"' . $placeholder . ' value="' . $email . '" size="20" class="fusion-login-email input-text" id="user_email-' . $this->login_counter . '" />';
$html .= '</div>';
------------------------- CUT HERE -------------------------
return apply_filters( 'fusion_element_user_register_content', $html, $args );
}
This function is set as the handler of fusion_register
shortcode. Notice that the $username
and $email
variables are coming from GET parameters and will be directly constructed inside HTML tag attribute of $html
variable. Notice that the implementation of sanitize_text_field
function is not enough since it doesn’t perform HTML-escape on the string, and we could break out of the HTML attribute to perform DOM based XSS.
Contributor+ Arbitrary File Upload
The underlying vulnerable code exist in the ajax_import_options
function:
public function ajax_import_options() {
check_ajax_referer( 'fusion_load_nonce', 'fusion_load_nonce' );
$wp_filesystem = Fusion_Helper::init_filesystem();
$upload_dir = wp_upload_dir();
$dir_path = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) . 'fusion-page-options-export/' );
$content_json = false;
// If its an uploaded file.
if ( isset( $_FILES['po_file_upload'] ) ) {
if ( ! isset( $_FILES['po_file_upload']['name'] ) ) {
return;
}
$json_file_path = wp_normalize_path( $dir_path . wp_unslash( $_FILES['po_file_upload']['name'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
if ( ! file_exists( $dir_path ) ) {
wp_mkdir_p( $dir_path );
}
if ( ! isset( $_FILES['po_file_upload'] ) || ! isset( $_FILES['po_file_upload']['tmp_name'] ) ) {
return;
}
// We're already checking if defined above.
if ( ! $wp_filesystem->move( wp_normalize_path( $_FILES['po_file_upload']['tmp_name'] ), $json_file_path, true ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
return;
}
$content_json = $wp_filesystem->get_contents( $json_file_path );
$wp_filesystem->delete( $json_file_path );
------------------------- CUT HERE -------------------------
The function can be called from the wp_ajax_fusion_panel_import
ajax action and the value of fusion_load_nonce
can be fetched from a Contributor role account. The function tries to upload $_FILES['po_file_upload']
to the $json_file_path
using wp_filesystem->move()
function. Since there is no file extension check, the Contributor role is able to upload arbitrary files including PHP files. Note that this vulnerability has a high Attack Complexity (AC) since the uploaded file will be deleted on the end of the code using $wp_filesystem->delete()
function, so attacker need some kind of race condition to access the uploaded file in order to rapidly execute the PHP file and further exploit the site.
Author+ Unrestricted Zip Extraction
The underlying vulnerable code exist in the process_upload
and regenerate_icon_files
functions:
------------------------- CUT HERE -------------------------
$package_path = get_attached_file( $icon_set['attachment_id'] );
$status = false;
if ( $package_path && file_exists( $package_path ) ) {
// Create icon set path.
$icon_set_dir_name = $this->get_unique_dir_name( pathinfo( $package_path, PATHINFO_FILENAME ), FUSION_ICONS_BASE_DIR );
$icon_set_path = FUSION_ICONS_BASE_DIR . $icon_set_dir_name;
// Create icon set directory.
wp_mkdir_p( $icon_set_path );
// Attempt to manually extract the zip file first. Required for fptext method.
if ( class_exists( 'ZipArchive' ) ) {
$zip = new ZipArchive();
if ( true === $zip->open( $package_path ) ) {
$zip->extractTo( $icon_set_path );
$zip->close();
$status = true;
}
} else {
$status = unzip_file( $package_path, $icon_set_path );
}
}
------------------------- CUT HERE -------------------------
Both of the mentioned functions have above code. The function is used to handle Custom Icons feature upload which can be accessed from the Author role. Notice that the code will directly unzip the $package_path
to the $icon_set_path
directory without any check on the filename. This allows a user with the Author role to upload a zip file containing arbitrary files such as PHP files to achieve remote code execution.
Contributor+ Server Side Request Forgery
The underlying vulnerable code exist in the ajax_import_options
function:
public function ajax_import_options() {
check_ajax_referer( 'fusion_load_nonce', 'fusion_load_nonce' );
$wp_filesystem = Fusion_Helper::init_filesystem();
$upload_dir = wp_upload_dir();
$dir_path = wp_normalize_path( trailingslashit( $upload_dir['basedir'] ) . 'fusion-page-options-export/' );
$content_json = false;
// If its an uploaded file.
if ( isset( $_FILES['po_file_upload'] ) ) {
------------------------- CUT HERE -------------------------
} elseif ( isset( $_POST['toUrl'] ) ) {
$args = [
'user-agent' => 'avada-user-agent',
];
$content_json = wp_remote_retrieve_body( wp_remote_get( esc_url( wp_unslash( $_POST['toUrl'] ) ), $args ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
}
echo wp_json_encode( $content_json );
die();
}
------------------------- CUT HERE -------------------------
The code allow the user to fetch remote content using wp_remote_get
function and will fully return the content of the targeted URL. By default, wp_remote_get
doesn’t protect against SSRF which allow user with Contributor role to fetch content of internal service on the WordPress server.
The patch
For the authenticated SQL Injection vulnerability, the vendor decided to apply a proper nonce check to the affected function and apply intval() and esc_sql() to the affected variables. The patch can be seen below:
For the reflected XSS vulnerability, escaping the $username
and $email
variables using esc_attr
function should be enough to patch the issue. The patch can be seen below:
For the Contributor+ Arbitrary File Upload vulnerability, the vendor decided to restrict the uploaded file extension to only accept JSON. The patch can be seen below:
For the Author+ Unrestricted Zip Extraction vulnerability, the patch applied by checking all of the filenames inside of the zip and only accept certain type of files before extracting the zip file. The patch can be seen below:
For the Contributor+ Server-Side Request Forgery vulnerability, changing the wp_remote_get
function to wp_safe_remote_get
function should be enough to prevent SSRF attacks. The patch can be seen below:
Conclusion
User input processing need to be secured on all aspects of the WordPress plugin and theme. For input strings that will be processed within a SQL query, always try to sanitize the value. Depending on the context of the placed string, usage of esc_sql function should be enough to prevent injection on variable that placed inside a quotes in SQL query. Though ideally, the WPDB prepare function is used for communication with the database.
For input strings that will be reflected as HTML code, we can use esc_html to escape string on a regular context of HTML and esc_attr to escape string that will be placed inside HTML tag attributes.
In case of file upload processes, always limit what type of file can be uploaded by the user. We recommend using a whitelist check instead of blacklist check to prevent more cases of arbitrary file upload. We also need to pay extra attention to a decompress file process like unzip. Before extracting the files, always check all of the files inside the zip and only allow certain file type using a whitelist.
Lastly, for features that need to fetch content or status from specific remote source that could be partially or fully controlled by the user, always use a safe built-in function such as wp_safe_remote_get
which can prevent SSRF attacks.
Timeline
Help us make the Internet a safer place
Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.
- If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
- If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.