CVE case study
CVE-2026-65475: A Reverted Link Fix Reopened Stored XSS in Modula
Modula Image Gallery 2.14.25 through 2.14.30 appended a contributor-controlled tab value to an admin edit link without context-safe encoding, reopening a stored XSS path that 2.14.24 had closed.
- Severity
- Medium (6.5)
- Scoring
- CVSS 3.1
- Weakness
- CWE-79
- Affected
- Modula Image Gallery 2.14.25 through 2.14.30
- Remediation state
- Upgrade to Modula Image Gallery 2.14.31 or later
- Advisory published
- 22 Jul 2026
Official vectorCVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:L
Why it matters
A WordPress contributor could edit a gallery they owned and save its last visited settings tab. Modula later appended that stored value to the gallery's admin edit link. In versions 2.14.25 through 2.14.30, the value could break out of the link's href attribute and become active markup when a privileged user opened the gallery list.
The attack required a low-privileged authenticated contributor and a privileged user interaction. My local validation used an inert document.title marker on an owned WordPress installation to confirm browser execution. I did not create an administrator, modify a theme, persist a second payload, or test a third-party site.
Patchstack assigns the record Low priority but the CVE Program score is Medium at 6.5. WordPress.org listed more than 100,000 active installations when reviewed on 30 July 2026. That number describes current product reach; it is not a count of vulnerable sites or evidence of exploitation.
How I found it
I found this issue while checking whether an earlier Modula stored-XSS fix had remained effective across later releases. Patchstack's CVE-2026-42688 record said the same edit-link path was fixed in 2.14.24, which made the surrounding version history a useful regression target.
The source history was unusually clear. Version 2.14.24 returned the filtered edit link through esc_url(). The complete 2.14.24-to-2.14.25 change at that return point removed the URL escaping and restored return $link. Versions through 2.14.30 kept the raw return.
I traced the value backward from that sink. A contributor editing their own gallery receives the plugin's save nonce and passes the edit_post capability check. The modula_remember_tab AJAX action stores the submitted tab after sanitize_text_field(), which does not remove every character that can terminate a quoted HTML attribute.
On the read side, Modula's get_edit_post_link filter appended that stored value to the URL fragment. The WordPress admin gallery list then used the filtered link in the row title. The stored quote boundary reached the final href context without context-safe encoding.
I validated the full chain on a locally owned WordPress installation with a contributor, an administrator, and synthetic gallery data. A bad nonce and a subscriber were negative controls. For browser evidence I used a benign title-change marker rather than performing any privileged action. I reported the regression on 17 July 2026; Patchstack published it on 22 July, and the CVE Program record followed on 23 July.
Root cause
The AJAX save handler accepted the gallery tab value from a user who supplied a valid nonce and could edit the selected gallery. It passed the value through sanitize_text_field() and stored it as last_visited_tab. That sanitizer removes tags and control characters, but a double quote can remain because the value is still ordinary text.
The get_edit_post_link filter then appended the stored tab directly to the edit URL and returned the combined string. WordPress's gallery-list title path used that filtered value as an href. A quote in the stored tab could therefore end the URL attribute and introduce another attribute on the row-title link.
This was a regression. Version 2.14.24 returned the combined link through esc_url(). Version 2.14.25 changed that single line back to a raw return, and the unsafe behavior remained through 2.14.30. Version 2.14.31 fixes the boundary by converting the stored tab to a restricted key with sanitize_key() before it is appended.
Source-to-sink trace
- 01Contributor-controlled setting
POST tab value for an owned modula-galleryA contributor can edit their own gallery, receive the Modula save nonce, and reach the last-visited-tab save action.
- 02Authorization controls
modula_remember_tab_save()The handler verifies the nonce, checks the post type, and requires edit_post on the selected gallery. These controls set the attacker floor but do not encode the tab value.
- 03Storage boundary
sanitize_text_field() -> modula-settings[last_visited_tab]The text sanitizer stores the synthetic tab value but does not restrict it to a valid tab identifier or remove a quote used at the later attribute boundary.
- 04Link construction
Modula_CPT::modula_remember_tab() in 2.14.25 through 2.14.30The plugin appends the stored tab to the edit-link fragment and returns the complete URL without context-safe encoding.
- 05Admin HTML sink
WordPress gallery-list row titleThe filtered edit link becomes an href on the gallery title. The quote boundary can create another attribute on the link a privileged user interacts with.
- 06Regression repair
Modula Image Gallery 2.14.31The fixed version converts the stored tab to a restricted key with sanitize_key() before appending it to the URL.
Safe proof of concept
Prerequisites
- Python 3 and curl.
- Network access only to download public source files from the WordPress plugin repository.
- No WordPress account or running website. The synthetic model uses a local string and the standard-library HTML parser.
Step-by-step reproduction
- Download the public Modula CPT file from versions 2.14.24, 2.14.25, 2.14.30, and 2.14.31.
- Compare 2.14.24 with 2.14.25. Confirm that the earlier URL-escaping return was replaced by the raw link return.
- Compare 2.14.30 with 2.14.31. Confirm that the fixed version applies sanitize_key() to the stored tab before constructing the fragment.
- Run the inert attribute model below. It uses a data-proof marker, not a script or event handler.
- Confirm that the affected model produces a second parsed attribute while the fixed model keeps one href attribute.
- Repeat with the normal modula-general tab. Both models should keep a single href and preserve the expected fragment.
Compare the public regression and repair
LAB_DIR=$(mktemp -d "\${TMPDIR:-/tmp}/modula-cve.XXXXXX")
SOURCE_PATH=includes/admin/cpt/class-modula-cpt.php
for VERSION in 2.14.24 2.14.25 2.14.30 2.14.31; do
curl -fsSL \
"https://plugins.svn.wordpress.org/modula-best-grid-gallery/tags/$VERSION/$SOURCE_PATH" \
-o "$LAB_DIR/modula-$VERSION.php"
done
diff -u "$LAB_DIR/modula-2.14.24.php" "$LAB_DIR/modula-2.14.25.php"
diff -u "$LAB_DIR/modula-2.14.30.php" "$LAB_DIR/modula-2.14.31.php"Inert HTML-attribute boundary model
from html.parser import HTMLParser
import re
BASE = "/wp-admin/post.php?post=1042&action=edit"
MARKER = 'x" data-proof="SYNTHETIC_ATTRIBUTE_BOUNDARY'
def sanitize_key(value):
return re.sub(r"[^a-z0-9_-]", "", value.lower())
def render(link):
return f'<a href="{link}">Synthetic gallery</a>'
class Capture(HTMLParser):
def __init__(self):
super().__init__()
self.attrs = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.attrs = attrs
def attributes(html):
parser = Capture()
parser.feed(html)
return parser.attrs
affected = render(f"{BASE}#!{MARKER}")
fixed = render(f"{BASE}#!{sanitize_key(MARKER)}")
normal = render(f"{BASE}#!modula-general")
print("affected:", attributes(affected))
print("fixed:", attributes(fixed))
print("normal:", attributes(normal))Expected inert result
affected: [('href', '/wp-admin/post.php?post=1042&action=edit#!x'), ('data-proof', 'SYNTHETIC_ATTRIBUTE_BOUNDARY')]
fixed: [('href', '/wp-admin/post.php?post=1042&action=edit#!xdata-proofsynthetic_attribute_boundary')]
normal: [('href', '/wp-admin/post.php?post=1042&action=edit#!modula-general')]Expected evidence
- The 2.14.24-to-2.14.25 diff shows the earlier encoded return becoming a raw link return.
- The affected synthetic output parses into two attributes: the intended href and the inert data-proof marker.
- The 2.14.31 model keeps all data inside one href because the tab has been reduced to a key.
- A normal tab identifier remains stable, showing that the fixed model preserves expected gallery navigation.
Use the normal modula-general identifier and confirm that both models produce one href with no additional attribute. In a full owned WordPress lab, an invalid nonce or a user who cannot edit the gallery should be rejected before the value is stored.
Review or install Modula Image Gallery 2.14.31 or later in an owned test site. The saved tab must be reduced to a valid key before link construction, an inert quote-boundary marker must not create a second attribute, and ordinary tab navigation must continue to work.
Impact
A contributor who could edit their own Modula gallery could store markup that executed in the WordPress origin when a higher-privileged user interacted with that gallery's row title in the admin list.
JavaScript running in an administrator's origin can act with the victim's browser authority and access same-origin page data. The official vector records low confidentiality, integrity, and availability impact with changed scope. The research demonstrated execution with a benign marker only, not account creation or site takeover.
The issue was not unauthenticated and did not affect every page visitor. It depended on the vulnerable version range, a contributor-controlled gallery, a saved malicious tab value, and a privileged user visiting and interacting with the affected admin link.
Fix and retest
Upgrade Modula Image Gallery to 2.14.31 or later. Version 2.14.31 treats the fragment as the key it is meant to be: sanitize_key() removes quote and markup characters before the tab is appended to the edit link.
For defense in depth, validate the tab against the plugin's known tab identifiers when saving it, revalidate stored values before use, and apply URL or HTML-attribute escaping at the final output boundary. A nonce proves request intent; it does not make a value safe for a later HTML context.
Retest with three controls in an owned WordPress lab: a normal tab name, an inert quote-boundary marker on 2.14.30, and the same marker on 2.14.31. The fixed version should keep the entire value inside a single URL attribute and preserve normal gallery navigation.
Engineering lesson
Security fixes need regression tests and version-diff review. A one-line hardening change can disappear in the next release even when the vulnerable function otherwise looks unchanged.
Sanitization is context-specific. Text sanitization can be correct for storage and still be wrong for an HTML attribute. Values should be constrained to their business type first, then escaped for the interpreter that consumes them.
References
- Patchstack CVE-2026-65475 record and researcher credit opens in a new tab
- Central CVE-2026-65475 record opens in a new tab
- Modula Image Gallery plugin page and release changelog opens in a new tab
- Version 2.14.24 source with the earlier URL fix opens in a new tab
- Version 2.14.25 source where the earlier fix was reverted opens in a new tab
- Version 2.14.30 affected source opens in a new tab
- Version 2.14.31 fixed source opens in a new tab
- Earlier same-path record CVE-2026-42688 opens in a new tab