//nefariousplan

CVE-2026-8181: Burst Statistics Tests For WP_Error. Null Is Not WP_Error.

pattern

cve

proof of concept

Burst Statistics 3.4.0 through 3.4.1.1 ships an authentication gate built on a single question: did this fail? If the answer is no, the gate grants WordPress admin to the caller. The function the gate asks returns three shapes: a WP_User on success, a WP_Error on failure, and null when it could not run yet. The check is_wp_error() rejects one of those. The other two are accept.

The gate has three exits, the plugin watched one

The vulnerable function is is_mainwp_authenticated() in includes/Frontend/class-mainwp-proxy.php. It is called from has_admin_access() in includes/Traits/trait-admin-helper.php, which is itself called from Burst::bootstrap() during the plugins_loaded action at priority 9. Here is the function in 3.4.1.1:

public function is_mainwp_authenticated(): bool {
    $auth_header = sanitize_text_field(
        wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '' )
    );

    if ( ! empty( $auth_header ) && stripos( $auth_header, 'basic ' ) === 0 ) {
        $credentials = base64_decode( substr( $auth_header, 6 ), true );
        if ( ! $credentials ) {
            return false;
        }
        $parts = explode( ':', $credentials, 2 );
        if ( count( $parts ) !== 2 ) {
            return false;
        }
        $username = $parts[0];
        $password = $parts[1];
        $is_valid = wp_authenticate_application_password( null, $username, $password );
        if ( is_wp_error( $is_valid ) ) {
            return false;
        }
        $user = get_user_by( 'login', $username );
        if ( ! $user || ! user_can( $user, 'manage_burst_statistics' ) ) {
            return false;
        }
        wp_set_current_user( $user->ID );
        return true;
    }
    return false;
}

The variable $is_valid records the result of WordPress's application-password authentication call. The check on the next line tests is_wp_error( $is_valid ). If the result is a WP_Error, the function bails. If it is not, the function reads the username straight back out of the Basic header and calls get_user_by( 'login', $username ) to look the user up by name only. Then wp_set_current_user( $user->ID ) switches the request's identity to that administrator for the rest of the lifecycle.

WordPress core's wp_authenticate_application_password() does not return only WP_User and WP_Error. Its third return type is the $input_user it was given. Burst hands it null for that argument. Whenever the function reaches a return-input-user path (and there are two of them), Burst's call site receives null. is_wp_error( null ) is false. The plugin reads false and proceeds. The lookup runs against a username string the attacker controls. The user resolved is whichever administrator was named in the Basic auth field. wp_set_current_user() then mutates the global current-user for the remainder of the request, so any code dispatched later, including the REST endpoint that has not yet been routed (such as POST /wp/v2/users), runs as that administrator.

The gate's only assertion was "the function did not return a WP_Error." That assertion is true for WP_User. That assertion is also true for null. Two of three return shapes pass.

The gate runs before the request has been classified

Both PoCs describe the precondition as "HTTP only," meaning sites where wp_is_application_passwords_available() returns false. That condition by itself does not produce null. WordPress core returns a WP_Error on that path, and is_wp_error() would catch it. The conditions that actually produce null are different. Here is the relevant prologue of wp_authenticate_application_password() in WordPress core:

function wp_authenticate_application_password( $input_user, $username, $password ) {
    if ( $input_user instanceof WP_User ) {
        return $input_user;
    }

    if ( ! WP_Application_Passwords::is_in_use() ) {
        return $input_user;
    }

    // The 'REST_REQUEST' check here may happen too early for the constant to be available.
    $is_api_request = ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )
        || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) );

    $is_api_request = apply_filters( 'application_password_is_api_request', $is_api_request );

    if ( ! $is_api_request ) {
        return $input_user;
    }
    // ... actual authentication ...
}

Two paths return $input_user, and the second one carries a comment WordPress's own commit author wrote into the source: // The 'REST_REQUEST' check here may happen too early for the constant to be available. That comment is the warning. The application_password_is_api_request filter that follows is the workaround core provides for plugins whose call site fires before WordPress sets REST_REQUEST. The unpatched Burst code does not use the filter. The patched Burst code does.

