Research by: Omri Herscovici and Sagi Tzadik
The COVID-19 pandemic has changed the way we live and work. “Sheltering in place” requires many people to work from home, thereby necessitating the use of virtual environments. The pandemic has also affected students globally, who are now at home learning via virtual classrooms online. This, in turn, has required many educational establishments to quickly integrate new Learning Management Systems into their platforms.
Learning Management Systems are E-Learning platforms used for delivering educational courses and training programs remotely. Not only university students use LMS; it’s for anyone interested in online learning.
In light of the increasing popularity of these platforms, we decided to audit the security of a few of them. Despite the somewhat shady reputation of WordPress plugins, they are still heavily used and are an integral part of most WordPress websites. This seems to be especially true in the case of Learning Management Systems, in which WordPress websites are the majority of the independent websites offering this service.
The 3 leading WordPress LMS Plugins are: LearnPress, LearnDash, and LifterLMS. These platforms can transform any WordPress website into a fully functioning and easy to use LMS. The 3 systems are installed on more than 100,000 different educational platforms and include universities such as the University of Florida, University of Michigan, University of Washington as well as hundreds of online academies. The impact multiplies as it affects all of the students in all of these establishments.
We focused only on these 3 systems since they seemed to the most impactful in this category. The LMS we researched invested quite a lot of effort in their security and some even implemented a bug bounty program. And indeed, the bugs we found were not trivial and required reaching interesting sinks. Therefore, in addition to notifying the developers, we also decided to share some of them with the security community.
Our approach was to see if a motivated student can accomplish the childhood dream of every hacker – take control of his educational institution, get test answers and even change students’ grades. As we assume some of our readers might not be familiar with web application exploitation, we sorted the bugs in an increasing level of complexity, to make things easier to follow.
LearnPress is WordPress’ most popular LMS plugin, as it enables website managers to easily create and sell online courses. According to BuiltWith, it is the second most popular internet platform in the Learning Management System category, and the most popular one in the United States – more than Moodle (which is the dominating open-source learning platform worldwide), with a 24,000 installation base versus 23,000. Overall, according to the official WordPress plugin website, it has 80,000+ installations, and the company developing the plugin states it is used in over 21,000 schools.
TL;DR: This vulnerability is a Time-Based Blind SQL Injection which is very trivial to identify and exploit. It was a surprise to find it as we would have expected that by now prepared statements were a part of the norm. Although these types of vulnerabilities are usually easy to spot, they should not be underestimated as they have a large impact on the system’s integrity and can even result in the platform’s takeover.
The method _get_items
of the class LP_Modal_Search_Items
is vulnerable to SQL Injection. The method fails to sufficiently sanitize user-supplied data (the GET/POST parameter current_items
) before using it in an SQL query.
An authenticated user can trigger this vulnerability by calling the Ajax method learnpress_modal_search_items
which executes the following chain:LP_Admin_Ajax::modal_search_items
→ LP_Modal_Search_Items::get_items
→LP_Modal_Search_Items::_get_items
.
Although the technical details of this vulnerability are not particularly interesting, we decided to include it as a point for comparison to the one we found in LearnDash.
TL;DR: This vulnerability is a good example of legacy code forgotten behind resulting in a privilege escalation in the current design of the system.
The function learn_press_accept_become_a_teacher
can be used to upgrade a registered user to a teacher role, resulting in a privilege escalation. Unexpectedly, the code doesn’t check the permissions of the requesting user, therefore letting any student call this function.
function learn_press_accept_become_a_teacher() { $action = ! empty( $_REQUEST['action'] ) ? $_REQUEST['action'] : ''; $user_id = ! empty( $_REQUEST['user_id'] ) ? $_REQUEST['user_id'] : ''; if ( ! $action || ! $user_id || ( $action != 'accept-to-be-teacher' ) ) { return; } if ( ! learn_press_user_maybe_is_a_teacher( $user_id ) ) { $be_teacher = new WP_User( $user_id ); $be_teacher->set_role( LP_TEACHER_ROLE ); delete_transient( 'learn_press_become_teacher_sent_' . $user_id ); do_action( 'learn_press_user_become_a_teacher', $user_id ); $redirect = add_query_arg( 'become-a-teacher-accepted', 'yes' ); $redirect = remove_query_arg( 'action', $redirect ); wp_redirect( $redirect ); } } add_action( 'plugins_loaded', 'learn_press_accept_become_a_teacher' ); ...
This function is invoked once the activated plugins have been loaded, meaning that it can be called by simply supplying the action
and user_id
parameters to /wpadmin/
, without even having to log in.
Just prior to releasing the blog we found that this vulnerability was a duplicate and was also discovered by Wordfence.
Both of the vulnerabilities we reported received the same treatment from the author – the vulnerable functions were completely purged from the new patched version. A classic case of “the best code is no code at all”.
LearnDash is a leading WordPress LMS plugin. According to BuiltWith, over 33,000 websites currently run LearnDash. LearnDash also stated to have been integrated into Fortune 500 companies as well as leading universities such as the University of Florida, University of Michigan, and University of Washington.
TL;DR: This vulnerability is easy to spot but much harder to exploit, as it is not a simple case of incorporating untrusted user input directly into an SQL query. But this too, like any other SQL Injection, could have easily been prevented by the use of prepared statements.
The function learndash_get_course_groups
in the ld-groups.php
file is vulnerable to Second-Order SQL Injection. This function fails to sufficiently sanitize user-supplied data (although it is queried from the database, so one might assume it is safe) before using it in an SQL query. This vulnerability can be triggered without authentication.
Let’s have a look at the vulnerable function learndash_get_course_groups
:
function learndash_get_course_groups( $course_id = 0, $bypass_transient = false ) { ... $sql_str = $wpdb->prepare("SELECT DISTINCT REPLACE(meta_key, 'learndash_group_enrolled_', '') FROM ". $wpdb->postmeta ." WHERE meta_key LIKE %s AND post_id = %d and meta_value != ''", 'learndash_group_enrolled_%', $course_id ); ... $col = $wpdb->get_col( $sql_str ); ... $sql_str = "SELECT ID FROM $wpdb->posts WHERE post_type='groups' AND post_status = 'publish' AND ID IN (" . implode( ',', $col ) . ')'; $course_groups_ids = $wpdb->get_col( $sql_str ); ...
The function queries the table wp_postmeta
for meta_key
s of the format learndash_group_enrolled_%
, for a specific post ($course_id
). It then removes the learndash_group_enrolled_
prefix and uses the rest of the value in another SQL query. This means that if we could find a way to insert a malicious record into wp_postmeta
, where we control the value of meta_key
, we would have an SQL injection.
Conveniently enough, the file ipn.php
, which is responsible for handling IPN transactions, contains the following code:
... // log transaction ld_ipn_debug( 'Starting Transaction Creation.' ); $transaction = $_REQUEST; // <--- controlled input! $transaction['user_id'] = $user_id; $transaction['course_id'] = $course_id; // <--- controlled input! $transaction['log_file'] = basename($ipn_log_filename); $course_title = ''; $course = get_post( $course_id ); if ( ! empty( $course) ) { $course_title = $course->post_title; } ld_ipn_debug( 'Course Title: ' . $course_title ); $post_id = wp_insert_post( array('post_title' => "Course {$course_title} Purchased By {$email}", 'post_type' => 'sfwd-transactions', 'post_status' => 'publish', 'post_author' => $user_id) ); ld_ipn_debug( 'Created Transaction. Post Id: ' . $post_id ); foreach ( $transaction as $k => $v ) { update_post_meta( $post_id, $k, $v ); // <--- this creates the malicious post meta that is later queried and used in another SQL query. } ...
PayPal IPN is PayPal’s way to notify websites about PayPal transactions. Workflow:
verify_sign
parameter.cmd=_notify-validate
.VERIFIED
or INVALID
.
You can read more about PayPal IPN here. PayPal’s IPN Simulator can be used to craft IPN requests in a sandbox environment.
The code snippet from ipn.php
effectively allows us to create a record in the table wp_postmeta
(via the function update_post_meta) with a meta_key
and meta_value
of our choice, which is later exploited to an SQL Injection by invoking learndash_get_course_groups
.
Now that we can insert a malicious record in wp_postmeta
, we need to find a way to invoke learndash_get_course_groups
. Once again, ipn.php
can be used to achieve this goal.
ipn.php
naturally calls learndash_get_course_groups
via the following call chain:ipn.php
→ ld_update_course_access
→ learndash_update_course_users_groups
→ learndash_get_course_groups
We describe the exploitation process for PayPal’s sandbox environment for convenience sake, but this vulnerability exists in websites using the live environment as well.
The first step to exploit this vulnerability requires us to use the IPN Simulator to craft a valid IPN request. For the $course_id
needed in ipn.php
(item_number
in the IPN request), we use ID=1
(as it is never validated that this post is an actual course). Then, we extend the IPN request with the following parameters:
INJECTION_POINTdebug=1&learndash_group_enrolled_1)
%23=1
There are three points that should be noted about this request:
debug=1
causes the creation of a log file in the path /wp-content/uploads/learndash/paypal_ipn/
which contains the id
of the new post we just created which has a malicious meta_key
. We need this post id for later use. The log file name that is created is simply time()
.log, which can be guessed / enumerated. The post number can also be enumerated.value
part of the key=
value pair (either in GET query parameters or POST parameters). In this case, our injection occurs in the
key part of the
key=value
pair. We cannot use spaces there because PHP replaces them with underscores when accessing $_REQUEST
. However, we can use /**/
instead of spaces, as it is a valid character for the
key part and is a valid replacement for spaces in SQL queries (SELECT 1
is the same as SELECT/**/1
).%23
to the end of our injection point, which is an encoded representation of #
, to comment out the rest of the original query.
We now have to use the
post id we found in point #1, and send an IPN request with
post_id. This triggers the SQL Injection.item_number=
This is what the executed query looks like:
SELECT ID FROM wp_posts WHERE post_type='groups' AND post_status = 'publish' AND ID IN (1)[MALICIOUS SQL QUERY]#)
LifterLMS is a leading LMS WordPress plugin. According to BuiltWith, approximately 17,000 websites use this plugin, including WordPress agencies and educators, along with various school and educational establishments.
TL;DR: An interesting example of Arbitrary File Write vulnerability, exploiting the dynamic nature of PHP applications – requiring us to reach an interesting sink in an unexpected way.
Since WordPress enables plugins to register new actions
to its admin-ajax handler, the LifterLMS registered its own handle()
function:
public static function handle() { // Make sure we are getting a valid AJAX request check_ajax_referer( self::NONCE ); // $request = self::scrub_request( $_REQUEST ); $request = $_REQUEST; $response = call_user_func( 'LLMS_AJAX_Handler::' . $request['action'], $request ); ...
The function invokes call_user_func
in LLMS_AJAX_Handler
, based on the sent action
variable. One of the available functions in LLMS_AJAX_Handler
is export_admin_table
:
public static function export_admin_table( $request ) { require_once 'admin/reporting/class.llms.admin.reporting.php'; LLMS_Admin_Reporting::includes(); $handler = 'LLMS_Table_' . $request['handler']; if ( class_exists( $handler ) ) { $table = new $handler(); $file = isset( $request['filename'] ) ? $request['filename'] : null; return $table->generate_export_file( $request, $file ); ...
The function first creates a new class based on the handler
variable sent to it, and then calls its generate_export_file
function with the filename
variable also sent in the request. The generate_export_file
is an inherited function that should create a CSV file based on the information pulled in the correspondent LLMS_Table
class we created using the handler
variable.
However, the code fails to verify that the extension in the filename
variable is indeed a CSV. The reason is because when no $type
is sent, it defaults to CSV.
public function generate_export_file( $args = array(), $filename = null, $type = 'csv' ) { if ( 'csv' !== $type ) { return false; } .. $file_path = LLMS_TMP_DIR . $filename; … $handle = @fopen( $file_path, 'a+' ); ...
At this point, an attacker can intercept a standard Ajax request, and use the ajax_nonce
variable, created organically, to reach the generate_export_file
with a filename
that creates PHP files in an arbitrary location.
This bug enables an attacker to write PHP files, but without any control over the content. However, one of the LLMS_Tables
we can use is LLMS_Tables_Course_Students
.
The registered student can easily check what courses id
he is registered to, and send it in the Ajax request. This outputs his name to the generated file.
The user can go to his profile page and change his first name to a desired PHP code, i.e TEST <?php phpinfo(); /*
.
The WordPress input filter mechanism doesn’t allow for both opening and closing angle brackets (< >
) in the same input, but since PHP is a forgiving language, we can use only the opening angle bracket anywhere in the file, and comment out the rest of the content using (/*
).
At this point, simply browsing to the generated PHP file executes the PHP code written in the user’s first name – effectively achieving code execution on the server.
We focused on 3 of the most common LMS plugins: LearnPress, LearnDash and LifterLMS, and found vulnerabilities ranging from Privilege Escalation through SQL Injection up to full Remote Code Execution.
In total, we found 4 vulnerabilities that were assigned CVE-2020-6008, CVE-2020-6009 and CVE-2020-6010 and one duplicate CVE-2020-11511.
These vulnerabilities allow students and sometimes even unauthenticated users to gain sensitive information, edit personal records, and even take control of the LMS platforms.
Due to the recent increase in the popularity of E-Learning platforms, these are urgent issues. The developers have since released fixes to the platforms.
We urge users to upgrade to the latest versions of these platforms:
Check Point IPS protects against this threat:
WordPress LearnDash Plugin SQL Injection (CVE-2020-6009)
WordPress LearnPress Plugin Privilege Escalation
WordPress LearnPress Plugin SQL Injection
WordPress LifterLMS Plugin Arbitrary File Write (CVE-2020-6008)