Abrahack's Blog

Learnpress SQLi

Intro.

In this post we will be exploring two CVE’s, CVE-2024-8529 - CVSS 3.1 10.0 Critical & CVE-2024-8522 - CVSS 3.1 10.0 Critical affecting LearnPress – WordPress LMS Plugin.

A few months ago, I was engaged in a pentest and came across a WordPress asset utilizing the LearnPress – WordPress LMS Plugin. I quickly confirmed the version and realized the latest version had been installed, therefore no known CVEs would work.

I decided to review past vulnerabilities on the plugin and their respective patches to see whether I could find a bypass.

One endpoint that caught my attention was the lp/v1/courses/archive-course endpoint. In earlier versions, this endpoint was susceptible to SQL injection, prompting me to investigate the issue. Let’s take a moment to discuss my findings.

Discovery of CVE-2024-8529.

Let us start by locating the code that manages the endpoint lp/v1/courses/archive-course.

//learnpress.4.2.7/learnpress/inc/rest-api/v1/frontend/class-lp-rest-courses-controller.php
...
			'archive-course'         => array(
				array(
					'methods'             => WP_REST_Server::ALLMETHODS,
					'callback'            => array( $this, 'list_courses' ),
					'permission_callback' => '__return_true',
					'args'                => [],
				),
			),
...
	public function list_courses( WP_REST_Request $request ): LP_REST_Response {
		$response            = new LP_REST_Response();
		$response->data      = new stdClass();
		$listCoursesTemplate = ListCoursesTemplate::instance();
		$pagination_type     = LP_Settings::get_option( 'course_pagination_type' );

		try {
			$filter = new LP_Course_Filter();
			Courses::handle_params_for_query_courses( $filter, $request->get_params() );
...
			$total_rows = 0;
			$filter     = apply_filters( 'lp/api/courses/filter', $filter, $request );

			$courses     = Courses::get_courses( $filter, $total_rows );
			$total_pages = LP_Database::get_total_pages( $filter->limit, $total_rows );
			$return_type = $request['return_type'] ?? 'html';
...
		return apply_filters( 'lp/rest-api/frontend/course/archive_course/response', $response );
	}

After reviewing this function, the following line piqued my interest:

//learnpress.4.2.7/learnpress/inc/rest-api/v1/frontend/class-lp-rest-courses-controller.php
			Courses::handle_params_for_query_courses( $filter, $request->get_params() );

I chose to explore its origin to gain a complete understanding of its purpose.

//learnpress.4.2.7/learnpress/inc/Models/Courses.php
...
	public static function handle_params_for_query_courses( LP_Course_Filter &$filter, array $param = [] ) {
		$filter->page       = absint( $param['paged'] ?? 1 );
		$filter->post_title = LP_Helper::sanitize_params_submitted( trim( $param['c_search'] ?? '' ) );

		// Get Columns
		$fields_str = LP_Helper::sanitize_params_submitted( urldecode( $param['c_fields'] ?? '' ) );
		if ( ! empty( $fields_str ) ) {
			$fields         = explode( ',', $fields_str );
			$filter->fields = $fields;
		}

		// Exclude Columns
		$fields_exclude_str = LP_Helper::sanitize_params_submitted( urldecode( $param['c_exclude_fields'] ?? '' ) );
		if ( ! empty( $fields_exclude_str ) ) {
			$fields_exclude         = explode( ',', $fields_exclude_str );
			$filter->exclude_fields = $fields_exclude;
		}
...

		do_action( 'learn-press/courses/handle_params_for_query_courses', $filter, $param );
	}
...

This function processes HTTP input parameters (GET/POST) and selectively sets parts of the SQL query, such as ORDER and LIMIT.

Let us revisit the line of code below:

//learnpress.4.2.7/learnpress/inc/rest-api/v1/frontend/class-lp-rest-courses-controller.php
			$courses     = Courses::get_courses( $filter, $total_rows );

Here is the source code for the get_courses function:

//learnpress.4.2.7/learnpress/inc/Models/Courses.php
...
	public static function get_courses( LP_Course_Filter $filter, int &$total_rows = 0 ) {
		$lp_course_db = LP_Course_DB::getInstance();

		try {
			// Sort by
			$filter->sort_by = (array) $filter->sort_by;
...

			// Query get results
			$filter  = apply_filters( 'lp/courses/filter', $filter );
			$courses = LP_Course_DB::getInstance()->get_courses( $filter, $total_rows );
		} catch ( Throwable $e ) {
			$courses = [];
			error_log( __FUNCTION__ . ': ' . $e->getMessage() );
		}

		return $courses;
	}
...

At this point, the flow of the code is as follows:

1. LP_REST_Courses_Controller::list_courses #this is our entry point.
2. Courses::get_courses 
3. LP_Course_DB::get_courses 
4. LP_Course_DB::execute "child of" LP_Database::execute

Let us review the code for each step:

//learnpress.4.2.7/learnpress/inc/Databases/class-lp-course-db.php
class LP_Course_DB extends LP_Database {
...
	public function get_courses( LP_Course_Filter $filter, int &$total_rows = 0 ) {
		$default_fields = $filter->all_fields;
		$filter->fields = array_merge( $default_fields, $filter->fields );

...
		$filter = apply_filters( 'lp/course/query/filter', $filter );

		return $this->execute( $filter, $total_rows );
	}
...
//learnpress.4.2.7/learnpress/inc/Databases/class-lp-db.php
class LP_Database {
...
	public function execute( LP_Filter $filter, int &$total_rows = 0 ) {
		$result = null;

		// Where
		$WHERE = array( 'WHERE 1=1' );

		// Fields select
		$FIELDS = '*';
		if ( ! empty( $filter->only_fields ) ) {
			$FIELDS = implode( ',', array_unique( $filter->only_fields ) );
		} elseif ( ! empty( $filter->fields ) ) {
			// exclude more fields
			if ( ! empty( $filter->exclude_fields ) ) {
				foreach ( $filter->exclude_fields as $field ) {
					$index_field = array_search( $field, $filter->fields );
					if ( $index_field ) {
						unset( $filter->fields[ $index_field ] );
					}
				}
			}
			$FIELDS = implode( ',', array_unique( $filter->fields ) );
		}
		$FIELDS = apply_filters( 'lp/query/fields', $FIELDS, $filter );
...
		// Query
		$query = "SELECT $FIELDS FROM $COLLECTION AS $ALIAS_COLLECTION
		$INNER_JOIN
		$WHERE
		$GROUP_BY
		$ORDER_BY
		$LIMIT
		";

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

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

...
		$this->check_execute_has_error();

		return $result;
	}
...

Ultimately, due to improper sanitization of the c_fields GET/POST HTTP parameter, we have complete control over the $FIELDS section of the final SQL query, which allows us to develop our proof of concept. The sanitization essentially removes a charset of:

  • new lines
  • single quotes
  • double quotes
  • less than & greater than characters

With this in mind, we utilize the “Column Value Override” SQL injection technique, our objective will be to override the post_title column granting us full control to read anything in the database.

CVE-2024-8529 POC

Discovery of CVE-2024-8522.

Whenever I encounter SQL injection (SQLi) vulnerabilities in an application, I suspect there may be additional issues. This belief stems from the fact that SQLi often arises from poor design choices or practices within the application. If developers opt for raw queries instead of using an ORM system or parameterised queries, they may have introduced errors in several areas of the code.

With this in mind, I decided to investigate whether there was another entry point for this SQL injection.

So I found the below API endpoint learnpress/v1/courses.

//learnpress.4.2.7/learnpress/inc/jwt/rest-api/version1/class-lp-rest-courses-v1-controller.php
...
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_courses' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
...
	public function get_courses( WP_REST_Request $request ) {
		$res         = new LP_REST_Response();
		$courses     = [];
		$total       = 0;
		$total_pages = 0;
		try {
			$filter = new LP_Course_Filter();
			$params = $request->get_params();
			$params = $this->convert_params_query_courses( $params );
			if ( ! empty( $params['c_only_fields'] ) ) {
				$filter->only_fields = $params['c_only_fields'];
			}

			Courses::handle_params_for_query_courses( $filter, $params );
...
			$rs_courses  = Courses::get_courses( $filter, $total );
			$courses     = $this->prepare_struct_courses_response( $rs_courses, $params );
			$total_pages = LP_Database::get_total_pages( $filter->limit, $total );
		} catch ( Throwable $e ) {
			$res->message = $e->getMessage();

			return $res;
		}

		$response = rest_ensure_response( $courses );
		$response->header( 'X-WP-Total', $total );
		$response->header( 'X-WP-TotalPages', $total_pages );
...
		return $response;
	}
...

As you can see, the same problem as discussed earlier in the post is present in this endpoint; the difference here is the only_fields filter is fully controlled by the c_only_fields GET HTTP parameter.

Keeping this in mind and utilizing the “Column Value Override” SQL injection technique to override the post_title column, we can create our proof of concept (POC).

CVE-2024-8522 POC

Conclusion

Finding vulnerabilities in WordPress plugins is a new experience for me and I have come to find it can be a fun and thrilling experience.

My findings showcase that vulnerabilities can still exist even in battle-hardened plugins. Nevertheless I appreciate everyone who worked on the security of this plugin before me.

I’m really starting to enjoy it. It’s a different kind of thrill from traditional bug-bounty setups.

To my amazing readers, this is the beginning of a larger series, God willing. Join me on this adventure as we uncover new vulnerabilities.

I hope my work will inspire more people to indulge in finding WordPress plugin vulnerabilities.


Thanks for Reading!