CVE-2026-94461 Stored XSS in the Ditty WordPress plugin

Intrudify's autonomous pentesting engine discovered a stored cross-site scripting (XSS) vulnerability in the Ditty WordPress plugin, affecting all versions up to and including 3.1.69. A Contributor-level account can store a payload that bypasses WordPress KSES sanitisation using JSON unicode escapes, then executes in the browser of the administrator who reviews the post and of every visitor once it is published.

Discovered by
Intrudify autonomous engine
Validated & reported by
Tudor Lasuschevici
Disclosure
23 September 2026
Severity
Medium · CVSS 6.5
Vulnerability class
Stored XSS, CWE-79
Affected
Ditty ≤ 3.1.69
Fixed in
3.1.70
Active installs
30,000+
Privilege required
Contributor
Attack vector
Authenticated shortcode input

Summary

Ditty is a WordPress plugin for building news tickers, feeds and dynamic content lists. Intrudify's autonomous testing engine found that the [ditty] shortcode merges attacker-supplied JSON into its display settings with no key allow-list, and that the merged value reaches a jQuery .html() sink in the browser. The interesting part is how the payload survives WordPress sanitisation: written as JSON unicode escapes, it contains no literal < character when KSES inspects it, and is decoded back into real markup at render time, after the sanitiser has already run.

Technical detail

The unfiltered merge

The [ditty] shortcode accepts a display_settings attribute holding JSON. Both ditty_render() and Ditty_Singles::init() decode it and merge the settings object over the display arguments:

// includes/class-ditty-singles.php
$custom_display_array = json_decode( html_entity_decode( $custom_display_settings ), true );
if ( isset( $custom_display_array['settings'] ) ) {
    $args = wp_parse_args( $custom_display_array['settings'], $args );
}

There is no key allow-list and no sanitisation on the merge, so the attacker controls settings.title. The merged array is then json_encode()d into an inline <script> block and handed to the ticker widget.

The sink is in JavaScript, not PHP

The widget builds DOM from the value using jQuery HTML parsing, so any markup in settings.title is parsed and inserted into the live page:

// assets/build/dittyDisplayTicker.js (_styleTitle)
var s = t( "<" + i + ' class="ditty__title__element">' + this.settings.title + "</" + i + ">" );
this.$titleContents.html( s );

This is worth noting for anyone auditing similar code. On the PHP side the output is correctly json_encode()d, so a static analyser following the PHP path sees a safe serialisation and stops. The actual sink is .html() in a bundled JavaScript file. Following the chain means crossing both an encode/decode round trip and a PHP to JavaScript boundary.

Getting past KSES

A Contributor has no unfiltered_html capability, so WordPress runs wp_filter_post_kses() over their post_content. Typing <img onerror=...> directly gets the attribute stripped on save. The bypass is to write the angle brackets as JSON unicode escapes:

// stored as a Contributor, then Submit for Review
[ditty id=1 display_settings='{"type":"ticker","settings":{
  "titleDisplay":"inline",
  "title":"\u003cimg src=x onerror=alert(document.domain)\u003e"
}}']

The stored post_content now contains no < character at all, so KSES sees plain text and leaves it alone. At render time json_decode() restores the real angle brackets and reconstructs the <img> tag, which then reaches the jQuery sink. The sanitiser and the sink disagree about what the data is, and the decode happens after sanitisation.

Root cause

Two independent defects, and fixing either one breaks the chain. First, wp_parse_args() merges user-supplied JSON with no allow-list of permitted keys. Second, _styleTitle() uses .html() where the value is text rather than markup.

The generalisable lesson: any plugin that accepts JSON in a shortcode attribute and decodes it after KSES has run carries this exposure, because KSES inspects the encoded form of data that is decoded downstream.

Impact

A Contributor is roughly one step above a Subscriber and cannot normally publish anything, but the payload runs in the browser of whoever opens the post. In practice that is the Administrator or Editor reviewing the pending submission, which is a guaranteed step in the workflow rather than something an attacker has to engineer, and then every visitor once the post goes live.

We demonstrated script execution in the site origin inside an authenticated session. From there the usual consequences follow: creating an administrator account, installing a plugin, or reading the REST nonce. Full site control from the lowest content-authoring role.

Remediation

Update Ditty to 3.1.70 or later.

For maintainers of similar code, either fix closes it. Apply an allow-list of permitted keys before merging decoded JSON into display arguments, so title and items cannot be overridden by shortcode input. Or switch _styleTitle() to .text(), since the value is text and never needs HTML parsing. Doing both is better than doing one.

If you cannot update immediately, audit who holds Contributor accounts and treat pending-review posts as untrusted content.

Disclosure timeline

  • 2026-09-18 Vulnerability identified and validated by Intrudify
  • 2026-09-18 Reported to the vendor via Patchstack
  • 2026-09-22 Fixed release 3.1.70 published by the vendor
  • 2026-09-23 Public disclosure · CVE-2026-94461 assigned

Questions

What is CVE-2026-94461?

CVE-2026-94461 is a stored cross-site scripting (XSS) vulnerability in the Ditty WordPress plugin, versions 3.1.69 and earlier. It was discovered autonomously by the Intrudify AI penetration testing engine. A contributor-level user can inject a script payload that executes in the browser of any user who views the affected content.

Which versions of Ditty are affected?

All Ditty versions up to and including 3.1.69 are affected. The issue is resolved in version 3.1.70.

How do I fix CVE-2026-94461?

Update the Ditty plugin to version 3.1.70 or later. If you cannot update immediately, restrict contributor-level access and deploy a virtual patch through a web application firewall.

References

More advisories from Intrudify

Join the Future of
AI-Driven Pentesting