Equalize Digital Accessibility Checker <= 1.42.0 - Missing Authorization to Authenticated (Subscriber+) Arbitrary Accessibility Issue Modification via edac_insert_ignore_data AJAX Action
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:NTechnical Details
<=1.42.0What Changed in the Fix
Changes introduced in v1.42.1
Source Code
WordPress.org SVN# 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_datanonce: A valid nonce for theajax-nonceaction string.object: The identifier for the accessibility issue (often a CSS selector or hash).ignore_state: The new state (e.g.,1for ignored,0for 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 sameobjectidentifier across the database.post_id: (Required for context) The ID of the post the issue is associated with.
3. Code Flow
- Hook Registration: In
admin/class-ajax.php, theinit_hooks()method registers the AJAX action:add_action( 'wp_ajax_edac_insert_ignore_data', [ $this, 'add_ignore' ] ); - Missing Check: The
add_ignoremethod (inferred from thesummarymethod pattern in the same class) likely verifies the nonce usingwp_verify_nonce( $_REQUEST['nonce'], 'ajax-nonce' ). - Authorization Failure: Unlike the
summary()method which explicitly checkscurrent_user_can( 'edit_post', $post_id ), theadd_ignoremethod omits this check or uses an insufficient check that allows Subscriber access. - Data Persistence: The method processes the
objectandignore_stateparameters and updates the plugin's custom database table (likelywp_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.
- 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. - Localization Key: Based on standard plugin patterns and the
summaryfunction, the nonce is likely localized under a JS object namededac,edac_ajax, or similar. - 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.phprelated 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
- Target Content: Create a post with a deliberate accessibility error (e.g., an image without an
altattribute).wp post create --post_type=post --post_title="Vuln Test" --post_content='<img src="https://example.com/test.jpg">' --post_status=publish - Trigger Scan: Use the plugin's scan functionality (or wait for the auto-scan) so the issue is populated in the database.
- Identify Object: Check the database for the issue's
objectidentifier.wp db query "SELECT object, post_id FROM wp_edac_issues LIMIT 1;" - 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
objectselector 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 thatobjectselector) will be dismissed.
8. Verification Steps
- DB Check: Verify the
is_ignored(or similar column name) status in the plugin table.
Check ifwp db query "SELECT * FROM wp_edac_issues WHERE object = '[TARGET_SELECTOR]';"ignore_reasonmatches "Exploit Test". - 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
objector common selector (e.g.,img) andlargeBatch=trueto see if it clears all image-related issues. - Permissions Oracle: If the
post_idparameter is strictly validated, iterate through common post IDs. If thesummaryaction (which has a check) returns a permission error butedac_insert_ignore_datareturns success for the same ID, authorization bypass is confirmed.
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
@@ -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.