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

  1. 01
    Contributor-controlled settingPOST tab value for an owned modula-gallery

    A contributor can edit their own gallery, receive the Modula save nonce, and reach the last-visited-tab save action.

  2. 02
    Authorization controlsmodula_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.

  3. 03
    Storage boundarysanitize_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.

  4. 04
    Link constructionModula_CPT::modula_remember_tab() in 2.14.25 through 2.14.30

    The plugin appends the stored tab to the edit-link fragment and returns the complete URL without context-safe encoding.

  5. 05
    Admin HTML sinkWordPress gallery-list row title

    The 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.

  6. 06
    Regression repairModula Image Gallery 2.14.31

    The 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

  1. Download the public Modula CPT file from versions 2.14.24, 2.14.25, 2.14.30, and 2.14.31.
  2. Compare 2.14.24 with 2.14.25. Confirm that the earlier URL-escaping return was replaced by the raw link return.
  3. Compare 2.14.30 with 2.14.31. Confirm that the fixed version applies sanitize_key() to the stored tab before constructing the fragment.
  4. Run the inert attribute model below. It uses a data-proof marker, not a script or event handler.
  5. Confirm that the affected model produces a second parsed attribute while the fixed model keeps one href attribute.
  6. 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.
Negative control

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.

Fixed-version re-test

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

Follow this thread

Related research, source, and writing.

These paths share a published research boundary, project source, article series, or authorized lab context with this record.

Back to article start