CVE-2026-9015

Equalize Digital Accessibility Checker <= 1.42.0 - Missing Authorization to Authenticated (Subscriber+) Arbitrary Accessibility Issue Modification via edac_insert_ignore_data AJAX Action

mediumMissing Authorization
4.3
CVSS Score
4.3
CVSS Score
medium
Severity
1.42.1
Patched in
1d
Time to patch

Description

The Equalize Digital Accessibility Checker – WCAG, ADA, EAA and Section 508 compliance plugin for WordPress is vulnerable to authorization bypass in all versions up to, and including, 1.42.0. This is due to the plugin not properly verifying that a user is authorized to perform an action. This makes it possible for authenticated attackers, with subscriber-level access and above, to modify the ignore state, ignore reason, and ignore comment of arbitrary accessibility issues across the entire site — including mass modification of all rows sharing an 'object' identifier when largeBatch=true is supplied — corrupting accessibility audit integrity by hiding or dismissing findings outside their authorization scope.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
Low
User Interaction
None
Scope
Unchanged
None
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=1.42.0
PublishedMay 27, 2026
Last updatedMay 28, 2026
Affected pluginaccessibility-checker

What Changed in the Fix

Changes introduced in v1.42.1

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# Exploitation Research Plan: CVE-2026-9015 ## 1. Vulnerability Summary The **Equalize Digital Accessibility Checker** plugin (up to version 1.42.0) contains a missing authorization vulnerability in its AJAX handler for dismissing or ignoring accessibility issues. While the plugin implements a nonc…

Show full research plan

Exploitation Research Plan: CVE-2026-9015

1. Vulnerability Summary

The Equalize Digital Accessibility Checker plugin (up to version 1.42.0) contains a missing authorization vulnerability in its AJAX handler for dismissing or ignoring accessibility issues. While the plugin implements a nonce check (protecting against CSRF), it fails to verify if the authenticated user possesses the necessary capabilities (like manage_options or the ability to edit the specific post) to modify accessibility audit data. This allows any authenticated user, including those with Subscriber roles, to dismiss accessibility findings site-wide, potentially hiding critical compliance issues.

2. Attack Vector Analysis

  • Endpoint: /wp-admin/admin-ajax.php
  • Action: edac_insert_ignore_data
  • Authentication: Authenticated (Subscriber+)
  • Parameters:
    • action: edac_insert_ignore_data
    • nonce: A valid nonce for the ajax-nonce action string.
    • object: The identifier for the accessibility issue (often a CSS selector or hash).
    • ignore_state: The new state (e.g., 1 for ignored, 0 for active).
    • ignore_reason: The reason for dismissal (e.g., "Manual Review").
    • ignore_comment: A text comment explaining the dismissal.
    • largeBatch: (Boolean string) If set to "true", the plugin modifies all issue rows sharing the same object identifier across the database.
    • post_id: (Required for context) The ID of the post the issue is associated with.

3. Code Flow

  1. Hook Registration: In admin/class-ajax.php, the init_hooks() method registers the AJAX action:
    add_action( 'wp_ajax_edac_insert_ignore_data', [ $this, 'add_ignore' ] );
    
  2. Missing Check: The add_ignore method (inferred from the summary method pattern in the same class) likely verifies the nonce using wp_verify_nonce( $_REQUEST['nonce'], 'ajax-nonce' ).
  3. Authorization Failure: Unlike the summary() method which explicitly checks current_user_can( 'edit_post', $post_id ), the add_ignore method omits this check or uses an insufficient check that allows Subscriber access.
  4. Data Persistence: The method processes the object and ignore_state parameters and updates the plugin's custom database table (likely wp_edac_issues) to mark the issue as dismissed.

4. Nonce Acquisition Strategy

The ajax-nonce is used across several EDAC AJAX actions. It is localized for the WordPress admin environment.

  1. Accessibility: Since Subscribers can access the WordPress dashboard (/wp-admin/profile.php), the plugin likely enqueues its admin scripts there if it's not properly restricted.
  2. Localization Key: Based on standard plugin patterns and the summary function, the nonce is likely localized under a JS object named edac, edac_ajax, or similar.
  3. Acquisition Steps:
    • Log in as a Subscriber.
    • Navigate to /wp-admin/profile.php.
    • Execute the following in the browser console (via browser_eval):
      // Search for the localized object containing 'ajax-nonce'
      // Verification needed: The actual object name is often edac_params or edac_ajax
      window.edac_params?.nonce || window.edac_ajax?.nonce || window.edac?.nonce;
      
    • If not found on the profile page, create a post/page with a shortcode if the plugin allows it, but since this is an admin AJAX action, checking admin-ajax.php related scripts in the admin header is the primary route.

