Abrahack's Blog

Learnpress Sensitive Information Exposure

Introduction

This post details the discovery and analysis of CVE-2024-11868, a medium-severity vulnerability (CVSS 3.1: 5.3) in the LearnPress – WordPress LMS Plugin. The flaw allows unauthenticated attackers to access sensitive course materials, bypassing payment requirements.

Background

Following my previous analysis of LearnPress, I revisited the plugin to audit the latest version (4.2.7.3). Within hours, I identified an unauthenticated Sensitive Information Exposure vulnerability affecting paid course content.

Affected Versions

  • Plugin: LearnPress – WordPress LMS Plugin
  • Version: ≤ 4.2.7.3

Initial Analysis

My vulnerability discovery process began by identifying potential entry points. So I began to look for potential REST endpoints, with unauthenticated access.

//learnpress.4.2.7.3/learnpress/inc/rest-api/v1/frontend/class-lp-rest-material-controller.php
class LP_Rest_Material_Controller extends LP_Abstract_REST_Controller {

    public function __construct() {
        $this->namespace = 'lp/v1/';
        $this->rest_base = 'material';

        parent::__construct();
    }

    public function register_routes() {
        $this->routes = array(
            'item-materials/(?P<item_id>[\d]+)' => array(
                'args' => array(
                    'item_id' => array(
                        'description'       => __( 'A unique identifier for the resource.', 'learnpress' ),
                        'type'              => 'integer',
                        'sanitize_callback' => 'absint',
                    ),
                ),
...
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_materials_by_item' ),
                    'permission_callback' => '__return_true',
                ),
...
            ),
            '(?P<file_id>[\d]+)'                => array(
                'args' => array(
                    'file_id' => array(
                        'description' => __( 'A unique identifier for the resource.', 'learnpress' ),
                        'type'        => 'integer',
                    ),
                ),
...
                array(
                    'methods'             => WP_REST_Server::READABLE,
                    'callback'            => array( $this, 'get_material' ),
                    'permission_callback' => '__return_true',
                ),
            ),
...
    public function get_materials_by_item( WP_REST_Request $request ): LP_REST_Response {
        $response = new LP_REST_Response();

        try {
            $params  = $request->get_params();
            $item_id = $params['item_id'] ?? 0;
            if ( ! $item_id ) {
                throw new Exception( esc_html__( 'Invalid course or lesson identifier', 'learnpress' ) );
            }

            $is_admin       = $params['is_admin'] ?? false;
            $material_init  = LP_Material_Files_DB::getInstance();
            $page           = absint( $params['page'] ?? 1 );
            $per_page       = $params['per_page'] ?? (int) LP_Settings::get_option( 'material_file_per_page', - 1 );
            $offset         = ( $per_page > 0 && $page > 1 ) ? $per_page * ( $page - 1 ) : 0;
            $total          = $material_init->get_total( $item_id );
            $pages          = $per_page > 0 ? ceil( $total / $per_page ) : 0;
            $item_materials = $material_init->get_material_by_item_id( $item_id, $per_page, $offset, $is_admin );

            if ( $item_materials ) {
                if ( $is_admin ) {
                    $response->data->items = $item_materials;
                } else {
                    $response->data->load_more = $page < $pages && $per_page > 0;
                    ob_start();
                    $material_template = CourseMaterialTemplate::instance();
                    foreach ( $item_materials as $m ) {
                        $m->current_item_id = $item_id;
                        echo $material_template->material_item( $m );
                    }
                    $response->data->items = ob_get_clean();
                }

                $response->message = esc_html__( 'Successfully', 'learnpress' );
            } else {
                $response->message = esc_html__( 'Empty material!', 'learnpress' );
            }

            $response->status = 'success';
        } catch ( Throwable $th ) {
            $response->message = $th->getMessage();
        }

        return $response;
    }
...
    public function get_material( $request ) {
        $response = new LP_REST_Response();
        try {
            $id = $request['file_id'];
            if ( ! $id ) {
                throw new Exception( esc_html__( 'Invalid identifier', 'learnpress' ) );
            }
            $material_init = LP_Material_Files_DB::getInstance();
            $file          = $material_init->get_material( $id );
            if ( $file ) {
                if ( $file->method == 'upload' ) {
                    $file->file_path = wp_upload_dir()['baseurl'] . $file->file_path;
                }
                $response_data = $file;
                $message       = esc_html__( 'Get file successfully.', 'learnpress' );
            } else {
                $response_data = [];
                $message       = esc_html__( 'The file is not exist', 'learnpress' );
            }
            $response->message = $message;
            $response->data    = $response_data;
            $response->status  = 200;
        } catch ( Throwable $th ) {
            $response->message = $th->getMessage();
        }

        return rest_ensure_response( $response );
    }

To understand better let’s get the source code of the LP_Material_Files_DB class.

<?php
...
class LP_Material_Files_DB extends LP_Database {
...
    protected function __construct() {
        parent::__construct();
        $this->table_name = $this->tb_lp_files;
    }

    public static function getInstance() {
        if ( is_null( self::$_instance ) ) {
            self::$_instance = new self();
        }

        return self::$_instance;
    }
...
    public function get_material( $file_id = 0 ) {
        if ( ! is_int( $file_id ) ) {
            return;
        }
        $row = $this->wpdb->get_row(
            $this->wpdb->prepare(
                "SELECT * FROM $this->table_name WHERE file_id = %d",
                $file_id
            )
        );
        $this->check_execute_has_error();
        return $row;
    }
...
    public function get_material_by_item_id( $item_id = 0, $perpage = 0, $offset = 0, $is_admin = false ) {
        if ( ! is_int( $item_id ) ) {
            return;
        }
        $result = array();
        if ( get_post_type( $item_id ) == LP_COURSE_CPT && ! $is_admin ) {
            $sql = "SELECT * FROM $this->table_name WHERE item_id 
                IN ( SELECT si.item_id FROM $this->tb_lp_section_items AS si
                INNER JOIN $this->tb_lp_sections AS s ON s.section_id = si.section_id 
                WHERE s.section_course_id=%d ) 
                OR item_id=%d ORDER BY item_id, orders";
            if ( $perpage > 0 ) {
                $sql .= ' LIMIT ' . intval( $perpage );
            }
            if ( $offset > 0 && $perpage > 0 ) {
                $sql .= ' OFFSET ' . intval( $offset );
            }
            $result = $this->wpdb->get_results(
                $this->wpdb->prepare(
                    $sql,
                    $item_id,
                    $item_id
                )
            );
        } else {
            $sql = "SELECT * FROM $this->table_name WHERE item_id = %d ORDER BY orders";
            if ( $perpage > 0 ) {
                $sql .= ' LIMIT ' . intval( $perpage );
            }
            if ( $offset > 0 && $perpage > 0 ) {
                $sql .= ' OFFSET ' . intval( $offset );
            }
            $result = $this->wpdb->get_results(
                $this->wpdb->prepare(
                    $sql,
                    $item_id
                )
            );
        }
        $this->check_execute_has_error();
        return $result;
    }

...
    public function get_total( $item_id ) {
        if ( ! $item_id ) {
            return;
        }
        $item_id = (int) $item_id;
        if ( get_post_type( $item_id ) == LP_COURSE_CPT ) {
            $sql    = "SELECT COUNT(file_id) FROM $this->table_name WHERE item_id 
                IN ( SELECT si.item_id FROM $this->tb_lp_section_items AS si
                INNER JOIN $this->tb_lp_sections AS s ON s.section_id = si.section_id 
                WHERE s.section_course_id=%d ) 
                OR item_id=%d ORDER BY item_id";
            $result = $this->wpdb->get_var(
                $this->wpdb->prepare(
                    $sql,
                    $item_id,
                    $item_id
                )
            );
        } else {
            $sql    = "SELECT COUNT(file_id) FROM $this->table_name WHERE item_id = %d";
            $result = $this->wpdb->get_var(
                $this->wpdb->prepare(
                    $sql,
                    $item_id
                )
            );
        }
        $this->check_execute_has_error();
        return (int) $result;
    }
...

From the first code snipping we can see two unauthenticated REST endpoints are defined:

  • /wp-json/lp/v1/material/item-materials/<item_id>
  • /wp-json/lp/v1/material/<file_id>

Let’s examine the lp/v1/material/item-materials/<item_id> endpoint, the callback is the get_materials_by_item function.

From it we can see that the item_id,is_admin,per_page HTTP GET parameters, are parsed into the LP_Material_Files_DB::get_material_by_item_id method. The item_id is the ID of the course, if we inspect the flow of the code we can see there is no check in place to confirm that we own the course or need to purchase the course. Therefore all an attacker needs to know is the course ID, with this information he can download the course for free.

Let’s examine the /wp-json/lp/v1/material/<file_id> endpoint, the callback is the get_material function.

From it we can see that the file_id HTTP GET parameter, is parsed into the LP_Material_Files_DB::get_material method. The file_id is the ID of the uploaded material, if we inspect the flow of the code we can see there is no check in place to confirm that we own the course or need to purchase the course. Therefore all an attacker needs to know is the material ID, with this information he can download the course for free.

Proof of Concept

CVE-2024-11868 POC

CVE-2024-11868 POC

Patch Analysis

After reporting this vulnerability, the vendor addressed it by implementing proper access control checks.

Remediation and Disclosure

  • Reported to vendor on [26/11/2024]
  • CVE assigned: CVE-2024-11868 [27/11/2024]
  • Fixed in version 4.2.7.4 [03/12/2024]
  • Public Disclosure [10/12/2024]

Conclusion

This was an interesting vulnerability! It improve my understanding of REST endpoints in WordPress.


Thanks for Reading!