之前发简书的thinkphp-vuln系列直接被转自己可见把我搞怕了......这里试着发下tp5反序列化的pop链分析。不过不放exp.应该不会被吞了吧。
...
tp5.0
这一部分主要是跟下tp5.0版本的反序列化pop链。不过这里不会分享exp.(网上跟先知应该都能很方便找到)。如果需要的话自己SCTF2020wp里有绕过短标签的exp。以及以前跟php框架有几个其他的exp可以自行寻找。当然我记得wh1t3P1g大佬自己把tp的popchain集成到phpggc中了。也可以自动生成。
然后就是windows下写文件的方法。目前能够在php7以前的版本写shell exp是有的。但php7的windows写shell我还没成功过。理论上windows不能成功的原因只是因为文件名不允许<
,?
的。但是如果用过滤器绕过的话应该是没问题的......
php5.4.45+windows 成功写入phpinfo() (关闭短标签)
上面口胡了。php7可写可写。就是用的上面说的方法
这里我就直接跟下linux的payload吧。
首先是入口点。肯定是找__destruct
函数。不难发现一共只有几个可用。我们找到 library\think\process\pipes下windows.php。发现其调用了$this->removeFiles
.而removeFiles
又调用了file_exists
可以触发__toString
方法。
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
ps.关于file_exists可以触发__toString
自己以前还没有注意过。具体不妨去l3m0n师傅这篇文章下评论看看。
(应该是因为file_exists接受字符串参数,而只要对象被当做字符串即会触发__toString
)
全局继续找toString
.也只有几个选择。这里找到 think\Model.php
.它调用了toJson
。而toJson
继续调用了toArray
.
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
同时注意这里Model类是抽象类。所以实际编写exp时我们必须用它的子类。比如此处的Pivot。
我们直接来到toArray。这里首先主要看有没有可以触发__call
的情况。5.0.24版本下应该又三处都是可以满足的
$relation = $this->getAttr($key);
$value = $this->getRelationData($modelRelation);
$item[$key] = $value ? $value->getAttr($attr) : null;
与其说是找触发__call
的。不如说是找可用方法。多数情况下这些方法基本利用不了。但是如果满足this->xxx($var)
或者进一步可控类->xxx(可控变量)
。我们就能找任意类的__call
进行进一步挖掘。这也是pop链中call方法经常用到的原因之一。
为了了解我们如何控制这一步用来调用__call
。我们先放一下全局找__call
的过程。来选择一个触发方式。
对于$item[$key] = $value ? $value->getAttr($attr) : null;
这里看看$value
与$attr
依次是怎么被赋值的。
value
$relation = Loader::parseName($name, 1, false);
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);
主要是对$name
调用parseName。而$name
来自可控数组$this->append
。
也就是说。$modelrelation
是Model这个类任意方法的返回值。(.$relation()
)。所以找一个直接返回可控数据的方法即可。比如getError
public function getError()
{
return $this->error;
}
回到上面。现在我们继续跟进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
类的。所以我们上面返回的结果$this->error
肯定也是一个Relation
的对象了。(Relation也是抽象类。所以实例化时要用它的子类)
然后此处我们自然要走第一个if分支来控制返回值。分别看下isSelfRelation()
跟getModel()
发现都只是简单返回this->relation
与this->query->model()
。全部可控。
那只剩下让get_class($modelRelation->getModel()) == get_class($this->parent)
成立了。
意思就是$modelRelation->getModel()
和$this->parent
为同类,也就是要求$value->getAttr($attr)
中的$value
和上面可控的model为同类
那么现在$value->getAttr($attr)
的value
跟完了。我们来看看$attr
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr)
......
上面提到过,modelRelation
因为取自可控方法所以是任意值。我们直接全局找getBindAttr
方法。只有一个接口类:Relation的子类OnetoOne
public function getBindAttr()
{
return $this->bindAttr;
}
数据可控。不过OnetoOne是抽象类。所以继续找子类。这里就只有两个子类。我们选择HasOne
.
现在。我们做到了任意调用__call
。剩下的就是找可用的__call
。
那么全局找可用的__call
。此处可以找到
think\console\Output 类。
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);
}
}
那么首先上面触发点里$this->parent
肯定是要传Output类的实例了。
下面看这里的$this->block()
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
调用的是$this->handle->write
。既然$this->handle
可控,那么此处找一个同名的write
方法。我们全局搜索找到Memcached类
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
同理。还是可以全局找set
方法。第一个就是我们曾经在tp5RCE中见到的File类
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;
}
}
之前曾经说过rce时缓存文件名不可控。但是在反序列化中就不存在这个问题。
$filename = $this->getCacheKey($name, true);
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;
}
文件名来自$filename = $this->options['path'] . $name . '.php';
.可控。
我们再看文件内容如何控制。
$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
这里就是pop链最大的难点了。$data
其实并不可控。具体可以回溯到我刚刚上面放的一连串调用write方法的源码处。注意到
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}
write第二个参数是写死的true
。它会一路传到File类作为$data
写入。那么我们写文件等于控制不了写入内容。
但是没有关系。set在这个file_put_contents下还调用了一个函数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);
}
}
在这里我们又一次调用了set
。并且两个参数全部可控。所以最后循环调用我们就知道,写入文件的文件名为md5('tag_' . md5($this->tag)).'.php'
。内容为$data = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;
当然。这里显然存在一个绕过死亡exit的问题。使用rot13即可。然后rot13的payload绕不过默认的短标签。所以会需要加过滤器组合拳。除了常见的base64,WMCTF中使用到其他的iconv
或者其他组合也是可行的。
原理不再赘述
那么。控制payload只要控制File类$this->options['path'] = php://filter/write=string.rot13/resource=<?cuc @riny($_TRG[_]);?>
即可。
执行exp打的话。会发现存在两个文件。这是上面我们调用了两次set
的缘故。而文件名由我们的$tag
决定。具体计算方法也在上面提及了。当然最好的方法永远是本地自己打一遍。这样才能确信文件名这种远程不可见的东西。
另外我相信大家肯定发现这个pop链有个变招。那就是linux,windows通用的写目录。回到上面getCacheKey
$dir = dirname($filename);
if ($auto && !is_dir($dir)) {
mkdir($dir, 0755, true);
}
只要把$this->options['path']
设置为目录的话。直接可以写755权限目录。
SCTF2020 考察tp5.024那道题当时使用了python脚本高强度删文件。导致我以为当前目录不可写。但是换成写目录的payload后发现可以创建目录。并且可以存在相同于靶机重启时间的3分钟。所以使用这种payload黑盒探测不失为一种办法。
tp5.1
今天来跟下5.1的pop链。相比5.0而言思路大致相同。只有几个类的区别。并且其exp已经集成到phpggc上了。
还是从起点开始看。跟昨天5.0的链子是一样的。从Windows类开始。然后=>file_exists => __toString()。然后接着全局搜索。此处利用Conversion的__toString() => __toJson() => toArray()
而不是5.0中的Model类
看到thinkphp\library\think\model\concern\Conversion.php
中的toArray()
.我们同样寻找可以触发__call()
的代码
此处主要是$relation->visible($name)
会触发__call
.选择这一处的代码是因为,relation来自$this->getRelation($key)
.$name
来自$this->append
.
看到getRelation
public function getRelation($name = null)
{
if (is_null($name)) {
return $this->relation;
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
return;
}
我们要进入visble
的分支。必须要$relation
为空。所以$this->relation
直接置空即可。
此时$relation
由$relation = $this->getAttr($key);
决定。它会依次调用\thinkphp\library\think\model\concern\Attribute.php 的getAttr()与getData()
public function getAttr($name, &$item = null)
{
try {
$notFound = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
......
public function getData($name = null)
{
if (is_null($name)) {
return $this->data;
} elseif (array_key_exists($name, $this->data)) {
return $this->data[$name];
} elseif (array_key_exists($name, $this->relation)) {
return $this->relation[$name];
}
throw new InvalidArgumentException('property not exists:' . static::class . '->' . $name);
}
这样就能确认我们的$relation
来自Attribute类的$this->data[$name]
.
现在需要注意的是。我们必须得找到一个既能调用Conversion还能调用Attribute属性的类。即继承了Attribute类和Conversion类的子类。这个其实就是我们之前5.0链子中用过的Model.php。
加上Model是抽象类。所以编写exp中使用它的子类Pivot实例化。这点不必多说。
接下来看$relation->visible($name)
中的$name
.它是遍历$this->append
得到的。可控。只需注意将其赋值为数组即可。(因为要进入if (is_array($name))
的分支)
既然已经拥有触发__call
的条件了。我们现在找一个可用的__call
。5.1版本中的gadget就是来自Request类的__call
。
public function __call($method, $args)
{
if (array_key_exists($method, $this->hook)) {
array_unshift($args, $this);
return call_user_func_array($this->hook[$method], $args);
}
throw new Exception('method not exists:' . static::class . '->' . $method);
}
显然$this->hook
可以让我们控制为["visable"->"arbitrary method"]
这个数组任意调用方法。但是注意array_unshift($args, $this)
会强行把$this
放到$args
数组的第一位。其后果是怎样的呢?我们看下call_user_func_array
call_user_func_array([$obj,"arbitrary method"],[$this,$arg])
=>
$obj->$func($this,$argv)
这种方法执行几乎没有。所以这就限制我们要找一个不受这种调用方式影响的函数。
在以前tp5的漏洞分析中,曾经用到过think\Request
类中的input 方法。里面有call_user_func($filter,$data)
可以用于命令执行。
但是前面说过, $args
数组变量的第一个元素,是一个固定死的类对象,所以这里我们不能直接调用 input 方法,而应该寻找调用 input 的方法。
整个Request类中一共有7处调用input方法的其他方法。我们选择param
方法为例
public function param($name = '', $default = null, $filter = '')
{
if (!$this->mergeParam) {
$method = $this->method(true);
// 自动获取请求变量
switch ($method) {
case 'POST':
$vars = $this->post(false);
break;
case 'PUT':
case 'DELETE':
case 'PATCH':
$vars = $this->put(false);
break;
default:
$vars = [];
}
// 当前请求参数和URL地址中的参数合并
$this->param = array_merge($this->param, $this->get(false), $vars, $this->route(false));
$this->mergeParam = true;
}
if (true === $name) {
// 获取包含文件上传信息的数组
$file = $this->file();
$data = is_array($file) ? array_merge($this->param, $file) : $this->param;
return $this->input($data, '', $default, $filter);
}
return $this->input($this->param, $name, $default, $filter);
}
调用了input
方法但是只有一个$param
是可控的。所以还要继续找调用param
的方法。
public function isAjax($ajax = false)
{
$value = $this->server('HTTP_X_REQUESTED_WITH');
$result = 'xmlhttprequest' == strtolower($value) ? true : false;
if (true === $ajax) {
return $result;
}
$result = $this->param($this->config['var_ajax']) ? true : $result;
$this->mergeParam = false;
return $result;
}
isAjax方法返回值由$this->config['var_ajax']
控制。那么等于控制了param的参数$name
.等于控制了input 的参数$name
.
最后再来到input方法这看调用。
public function input($data = [], $name = '', $default = null, $filter = '')
{
if (false === $name) {
// 获取原始数据
return $data;
}
$name = (string) $name;
if ('' != $name) {
// 解析name
if (strpos($name, '/')) {
list($name, $type) = explode('/', $name);
}
$data = $this->getData($data, $name);
if (is_null($data)) {
return $default;
}
if (is_object($data)) {
return $data;
}
}
// 解析过滤器
$filter = $this->getFilter($filter, $default);
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
if (version_compare(PHP_VERSION, '7.1.0', '<')) {
// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针
$this->arrayReset($data);
}
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
getData顺着看一下可控。且$data
=$data[$name]
,$filter
来自$this->filter
.最后到了array_walk_resursive相当于直接对数组每一个值调用了回调函数$this->filterValue($filter)
。
if (is_array($data)) {
array_walk_recursive($data, [$this, 'filterValue'], $filter);
$this->filterValue
是通过call_user_func执行的自然不必说了。
既然如此。控制$this->filter
为system,$data
数组第一个值为命令whoami
之类的就可以执行命令了。
至此pop链就完整了。其实中间有个步骤就是解决call_user_func_array
那找到不受定死参数影响的命令执行这块,主要思路就是利用thinkphp过滤器,覆盖filter的方法去执行代码。
而在找到input作为主要下手点时,call_user_func_array(array(任意类,任意方法),$args)
中 $args
数组的第一个变量,即我们前面说的一个固定死的类对象会作为 $data 传给 input 方法,那么在强转成字符串的时候,框架就会报错退出。所以我们找不到就继续找上层调用input的函数。直到找到可控参数的函数isAjax
.就能解决参数不可控的问题。
tp5.2.x-unserialize
5.2版本的链子貌似跟之前没啥区别。但是我composer一直安装不上。加上5.2版本作为dev版本本身出现的不多,所以这里用thinkphp-vuln里的例子简单提一下。
前面入手点大同小异。唯一有区别的地方在触发__call
的代码$relation->visible($name)
这。看似tp5.2已经把这句代码删了。但是实际上是被转移到了appendAttrToArray
这个方法中。因此基本没有区别。我就不跟了。
放上几张图
真正的执行点在下面的$closure($value,$this->data)
这里的动态调用。参数均可控。所以赋值命令执行的参数即可。
因为后面部分具体细节跟6.0一样。所以我会把内容在6.0里提一下。
tp6.0.x-unserialize
今天重新看了下之前在 php框架反序列化练习 文章里的内容。才想起来5.2跟6.0的链子应该是跟过了。不过当时没有动态调试,理解也没那么深刻。所以还是再看一下。
还是老样子更改Index.php
<?php
namespace app\controller;
class Index
{
public function index()
{
$u = unserialize($_GET['c']);
return 'ThinkPHP V6.x';
}
}
直接用wh1t3p1g 师傅集成好的。顺带也推荐下师傅在安全客上针对thinkphp链子的分析文章。
首先6.0版本的主要问题是前面5.*版本的利用起点Windows类都没了。也就是少了一个__destruct()
。那么我们需要找到一个替代的__destruct
作为起点。并且最好它能够在中间某个环节起到与其他链子相同作用比如触发__call
,__toString
之类的。这样的逻辑也是第5空间laravel那题的解题思路吧。因为跟过链子的人都知道只需要两个类就能rce.既然其中一个destruct被处理了。找一个替代的自然是最简单的办法。
vendor/topthink/think-orm/src/Model.php
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
构造lazySave为真值。进入save函数
public function save(array $data = [], string $sequence = null): bool
{
// 数据对象赋值
$this->setAttrs($data);
if ($this->isEmpty() || false === $this->trigger('BeforeWrite')) {
return false;
}
$result = $this->exists ? $this->updateData() : $this->insertData($sequence);
if (false === $result) {
return false;
}
// 写入回调
$this->trigger('AfterWrite');
// 重新记录原始数据
$this->origin = $this->data;
$this->set = [];
$this->lazySave = false;
return true;
}
这里关键函数是updateData
.不过既然如此我们不能进入上面那个if分支。
isEmpty与trigger
public function isEmpty(): bool
{
return empty($this->data);
}
protected function trigger(string $event): bool
{
if (!$this->withEvent) {
return true;
}
......
显然。需要
1.$this->data
为非空数组。
2.$this->withEvent
为false
3.$this->exists
为true进入updateData函数
跟进到updateData后。我们不妨先看下哪一个函数可以利用,再回头考虑参数的构造。这里顺着看到checkAllowFields后
$table = $this->table ? $this->table . $this->suffix : $query->getTable();
存在可控变量的拼接。那么我们就可以触发__toString
了。在经历了前面几个版本的反序列化构造后,我们当然清楚tp5.1~5.2版本的链子分别是__destruct()=> __toString() => __call() => call_user_func_array / $closure($value,$this->data)
的一系列调用。那么此处我们自然可以继续达成__toString来延续链子。
回头再检查updateData这的参数需要。首先第一个trigger我们已经满足条件了。然后if (empty($data))
这个分支不能进入。那就要看向getChangedData
public function getChangedData(): array
{
$data = $this->force ? $this->data : array_udiff_assoc($this->data, $this->origin, function ($a, $b) {
if ((empty($a) || empty($b)) && $a !== $b) {
return 1;
}
return is_object($a) || $a != $b ? 1 : 0;
});
// 只读字段不允许更新
foreach ($this->readonly as $key => $field) {
if (isset($data[$field])) {
unset($data[$field]);
}
}
return $data;
}
令$this->force
为true.然后data就可控了。
接下来是回到利用函数checkAllowFields.拼接处之前的代码
if (empty($this->field)) {
if (!empty($this->schema)) {
$this->field = array_keys(array_merge($this->schema, $this->jsonType));
} else {
$query = $this->db();
需要
1.$this->field
为空进入分支
2.$this->schema
为空进入else
看一眼db()
public function db($scope = []): Query
{
/** @var Query $query */
$query = self::$db->connect($this->connection)
->name($this->name . $this->suffix)
->pk($this->pk);
原来db()函数这里也有一个变量拼接......不过殊途同归。我们用哪一个都差不多。例如exp中链子是把$this->suffix
作为触发的对象的。
然后后面就是一路畅通了。这里跟5.1(注意不是5.0,5.0 的 toString用的是model类的)一样用的是Conversion类里的__toString() => toJson => toArray => getAttr => getValue()
我们主要在getAttr,getValue里构造
public function getAttr(string $name)
{
try {
$relation = false;
$value = $this->getData($name);
} catch (InvalidArgumentException $e) {
$relation = $this->isRelationAttr($name);
$value = null;
}
return $this->getValue($name, $value, $relation);
}
public function getData(string $name = null)//$name='wh1t3p1g'
{
if (is_null($name)) {
return $this->data;
}
$fieldName = $this->getRealFieldName($name);
if (array_key_exists($fieldName, $this->data)) {//$this->data = array("wh1t3p1g"=>"whoami");
return $this->data[$fieldName];//返回'whoami',回到getAttr
} elseif (array_key_exists($fieldName, $this->relation)) {
return $this->relation[$fieldName];
}
protected function getValue(string $name, $value, bool $relation = false)
{ //$name='wh1t3p1g' $value=‘ls’ $relation=false
// 检测属性获取器
$fieldName = $this->getRealFieldName($name); //该函数默认返回$name='wh1t3p1g'=$fieldName
$method = 'get' . App::parseName($name, 1) . 'Attr'; //拼接字符:getlinAttr
if (isset($this->withAttr[$fieldName])) { //['wh1t3p1g'=>'system']
if ($relation) { //$relation=false
$value = $this->getRelationValue($name);
}
$closure = $this->withAttr[$fieldName]; //$closure='system'
$value = $closure($value, $this->data);//system('whoami',$this->data
}
.......
return $value;
}
至此完成整条利用链。注意它的调用方法是system("whoami", ["wh1t3p1g"=>"whoami"])
.这是一种合法调用。
小结下。tp所有系列反序列化链就这么多了。大体上思路都是一样的。只有5.0版本是较为复杂的写文件。其他版本都可以直接rce.魔术方法也基本都是destruct,toString,call
调用。其中5.2,6.0是没有call的必要的。