最近想做一些自动化的代码审计工具,发现存在难点
最初考虑采用纯正则等方式匹配,但这种方式过于严格,程序员编写的代码有各种可能的组合
于是尝试自行实现Java词法分析和语法分析,稍作尝试后发现这不现实,一方面涉及到编译原理的一些算法,另外相比C语言等,Java语言本身较复杂,不是短时间能搞定的,深入研究编译原理背离了做审计工具的目的
后来找到了几种解决方案:Antlr
,JavaCC
,JDT
,javaparser
经过对比,最终选择javaparser
项目,该项目似乎是基于JavaCC
,核心开发者是effective java
的作者。使用起来比较方便,可以简单地以依赖的方式导入
xxxxxxxxxx
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>3.23.0</version>
</dependency>
笔者本想采用Golang编写该工具,查找相关资料后发现,Golang本身提供AST库,可以对Golang本身做语法分析,但找不到实现Java语法分析的库(考虑后续复习下编译原理自己尝试)
javaparser
最根本的类是CompilationUnit
,如果我们想对代码做分析,首先需要实例化该对象
xxxxxxxxxx
// code是读入的java代码字符串
// 也有其他重载,但这个比较方便
CompilationUnit compilationUnit = StaticJavaParser.parse(code);
给出一段最简单的XSS代码
xxxxxxxxxx
package testcode.xss.servlets;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class Demo extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String param = req.getParameter("xss");
resp.getWriter().write(param);
}
}
针对于该案例,我们写审计工具的原理
关于验证导入包的情况,简单做了一个方法
xxxxxxxxxx
public static boolean isImported(CompilationUnit compilationUnit, String fullName) {
// lambda表达式中必须用这种方式修改值
final boolean[] flag = new boolean[1];
compilationUnit.getImports().forEach(i -> {
if (i.getName().asString().equals(fullName)) {
flag[0] = true;
}
});
return flag[0];
}
如果要验证请求和相应包的导入情况
xxxxxxxxxx
final String SERVLET_REQUEST_IMPORT = "javax.servlet.http.HttpServletRequest";
final String SERVLET_RESPONSE_IMPORT = "javax.servlet.http.HttpServletResponse";
boolean imported = isImported(compilationUnit, SERVLET_REQUEST_IMPORT) &&
isImported(compilationUnit, SERVLET_RESPONSE_IMPORT);
if (!imported) {
logger.warn("no servlet xss");
return results;
}
首先拿到Demo这个Class,因为一个java文件中不一定只有一个类
xxxxxxxxxx
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
// 不是接口且不是抽象类
.filter(c->!c.isInterface()&&!c.isAbstract()).forEach(c->{
System.out.println(c.getNameAsString());
});
// 输出
// Demo
进一步,我们需要判断该类是否继承自HttpServlet
xxxxxxxxxx
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
.filter(c->!c.isInterface()&&!c.isAbstract())
.forEach(c->{
boolean isHttpServlet = false;
// 继续用lambda反而不方便
NodeList<ClassOrInterfaceType> eList = c.getExtendedTypes();
for (ClassOrInterfaceType e:eList){
if (e.asString().equals("HttpServlet")){
isHttpServlet = true;
break;
}
}
if (isHttpServlet){
// 这里面做进一步的逻辑
System.out.println("hello");
}
});
只有得到类节点,才可以继续遍历抽象语法树拿到方法等信息
遍历得到方法节点,并且拿到具体的请求和响应参数名称
之所以要拿到方法参数名,是为了做进一步的追踪
xxxxxxxxxx
if (isHttpServlet){
c.getMethods().forEach(m->{
// lambda不允许直接复制,所以借助map
Map<String,String> params = new HashMap<>();
m.getParameters().forEach(p->{
// resp(真实情况未必一定是resp)
if (p.getType().asString().equals("HttpServletResponse")) {
params.put("response", p.getName().asString());
}
// req(真实情况未必一定是req)
if (p.getType().asString().equals("HttpServletRequest")) {
params.put("request", p.getName().asString());
}
});
System.out.println("request:"+params.get("request"));
System.out.println("response:"+params.get("response"));
});
}
// 输出
// request:req
// response:resp
审计漏洞的关键点就在于参数的可控,这也是难点
就本案例而言,如果某个参数是req.getParameter("...")
获取的,那么就可以认为是可控
实际上这个req
并不一定是req,可能是request,requ等,这也是上一步需要一个map保存的原因
可以加上参数校验
xxxxxxxxxx
if (params.get("request") != null && !params.get("request").equals("") ||
params.get("response") != null && !params.get("response").equals("")) {
return;
}
获取所有的赋值表达式,确定是否调用了req.getParameter
这样的参数
并且参考上文的方式使用map保存这个参数结果,用于后续校验
xxxxxxxxxx
Map<String,String> var = new HashMap<>();
m.findAll(VariableDeclarationExpr.class).forEach(v->{
MethodCallExpr right;
boolean isGetParam = false;
// 获取赋值语句右边部分
if (v.getVariables().get(0).getInitializer().get() instanceof MethodCallExpr) {
// 强转不验证会出问题
right = (MethodCallExpr) v.getVariables().get(0).getInitializer().get();
if (right.getScope().get().toString().equals(params.get("request"))){
// 确定是否调用了req.getParameter
if (right.getName().asString().equals("getParameter")){
isGetParam = true;
}
}
}
if(isGetParam){
var.put("reqParameter",v.getVariables().get(0).getNameAsString());
logger.info("find req.getParameter");
}
});
触发点在本案例中是resp.getWriter().write()
这是一个方法调用,所以搜索MethodCallerExpr
xxxxxxxxxx
m.findAll(MethodCallExpr.class).forEach(im -> {
if (im.getScope().get().toString().equals(params.get("response"))) {
// 如果调用了response.getWriter
if (im.getName().asString().equals("getWriter")) {
MethodCallExpr method;
// 直接强转会出问题
if (im.getParentNode().get() instanceof MethodCallExpr) {
// 后一步方法
method = (MethodCallExpr) im.getParentNode().get();
} else {
return;
}
// response.getWriter.write();
if (method.getName().asString().equals("write")) {
// 该案例中write的是常量param,所以搜NameExpr
method.findAll(NameExpr.class).forEach(name -> {
// 这里用到了之前保存在map的reqParameter
if (name.getNameAsString().equals(var.get("reqParameter"))) {
// 认为存在XSS
logger.info("find xss");
}
});
}
}
}
});
针对于这个基础案例,可以再加入几个规则,针对于response.getOutputStream
方式
xxxxxxxxxx
if (im.getName().asString().equals("getOutputStream")) {
MethodCallExpr method;
if (im.getParentNode().get() instanceof MethodCallExpr) {
method = (MethodCallExpr) im.getParentNode().get();
} else {
return;
}
// response.getOutputStream.print();
// response.getOutputStream.println();
if (method.getName().asString().equals("print") ||
method.getName().asString().equals("println")) {
method.findAll(NameExpr.class).forEach(name -> {
if (name.getNameAsString().equals(var.get("reqParameter"))) {
logger.info("find xss");
}
});
}
}
尝试让原来的XSS代码复杂一些,看看审计的效果
xxxxxxxxxx
public class Demo extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String param = req.getParameter("xss");
if(param.equals("hello world")){
// do other
}else{
demoService.doSearch();
}
int a = 1;
int b = 2;
logger.log(String.format("%d+%d=%d",a,b,a+b));
try{
// todo
}catch (Exception e){
e.printStackTrace();
}
resp.getWriter().write(param);
}
}
运行后成功检测到XSS
只针对最基本的Servlet XSS做了审计,实际上无论从广度还是深度,都有巨大的工作量:
简单写了个输出html的页面:
从最简单的JDBC原生SQL注入来看,怎样的语句是存在注入的?
1.不使用prepareStatement
而使用createStatement
executeQuery
或executeUpdate
方法给出以下三个案例:
1.直接在方法内拼接
xxxxxxxxxx
public void query(String input) throws SQLException {
Statement stmt = con.createStatement();
ResultSet rs = stmt.executeQuery("select * from Users where name = '" + input + "'");
}
2.新建一个SQL变量拼接赋值并传入
xxxxxxxxxx
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
参数进行格式化
xxxxxxxxxx
public void query(String input) throws SQLException {
Statement stmt = con.createStatement();
ResultSet rs = stmt.executeQuery(String.format("select * from Users where name = '%s'", input));
}
其实还应该有多种情况,但其他方式解析方式类似
问题不在于如何解析,而在于考虑多种情况必然出现疏漏,无法完整地覆盖
比如有两个问题待解决,这里就是局限性:
除了JDBC原生,注意到SpringJdbcTemplate
和JPA
也存在问题,但审计和分析原理大同小异。同样存在局限性:无法考虑到所有的编写情况
xxxxxxxxxx
// 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框架是Java开发常用的框架,这里先看注解形的审计规则
Mapper
Param
注解并且类型要求是String
Select
等,并且value内包含了${}
(其实Mybatis这里是一个值得讨论的点,并不是说有了一定存在注入,也不是说有#一定安全。存在一些复杂的问题,但目前先粗略地认为只要有那么就是有漏洞的,可以参考大佬文章MyBatis 和 SQL 注入的恩恩怨怨)局限性:
#{}
和${}
取的是类属性如何处理xxxxxxxxxx
public interface CategoryMapper {
"select * from category_ where name= '${name}' ") (
public CategoryM getByName( ("name") String name);
}
使用注解方式的Mybatis是最常见的手段,原理类似上文,对${}
做检查,简单的规则可以总结如下:
mapper
标签下的select
等标签select
标签内容匹配到${}
认为存在漏洞问题以及局限性:
${}
是有可能在这些标签里的(从实践来看,不少的后端开发程序员并不喜欢这些标签,更喜欢自己手写SQL语句)xxxxxxxxxx
<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
解析XML
xxxxxxxxxx
// 使用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
xxxxxxxxxx
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");
......
}
});
跑了下
拿到interface并遍历所有method,对于类注解mapper忽略,因为不是必须
xxxxxxxxxx
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
// mybatis interface
.filter(ClassOrInterfaceDeclaration::isInterface).forEach(i -> {
// all method
i.getMethods().forEach(m -> {
验证方法的参数注解是否合规,拿到可能存在注入的参数,做进一步分析
xxxxxxxxxx
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
xxxxxxxxxx
// 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");
}
}
});
}
});
跑了下
拿到非接口非抽象类的类对象,遍历所有方法
xxxxxxxxxx
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,分别是最终的判断条件,方法内变量,方法参数
xxxxxxxxxx
// 保存两个存在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<>();
对传入的参数进行保存,后续判断会用到
xxxxxxxxxx
// all parameter
m.getParameters().forEach(p -> {
// save to map
paramVar.put(p.getType().asString(), p.getName().asString());
});
检查方法内变量
xxxxxxxxxx
// 所有的变量声明表达式,用来保存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,很奇怪)
xxxxxxxxxx
// 如果调用的方法是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;
}
前两种情况
xxxxxxxxxx
// 上文: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");
}
}
}
}
}
}
最后一种情况的分析
xxxxxxxxxx
// 目标: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的实现)