5. Exploitation Strategy

Step 1: Discover a Target Issue

First, identify an existing accessibility issue in the database to dismiss. Accessibility issues are tied to an object (usually a DOM selector).

Step 2: Craft the Payload

Construct a POST request to admin-ajax.php.

Request Details:

  • URL: http://vulnerable-site.com/wp-admin/admin-ajax.php
  • Method: POST
  • Content-Type: application/x-www-form-urlencoded
  • Body:
    action=edac_insert_ignore_data&nonce=[NONCE]&post_id=[TARGET_POST_ID]&object=[TARGET_SELECTOR]&ignore_state=1&ignore_reason=Exploit+Test&ignore_comment=Unauthorized+Dismissal&largeBatch=true
    

Step 3: Execute via http_request

Use the Playwright-based http_request tool to send the authenticated request as the Subscriber.

6. Test Data Setup

  1. Target Content: Create a post with a deliberate accessibility error (e.g., an image without an alt attribute).
    wp post create --post_type=post --post_title="Vuln Test" --post_content='<img src="https://example.com/test.jpg">' --post_status=publish
    
  2. Trigger Scan: Use the plugin's scan functionality (or wait for the auto-scan) so the issue is populated in the database.
  3. Identify Object: Check the database for the issue's object identifier.
    wp db query "SELECT object, post_id FROM wp_edac_issues LIMIT 1;"
    
  4. Attacker User: Create a Subscriber user.
    wp user create attacker attacker@example.com --role=subscriber --user_pass=password123
    

7. Expected Results

  • Response: The server should return a JSON success message (e.g., {"success": true}).
  • Effect: The accessibility issue identified by the object selector will be marked as "Dismissed" or "Ignored" in the database.
  • Site Impact: If largeBatch=true, all instances of that specific accessibility issue across the entire site (associated with that object selector) will be dismissed.

8. Verification Steps

  1. DB Check: Verify the is_ignored (or similar column name) status in the plugin table.
    wp db query "SELECT * FROM wp_edac_issues WHERE object = '[TARGET_SELECTOR]';"
    
    Check if ignore_reason matches "Exploit Test".
  2. UI Check: Log in as Administrator and view the Accessibility Checker "Dismissed Issues" log. The unauthorized dismissal should appear there, attributed to the Subscriber user.

9. Alternative Approaches

If the object identifier is a complex hash or hard to find:

  • Mass Dismissal: Attempt to send the request with a generic object or common selector (e.g., img) and largeBatch=true to see if it clears all image-related issues.
  • Permissions Oracle: If the post_id parameter is strictly validated, iterate through common post IDs. If the summary action (which has a check) returns a permission error but edac_insert_ignore_data returns success for the same ID, authorization bypass is confirmed.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Equalize Digital Accessibility Checker plugin for WordPress fails to perform authorization checks in its 'edac_insert_ignore_data' AJAX action. This allows authenticated users with Subscriber-level permissions or higher to dismiss or ignore accessibility issues for any post, and even perform mass dismissals across the entire site using the 'largeBatch' parameter.

Vulnerable Code

// admin/class-ajax.php - approx line 819 (reconstructed from patch context in add_ignore method)

		global $wpdb;
		$table_name           = $wpdb->prefix . 'accessibility_checker';
		$raw_ids              = isset( $_REQUEST['ids'] ) ? (array) wp_unslash( $_REQUEST['ids'] ) : [];
		$ids                  = array_map(
			function ( $value ) {
				return (int) $value;
			},
			$raw_ids
		);
		$action               = isset( $_REQUEST['ignore_action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_action'] ) ) : '';
		$type                 = isset( $_REQUEST['ignore_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_type'] ) ) : '';
		$siteid               = get_current_blog_id();

        // Missing: current_user_can('edit_post', $post_id) check

Security Fix

--- /home/deploy/wp-safety.org/data/plugin-versions/accessibility-checker/1.42.0/admin/class-ajax.php	2026-03-17 18:08:32.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/accessibility-checker/1.42.1/admin/class-ajax.php	2026-05-20 19:37:20.000000000 +0000
@@ -819,17 +819,54 @@
 		}
 
 		global $wpdb;
