Security News

Cybersecurity news aggregator

🔓
CRITICAL Vulnerabilities Reddit r/netsec

Perfex CRM: Autologin cookie fed into unserialize() gives unauthenticated RCE

A critical unauthenticated remote code execution vulnerability (CVE requested, CVSS 10.0) exists in Perfex CRM due to the deserialization of untrusted data from the `autologin` cookie. The attack vector involves a crafted cookie that bypasses CodeIgniter's XSS filter by using PHP's `S:` serialization format to exploit object injection, leading to code execution via GuzzleHttp's `FileCookieJar`. Affected versions include Perfex CRM 3.4.0 and earlier.
Read Full Article →

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

Share this article