Critical vulnerability fixed in WordPress LifterLMS plugin.

The WordPress LifterLMS plugin, which has 9,000+ active installations, fixed a critical vulnerability in version 3.34.5 and earlier.

Reference

CVE-2019-15896

Summary

LifterLMS is a WordPress Learning Management System plugin. It was prone to an unauthenticated options import vulnerability that could lead to administrator account creation, content injection, website redirection and multiple stored XSS (front-end and back-end).

Unauthenticated options import

In the main “lifterlms.php” script, line 268, the plugin loads several scripts when the back-end is accessed, either by an authenticated or unauthenticated user (it simply relies on the is_admin function):

if ( is_admin() ) {

   include_once 'includes/admin/class-llms-admin-users-table.php';

   include_once 'includes/class-llms-staging.php';
   include_once 'includes/class.llms.dot.com.api.php';

   include_once 'includes/class.llms.generator.php';
   include_once 'includes/admin/class.llms.admin.import.php';
   ...
   ...

One of them is “includes/admin/class.llms.admin.import.php”.
Line 29, this script registers the upload_import function via the WordPress init hook:

public function __construct() {
   add_action( 'init', array( $this, 'upload_import' ) );
}

This function, located lines 53-123, is used to import LifterLMS options and data, including courses, lessons and users. It lacks capability check and a security nonce, allowing an unauthenticated user to import a malicious JSON encoded payload:

public function upload_import() {

   if ( ! isset( $_FILES['llms_import'] ) || ! $_FILES['llms_import'] ) {
      return;
   }

   // Fixes an issue where hooks are loaded out of order causing template functions required to parse an import aren't available?
   LLMS()->include_template_functions();

   $validate = $this->validate_upload( $_FILES['llms_import'] );

   if ( is_wp_error( $validate ) ) {
      return LLMS_Admin_Notices::flash_notice( $validate->get_error_message(), 'error' );
   }

   $raw = file_get_contents( $_FILES['llms_import']['tmp_name'] );

   $generator = new LLMS_Generator( $raw );
   if ( is_wp_error( $generator->set_generator() ) ) {
      return LLMS_Admin_Notices::flash_notice( $generator->error->get_error_message(), 'error' );
   } else {
      $generator->generate();
      if ( $generator->is_error( ) ) {
         return LLMS_Admin_Notices::flash_notice( $generator->error->get_error_message(), 'error' );
      } else {

         $msg = '<strong>' . __( 'Import Successful', 'lifterlms' ) . '</strong><br>';

         $msg .= '<ul>';

         foreach ( $generator->get_results() as $stat => $count ) {

            // translate like a boss ya'll
            switch ( $stat ) {

               case 'authors':
                  $name = __( 'Authors', 'lifterlms' );
               break;

               case 'courses':
                  $name = __( 'Courses', 'lifterlms' );
               break;

               case 'sections':
                  $name = __( 'Sections', 'lifterlms' );
               break;

               case 'lessons':
                  $name = __( 'Lessons', 'lifterlms' );
               break;

               case 'plans':
                  $name = __( 'Plans', 'lifterlms' );
               break;

               case 'quizzes':
                  $name = __( 'Quizzes', 'lifterlms' );
               break;

               case 'questions':
                  $name = __( 'Questions', 'lifterlms' );
               break;

               case 'terms':
                  $name = __( 'Terms', 'lifterlms' );
               break;

            }

            $msg .= '<li>' . sprintf( '%s: %d', $name, $count ) . '</li>';

         }// End foreach().
            $msg .= '</ul>';

         return LLMS_Admin_Notices::flash_notice( $msg, 'success' );
      }// End if().
   }// End if().

}

An attacker can leverage this vulnerability to perform several critical attacks such as:

1. Website redirection

Some posts (courses), have an option to redirect visitors to another location. An unauthenticated attacker could redirect them to an external malicious site. Redirection is performed via the “Location:” header:

HTTP/1.1 302 Found
Server: nginx/1.14.2
Date: Tue, 03 Sep 2019 17:38:08 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Set-Cookie: _wp_session=daae77d9873ebb9dc0f3e2d11ec36bc4%7C%7C1567575488%7C%7C1627148288; expires=Wed, 04-Sep-2019 05:38:08 GMT; Max-Age=43200; path=/
X-Redirect-By: WordPress
Location: https://evil.com

2. Administrator account creation

In the get_author_id function from the “includes/class.llms.generator.php” script, LifterLMS attempts to retrieve the user ID and email address of the author of the courses in the imported payload. However, if they are unknown to WordPress, the plugin will automatically create the corresponding administrator account:

if ( ! $author_id ) {

	if ( isset( $raw['email'] ) ) {

		// see if we have a user that matches by email
		$user = get_user_by( 'email', $raw['email'] );

		// user exists, use this user
		if ( $user ) {
			$author_id = $user->ID;
		}
	}
}

// no author id, create a new one using the email
if ( ! $author_id && isset( $raw['email'] ) ) {

	$data = array(
		'role' => 'administrator',
		'user_email' => $raw['email'],
		'user_login' => LLMS_Person_Handler::generate_username( $raw['email'] ),
		'user_pass' => wp_generate_password(),
	);

	if ( isset( $raw['first_name'] ) && isset( $raw['last_name'] ) ) {
		$data['display_name'] = $raw['first_name'] . ' ' . $raw['last_name'];
		$data['first_name'] = $raw['first_name'];
		$data['last_name'] = $raw['last_name'];
	}

	if ( isset( $raw['description'] ) ) {
		$data['description'] = $raw['description'];
	}

	$author_id = wp_insert_user( apply_filters( 'llms_generator_new_author_data', $data ), $raw );

A random administrator password is created using the WordPress wp_generate_password function and can be changed by the attacker by clicking on the login page “Lost your password” link.

3. Content injection

Attackers can create new posts and define their unique slug. Injected content can include formatted text, local or remote images as well as hyperlinks:

4. Multiple stored XSS

The above content injection attack does not allow the injection of JavaScript, or any HTML code, because the post content is saved to the database using the WordPress wp_insert_post function that removes HTML tags, e.g., <script>alert("Stored XSS")</script> would be turned into alert(\”Stored XSS\”).
However, with a little help from oEmbed, it is possible to inject any code. The add_custom_values function from the “includes/class.llms.generator.php” script handles custom data, including embeded content:

public function add_custom_values( $post_id, $raw ) {
   if ( isset( $raw['custom'] ) ) {
      foreach ( $raw['custom'] as $custom_key => $custom_vals ) {
         foreach ( $custom_vals as $val ) {
            // if $val is a JSON string, add slashes before saving.
            if ( is_string( $val ) && null !== json_decode( $val, true ) ) {
               $val = wp_slash( $val );
            }
            add_post_meta( $post_id, $custom_key, maybe_unserialize( $val ) );
         }
      }
   }
}

Saving data to the WordPress postmeta database table is done via the add_post_meta function that, unlike wp_insert_post, allows HTML code. Here’s an example of a malicious payload that I injected, unfiltered, in the database:

> select * from `wp_postmeta` where `meta_value` like '%Stored XSS%';
+---------+---------+------------------------------------------+--------------------------------------+
| meta_id | post_id | meta_key                                 | meta_value                           |
+---------+---------+------------------------------------------+--------------------------------------+
|   15898 |     701 | _oembed_9dc348fb2f241311fa713df9b76fa464 | <script>alert("Stored XSS")</script> |
+---------+---------+------------------------------------------+--------------------------------------+

It is triggered on the blog front-end when visiting the corresponding post:

It can also be used to target logged-in admininistrators when they are editing the malicious post in the back-end section of WordPress:

Additional issues

In the “includes/llms.functions.core.php” script, the llms_get_ip_address function is called to retrieve the user IP address:

function llms_get_ip_address() {

   if ( isset( $_SERVER['X-Real-IP'] ) ) {
      return $_SERVER['X-Real-IP'];
   } elseif ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
      // Proxy servers can send through this header like this: X-Forwarded-For: client1, proxy1, proxy2
      // Make sure we always only send through the first IP in the list which should always be the client IP.
      return trim( current( explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
   } elseif ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
      return $_SERVER['REMOTE_ADDR'];
   }
   return '';

}

It relies on untrusted user input (X-Real-IP and HTTP_X_FORWARDED_FOR) that are neither validated, nor sanitized.

Timeline

The vulnerability was discovered and reported to the wordpress.org team on September 03, 2019 and a new version 3.35.0 was released on September 04, 2019.

Recommendations

Update as soon as possible if you have version 3.34.5 or below installed.
If you are using our web application firewall for WordPress, NinjaFirewall WP Edition (free) and NinjaFirewall WP+ Edition (premium), you are protected against this vulnerability.

Stay informed about the latest vulnerabilities in WordPress plugins and themes: @nintechnet