-		$table_name           = $wpdb->prefix . 'accessibility_checker';
-		$raw_ids              = isset( $_REQUEST['ids'] ) ? (array) wp_unslash( $_REQUEST['ids'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization handled below.
-		$ids                  = array_map(
+		$table_name = $wpdb->prefix . 'accessibility_checker';
+		$raw_ids    = isset( $_REQUEST['ids'] ) ? (array) wp_unslash( $_REQUEST['ids'] ) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization handled below.
+		$ids        = array_map(
 			function ( $value ) {
 				return (int) $value;
 			},
 			$raw_ids
 		); // Sanitizing array elements to integers.
-		$action               = isset( $_REQUEST['ignore_action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_action'] ) ) : '';
-		$type                 = isset( $_REQUEST['ignore_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_type'] ) ) : '';
-		$siteid               = get_current_blog_id();
+		$action     = isset( $_REQUEST['ignore_action'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_action'] ) ) : '';
+		$type       = isset( $_REQUEST['ignore_type'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['ignore_type'] ) ) : '';
+		$siteid     = get_current_blog_id();
+
+		// Capability check: verify edit_post on every post affected by this request.
+		$batch_object = null;
+		$first_id     = reset( $ids );
+		$valid_table  = edac_get_valid_table_name( $table_name );
+
+		if ( ! $first_id || ! $valid_table ) {
+			wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) );
+		}
+
+		if ( isset( $_REQUEST['largeBatch'] ) && 'true' === $_REQUEST['largeBatch'] ) {
+			// largeBatch updates every row sharing the same object string across the site;
+			// collect all distinct postids that would be affected and check each one.
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Permission check requires direct lookup.
+			$batch_object = $wpdb->get_var( $wpdb->prepare( 'SELECT object FROM %i WHERE id = %d', $valid_table, $first_id ) );
+			if ( ! $batch_object ) {
+				wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) );
+			}
+			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Permission check requires direct lookup.
+			$affected_post_ids = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT postid FROM %i WHERE siteid = %d AND object = %s', $valid_table, $siteid, $batch_object ) );
+		} else {
+			// Small batch: look up the post for every supplied ID in one query.
+			$id_placeholders   = implode( ', ', array_fill( 0, count( $ids ), '%d' ) );
+			$query_args        = array_merge( [ $valid_table ], $ids );
+			$affected_post_ids = $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT postid FROM %i WHERE id IN ({$id_placeholders})", $query_args ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder -- Permission check requires direct lookup.
+		}
+
+		if ( empty( $affected_post_ids ) ) {
+			wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) );
+		}
+
+		foreach ( $affected_post_ids as $affected_post_id ) {
+			if ( ! current_user_can( 'edit_post', (int) $affected_post_id ) ) {
+				wp_send_json_error( new \WP_Error( '-5', __( 'Permission Denied', 'accessibility-checker' ) ) );
+			}
+		}
+
 		$ignre                = ( 'enable' === $action ) ? 1 : 0;
 		$ignre_user           = ( 'enable' === $action ) ? get_current_user_id() : null;
 		$ignre_user_info      = ( 'enable' === $action ) ? get_userdata( $ignre_user ) : '';
@@ -844,14 +881,8 @@
 		// instead of IDs. It is a much less efficient query than by IDs - but many IDs run
 		// into request size limits which caused this to not function at all.
 		if ( isset( $_REQUEST['largeBatch'] ) && 'true' === $_REQUEST['largeBatch'] ) {
-			// Get the 'object' from the first id.
-			$first_id = $ids[0];
-			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- We need to get the latest value, not a cached value.
-			$object = $wpdb->get_var( $wpdb->prepare( 'SELECT object FROM %i WHERE id = %d', $table_name, $first_id ) );
-
-			if ( ! $object ) {
-				wp_send_json_error( new \WP_Error( '-2', __( 'No ignore data to return', 'accessibility-checker' ) ) );
-			}
+			// $batch_object was already resolved during the capability check above.
+			$object = $batch_object;
 			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Safe variable used for table name, caching not required for one time operation.
 			$wpdb->query( $wpdb->prepare( 'UPDATE %i SET ignre = %d, ignre_user = %d, ignre_date = %s, ignre_comment = %s, ignre_reason = %s, ignre_global = %d WHERE siteid = %d and object = %s', $table_name, $ignre, $ignre_user, $ignre_date, $ignre_comment, $ignre_reason, $ignore_global, $siteid, $object ) );
 		} else {

Exploit Outline

To exploit this vulnerability, an attacker first authenticates as a Subscriber and obtains a valid security nonce from the WordPress dashboard (localized as 'ajax-nonce' in the plugin's JavaScript parameters). The attacker then identifies the 'object' identifier for an accessibility issue they wish to dismiss. Finally, the attacker sends a POST request to wp-admin/admin-ajax.php with the action 'edac_insert_ignore_data', the obtained nonce, the target issue's ID, and the desired ignore state. By setting the 'largeBatch' parameter to 'true', the attacker can dismiss all instances of that accessibility issue across the entire site.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.