-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The gate has three exits, the plugin watched one\n\nThe 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:\n\n```php\npublic function is_mainwp_authenticated(): bool {\n $auth_header = sanitize_text_field(\n wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '' )\n );\n\n if ( ! empty( $auth_header ) && stripos( $auth_header, 'basic ' ) === 0 ) {\n $credentials = base64_decode( substr( $auth_header, 6 ), true );\n if ( ! $credentials ) {\n return false;\n }\n $parts = explode( ':', $credentials, 2 );\n if ( count( $parts ) !== 2 ) {\n return false;\n }\n $username = $parts[0];\n $password = $parts[1];\n $is_valid = wp_authenticate_application_password( null, $username, $password );\n if ( is_wp_error( $is_valid ) ) {\n return false;\n }\n $user = get_user_by( 'login', $username );\n if ( ! $user || ! user_can( $user, 'manage_burst_statistics' ) ) {\n return false;\n }\n wp_set_current_user( $user->ID );\n return true;\n }\n return false;\n}\n```\n\nThe 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.\n\nWordPress 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.\n\nThe 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.\n\n## The gate runs before the request has been classified\n\nBoth 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:\n\n```php\nfunction wp_authenticate_application_password( $input_user, $username, $password ) {\n if ( $input_user instanceof WP_User ) {\n return $input_user;\n }\n\n if ( ! WP_Application_Passwords::is_in_use() ) {\n return $input_user;\n }\n\n // The 'REST_REQUEST' check here may happen too early for the constant to be available.\n $is_api_request = ( ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST )\n || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) );\n\n $is_api_request = apply_filters( 'application_password_is_api_request', $is_api_request );\n\n if ( ! $is_api_request ) {\n return $input_user;\n }\n // ... actual authentication ...\n}\n```\n\nTwo 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.\n\nThe 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.\n\nThe 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`.\n\nThe \"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.\"\n\n## The patch does three things, and all three are admissions\n\nIn 3.4.2:\n\n```diff\n- $is_valid = wp_authenticate_application_password( null, $username, $password );\n- if ( is_wp_error( $is_valid ) ) {\n+ $allow_application_password_request = static function (): bool {\n+ return true;\n+ };\n+ add_filter( 'application_password_is_api_request', $allow_application_password_request, 999 );\n+ $authenticated_user = wp_authenticate_application_password( null, $parts[0], $parts[1] );\n+ remove_filter( 'application_password_is_api_request', $allow_application_password_request, 999 );\n+\n+ if ( ! $authenticated_user instanceof \\WP_User ) {\n return false;\n }\n- $user = get_user_by( 'login', $username );\n- if ( ! $user || ! user_can( $user, 'manage_burst_statistics' ) ) {\n+ if ( ! hash_equals( (string) $authenticated_user->user_login, $parts[0] ) ) {\n return false;\n }\n- wp_set_current_user( $user->ID );\n+ if ( ! user_can( $authenticated_user, 'manage_burst_statistics' ) ) {\n+ return false;\n+ }\n+ wp_set_current_user( $authenticated_user->ID );\n```\n\nThree separate changes. Each is its own admission.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nThree 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.\n\n## Fail-open-intercept across five gates, five languages\n\nThis is the fifth post we have tagged [fail-open-intercept](/patterns/fail-open-intercept). The shape:\n\n- 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](/posts/tomcat-encryptinterceptor-fails-open).\n- 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.\n- 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.\n- 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.\n- 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()`.\n\nFive 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.\n\nTomcat'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.\n\nEach 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.\n\n## The README is a confession\n\nThe 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.\n\nThe zycoder0day repository's README contains a section titled `## ✅ Validasi Hasil` (\"Validation Results\"), with a subsection labeled `### Validasi Live Target`. The table has one row:\n\n| Target | Hasil |\n|--------|-------|\n| `ausdermitte-binz.de` | **BERHASIL PWNED**: Burst 3.4.1.1, bypass via `binzwpadmin`, akun `xenon1337` dibuat (ID:30) |\n\nThe 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.\n\nThe 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.\n\nThe 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`.\n\nNeither PoC is a research artifact. They are tools. One has been used at least once, and the README documents the use.\n\n## Close\n\nThe 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.\n\nPoC: [zycoder0day/CVE-2026-8181](https://github.com/zycoder0day/CVE-2026-8181), [murrez/CVE-2026-8181](https://github.com/murrez/CVE-2026-8181)","closing_line":"The gate ran. The gate refused. The plugin never heard the refusal.","hook_md":"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.","post_id":292,"slug":"burst-statistics-cve-2026-8181-null-is-not-failure","title":"CVE-2026-8181: Burst Statistics Tests For WP_Error. Null Is Not WP_Error.","type":"initial","unreadable_sentence":"`wp_authenticate_application_password()` returns `WP_User` on success, `WP_Error` on failure, and `null` when it cannot run. `is_wp_error()` rejects one of the three. The other two are accept."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCauIrZAAKCRDeZjl4jgkQ Jtr3AQCrWzPVc+0fRgB1SFdfm5WogFiINrHssEGRYFJXQ8HbOAD/foTSUhzx33qk J5Iw4NNnHKBjSGUR4ySOSdsfi+473gM= =+1Ae -----END PGP SIGNATURE-----