CVE-2026-84753: Mail Mint, unauthenticated RCE
Mail Mint 1.31.0 stores extra form fields as contact meta, then maybe_unserialize()s them. Objects run before the is_array discard. The function is named safe_unserialize_meta. Unauthenticated. Two POSTs.
- Name
- CVE-2026-84753: Mail Mint, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-84753
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- WPFunnels
- Affected
- Mail Mint (mail-mint), all versions through 1.31.0; patched in 1.31.1
- Published
- 18 Sept 2026
- Updated
- 18 Sept 2026
- Tags
- n-day, wordpress, object-injection, cwe-502, unauthenticated, rce
The function is named safe
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-84753 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-84753 (NVD, GHSA-28vg-wv39-3g8h, Patchstack) is unauthenticated PHP object injection in Mail Mint 1.31.0, slug mail-mint. CWE-502. 9.8 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). Patched in 1.31.1.
The advisory names object injection. That is maybe_unserialize on contact meta, not an action=. HTTP is REST mint-mail/v1/mint-form-submit. If you POST admin-ajax.php you get the theme back, tens of kilobytes of HTML, and a very convincing sense that you have done something. You have not.
This is the map I used to get from that 200 to a class that actually ran. Isolated lab, loopback only. I am not publishing a gadget chain or a shell. Instantiation of a lab canary is the witness. A POP gadget in the autoload is how this becomes RCE in the wild. 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
POST the public form twice with the same email and a serialized object in an extra field. First request stores. Second request loads. __wakeup / __destruct run in the WordPress process. A POP gadget on a real autoload is RCE. The lab ships a canary class, not a gadget.
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.31.0 of the plugin next to compose as ./mail-mint (SVN tag).
# Loopback lab image pin for CVE-2026-84753. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-84753 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
- ./mail-mint:/var/www/html/wp-content/plugins/mail-mint:ro
depends_on:
db:
condition: service_healthy
wpcli:
image: wordpress:cli
user: "33:33"
volumes:
- wp_data:/var/www/html
- ./mail-mint:/var/www/html/wp-content/plugins/mail-mint: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. It is not required to hit the sink.
# 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 published form, a public wp_rest nonce, double opt-in off (or the mailer fatals before meta is stored), and a canary class in a mu-plugin so unserialize has something to instantiate that is not a gadget. That fixture:
<?php
/**
* Lab fixture for Mail Mint 1.31.0 (CVE-2026-84753).
* Published form, public wp_rest nonce, double-opt-in off, POI canary class.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$optin = get_option( '_mrm_optin_settings', array() );
if ( ! is_array( $optin ) ) {
$optin = array();
}
$optin['enable'] = false;
update_option( '_mrm_optin_settings', $optin );
$mu_dir = WP_CONTENT_DIR . '/mu-plugins';
if ( ! is_dir( $mu_dir ) ) {
wp_mkdir_p( $mu_dir );
}
$witness = <<<'PHP'
<?php
/**
* Lab canary for CVE-2026-84753. Instantiation logs a unique string.
* Not a gadget chain and not a shell.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'POCWitness84753', false ) ) {
class POCWitness84753 {
private static function mark() {
error_log( 'POCWitness84753' );
echo 'POCWitness84753';
}
public function __wakeup() {
self::mark();
}
public function __destruct() {
self::mark();
}
}
}
PHP;
file_put_contents( $mu_dir . '/mm-lab-witness.php', $witness );
global $wpdb;
$table = $wpdb->prefix . 'mint_forms';
$exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE id = %d", 1 ) );
if ( ! $exists ) {
$wpdb->insert(
$table,
array(
'id' => 1,
'title' => 'lab form',
'form_body' => '',
'form_position' => '',
'group_ids' => '{}',
'status' => 'published',
'created_at' => current_time( 'mysql' ),
'updated_at' => current_time( 'mysql' ),
)
);
}
wp_set_current_user( 0 );
$nonce = wp_create_nonce( 'wp_rest' );
$slug = 'mm-lab-nonce';
$body = 'MM_NONCE=' . $nonce;
$existing = get_page_by_path( $slug, OBJECT, 'page' );
if ( $existing ) {
wp_update_post(
array(
'ID' => (int) $existing->ID,
'post_content' => $body,
'post_status' => 'publish',
)
);
$post_id = (int) $existing->ID;
} else {
$post_id = wp_insert_post(
array(
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => 'mm lab nonce',
'post_name' => $slug,
'post_content' => $body,
),
true
);
if ( is_wp_error( $post_id ) ) {
WP_CLI::error( $post_id->get_error_message() );
}
}
WP_CLI::success( 'mint_forms id=1 nonce page id=' . (int) $post_id . ' MM_NONCE=' . $nonce );Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-84753
cd CVE-2026-84753/lab
svn export https://plugins.svn.wordpress.org/mail-mint/tags/1.31.0 mail-mint
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
--url=http://127.0.0.1:8088 \
--title='CVE-2026-84753 Lab' \
--admin_user=admin \
--admin_password=labadmin \
--admin_email=lab@localhost.invalid \
--skip-email
docker compose exec -T wpcli wp plugin activate mail-mint
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-84753-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
FormRoute registers mint-mail/v1 / mint-form-submit as EDITABLE. permission_callback is FrontendBaseController::rest_permissions_check: return true. Unauthenticated by design. The nonce is wp_rest for uid 0, which a public page can print.
public function rest_permissions_check() {
return true;
}FormSubmissionController::mrm_submit_form verifies that nonce, then hands post_data to FormAction::handle_form_submission. Extra fields that are not email / name / form_id become meta_fields via sanitize_textarea_field. A PHP serialized string survives that. It is stored as contact meta.
A GET is the wrong method. A missing nonce is Seems like you are a bot. Theme HTML is a router miss.
Unserialize, then decide it was unsafe
ContactModel::safe_unserialize_meta is the whole CVE. The comment says deserialized objects will be discarded. The code builds them first.
private static function safe_unserialize_meta( $value ) {
if ( ! is_serialized( $value ) ) {
return $value;
}
$unserialized = maybe_unserialize( $value );
return is_array( $unserialized ) ? $unserialized : $value;
}maybe_unserialize calls PHP unserialize with no allowed_classes. __wakeup and __destruct run. Then is_array fails for an object, and the raw string is returned. The object already existed. Naming the function safe_ does not move the type check in front of the constructor.
First POST: new contact, meta stored, no ContactModel::get of that row yet. You can get "status":"success" and "Form is not valid" in the same breath. That is not the witness.
Second POST, same email: existing contact. ContactModel::get walks meta through safe_unserialize_meta. That is the instantiation.
1.31.1 rejects is_serialized input up front and unserializes with allowed_classes => false. That is the patch. Update.
The 200 that was a form message
The first client I pointed at this was polite. It used admin-ajax.php because that is where WordPress plugins live in folklore. This one lives on REST. You get the theme. You get no class.
A few other ways to lose without learning anything:
Seems like you are a bot. Nonce miss. Pullwp_restthe way a visitor would.Form is not valid/Email Field Not foundon the first POST only. Meta may still have been stored. Hit it twice."status":"success"with no class name in the body and no line indebug.log. The object never instantiated. Serialized class-length prefix has to match the class name. Side paths (mailer fatal with double opt-in on) abort before store.- A reverse shell or an outbound connect. Theatre. The witness is the canary class name.
- One POST. Store is not load.
The tell, once the router matches: the JSON is small, not a homepage. The witness is the unique class name in that body, or in the debug log, on the second submit. If you are still reading a DOCTYPE, you are still lost.
What I actually did
Treat the on-disk product as the spec. Patchstack named object injection. I read the REST route, then FormAction extra-field storage, then safe_unserialize_meta. Proof of concept: CVE-2026-84753-Abraxas-Labs.py.
Nonce like a visitor. GET the public page. Pull MM_NONCE=. That is wp_create_nonce('wp_rest') for uid 0.
Two POSTs, same email. JSON body with wp_nonce and post_data as a query string: email, form_id=1, extra field whose value is a serialized instance of the lab canary. First request stores. Second request loads. I am not printing a gadget chain here.
Witness in the body or the log. POCWitness84753 from __wakeup / __destruct. If that string is present, unserialize built an attacker-named class from contact meta. That is the proof. A POP gadget is how you turn that into RCE on a real autoload. I am not shipping one.
Last lab run, trimmed:
nonce GET status=200
step1 status=200
{"status":"success","message":"Form is not valid"}
step2 status=200
POCWitness84753POCWitness84753{"status":"success","message":"Form is not valid"}
SUCCESS CVE-2026-84753The JSON still says the form is not valid. The class still ran. That is the whole argument. The client that produced it is on GitHub.
Wrong turns: treating action=safe_unserialize_meta as a route; stopping at {"status":"success","message":"Form is not valid"} (that JSON is not a miss); hard-coding a nonce; calling this "WordPress unserializes meta" (core post meta is a different story).
What this is not
It is not "WordPress unserializes meta." Core post meta is a different story. This is a plugin REST form that copies extra fields into its own contact-meta table and then maybe_unserializes them on the next submit.
Update to 1.31.1 or newer. Re-run the loopback client against the patched build: the witness must not appear.
I am not going to print a gadget you can paste at someone else's mint-form-submit. The call chain and the unserialize-then-discard order 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-84753 · CVE-2026-84753-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-84753 · NVD · GHSA-28vg-wv39-3g8h · CWE-502 · Patchstack
- Trac 1.31.0: FormRoute, rest_permissions_check, mrm_submit_form, FormAction extra fields, safe_unserialize_meta
- Source: SVN tags · Trac browser