前言
thinkphp5.0.9的一个比较鸡肋的SQL注入。主要还是借以了解tp中报错注入的原理。
创建:
composer create-project topthink/think=5.0.9 thinkphp5.0.9
"require": {
"php": ">=5.4.0",
"topthink/framework": "5.0.9"
},
composer update
index控制器这样写:
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$id = input("id/a");
$data = db("users")->where("id","in",$id)->select();
dump($data);
}
}
还是用的sqli-labs的数据库:
分析
payload:
?id[0,updatexml(0,concat(0x7e,user(),0x7e),0)]=1
不过还是先正常的走一遍流程,?id=1
input方法没啥好说的,直接进入where方法,
这里就不具体分析了,where之前分析过很多次了,这里基本上就是处理$this->options['where']
。
再进入select方法,还是老样子,看一下SQL语句的生成:
public function select($options = [])
{
$sql = str_replace(
['%TABLE%', '%DISTINCT%', '%FIELD%', '%JOIN%', '%WHERE%', '%GROUP%', '%HAVING%', '%ORDER%', '%LIMIT%', '%UNION%', '%LOCK%', '%COMMENT%', '%FORCE%'],
[
$this->parseTable($options['table'], $options),
$this->parseDistinct($options['distinct']),
$this->parseField($options['field'], $options),
$this->parseJoin($options['join'], $options),
$this->parseWhere($options['where'], $options),
$this->parseGroup($options['group']),
$this->parseHaving($options['having']),
$this->parseOrder($options['order'], $options),
$this->parseLimit($options['limit']),
$this->parseUnion($options['union']),
$this->parseLock($options['lock']),
$this->parseComment($options['comment']),
$this->parseForce($options['force']),
], $this->selectSql);
return $sql;
}
还是熟悉的替换,进入parseWhere()方法:
/**
* where分析
* @access protected
* @param mixed $where 查询条件
* @param array $options 查询参数
* @return string
*/
protected function parseWhere($where, $options)
{
$whereStr = $this->buildWhere($where, $options);
if (!empty($options['soft_delete'])) {
// 附加软删除条件
list($field, $condition) = $options['soft_delete'];
$binds = $this->query->getFieldsBind($options);
$whereStr = $whereStr ? '( ' . $whereStr . ' ) AND ' : '';
$whereStr = $whereStr . $this->parseWhereItem($field, $condition, '', $options, $binds);
}
return empty($whereStr) ? '' : ' WHERE ' . $whereStr;
}
where语句在buildWhere
中生成,然后在前面拼接上"WHERE"这个字符串。跟进buildWhere()
方法:
/**
* 生成查询条件SQL
* @access public
* @param mixed $where
* @param array $options
* @return string
*/
public function buildWhere($where, $options)
{
if (empty($where)) {
$where = [];
}
if ($where instanceof Query) {
return $this->buildWhere($where->getOptions('where'), $options);
}
$whereStr = '';
$binds = $this->query->getFieldsBind($options);
foreach ($where as $key => $val) {
$str = [];
foreach ($val as $field => $value) {
if ($value instanceof \Closure) {
// 使用闭包查询
$query = new Query($this->connection);
call_user_func_array($value, [ & $query]);
$whereClause = $this->buildWhere($query->getOptions('where'), $options);
if (!empty($whereClause)) {
$str[] = ' ' . $key . ' ( ' . $whereClause . ' )';
}
} elseif (strpos($field, '|')) {
// 不同字段使用相同查询条件(OR)
$array = explode('|', $field);
$item = [];
foreach ($array as $k) {
$item[] = $this->parseWhereItem($k, $value, '', $options, $binds);
}
$str[] = ' ' . $key . ' ( ' . implode(' OR ', $item) . ' )';
} elseif (strpos($field, '&')) {
// 不同字段使用相同查询条件(AND)
$array = explode('&', $field);
$item = [];
foreach ($array as $k) {
$item[] = $this->parseWhereItem($k, $value, '', $options, $binds);
}
$str[] = ' ' . $key . ' ( ' . implode(' AND ', $item) . ' )';
} else {
// 对字段使用表达式查询
$field = is_string($field) ? $field : '';
$str[] = ' ' . $key . ' ' . $this->parseWhereItem($field, $value, $key, $options, $binds);
}
}
$whereStr .= empty($whereStr) ? substr(implode(' ', $str), strlen($key) + 1) : implode(' ', $str);
}
return $whereStr;
}
前面的if都进不去,最后是执行这里:
$field = is_string($field) ? $field : '';
$str[] = ' ' . $key . ' ' . $this->parseWhereItem($field, $value, $key, $options, $binds);
进行了字符串的拼接,$key
是"AND",但是注意到了最后的substr还会把这个前面的"AND"给去掉。跟进一下parseWhereItem()方法,一路上一些琐碎的操作就不说了,主要是这里:
} elseif (in_array($exp, ['NOT IN', 'IN'])) {
// IN 查询
if ($value instanceof \Closure) {
$whereStr .= $key . ' ' . $exp . ' ' . $this->parseClosure($value);
} else {
$value = is_array($value) ? $value : explode(',', $value);
if (array_key_exists($field, $binds)) {
$bind = [];
$array = [];
foreach ($value as $k => $v) {
if ($this->query->isBind($bindName . '_in_' . $k)) {
$bindKey = $bindName . '_in_' . uniqid() . '_' . $k;
} else {
$bindKey = $bindName . '_in_' . $k;
}
$bind[$bindKey] = [$v, $bindType];
$array[] = ':' . $bindKey;
}
$this->query->bind($bind);
$zone = implode(',', $array);
} else {
$zone = implode(',', $this->parseValue($value, $field));
}
$whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
}
}
首先$exp
是in,满足条件进入if。然后foreach遍历$value
,然后拼接出一个$bindKey
。$bindName
是where_id
,$bindKey
是where_id_in_0
这部分的操作基本就是这些:
$bindKey = $bindName . '_in_' . $k;
$array[] = ':' . $bindKey;
$zone = implode(',', $array);
$whereStr .= $key . ' ' . $exp . ' (' . (empty($zone) ? "''" : $zone) . ')';
这里$key
是0,因为传入的id被处理成数组了,0是键。然后就相当于直接拼接到了预编译参数的后面,产生这样的$whereStr
:
`id` IN (:where_id_in_0)
SQL语句如下:
SELECT * FROM `users` WHERE `id` IN (:where_id_in_0)
因此问题就很明显了,就是:where_id_in_
的后面,直接拼接我们传入的id的键,并没有进行过滤,因此构造:
?id[0,updatexml(1,concat(0x7e,database(),0x7e),1)]=1
但是复现这个漏洞的关键不在这里。产生SQL语句之后继续跟进,在这里进行SQL语句的执行:
再不断跟进,进入到最终的query方法:
public function query($sql, $bind = [], $master = false, $pdo = false)
{
$this->initConnect($master);
if (!$this->linkID) {
return false;
}
// 记录SQL语句
$this->queryStr = $sql;
if ($bind) {
$this->bind = $bind;
}
// 释放前次的查询结果
if (!empty($this->PDOStatement)) {
$this->free();
}
Db::$queryTimes++;
try {
// 调试开始
$this->debug(true);
// 预处理
if (empty($this->PDOStatement)) {
$this->PDOStatement = $this->linkID->prepare($sql);
}
// 是否为存储过程调用
$procedure = in_array(strtolower(substr(trim($sql), 0, 4)), ['call', 'exec']);
// 参数绑定
if ($procedure) {
$this->bindParam($bind);
} else {
$this->bindValue($bind);
}
// 执行查询
$this->PDOStatement->execute();
// 调试结束
$this->debug(false);
// 返回结果集
return $this->getResult($pdo, $procedure);
} catch (\PDOException $e) {
if ($this->isBreak($e)) {
return $this->close()->query($sql, $bind, $master, $pdo);
}
throw new PDOException($e, $this->config, $this->getLastsql());
} catch (\ErrorException $e) {
if ($this->isBreak($e)) {
return $this->close()->query($sql, $bind, $master, $pdo);
}
throw $e;
}
}
比较熟悉的,之前都分析过很多次了,关键在这里:
执行到这里的时候,就会抛出异常了,只是停留在了prepare这里。
预编译有三步:
prepare($SQL)
编译SQL语句bindValue($param, $value)
将value绑定到param的位置上execute()
执行
我本来理解的是SQL语句是执行应该是在第三步,在执行的时候报错,但实际上只执行到prepare的时候就抛出了异常,这样SQL语句应该是没有执行的,怎么会出现报错注入呢?
下面参考了p神的文章,放一下p神的例子:
<?php
$params = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
];
$db = new PDO('mysql:dbname=security;host=127.0.0.1;', 'root', 'root', $params);
try {
$link = $db->prepare('SELECT * FROM users WHERE id in (:where_id, updatexml(0,concat(0x7e,user(),0x7e),0))');
} catch (\PDOException $e) {
var_dump($e);
}
虽然抛出了异常,但是仍然成功执行了SQL语句,var_dump出了结果。
原因就是这个:
$params = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
];
将ATTR_EMULATE_PREPARES
设置成了false。
官方文档是这样说的:
这个选项涉及到PDO的“预处理”机制:因为不是所有数据库驱动都支持SQL预编译,所以PDO存在“模拟预处理机制”。如果说开启了模拟预处理,那么PDO内部会模拟参数绑定的过程,SQL语句是在最后execute()的时候才发送给数据库执行;如果我这里设置了PDO::ATTR_EMULATE_PREPARES => false,那么PDO不会模拟预处理,参数化绑定的整个过程都是和Mysql交互进行的。
非模拟预处理的情况下,参数化绑定过程分两步:第一步是prepare阶段,发送带有占位符的sql语句到mysql服务器(parsing->resolution),第二步是多次发送占位符参数给mysql服务器进行执行(多次执行optimization->execution)。
这时,假设在第一步执行prepare($SQL)的时候我的SQL语句就出现错误了,那么就会直接由mysql那边抛出异常,不会再执行第二步。
全局查找一下,发现thinkphp5的默认设置确实是false:
因此在第一步prepare的时候就会与mysql进行交互,当时报错注入。
但是这样的SQL注入鸡肋就鸡肋在只能报错出user(),database()这样的,子查询都不行:
但是,如果你将user()改成一个子查询语句,那么结果又会爆出Invalid parameter number: parameter was not defined的错误。因为没有过多研究,说一下我猜测:预编译的确是mysql服务端进行的,但是预编译的过程是不接触数据的 ,也就是说不会从表中将真实数据取出来,所以使用子查询的情况下不会触发报错;虽然预编译的过程不接触数据,但类似user()这样的数据库函数的值还是将会编译进SQL语句,所以这里执行并爆了出来。
所以这种SQL注入略显鸡肋,不过还是学习了一下思路,以及PDO的这个ATTR_EMULATE_PREPARES的问题。
修复
取消了对键的处理,改成对值得处理,而且拼接用得时$i
这个计数,最后拼接得预处理也只能是:where_id_in_1
,:where_id_in_1
这样的了。