Multiple Critical Vulnerabilities Fixed In LearnPress Plugin Version <= 4.1.7.3.2

Published 24 January 2023
Updated 24 July 2023
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

If you're a LearnPress user, please update the plugin to at least version 4.2.0.

Patchstack paid plan users are protected from the vulnerability. You can also sign up 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.

Introduction to the LearnPress plugin vulnerability

The plugin LearnPress (versions 4.1.7.3.2 and below), which has over 100,000 active installations is a comprehensive WordPress LMS Plugin for WordPress. This is one of the most popular WordPress LMS Plugins which can be used to easily create & sell courses online. We can create a course curriculum with lessons & quizzes included which is managed with an easy-to-use interface for users.

LearnPress plugin

This plugin suffers from multiple critical vulnerabilities. These vulnerabilities allow any unauthenticated users to inject a SQL query to the database and perform local file inclusion. We also found another SQL injection that would need a user with at least "Contributor" role to be exploited. The described vulnerability was fixed in version 4.2.0.

The security vulnerability in LearnPress

Unauthenticated Local File Inclusion (CVE-2022-47615)

https://patchstack.com/database/vulnerability/learnpress/wordpress-learnpress-plugin-4-1-7-3-2-local-file-inclusion

The vulnerable code responsible for this vulnerability is located on inc/rest-api/v1/frontend/class-lp-rest-courses-controller.php function list_courses . This function is used to handle API request to lp/v1/courses/archive-course .

$template_pagination_path = $request['template_pagination_path'] ?? '';
if ( ! isset( $request['no_pagination'] ) ) {
    if ( ! empty( $template_pagination_path ) ) {
        $response->data->pagination = include $template_pagination_path;
    } else {
        $response->data->pagination = learn_press_get_template_content(
            'loop/course/pagination.php',
            array(
                'total' => $total_pages,
                'paged' => $filter->page,
            )
        );
    }
}
// End Pagination

// Content items
ob_start();
$template_path_item = urldecode( $request['template_path_item'] ?? '' );
$template_path      = urldecode( $request['template_path'] ?? '' ); // For wrapper all items, no foreach
$args_custom        = json_decode( wp_unslash( $request['args_custom'] ?? '' ), true );

