CVE-2026-102399 The twenty-sixth action in Photo Gallery by Supsystic

Intrudify's autonomous pentesting engine discovered a cross-site request forgery vulnerability in Photo Gallery by Supsystic, affecting all versions up to and including 1.21.0. The admin router enforces a nonce only on actions named in an allow-list. Twenty-five are listed. The preview action is not, and it writes an unsanitised query parameter into a post that the plugin then renders back on the same admin page.

Discovered by
Intrudify autonomous engine
Validated & reported by
Tudor Lasuschevici
Disclosure
30 September 2026
Severity
Medium · CVSS 5.4
Vulnerability class
CSRF to stored XSS, CWE-352
Affected
Photo Gallery by Supsystic ≤ 1.21.0
Fixed in
1.21.1
Active installs
20,000+
Attacker privilege
None, unauthenticated
User interaction
Administrator opens one link
Outcome
New administrator account

Summary

Photo Gallery by Supsystic builds responsive image galleries. Intrudify's autonomous testing engine found that its admin router applies CSRF protection selectively, through a per-controller list of action names, and that one reachable action is missing from that list.

That action is not merely unprotected. It performs a write, the write is attacker-influenced, and the result is rendered straight back into the administrator's own admin page. Those three properties together turn a missing nonce into administrator account creation.

Technical detail

An allow-list with one gap

In src/GridGallery/Galleries/Controller.php, the controller declares which of its actions require a nonce:

public function requireNonces()
{
    return [
        'createAction', 'sideloadSaveAction', 'attachAction', 'chooseAction',
        'renameAction', 'deleteAction', 'saveSettingsAction', ...
    ];   // 25 actions listed. previewAction is not one of them.
}

The router enforces nothing on an action that is absent from the list, so previewAction is reachable by a plain GET with no nonce and no token.

The action writes a post

public function previewAction(RscSgg_Http_Request $request)
{
    $galleryId = $request->query->get('gallery_id');       // unsanitised
    $shortcode = ...;
    $postId = $preview->setPostContent(sprintf('[%s id="%s"]', $shortcode, $galleryId));
    ...
}

The id goes into the shortcode string with no sanitisation and no escaping. A value containing a double quote and a closing bracket terminates the shortcode and appends arbitrary markup after it.

Why the markup is stored unfiltered

The model writes the content with wp_update_post(), which applies kses only for users lacking unfiltered_html. The request runs in the session of the administrator who opened the link, so no filtering occurs and a script tag is stored verbatim.

This is the inversion worth noticing. In most stored XSS findings the attacker's low privilege is what the sanitiser keys on, and the attacker has to smuggle a payload past it. Here the attacker borrows the victim's privilege. The more trusted the person who clicks, the less filtering applies to the attacker's content.

The chain

  1. An unauthenticated attacker hosts a page on their own origin that redirects to the victim site with a crafted gallery_id.
  2. A logged-in administrator opens it. One top-level navigation, no nonce, no prior knowledge of the victim site required.
  3. The action writes the attacker's markup into a draft post and the plugin renders that post back on the admin page.
  4. The script runs same-origin with /wp-admin/ in the administrator's session, so it can read any nonce it needs.
  5. In the verified run it fetched the create-user nonce and submitted that form, creating a new account with the administrator role.

A gallery id that does not exist renders nothing from the shortcode itself, so only the injected markup remains on the page.

Verification

We reproduced this across two origins on a clean installation of WordPress 7.1.2 and Photo Gallery by Supsystic 1.21.0, freshly activated with default settings, no galleries created and no other plugins active. The attacker had no account on the victim site.

Before the run the site had one administrator. After the administrator opened the attacker's page, a second account existed holding the administrator role and manage_options, read out of band rather than inferred from the response. The stored post content confirmed the payload was written unfiltered alongside the shortcode.

As a negative control, the same request with a plain numeric gallery_id renders the same preview screen, stores only the plain shortcode, and leaves the administrator list unchanged at one entry.

Impact

An unauthenticated attacker who gets a logged-in administrator to open a single link obtains an administrator account on the site. There is no form to submit, no prior foothold, and nothing the administrator must approve beyond following a link.

The resulting account is a durable backdoor: it survives password changes on the original administrator and does not appear as a modification to any existing user.

Remediation

Update Photo Gallery by Supsystic to 1.21.1 or later.

For maintainers, two changes are worth making together. Add the preview action to the nonce allow-list so the router enforces a check. And cast the gallery id with absint() before placing it in the shortcode string, since the only legitimate value is a numeric id.

The wider lesson

The design choice at the root of this is opt-in security. The router protects the actions it is told to protect, so adding a new action is safe by default only if the developer remembers the second step. Twenty-five entries in that list is evidence the team took CSRF seriously; it is also twenty-five opportunities for the twenty-sixth to be forgotten.

An allow-list of protected actions inverts the safer arrangement. Requiring a nonce by default and listing the exemptions means a forgotten entry causes a visible breakage rather than a silent hole, and the list of things deliberately left unprotected is short enough to review. The same reasoning applies to any dispatcher that decides per-route whether to apply a security control.

The second point concerns which privileges a forged request carries. A CSRF finding is often triaged on what the action does, and a preview screen sounds harmless. What makes this one serious is that the action writes content, and the write inherits the victim's unfiltered_html. Worth asking of any unprotected action: not just what it changes, but whose capabilities it changes it with.

This is the second finding in this research set where a nonce mechanism was present, correct and simply not applied to one path, after a similar allow-list gap in another role-management plugin. Both had working CSRF protection almost everywhere.

Disclosure timeline

  • 2026-09-26 Vulnerability identified and full chain validated by Intrudify, reported via Patchstack
  • 2026-09-30 Public disclosure · CVE-2026-102399 assigned

Questions

What is CVE-2026-102399?

CVE-2026-102399 is a cross-site request forgery vulnerability in the Photo Gallery by Supsystic WordPress plugin, versions 1.21.0 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. The admin router only enforces a nonce on actions named in an allow-list, and the preview action is not on it, so a plain GET request writes an unsanitised query parameter into a post that is rendered back in the administrator's session.

What can an attacker achieve with CVE-2026-102399?

An unauthenticated attacker who gets a logged-in administrator to open a single link obtains an administrator account on the site. Because the write runs in the administrator's session, unfiltered_html applies and the injected script is stored without filtering, then executes same-origin inside wp-admin.

How do I fix CVE-2026-102399?

Update Photo Gallery by Supsystic to version 1.21.1 or later. The underlying fix is to add the preview action to the router's nonce allow-list and to cast the gallery id with absint before placing it in the shortcode string.

References

More advisories from Intrudify

Join the Future of
AI-Driven Pentesting