The first $input_user path returns null on every site that has never had an Application Password created. A fresh WordPress install. A staging clone where no administrator has clicked through to Users → Profile → Application Passwords. The plugin's authentication path on those sites was always going to fall through.

The second $input_user path returns null when REST_REQUEST and XMLRPC_REQUEST are both undefined and no filter overrides. REST_REQUEST is defined inside WP_REST_Server::serve_request(), which fires from the parse_request action. plugins_loaded fires several hooks earlier. Burst calls is_mainwp_authenticated() from Burst::bootstrap() during plugins_loaded. The request has not been classified yet. The constant is not defined yet. The filter has no caller registered. The function reaches the second return $input_user line and returns null.

The "HTTP only" framing is wrong about the cause. HTTPS does not save you. A correctly configured TLS chain does not save you. A site that has never created an Application Password is reachable. A site whose request lifecycle has not yet flagged this as an API request is reachable. The bug is not about whether the gate trusts the transport. The bug is about whether the gate had the chance to run, and the bug is about what the gate's caller does when the answer is "not yet."

The patch does three things, and all three are admissions

In 3.4.2:

-            $is_valid = wp_authenticate_application_password( null, $username, $password );
-            if ( is_wp_error( $is_valid ) ) {
+            $allow_application_password_request = static function (): bool {
+                return true;
+            };
+            add_filter( 'application_password_is_api_request', $allow_application_password_request, 999 );
+            $authenticated_user = wp_authenticate_application_password( null, $parts[0], $parts[1] );
+            remove_filter( 'application_password_is_api_request', $allow_application_password_request, 999 );
+
+            if ( ! $authenticated_user instanceof \WP_User ) {
                 return false;
             }
-            $user = get_user_by( 'login', $username );
-            if ( ! $user || ! user_can( $user, 'manage_burst_statistics' ) ) {
+            if ( ! hash_equals( (string) $authenticated_user->user_login, $parts[0] ) ) {
                 return false;
             }
-            wp_set_current_user( $user->ID );
+            if ( ! user_can( $authenticated_user, 'manage_burst_statistics' ) ) {
+                return false;
+            }
+            wp_set_current_user( $authenticated_user->ID );

Three separate changes. Each is its own admission.

The first change wraps the call in a filter that forces application_password_is_api_request to return true. Inside the closure's window, WordPress treats the call as a REST request, so the function runs to actual authentication regardless of where in the lifecycle the call site fires. The filter is removed afterward to avoid affecting unrelated code on the same request. The fact that this filter is present in the patch is an admission that without it, the function had been returning null on every call from plugins_loaded, exactly as the WordPress core comment warned.

The second change replaces is_wp_error( $is_valid ) with ! $authenticated_user instanceof \WP_User. The patched check tests for the success shape. A WP_Error is not a WP_User, so it is rejected. A null is not a WP_User, so it is rejected. Whatever the function returns, the gate now refuses it unless it is the type the gate was supposed to be looking for in the first place.

The third change adds hash_equals( $authenticated_user->user_login, $parts[0] ). WordPress's application-password flow accepts the user's email address in the username field, so without this check, a caller who knew an admin's email could have authenticated using an Application Password tied to that email and the gate would have set current_user to a user whose user_login differed from the supplied string. The pre-patch lookup, get_user_by( 'login', $username ), was not narrowing the way the developer thought it was.

Three guards, all on one function, all added in one commit. The patch is a paragraph of admissions about what the previous version had been doing.

Fail-open-intercept across five gates, five languages

This is the fifth post we have tagged fail-open-intercept. The shape:

  • Apache Tomcat's EncryptInterceptor caught a GeneralSecurityException from the AES/CBC decrypt call, logged the failure, and called super.messageReceived(msg) outside the try block, forwarding undecrypted bytes downstream to ObjectInputStream.readObject(). See tomcat-encryptinterceptor-fails-open.
  • DataEase's CommunityTokenFilter caught a JWT verification exception, wrote a 401 response body, and called filterChain.doFilter() outside the catch, forwarding the request to the handler with the forged token still in hand.
  • Admidio's validateSignature() returned a translation key on failure. The two call sites in SAMLService.php discarded the return value entirely, with a comment above each that read // Validate signatures. Will throw an exception. The function did not throw.
  • Xerte's connector.php called header( 'Location: ...', true, 302 ) to refuse unauthenticated callers and continued executing the elFinder dispatcher, because PHP's header() does not terminate the script.
  • Burst Statistics calls wp_authenticate_application_password( null, $parts[0], $parts[1] ) during plugins_loaded, before WordPress has set REST_REQUEST. The function returns null. The plugin checks is_wp_error( $is_valid ), which is false for both WP_User and null, and proceeds to wp_set_current_user().

Five gates. Five languages. In every case the gate ran. In every case the gate's verdict was reject. In every case the rest of the code never heard the verdict, because the listener was wired for a different channel than the speaker.

Tomcat's failure traveled through a thrown exception that the catch swallowed and let the original payload past. DataEase's failure traveled through a thrown exception that the catch wrote a 401 for and let the request past. Admidio's failure traveled through a string return type the callers never read, accompanied by a comment that asserted the wrong communication mechanism. Xerte's failure traveled through an HTTP response header that the PHP runtime does not enforce as termination. Burst's failure traveled through a return value of a type the gate's check did not match against.

Each gate had a coherent way of saying "no." Each gate's caller listened on a coherent channel for "no." Each gate's design failed because those two channels were not the same channel.

The README is a confession

The two PoC repositories on GitHub are not equal. One is a Python file and a README. The other is the same Python file, the same README, and a results table.

The zycoder0day repository's README contains a section titled ## ✅ Validasi Hasil ("Validation Results"), with a subsection labeled ### Validasi Live Target. The table has one row:

Target Hasil
ausdermitte-binz.de BERHASIL PWNED: Burst 3.4.1.1, bypass via binzwpadmin, akun xenon1337 dibuat (ID:30)

The Indonesian label translates to "successfully pwned." The line names a third-party domain, the version of Burst running on it, the existing administrator account whose username was used to drive the bypass, and the new administrator account the author created on that site at user ID 30. The README states the account creation as a completed fact. The README is signed zycoder0day and dated 8 Mei 2026. Seven days from publication to the moment you are reading this. Unless an administrator with access to that WordPress installation has noticed the new account and removed it, xenon1337 is still listed in the site's users table.

The murrez repository's README, in the section titled "Empty Password: field," explains why its output reports an empty Password: line on a successful run unless --create-user is set: the Basic-auth password sent to trigger the bypass is fake, so the victim's real password is not exfiltrated. Only their administrator role is hijacked. The README is bilingual Turkish and English. The tool ships a thread-pool worker pattern that caps at 200, a targets.txt loader that reads one URL per line, and a combined-output flag that appends only successful exploits to a single file. The --create-user mode plants an administrator with a generated password and a @protonmail.com email address. A Login Address: line in the report makes the access human-clickable.

The two repositories do not share an author. They share a CVE, a thread pool, a targets-file format, the same exploit chain, and a tool design that treats one-host execution as the degenerate case of many-host execution. The murrez account's other pinned repositories include Mass-Mirai-IoT-Exploit, ASUS-AiCloud-RCE, and CVE-2026-1306 (a separate WordPress vulnerability scanner). The account's bio reads bazen iyi bazen kötü, which translates as "sometimes good, sometimes bad." The zycoder0day account's other repositories include Bypass-Webshell, Wordpress-Trick, and scampage.

Neither PoC is a research artifact. They are tools. One has been used at least once, and the README documents the use.

Close

The advisory tells site administrators what to update. The PoCs tell the next operator what to send. The README on the zycoder0day repository tells anyone who searched GitHub for CVE-2026-8181 on the day it was published which specific WordPress site was compromised and what username currently holds administrator privileges on it. Wordfence will revise its threat intelligence. WordPress.org will count installs of 3.4.2. The README will sit there.

PoC: zycoder0day/CVE-2026-8181, murrez/CVE-2026-8181

The gate ran. The gate refused. The plugin never heard the refusal.