我这边之前也利用这个库自己实现了一个代码审计工具...我感觉如果是在SDL中倒是个不错的方案,目前用起来其实也挺爽的,我已经做了流跟踪。目前没有做santilizer,另外当时偷懒没有处理内部类、this情况,现在也懒得动代码了。还有就是处理spring-boot thymeleaf ssti等不符合污点传播的特殊情况要另外写代码来处理.,也是蛋疼..
我处理mybatis是拿的原生的mybatis代码来处理,毕竟需要考虑到include等标签的处理也很麻烦
回顾
笔者是大四学生,初涉安全的萌新,如果文章有错误之处还请大佬指出!
在浅谈编写Java代码审计工具(1)入门案例中,和大家介绍了JavaParser
项目和一个最基本的Servlet型XSS的审计。本文和大家说一说几种简单的SQL注入审计的编写思路,最终得出AST的局限性原因以及解决方案
介绍
JDBC
从最简单的JDBC原生SQL注入来看,怎样的语句是存在注入的?
1.不使用prepareStatement
而使用createStatement
- 调用了
executeQuery
或executeUpdate
方法 - SQL语句进行了拼接操作
- 拼接的地方应该是String类型
给出以下三个案例:
1.直接在方法内拼接
public void query(String input) throws SQLException {
Statement stmt = con.createStatement();
ResultSet rs = stmt.executeQuery("select * from Users where name = '" + input + "'");
}
2.新建一个SQL变量拼接赋值并传入
public void query(String input) throws SQLException {
Statement stmt = con.createStatement();
String sql = "select * from Users where name = '" + input + "'";
ResultSet rs = stmt.executeQuery(sql);
}
3.用String.format
参数进行格式化
public void query(String input) throws SQLException {
Statement stmt = con.createStatement();
ResultSet rs = stmt.executeQuery(String.format("select * from Users where name = '%s'", input));
}
其实还应该有多种情况,但其他方式解析方式类似
问题不在于如何解析,而在于考虑多种情况必然出现疏漏,无法完整地覆盖
比如有两个问题待解决,这里就是局限性:
- 传入的如果不是String,而是DTO包装类,在拼接的时候get属性如何处理
- 在拼接的时候如果调用了其他Util,应该如何处理
除了JDBC原生,注意到SpringJdbcTemplate
和JPA
也存在问题,但审计和分析原理大同小异。同样存在局限性:无法考虑到所有的编写情况
// SpringJdbcTemplate
JdbcOperations jdbcTemplate;
public void query1(String input) throws DataAccessException {
jdbcTemplate.execute("select * from Users where name = '"+input+"'");
}
// JPA
public void getUserByUsername(String username) {
TypedQuery<UserEntity> q = em.createQuery(
String.format("select * from Users where name = %s", username),
UserEntity.class);
UserEntity res = q.getSingleResult();
}
Mybatis Annotation
Mybatis框架是Java开发常用的框架,这里先看注解形的审计规则
- 某类是接口类型(interface)并且有注解
Mapper
- 参数必须有
Param
注解并且类型要求是String
- 方法注解必须是
Select
等,并且value内包含了${}
(其实Mybatis这里是一个值得讨论的点,并不是说有了$一定存在注入,也不是说有#一定安全。存在一些复杂的问题,但目前先粗略地认为只要有$那么就是有漏洞的,可以参考大佬文章MyBatis 和 SQL 注入的恩恩怨怨)
局限性:
- 如果传入的是一个包装类,然后
#{}
和${}
取的是类属性如何处理 - 如何从controller->service->mapper这一个流程进行追踪
@Mapper
public interface CategoryMapper {
@Select("select * from category_ where name= '${name}' ")
public CategoryM getByName(@Param("name") String name);
}
Mybatis XML
使用注解方式的Mybatis是最常见的手段,原理类似上文,对${}
做检查,简单的规则可以总结如下:
- 解析XML找到
mapper
标签下的select
等标签 - 如果
select
标签内容匹配到${}
认为存在漏洞
问题以及局限性:
- 如果传入的是包装类
- mapper标签其实并不是必须,因为Spring可以配置扫描包路径
- mybatis的xml支持多种标签,比如if,where,when等,
${}
是有可能在这些标签里的(从实践来看,不少的后端开发程序员并不喜欢这些标签,更喜欢自己手写SQL语句)
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.emyiqing.mapper.CategoryMapper">
<select id="getName" resultType="cn.seaii.springboot.pojo.CategoryM">
select * from category_ where id= ${id}
</select>
</mapper>
代码实现
代码实现这里从简单到难,先从Mybatis这两种分析,再到结合具体语法分析的JDBC
Mybatis XML
解析XML
// 使用Java原生库进行XML解析
DocumentBuilder db = dbf.newDocumentBuilder();
Document document = db.parse(is);
// 找到根标签mapper
NodeList mapper = document.getElementsByTagName("mapper");
// 遍历
for (int i = 0; i < mapper.getLength(); i++) {
Node temp = mapper.item(i);
NodeList childNodes = temp.getChildNodes();
for (int k = 0; k < childNodes.getLength(); k++) {
if (childNodes.item(k).getNodeType() == Node.ELEMENT_NODE) {
// 如果mapper下的的标签名是select等
if (childNodes.item(k).getNodeName().toLowerCase(Locale.ROOT).equals("select") ||
childNodes.item(k).getNodeName().toLowerCase(Locale.ROOT).equals("delete") ||
childNodes.item(k).getNodeName().toLowerCase(Locale.ROOT).equals("update") ||
childNodes.item(k).getNodeName().toLowerCase(Locale.ROOT).equals("insert")) {
// 返回标签ID和标签的Value做进一步处理
sql.put(childNodes.item(k).getAttributes().getNamedItem("id").getNodeValue().trim(),
childNodes.item(k).getFirstChild().getNodeValue().trim());
}
}
}
}
分析Value
sqlMap.forEach((key, sql) -> {
// 对value进行${}的正则匹配
String regex = ".*?\\$\\{(.*?)\\}.*?";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(sql);
if (matcher.find()) {
logger.debug("find mybatis xml sql inject");
......
}
});
跑了下
Mybatis Annotation
拿到interface并遍历所有method,对于类注解mapper忽略,因为不是必须
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
// mybatis interface
.filter(ClassOrInterfaceDeclaration::isInterface).forEach(i -> {
// all method
i.getMethods().forEach(m -> {
验证方法的参数注解是否合规,拿到可能存在注入的参数,做进一步分析
Map<String, String> injectParams = new HashMap<>();
m.getParameters().forEach(p -> {
// all parameter
p.getAnnotations().stream()
// must have param annotation
.filter(pa -> pa.getName().asString().equals("Param"))
// all param annotation
.forEach(pa -> {
Parameter parameter = (Parameter) pa.getParentNode().get();
// only string type can inject
if (parameter.getType().asString().equals("String")) {
// 可能存在注入的参数应该做保留,需要下一步结合SQL语句分析
injectParams.put("inject", parameter.getNameAsString());
}
});
});
分析注解内的Value
// all method annotation
m.getAnnotations().forEach(a -> {
// 暂时先考虑Select注解
if (a.getName().asString().equals("Select")) {
// StringLiteralExpr可以简单理解为String
a.findAll(StringLiteralExpr.class).forEach(s -> {
// 类似XML的正则匹配
String regex = ".*?\\$\\{(.*?)\\}.*?";
Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(s.asString());
if (matcher.find()) {
String name = matcher.group(1);
// 如果之前保存的可注入参数正好和${}中的参数相同
// 认为存在mybatis annotation注入
if (name.equals(injectParams.get("inject"))) {
logger.debug("find mybatis sql inject");
}
}
});
}
});
跑了下
JDBC
拿到非接口非抽象类的类对象,遍历所有方法
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
// not interface and not abstract class
.filter(c -> !c.isInterface() && !c.isAbstract()).forEach(c -> {
// all method
c.getMethods().forEach(m -> {
定义了三个map,分别是最终的判断条件,方法内变量,方法参数
// 保存两个存在JDBC SQL注入的条件
// 1.调用了createStatement
// 2.对SQL语句进行了拼接
Map<String, Boolean> condition = new HashMap<>();
// method variables
Map<String, String> methodVar = new HashMap<>();
// method params
Map<String, String> paramVar = new HashMap<>();
对传入的参数进行保存,后续判断会用到
// all parameter
m.getParameters().forEach(p -> {
// save to map
paramVar.put(p.getType().asString(), p.getName().asString());
});
检查方法内变量
// 所有的变量声明表达式,用来保存statement和sql语句
List<VariableDeclarationExpr> vList = m.findAll(VariableDeclarationExpr.class);
for (VariableDeclarationExpr v : vList) {
MethodCallExpr next;
// 拿到初始化方法,如果是一个方法调用,那么对next进行赋值
// Statement stmt = con.createStatement()
// next = con.createStatement()
if (v.getVariables().get(0).getInitializer().get() instanceof MethodCallExpr) {
next = (MethodCallExpr) v.getVariables().get(0).getInitializer().get();
} else {
next = null;
}
// 类似上面的例子,这里保存了Statement->stmt的对应关系
if (v.getVariables().get(0).getType().asString().equals("Statement")) {
methodVar.put("Statement", v.getVariables().get(0).getNameAsString());
}
// 验证是否存在SQL语句的拼接并保存给一个临时变量
// String querySql = "select * from Users where name = '" + input + "'"
if (v.getVariables().get(0).getType().asString().equals("String")) {
// 按照通常情况下大家的命名规范,这个变量应该是包含sql关键字的
// 例如querySql,doSql,insertSql等
if (v.getVariables().get(0).getNameAsString()
.toLowerCase(Locale.ROOT).contains("sql")) {
// 保存了sql->querySql的对应关系
methodVar.put("sql", v.getVariables().get(0).getNameAsString());
// 如果右端是简单的表达式(加减乘除)
if (v.getVariables().get(0).getInitializer().get() instanceof BinaryExpr){
// "select * from Users where name = '" + input + "'"
BinaryExpr be = (BinaryExpr) v.getVariables()
.get(0).getInitializer().get();
// 如果表达式的操作符是+号,认为存在sql语句拼接
if(be.getOperator().asString().equals("+")){
// condition保存
condition.put("addSql",true);
}
}
}
}
对之前拿到的MethodCall进行分析(值得一说的是lambda里的return是continue,很奇怪)
// 如果调用的方法是createStatement,保存这个条件作为最终判断依据
if (next != null && next.getName().asString().equals("createStatement")) {
condition.put("createState", true);
logger.debug("call createStatement method");
}
// 如果用了prepareStatement预编译,continue
if (next != null && next.getName().asString().equals("prepareStatement")) {
logger.debug("call prepareStatement method");
return;
}
前两种情况
// 上文:Statement stmt = con.createStatement()
// 这里的scope是stmt,判断和map保存的"Statement->stmt"是否一致
// 也就是判断是否到达stmt.xxxx()
if (next != null && next.getScope().get().toString()
.equals(methodVar.get("Statement"))) {
// 如果命中stmt.executeQuery或stmt.executeUpdate
if (next.getNameAsString().equals("executeQuery") ||
next.getNameAsString().equals("executeUpdate")) {
logger.debug("call execute method");
// 目标是:stmt.executeQuery("select ...'"+input+"'");
// MethodCallExpr的子节点的第3位开始是参数
// 如果第1个参数是加减乘除表达式进入if
if (next.getChildNodes().get(2) instanceof BinaryExpr) {
BinaryExpr b = (BinaryExpr) next.getChildNodes().get(2);
// 判断+号之前的值是否包含了SELECT等关键字
// 注意这里的SQL是"select ...'"+input
String sql = b.getLeft().toString();
if (sql.toUpperCase(Locale.ROOT).contains("SELECT") ||
sql.toUpperCase(Locale.ROOT).contains("DELETE") ||
sql.toUpperCase(Locale.ROOT).contains("INSERT") ||
sql.toUpperCase(Locale.ROOT).contains("UPDATE")) {
// 判断是否是+操作
if (b.getOperator().asString().equals("+")) {
if (b.getLeft() instanceof BinaryExpr) {
// sqlLeft:"select ...'"+input
BinaryExpr sqlLeft = (BinaryExpr) b.getLeft();
// sqlRight:input
String sqlRight = sqlLeft.getRight().toString();
// 判断拼接的部分(input)是否为方法传入的参数
// 这一步是判断参数是否可控
if (paramVar.containsValue(sqlRight)) {
// 如果之前的步骤调用了con.createStatement
if (condition.get("createState") != null &&
condition.get("createState")) {
// 第一种情况的JDBC SQL注入
logger.debug("find jdbc sql inject");
}
}
}
}
}
}
// String sql = "select ..." + input + "...";
// stmt.executeQuery(sql)
// 如果方法参数是NameExpr(直接的变量)
if (next.getChildNodes().get(2) instanceof NameExpr){
// 如果参数名和之前拼接的sql语句一致
if(next.getChildNodes().get(2).toString().equals(methodVar.get("sql"))){
// 拼接sql语句的条件判断(是否有拼接sql的情况)
if(condition.get("addSql")){
// 之前的步骤是否调用con.createStatement
if (condition.get("createState") != null &&
condition.get("createState")) {
// 第二种情况的JDBC SQL注入
logger.debug("find jdbc sql inject");
}
}
}
}
}
}
最后一种情况的分析
// 目标:stmt.executeQuery(String.format("select ... '%s'",input))
// 搜索所有的MethodCall
// 注意判断的目标是String.format()而不是executeQuery
m.findAll(MethodCallExpr.class).forEach(mce -> {
// 如果函数调用者是String
if (mce.getScope().get().toString().equals("String")) {
// 如果调用的函数是format
if (mce.getNameAsString().equals("format")) {
// 从调用者的child node中寻找简单字符串
mce.findAll(StringLiteralExpr.class).forEach(s -> {
// 如果简单字符串包含SELECT等关键字
String sql = s.asString().toUpperCase(Locale.ROOT);
if (sql.contains("SELECT") || sql.contains("DELETE") ||
sql.contains("INSERT") || sql.contains("UPDATE")) {
// s的爷节点(暂且这么称呼)应该是stmt的MethodCall
if (s.getParentNode().get().getParentNode().get()
instanceof MethodCallExpr) {
MethodCallExpr mc = (MethodCallExpr) s.getParentNode()
.get().getParentNode().get();
// 是否为stmt.executeQuery或stmt.executeUpdate
if (mc.getNameAsString().equals("executeQuery") ||
mc.getNameAsString().equals("executeUpdate")) {
// stmt是否和map中的Statement->stmt一致
if (mc.getScope().get().toString().equals(
methodVar.get("Statement"))) {
// getChildNodes().get(3)是String.format()的第二个参数
if (s.getParentNode().get().getChildNodes().get(3)
instanceof NameExpr) {
// 如果是简单的变量
NameExpr ne = (NameExpr) (NameExpr) s.getParentNode()
.get().getChildNodes().get(3);
// 如果String.format包含可控参数
if (paramVar.containsValue(ne.getNameAsString())) {
// 如果有con.createStatement的调用
if (condition.get("createState") != null &&
condition.get("createState")) {
// 第三种的JDBC SQL注入
logger.debug("find jdbc sql inject");
}
}
}
}
}
}
}
});
}
}
});
跑了下
总结
从上文的分析可以得出,AST存在较多的缺点,难以处理的缺点:
无法完全覆盖语句编写方式:虽然我这里处理了几种常规的方式,然而远远少于实际的可能性
无法从源头确认参数可控:难以实现调用关系与数据流动的分析
解决
一种方式是基于字节码和Java Code之间的代码,又被称为IR,可以有效地分析数据流动。另一种方式是使用ASM,从字节码本身触发,直接解析字节码,进而得到调用关系与数据流动(参考gadget inspector的实现)
将在后文介绍如何实现一个完整的流程分析