CVE-2026-4299

MainWP Child Reports <= 2.2.6 - Missing Authorization to Authenticated (Subscriber+) Information Disclosure via Heartbeat API

mediumMissing Authorization
5.3
CVSS Score
5.3
CVSS Score
medium
Severity
2.3
Patched in
1d
Time to patch

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:N
Attack Vector
Network
Attack Complexity
Low
Privileges Required
None
User Interaction
None
Scope
Unchanged
Low
Confidentiality
None
Integrity
None
Availability

Technical Details

Affected versions<=2.2.6
PublishedApril 7, 2026
Last updatedApril 8, 2026
Affected pluginmainwp-child-reports

What Changed in the Fix

Changes introduced in v2.3

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

# 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_stream table).

3. Code Flow

  1. Registration: In WP_MainWP_Stream\Admin::init(), the Live_Update class is instantiated.
  2. Hook: The Live_Update class registers a filter on heartbeat_received.
  3. Trigger: A user sends an AJAX POST request to admin-ajax.php with action=heartbeat.
  4. Processing: WordPress core validates the heartbeat-nonce. If valid, it fires the heartbeat_received filter, passing the data array.
  5. Vulnerable Sink: Live_Update::heartbeat_received( $response, $data ) checks if $data['wp-mainwp-stream-heartbeat'] is set.
  6. 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 $response array.
  7. Failure: The function performs no check against current_user_can( 'view_stream' ) or current_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.

  1. Login: Authenticate as a Subscriber user.
  2. Navigate: Go to a standard admin page accessible to Subscribers, such as /wp-admin/profile.php.
  3. Extract: Use the browser_eval tool to extract the nonce from the wp.heartbeat JavaScript object or from the localized script data.
    • Target: window.heartbeatSettings.nonce or window.wp.heartbeat.settings.nonce.
    • Alternative: Extract from the hidden input #heartbeat-nonce if present.

5. Exploitation Strategy

The goal is to trigger the Heartbeat response containing the activity logs.

Step-by-Step Plan:

  1. Setup Test Data: Generate some log activity (e.g., create a post, update settings).
  2. Authenticate: Log in as a Subscriber user.
  3. Acquire Nonce: Navigate to /wp-admin/profile.php and extract the heartbeat-nonce.
  4. Craft Heartbeat Request:
    • Method: POST
    • URL: http://localhost:8080/wp-admin/admin-ajax.php
    • Content-Type: application/x-www-form-urlencoded
    • Body Parameters:
      • action: heartbeat
      • screen_id: profile (matches the page we are on)
      • _nonce: [EXTRACTED_NONCE]
      • interval: 15
      • data[wp-mainwp-stream-heartbeat]: true
  5. Analyze Response: The response will be a JSON object. A successful exploit will contain a key named wp-mainwp-stream-heartbeat containing an array of activity log objects.

6. Test Data Setup

  1. 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
  2. Create Attacker: Create a subscriber user:
    • wp user create attacker attacker@example.com --role=subscriber --user_pass=password123
  3. 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

  1. HTTP Verification: Confirm the JSON response contains the summary and ip fields of recent logs.
  2. 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=profile is rejected, try screen_id=dashboard or screen_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. Try data[wp-mainwp-stream-heartbeat][last_time]=0.
  • Missing Nonce: Check if the plugin erroneously allows requests without the _nonce if the action is slightly different, though unlikely for the Heartbeat API.
Research Findings
Static analysis — not yet PoC-verified

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

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/mainwp-child-reports/2.2.6/classes/class-admin.php /home/deploy/wp-safety.org/data/plugin-versions/mainwp-child-reports/2.3/classes/class-admin.php
--- /home/deploy/wp-safety.org/data/plugin-versions/mainwp-child-reports/2.2.6/classes/class-admin.php	2024-08-06 15:57:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/mainwp-child-reports/2.3/classes/class-admin.php	2026-03-24 15:17:28.000000000 +0000
@@ -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.