// For custom template return all list courses no foreach
if ( ! empty( $template_path ) ) {
    if ( is_array( $args_custom ) && ! empty( $args_custom ) ) {
        extract( $args_custom );
    }

    if ( file_exists( $template_path ) ) {
        include $template_path;
    }
} else {
    // For custom template return all list courses foreach
    if ( ! empty( $template_path_item ) ) {
        if ( isset( $request['args_custom'] ) ) {
            $args_custom = json_decode( $request['args_custom'], true );
        }

        $template_path_item = urldecode( $template_path_item );
    }

    // Todo: tungnx - should rewrite call template
    foreach ( $courses as $course ) {
        $post = get_post( $course->ID );
        setup_postdata( $post );

        if ( ! empty( $template_path_item ) ) {
            if ( $args_custom ) {
                extract( $args_custom );
            }

            if ( file_exists( $template_path_item ) ) {
                include $template_path_item;
            }

There are 3 variable that unauthenticated user able to control to achieve LFI, $template_pagination_path , $template_path and $template_path_item .

Unauthenticated SQL Injection (CVE-2022-45808)

https://patchstack.com/database/vulnerability/learnpress/wordpress-learnpress-wordpress-lms-plugin-plugin-4-1-7-3-2-sql-injection

Besides our team findings, we received a report from our alliance member (Fadilah Agung Nugraha) that could trigger SQL injection as an unauthenticated user. The underlying code that is responsible for the vulnerability is located in inc/databases/class-lp-db.php function execute . The function accepts LP_Filter $filter as the first parameter, which could contain several data point for filtering purpose, such as "order by", "group by", etc.

The execute function could be seen called from several locations throughout the plugin, especially in the REST API process flow. This particular code responsible for the SQL injection:

// Order by
$ORDER_BY = '';
if ( ! $filter->return_string_query && $filter->order_by ) {
    $ORDER_BY .= 'ORDER BY ' . $filter->order_by . ' ' . $filter->order . ' ';
    $ORDER_BY  = apply_filters( 'lp/query/order_by', $ORDER_BY, $filter );
}

The code then will concatenate the $ORDER_BY variable as a whole query and then perform the SQL query:

// Query
$query = "SELECT $FIELDS FROM $COLLECTION AS $ALIAS_COLLECTION
$INNER_JOIN
$WHERE
$GROUP_BY
$ORDER_BY
$LIMIT
";

if ( $filter->return_string_query ) {
    return $query;
} elseif ( ! empty( $filter->union ) ) {
    $query  = implode( ' UNION ', array_unique( $filter->union ) );
    $query .= $GROUP_BY;
    $query .= $ORDER_BY;
    $query .= $LIMIT;
}

if ( ! $filter->query_count ) {
    // Debug string query
    if ( $filter->debug_string_query ) {
        return $query;
    }

    $result = $this->wpdb->get_results( $query );
}

// Query total rows
$query       = str_replace( array( $LIMIT, $ORDER_BY ), '', $query );
$query_total = "SELECT COUNT($filter->field_count) FROM ($query) AS $ALIAS_COLLECTION";
$total_rows  = (int) $this->wpdb->get_var( $query_total );

The above code then will trigger the SQL Injection.

Authenticated SQL Injection (CVE-2022-45820)

https://patchstack.com/database/vulnerability/learnpress/wordpress-learnpress-plugin-4-1-7-3-2-auth-sql-injection-sqli-vulnerability

There exists an authenticated SQL injection in 2 shortcodes of the plugin, learn_press_recent_courses and learn_press_featured_courses. The vulnerable code that handles those 2 shortcode are:

public function get_recent_courses( $args = array() ) {
    global $wpdb;

    $limit = ! empty( $args['limit'] ) ? $args['limit'] : - 1;
    $order = ! empty( $args['order'] ) ? $args['order'] : 'DESC';

    if ( $limit <= 0 ) {
        $limit = 0;
    }

    $query = apply_filters(
        'learn-press/course-curd/query-recent-courses',
        $wpdb->prepare(
            "
            SELECT DISTINCT p.ID
                FROM $wpdb->posts AS p
                WHERE p.post_type = %s
                AND p.post_status = %s
                ORDER BY p.post_date {$order}
                LIMIT %d
        ",
            LP_COURSE_CPT,
            'publish',
            $limit
        )
    );

    return $wpdb->get_col( $query );
}
public function get_featured_courses( $args = array() ) {
    global $wpdb;

    $limit    = ! empty( $args['limit'] ) ? $args['limit'] : - 1;
    $order_by = ! empty( $args['order_by'] ) ? $args['order_by'] : 'post_date';
    $order    = ! empty( $args['order'] ) ? $args['order'] : 'DESC';

    if ( $limit <= 0 ) {
        $limit = 0;
    }

    $query = apply_filters(
        'learn-press/course-curd/query-featured-courses',
        $wpdb->prepare(
            "
            SELECT DISTINCT p.ID
            FROM {$wpdb->posts} p
            LEFT JOIN {$wpdb->postmeta} as pmeta ON p.ID=pmeta.post_id AND pmeta.meta_key = %s
            WHERE p.post_type = %s
                AND p.post_status = %s
                AND pmeta.meta_value = %s
            ORDER BY p.{$order_by} {$order}
            LIMIT %d
        ",
            '_lp_featured',
            LP_COURSE_CPT,
            'publish',
            'yes',
            $limit
        )
    );

    return $wpdb->get_col( $query );
}

We can control the value of $args from the shortcode declaration. For example, in this case, the user that has at least Contributor role is able to trigger SQL Injection with using this shortcode on a drafted post:

[learn_press_recent_courses order=",(select sleep(10))" limit="1"]

The patch in LearnPress

Unauthenticated Local File Inclusion

Since these issues are mainly because the code tries to include some "template" from user input, the developer decided to remove the inclusion process completely. The patch can be found here:

LearnPress plugin
LearnPress plugin

Unauthenticated SQL Injection

Since the vulnerable part of the injection is located in the $ORDER_BY parameter, simply using a whitelist and sanitization on the variable would be sufficient. The patch can be seen below:

LearnPress plugin

Authenticated SQL Injection

Since the vulnerable part of the injection is located in the $order_by and $order parameter, simply using a whitelist and sanitization on the variable would be sufficient. The patch can be seen below:

LearnPress plugin
LearnPress plugin

Disclosure Timeline

30-11-2022 - We found the authenticated SQL Injection vulnerability and reached out to the plugin vendor.
02-12-2022 - We found the unauthenticated Local File Inclusion vulnerability.
04-12-2022 - We receive report of unauthenticated SQL Injection vulnerability from our alliance member (Fadilah Agung Nugraha).
05-12-2022 - We reached out to the plugin vendor again and gave them the additional information.
20-12-2022 - LearnPress plugin version 4.2.0 published to patch the reported issues.
20-01-2023 - Added the vulnerabilities to the Patchstack vulnerability database.
24-01-2023 - Published the article.

Help us make the internet a safer place

Making 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.

The latest in Security Advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu