A contributor sneaks a malicious tag into a course shortcode and waits for an admin to walk by
LearnPress is a widely-installed WordPress LMS plugin (100k+ active installs per WordPress.org). The class_wrapper_form attribute on one of its shortcodes fails to sanitize user-supplied input before rendering it inside page output. A user with contributor-or-higher role who can insert shortcodes into posts can weaponize the attribute to inject arbitrary JavaScript that executes in the browser of anyone viewing the rendered page — most usefully a site admin previewing or moderating the post.
The vendor's MEDIUM (6.4) rating is essentially correct. The CVSS vector AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N accurately reflects the trade-offs: network-reachable, low complexity, but requires *low-privilege authentication* (PR:L) and delivers only partial C/I impact. The Scope:Changed component is legitimate because stored XSS in an admin context can pivot to plugin/user management. We hold at MEDIUM — no meaningful reason to upgrade or downgrade.
4 steps from start to impact.
Obtain a contributor+ account
- Open user registration OR credential compromise OR insider
- Default role grants shortcode editing (Contributor+)
- Most production WP installs disable open registration or default new users to Subscriber (no shortcode-authoring rights)
- MFA on wp-admin blocks credential stuffing
Inject payload via class_wrapper_form shortcode attribute
class_wrapper_form value that breaks out of the attribute context and injects <script> or event-handler payloads. The payload is stored in wp_posts and rendered whenever the post is previewed or published.- LearnPress vulnerable version installed and active
- Attacker can save/preview the post
- Some managed WP hosts (WP Engine, Kinsta) run ModSecurity rules that catch obvious
<script>in POST bodies - Sites using wp-content CSP with
script-src 'self'neutralize inline payloads
Trigger execution in a privileged browser
BeEF or custom JS steal the admin's nonce and cookies or fire authenticated XHRs to /wp-admin/admin-ajax.php.- Admin/editor visits the post in an authenticated session
- No HttpOnly bypass needed — most WP admin actions rely on nonces reachable from JS context
- Admins reviewing content in a separate profile / logged-out session neutralize the chain
- CSP with
script-src 'self'blocks inline; Wordfence 2FA on admin actions blunts nonce theft
Escalate to site takeover
wp_ajax_install_plugin or theme editor endpoints.- Admin session has plugin/theme editing rights (DISALLOW_FILE_EDIT not set)
- Hardened sites set
DISALLOW_FILE_MODSandDISALLOW_FILE_EDITin wp-config - Plugin allow-listing via Patchstack/Wordfence blocks unknown plugin installs
wp plugin update learnpress or the WP admin auto-update toggle. Per noisgate remediation SLA for MEDIUM, complete within 365 days, but on any site with open contributor registration, treat this as 30-day priority.Settings → General, uncheck 'Anyone can register' or set 'New User Default Role' to Subscriber. This closes the primary path to obtaining Contributor privileges./wp-admin/post.php containing class_wrapper_form= with <, on\w+=, or javascript: patterns.DISALLOW_FILE_MODS and DISALLOW_FILE_EDIT to wp-config.php — Blocks the XSS→RCE escalation path via theme/plugin editor even if an admin session is hijacked. Standard hardening — no operational cost if you deploy via CI/CD.- Cloudflare bot-fight / rate limiting — the attack is a legitimate authenticated request, not a bot flood.
- HttpOnly cookies — WP nonces used for admin AJAX are readable from the DOM, so HttpOnly does not prevent the escalation path.
- Disabling XML-RPC — unrelated attack surface; does not affect shortcode rendering.
- WAF signatures on
<script>tags alone — attribute-injection payloads use event handlers (onerror=,onmouseover=) that generic XSS signatures often miss.
The supporting signals.
| In-the-wild exploitation | None observed as of 2026-07-01. No Patchstack, Wordfence, or Sucuri incident reports. |
|---|---|
| Public PoC | None published at disclosure. Historically Patchstack/Wordfence advisories include a redacted reproducer; expect a technical writeup within 2-4 weeks. |
| EPSS | Too new for EPSS scoring; comparable authenticated stored XSS CVEs in WP plugins historically sit at <0.5% probability, <30th percentile. |
| KEV status | Not listed. CISA has never added an authenticated stored XSS in a WP plugin to KEV. |
| CVSS 3.1 vector | AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N → base 6.4 MEDIUM. PR:L (contributor) and S:C (admin-context pivot) are the load-bearing metrics. |
| Affected versions | LearnPress plugin — all versions up to and including the version disclosed 2026-07-01. Confirm exact ceiling in the vendor changelog. |
| Fixed version | Vendor-released patch expected in the LearnPress 4.x branch; verify via wp plugin status learnpress. |
| Exposure population | LearnPress reports 100,000+ active installs on WordPress.org. Subset with open registration granting Contributor+ is realistically <10%. |
| Disclosure | 2026-07-01, CWE-79 (Stored XSS), reported through the standard WP plugin coordinated disclosure channel (Wordfence/Patchstack). |
| Scanner coverage | Wordfence, Patchstack, Sucuri, and WPScan CLI will ship detection signatures within days; Nuclei template likely within a week. |
Sources.
Why this verdict
- Authentication gate holds: Contributor role is required. Sites with closed registration or Subscriber-default registration are effectively immune without a separate credential compromise.
- User-interaction dependency: payload only pays off when an admin/editor loads the poisoned post in an authenticated browser. That's not zero, but it removes hands-off mass exploitation.
- Role multiplier — typical role (LMS content site): chain succeeds, blast radius = single site takeover. Not a fleet or identity component. No CRITICAL floor triggers.
- Role multiplier — high-value role (WordPress multisite network or MSP-hosted fleet): on a WP multisite with LearnPress network-activated, a compromise of the super-admin session could pivot across the network, but this is an edge configuration, not the canonical LearnPress deployment.
- No KEV, no active exploitation, no public PoC at disclosure — no external pressure to elevate.
- Vendor 6.4 baseline is fair: friction (PR:L, UI-adjacent trigger, single-site blast radius) balances the S:C pivot potential.
Why not higher?
Upgrading to HIGH would require either unauthenticated exploitation, an active exploitation campaign, or a fleet-scale blast radius. None apply: PR:L is a real barrier, the payload needs a privileged viewer, and impact is bounded to one WordPress instance.
Why not lower?
Downgrading to LOW ignores that Contributor accounts are cheap on many sites (paid-membership LMS platforms often auto-provision authoring capabilities to instructors), that stored XSS in admin context has a well-documented path to full site takeover, and that patch adoption in the WP plugin ecosystem is historically slow (long tail of unpatched sites for months).
Crowdsourced verification payload.
Run on the WordPress host as any user who can execute wp-cli against the install (typically the web-server user). Invoke as ./check-learnpress.sh /var/www/html, passing the WordPress document root. No elevated privileges required beyond read access to the plugin directory.
#!/usr/bin/env bash
# noisgate verifier — CVE-2026-12732 LearnPress stored XSS
# Usage: ./check-learnpress.sh <wordpress-docroot>
set -u
WP_ROOT="${1:-/var/www/html}"
PLUGIN_DIR="$WP_ROOT/wp-content/plugins/learnpress"
FIXED_VERSION="4.2.9" # UPDATE ONCE VENDOR PUBLISHES THE PATCHED RELEASE
if [ ! -d "$PLUGIN_DIR" ]; then
echo "UNKNOWN: LearnPress not installed at $PLUGIN_DIR"
exit 2
fi
# Prefer wp-cli if available
if command -v wp >/dev/null 2>&1; then
INSTALLED=$(wp --path="$WP_ROOT" --allow-root plugin get learnpress --field=version 2>/dev/null)
fi
# Fallback: parse plugin header
if [ -z "${INSTALLED:-}" ]; then
INSTALLED=$(grep -Ei '^\s*\*\s*Version:' "$PLUGIN_DIR/learnpress.php" 2>/dev/null | head -1 | awk -F: '{gsub(/ /,"",$2); print $2}')
fi
if [ -z "${INSTALLED:-}" ]; then
echo "UNKNOWN: could not determine LearnPress version"
exit 2
fi
# Semantic-version compare
lower_or_equal() {
[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -1)" = "$1" ]
}
if lower_or_equal "$INSTALLED" "$FIXED_VERSION" && [ "$INSTALLED" != "$FIXED_VERSION" ]; then
echo "VULNERABLE: LearnPress $INSTALLED < $FIXED_VERSION"
# Secondary check — does the vulnerable shortcode handler still contain unsanitized attribute?
if grep -RIn --include='*.php' 'class_wrapper_form' "$PLUGIN_DIR" | grep -viE 'esc_attr|sanitize_' >/dev/null; then
echo "CONFIRMED: unsanitized class_wrapper_form reference present"
fi
exit 1
else
echo "PATCHED: LearnPress $INSTALLED"
exit 0
fi