CVE-2026-82226: Tickera, unauthenticated RCE
Tickera 3.6.0.2 maybe_unserialize()s attendee owner_data *_post_meta in create_order. Cart nonce, cookie, free ticket, then process-payment. Objects run. Unauthenticated. Patched in 3.6.0.3.
- Name
- CVE-2026-82226: Tickera, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-82226
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- Tickera
- Affected
- Tickera (tickera-event-ticketing-system), all versions through 3.6.0.2; patched in 3.6.0.3
- Published
- 18 Sept 2026
- Updated
- 18 Sept 2026
- Tags
- n-day, wordpress, object-injection, cwe-502, unauthenticated, rce
Checkout unserializes the attendee
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-82226 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-82226 (NVD, GHSA-v3jw-vq2p-6xp9, Patchstack) is unauthenticated PHP object injection in Tickera 3.6.0.2, slug tickera-event-ticketing-system. 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 3.6.0.3.
The advisory names object injection. That is maybe_unserialize on attendee *_post_meta inside TC::create_order. HTTP is the cart page, then process-payment. Not admin-ajax.php. If you POST action=create_order 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
Fill a free-order checkout with a serialized object in owner_data_first_name_post_meta. create_order builds that class during payment. 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 3.6.0.2 of the plugin next to compose as ./tickera-event-ticketing-system (SVN tag).
# Loopback lab image pin for CVE-2026-82226. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-82226 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
- ./tickera-event-ticketing-system:/var/www/html/wp-content/plugins/tickera-event-ticketing-system:ro
depends_on:
db:
condition: service_healthy
wpcli:
image: wordpress:cli
user: "33:33"
volumes:
- wp_data:/var/www/html
- ./tickera-event-ticketing-system:/var/www/html/wp-content/plugins/tickera-event-ticketing-system: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 event, a free ticket (price_per_ticket=0), show_owner_fields=yes, a public cart nonce, and a canary class so unserialize has something to instantiate that is not a gadget. That fixture:
<?php
/**
* Lab fixture for Tickera 3.6.0.2 (CVE-2026-82226).
* Free event+ticket, cart nonce page, canary class.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$general = get_option( 'tickera_general_setting', array() );
if ( ! is_array( $general ) ) {
$general = array();
}
$general['show_owner_fields'] = 'yes';
$general['show_discount_field'] = 'no';
update_option( 'tickera_general_setting', $general );
$mu_dir = WP_CONTENT_DIR . '/mu-plugins';
if ( ! is_dir( $mu_dir ) ) {
wp_mkdir_p( $mu_dir );
}
$witness = <<<'PHP'
<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( ! class_exists( 'POCWitness82226', false ) ) {
class POCWitness82226 {
private static function mark() {
error_log( 'POCWitness82226' );
echo 'POCWitness82226';
}
public function __wakeup() {
self::mark();
}
public function __destruct() {
self::mark();
}
}
}
PHP;
file_put_contents( $mu_dir . '/tc-lab-witness.php', $witness );
$event_id = wp_insert_post(
array(
'post_type' => 'tc_events',
'post_status' => 'publish',
'post_title' => 'lab event',
'post_name' => 'lab-event',
),
true
);
$ticket_id = wp_insert_post(
array(
'post_type' => 'tc_tickets',
'post_status' => 'publish',
'post_title' => 'lab ticket',
'post_name' => 'lab-ticket',
),
true
);
update_post_meta( $ticket_id, 'event_name', (string) $event_id );
update_post_meta( $ticket_id, 'price_per_ticket', '0' );
global $tc;
if ( isset( $tc ) && method_exists( $tc, 'create_pages' ) ) {
$tc->create_pages();
}
flush_rewrite_rules( false );
wp_set_current_user( 0 );
$nonce = wp_create_nonce( 'tickera_cart_page' );
$hash = defined( 'COOKIEHASH' ) ? COOKIEHASH : md5( (string) get_option( 'siteurl' ) );
$cart_path = '/tickets-cart/';
$pay_path = '/tickets-process-payment/';
$body = 'TICKET_ID=' . (int) $ticket_id
. ' CART_NONCE=' . $nonce
. ' COOKIEHASH=' . $hash
. ' CART_PATH=' . $cart_path
. ' PAY_PATH=' . $pay_path;
wp_insert_post(
array(
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => 'tc lab nonce',
'post_name' => 'tc-lab-nonce',
'post_content' => $body,
),
true
);Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-82226
cd CVE-2026-82226/lab
svn export https://plugins.svn.wordpress.org/tickera-event-ticketing-system/tags/3.6.0.2 tickera-event-ticketing-system
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
--url=http://127.0.0.1:8088 \
--title='CVE-2026-82226 Lab' \
--admin_user=admin \
--admin_password=labadmin \
--admin_email=lab@localhost.invalid \
--skip-email
docker compose exec -T wpcli wp plugin activate tickera-event-ticketing-system
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-82226-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
admin_post_nopriv_tickera_cart points at TC::update_cart. Unauthenticated. The cart page also posts to itself. Nonce action is tickera_cart_page. Cart contents live in cookie tc_cart_{COOKIEHASH} as JSON {ticket_id: quantity}.
function update_cart() {
$cart_action = filter_input( INPUT_POST, 'cart_action', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
$valid_cart_actions = [
'empty_cart',
'update_cart',
'apply_coupon',
'proceed_to_checkout'
];
if ( $cart_action && in_array( $cart_action, $valid_cart_actions ) ) {
$cart_nonce = ( isset( $_POST['_wpnonce'] ) ? sanitize_key( wp_unslash( $_POST['_wpnonce'] ) ) : '' );
if ( ! wp_verify_nonce( $cart_nonce, 'tickera_cart_page' ) ) {
wp_die( esc_html__( 'Invalid cart request.', 'tickera-event-ticketing-system' ), 403 );
}proceed_to_checkout stores buyer and owner fields in the session, including owner_data_*_post_meta. Then you POST process-payment. Free orders (price_per_ticket=0) are permanently active, so tc_choose_gateway=free_orders plus tc_payment_submit=1 is enough. That calls create_order.
A GET of /cart/ with no cookie is The cart is empty. A bad nonce is Invalid cart request. Theme HTML is a router miss.
maybe_unserialize on the attendee
TC::create_order walks owner records. Fields matching /_post_meta/ go through maybe_unserialize with no allowed_classes. Then, if the result is not an array, sanitize_text_field. Objects already ran.
} elseif ( preg_match( '/_post_meta/', $owner_field_name ) ) {
$owner_field_value = maybe_unserialize( $owner_field_value );
$metas[ str_replace( '_post_meta', '', $owner_field_name ) ] = (
is_array( $owner_field_value )
? tickera_sanitize_array( $owner_field_value, false, true )
: sanitize_text_field( $owner_field_value )
);
}Same order as Mail Mint's safe_unserialize_meta: build the object, then decide it was not an array. __wakeup and __destruct do not wait for that is_array.
show_owner_fields has to be yes or the cart form never sends owner_data_*_post_meta. A paid cart that never reaches create_order never unserializes. The lab ticket is free so process-payment does not ask for a card.
3.6.0.3 rejects is_serialized owner fields and sanitizes arrays with allowed_classes => false. That is the patch. Update.
The serialized class-length prefix has to match the class name. POCWitness82226 is 15 characters. O:16: for that name never instantiates.
The 200 that was an empty cart
The first client I pointed at this was polite. It used admin-ajax.php and action=create_order. Function names in the advisory are PHP methods. You get the theme. You get no class.
A few other ways to lose without learning anything:
Invalid cart request. Nonce action istickera_cart_page. Pull it from the cart form or the fixture page.The cart is empty. Cookietc_cart_{COOKIEHASH}was missing or the ticket id was wrong.COOKIEHASHis WordPress's, not a plugin secret.All fields marked with * are required. Owner fields on, empty name/email. Fill them. Put the serialized canary inowner_data_first_name_post_meta[id][0], not in the email.- Cart POST 302 to payment, then you stop. Unserialize is on
create_order, which is process-payment. - A reverse shell. Theatre. The witness is the canary class name.
The tell is not a tiny JSON body. Checkout 302s. Process-payment 302s to order-confirmation and may echo the canary into the HTML before the redirect script. Follow the Location. If POCWitness82226 is in that body or in debug.log, unserialize built an attacker-named class from attendee meta. That is the proof.
What I actually did
Treat the on-disk product as the spec. Patchstack named object injection. I read update_cart, then create_order around the _post_meta branch. Proof of concept: CVE-2026-82226-Abraxas-Labs.py.
Discover like a visitor. GET the public fixture page. Ticket id, COOKIEHASH, cart path, payment path. Then GET the cart with the cookie set so the form nonce is in the HTML.
Cart, then pay. POST cart_action=proceed_to_checkout with the cart nonce and owner _post_meta set to a serialized instance of the lab canary. Then POST process-payment with tc_payment_submit and free_orders. I am not printing a gadget chain here.
Witness in the body or the log. POCWitness82226 from __wakeup / __destruct. If that string is present, maybe_unserialize built an attacker-named class during create_order. 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
cart GET status=200
step1 checkout status=302 loc=/tickets-payment/
step2 payment status=302 loc=/tickets-order-confirmation/...
POCWitness82226POCWitness82226
SUCCESS CVE-2026-82226The confirmation page is still a WordPress theme. The class still ran, on the payment POST, before the redirect. That is the whole argument. The client that produced it is on GitHub.
Wrong turns: admin-ajax.php action=create_order (theme); Invalid cart request (wrong nonce); The cart is empty (missing tc_cart_{COOKIEHASH} or wrong ticket id); All fields marked with * are required (empty name/email); stopping at the payment 302.
What this is not
It is not "WordPress carts unserialize cookies." The cart cookie is JSON. The injection is owner _post_meta in session, unserialized when the free gateway creates the order.
Update to 3.6.0.3 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 cart. The call chain and the unserialize-then-sanitize 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-82226 · CVE-2026-82226-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-82226 · NVD · GHSA-v3jw-vq2p-6xp9 · CWE-502 · Patchstack
- Trac 3.6.0.2: nopriv tickera_cart, update_cart, create_order, maybe_unserialize owner meta
- Source: SVN tags · Trac browser