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

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).

Dockerfile
# Loopback lab image pin for CVE-2026-82226. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# 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.

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 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
<?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/:

Plain text
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.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

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}.

PHP
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.

PHP
} 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 is tickera_cart_page. Pull it from the cart form or the fixture page.
  • The cart is empty. Cookie tc_cart_{COOKIEHASH} was missing or the ticket id was wrong. COOKIEHASH is WordPress's, not a plugin secret.
  • All fields marked with * are required. Owner fields on, empty name/email. Fill them. Put the serialized canary in owner_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:

Plain text
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-82226

The 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