Druid SQL注入防御模块技术浅析
官方参考:
前置知识
什么是Druid?
Druid是一个高效的数据查询系统,主要解决的是对于大量的基于时序的数据进行聚合查询。数据可以实时摄入,进入到Druid后立即可查,同时数据是几乎是不可变。通常是基于时序的事实事件,事实发生后进入Druid,外部系统就可以对该事实进行查询。
源码架构
这里解释一下druid从解析到判断sql语句的注入攻击性的代码检测流程:
1.词法解析(lexer类)
druid\src\main\java\com\alibaba\druid\sql\parser\sqlparser.java
druid\src\main\java\com\alibaba\druid\sql\parser\lexer.java
druid\src\main\java\com\alibaba\druid\sql\parser\token.java
druid\src\main\java\com\alibaba\druid\sql\parser\token.java
public enum token
{
select("select"),
delete("delete"),
insert("insert"),
update("update"),
from("from"),
having("having"),
where("where"),
order("order"),
......
这一步负责把整个sql字符串进行"词法解析(注意和语法解析区分)",即把一个完整的sql语句进行切分,拆分成一个个单独的sql token,即解析成"词法树"。
设置是否忽略注释,之后是通过大量的if判断对sql的token进行识别。
2.语法分析
语法解析器:在sql parser词法解析的基础上,对词法树(tokens)中的token节点进行语义识别(sql语义),将其解析成一个符合sql语法的规范化结构语法树。druid\src\main\java\com\alibaba\druid\sql\parser\sqlstatementparser.java
for (;;)
{
if (max != -1)
{
if (statementlist.size() >= max)
{
return;
}
}
if (lexer.token() == token.eof)
{
return;
}
if (lexer.token() == (token.semi))
{
lexer.nexttoken();
continue;
}
if (lexer.token() == token.select)
{
statementlist.add(parseselect());
continue;
}
... ...
因为SQL是结构化语言,所以通过递归遍历,以及又是一大段if判断,一层层解析出一个个子节点作为“特征单元”添加到语法树中,最后生成一个statementlist列表。
比如输入:
select name,pwd from admin where id=1 and 1=1;
最后生成的statementlist,每个list元素被打上不同的标签做不同的检测:
select name, pwd
from admin
where id = 1
and 1 = 1
lasttoken: eof
最终字符串被解析为一个有不同的“特征单元”组成的多层次语法树。
3.注入检测
程序会自动根据当前sqlstatement节点的节点类型,判断数据库类型,再去调用相应的类。后文所说的sql注入检测的规则,就是在这些类中抽象出的对象里体现出来的。
如果我们的规则匹配成功,即在用户输入的sql语句中检测到了注入攻击的行为,则调用addviolation()添加检测结果信息,报错的同时写入日志。
private static void addviolation(wallvisitor visitor, int errorcode, string message, sqlobject x)
{
visitor.addviolation(new illegalsqlobjectviolation(errorcode, message, visitor.tosql(x)));
}
检测思路
1.检测规则
上文的语义分析部分已经解释了SQL语句的解析方法,下面就是Druid根据解析出来的“特征单元”和语法树,编写不同的检测逻辑,针对不同的数据库进行的不同注入方式进行的针对性防护。
例子如下:
druid\src\main\java\com\alibaba\druid\wall\spi\wallvisitorutils.java
-
此处为针对char()+char()...+char()的绕过检测
-
此条规则说明:只要sql内容中包含超过四条char或者chr,则告警
if (groupList.size() >= 4) {
int chrCount = 0;
for (int i = 0; i < groupList.size(); ++i) {
SQLExpr item = groupList.get(i);
if (item instanceof SQLMethodInvokeExpr) {
SQLMethodInvokeExpr methodExpr = (SQLMethodInvokeExpr) item;
String methodName = methodExpr.getMethodName().toLowerCase();
//判断调用了char()方法
if ("chr".equals(methodName) || "char".equals(methodName)) {
if (methodExpr.getArguments().get(0) instanceof SQLLiteralExpr) {
chrCount++;
}
/*
* 此处为针对char()+char()...+char()的绕过检测
* 此条规则说明:只要sql内容中包含超过四条char或者chr,则告警
*/
}
/*
*对char函数内参数长度超过5的内容加白
*/
} else if (item instanceof SQLCharExpr) {
if (((SQLCharExpr) item).getText().length() > 5) {
chrCount = 0;
continue;
}
}
if (chrCount >= 4) {
addViolation(visitor, ErrorCode.EVIL_CONCAT, "evil concat", x);
break;
}
}
检测逻辑
- 只允许基本的crud命令(增删改查)
druid\src\main\java\com\alibaba\druid\wall\WallConfig.java
noneBaseStatementAllow参数绝对了是否允许非基本语句的其他语句,缺省关闭,通过这个选项就能够屏蔽诸如CREATE、DROP、ALERT等可能存在严重危害的DDL语言。
默认值为false,是最严格的过滤格式,基本不可行,现在正常的企业业务几乎不存在完全屏蔽crud之外所有命令还能正常运行的,开启之后会严重损害SQL的灵活性。
2.禁止访问系统级表
出于权限控制的需要,Druid对于系统表的操作进行了详细的限制,给予用户充分的自定义空间。举例:
select * from information_schema.columns;
该操作不存在注入点,只是对系统表进行简单查询,所以是被允许的。
但是如果是:
select id from admin where id = 1 and 5 = 6 union select concat(id, name, score) from (select column_name from information_schema.columns where table_name = class1)
因为SQL在子语句中使用了union进行了concat拼接,拼接之后连接了系统表进行查询。Druid在sql parser解析后,判断information_schema在层次中的位置,如果它的父节点为SQL表达式(select等)、左节点为"from",就会满足子句拼接的条件,从而被认为具有攻击性。
判断拼接的Druid代码位于druid\src\main\java\com\alibaba\druid\wall\spi\wallvisitorutils.java
代码中的owner参数由配置文件确定,可以自行修改,以mysql为例,位于druid\src\main\resources\META-INF\druid\wall\mysql\deny-schema.txt
3.禁止访问系统变量
Druid同样也是通过配置策略的方式限制用户对于系统敏感变量的访问,代码与系统表的限制类似,正常的针对version、basedir的查询不会报错,但是:
select * from database where id='1' and len(@@version)>0 and '1'='1'
上文的语句中使用逻辑表达式,尝试探测版本信息。因为@@version的内容在where或having之后,所以会被禁止。判断代码Druid\src\main\java\com\alibaba\druid\wall\spi\wallvisitorutils.java
Druid使用黑名单限制了对敏感的系统变量的访问,具体内容直接被写在配置文件Druid\src\main\resources\META-INF\druid\wall\mysql\deny-variant.txt中:
4.禁止访问系统函数
和系统敏感的表、变量一样,Druid冶金用了诸如sleep等危险的系统函数的使用,最新的Druid在mysql中摒弃了黑名单的做法,采用白名单的方式限制函数的使用,其他数据库仍旧使用黑名单。
而且在判断使用危险系统函数的时候,和上文一样,Druid会判断敏感函数在sql语句中出现的位置:
select load_file('\\etc\\passwd');
不会被禁止,原因也是一样,不存在注入点。
select * from ((select sleep(0))a);
会被禁止,因为显而易见的sleep函数出现在了可能存在注入点的位置(from的子节点)。
Druid\src\main\resources\META-INF\druid\wall\mysql\permit-function.txt
Druid\src\main\java\com\alibaba\druid\wall\spi\WallVisitorUtils.java
5.禁止出现注释
通常的业务SQl语句不会带有注释,而在SQL注入中类似的行为却很常见,Druid默认模式下,会在SQL parser解析之前,先消除语句中的单行和多行注释内容。
诸如'//or//'1'='2等常见绕waf手段都是利用了SQL的快注释符。
删除注释,并重新拼接为“合规”的sql语句的代码,位于
Druid\src\main\java\com\alibaba\druid\sql\parser\lexer.java
public final void nexttoken()
{
... ...
/*
解析'#'注释符
判断'#'解析出的节点是'单行注释'、或'多行注释'
*/
case '#':
scanSharp();
if ((token == Token.LINE_COMMENT || token == Token.MULTI_LINE_COMMENT) && skipComment) {
bufPos = 0;
continue;
}
return;
以“#”为例,首先判断#号的注释符,然后判断如果是单行或者多行注释。
这是一种对业务低伤害的防护方式,因为业务人员如果是正常使用sql的注释功能,删除之后正常进入解析器,不会对语句正常执行造成任何影响,而如果是恶意的SQL注入行为,则会报错告警。
6.禁止同时执行多条SQL语句
Druid默认每次只允许执行一条SQL,一次执行多条会被认为疑似是恶意SQL注入语句。
7.禁止永真条件
利用永真条件判断是否存在注入点是sql注入攻击最常用的手段。Druid对where、order by和group by节点之后的两个及以上永真条件进行过滤。
因为单纯的永真语句普遍存在于业务代码中,比如
$sql = "select info from admin where ID = $id";
其中$id为可控输入,如果输入为1,在数据库层就会变成永真条件。因此Druid目前的规则允许语句子句之后最多只存在一个永真逻辑表达式。
where id =-1 or 1=1;
之类的都会被拦截。
private static Object getValue_and(WallVisitor visitor, List<SQLExpr> groupList) {
int dalConst = 0;
Boolean allTrue = Boolean.TRUE;
for (int i = groupList.size() - 1; i >= 0; --i) {
SQLExpr item = groupList.get(i);
Object result = getValue(visitor, item);
Boolean booleanVal = SQLEvalVisitorUtils.castToBoolean(result);
if (Boolean.TRUE == booleanVal) {
final WallConditionContext wallContext = WallVisitorUtils.getWallConditionContext();
if (wallContext != null && !isFirst(item)) {
wallContext.setPartAlwayTrue(true);
}
dalConst++;
} else if (Boolean.FALSE == booleanVal) {
final WallConditionContext wallContext = WallVisitorUtils.getWallConditionContext();
if (wallContext != null && !isFirst(item)) {
wallContext.setPartAlwayFalse(true);
}
allTrue = Boolean.FALSE;
dalConst++;
} else {
if (allTrue != Boolean.FALSE) {
allTrue = null;
}
dalConst = 0;
}
if (dalConst == 2 && visitor != null && !visitor.getConfig().isConditionDoubleConstAllow()) {
addViolation(visitor, ErrorCode.DOUBLE_CONST_CONDITION, "double const condition", item);
}
}
8.禁止 getshell
into outfile是常用的利用注入点进行文件写入,从而getshell 的技术。
同样,druid的拦截是智能的,它只对真正的注入进行拦截,而正常的语句,例如:
记录每个用户的登录ip,写入文件中:
select "127.0.0.1" into outfile 'c:\index.php'; -- 允许
而攻击者常用的攻击语句(写入编码后的一句话)
select id from messages where id=?id=3 union select 1,0x3c3f706870206576616c28245f524551554553545b315d293b3f3e,3 into outfile 'C:\\Users\\Administrator.WIN2012\\Desktop\\phpStudy\\WWW\\outfile.php' --+
这个语句会被拦截下来
9.SQL盲注防御
盲注手法千千万万,也是防御模块最复杂的一部分,这里举几个例子来对防御方式进行说明:
0xa 盲注
- order by
select * from cnp_news where id='23' order by if((len(@@version)>0),1,0);
利用盲注思想来进行注入,获取敏感信息
- group by
select * from cnp_news where id='23' group by (select @@version);
利用数据库的错误信息报错来进行注入,获取敏感信息
- having
select * from users where id=1 having 1=(nullif(ascii((substring(user,1,1))),0));
利用数据库的错误信息进行列名的盲注、
druid\src\main\java\com\alibaba\druid\wall\spi\wallvisitorutils.java
/*
having
如果having条件出现了永真,则认为正处于被攻击状态。例如:
select f1, count(*) from t group by f1 having 1 = 1
*/
if (boolean.true == getconditionvalue(visitor, x, visitor.getconfig().isselecthavingalwaytruecheck()))
{
if (!issimpleconstexpr(x))
{
addviolation(visitor, errorcode.alway_true, "having alway true condition not allow", x);
}
}