最近想做一些自动化的代码审计工具,发现存在难点
最初考虑采用纯正则等方式匹配,但这种方式过于严格,程序员编写的代码有各种可能的组合
于是尝试自行实现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代码
xxxxxxxxxxpackage 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); }}针对于该案例,我们写审计工具的原理
关于验证导入包的情况,简单做了一个方法
xxxxxxxxxxpublic 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];}如果要验证请求和相应包的导入情况
xxxxxxxxxxfinal 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文件中不一定只有一个类
xxxxxxxxxxcompilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream() // 不是接口且不是抽象类 .filter(c->!c.isInterface()&&!c.isAbstract()).forEach(c->{ System.out.println(c.getNameAsString()); });
// 输出// Demo进一步,我们需要判断该类是否继承自HttpServlet
xxxxxxxxxxcompilationUnit.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"); } });只有得到类节点,才可以继续遍历抽象语法树拿到方法等信息
遍历得到方法节点,并且拿到具体的请求和响应参数名称
之所以要拿到方法参数名,是为了做进一步的追踪
xxxxxxxxxxif (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保存的原因
可以加上参数校验
xxxxxxxxxxif (params.get("request") != null && !params.get("request").equals("") || params.get("response") != null && !params.get("response").equals("")) { return;}获取所有的赋值表达式,确定是否调用了req.getParameter这样的参数
并且参考上文的方式使用map保存这个参数结果,用于后续校验
xxxxxxxxxxMap<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
xxxxxxxxxxm.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方式
xxxxxxxxxxif (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代码复杂一些,看看审计的效果
xxxxxxxxxxpublic 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.直接在方法内拼接
xxxxxxxxxxpublic void query(String input) throws SQLException { Statement stmt = con.createStatement(); ResultSet rs = stmt.executeQuery("select * from Users where name = '" + input + "'");}2.新建一个SQL变量拼接赋值并传入
xxxxxxxxxxpublic 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参数进行格式化
xxxxxxxxxxpublic 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// SpringJdbcTemplateJdbcOperations jdbcTemplate;
public void query1(String input) throws DataAccessException { jdbcTemplate.execute("select * from Users where name = '"+input+"'");}
// JPApublic 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开发常用的框架,这里先看注解形的审计规则
MapperParam注解并且类型要求是StringSelect等,并且value内包含了${}(其实Mybatis这里是一个值得讨论的点,并不是说有了一定存在注入,也不是说有#一定安全。存在一些复杂的问题,但目前先粗略地认为只要有那么就是有漏洞的,可以参考大佬文章MyBatis 和 SQL 注入的恩恩怨怨)局限性:
#{}和${}取的是类属性如何处理xxxxxxxxxxpublic 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);// 找到根标签mapperNodeList 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
xxxxxxxxxxsqlMap.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忽略,因为不是必须
xxxxxxxxxxcompilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream() // mybatis interface .filter(ClassOrInterfaceDeclaration::isInterface).forEach(i -> { // all method i.getMethods().forEach(m -> {验证方法的参数注解是否合规,拿到可能存在注入的参数,做进一步分析
xxxxxxxxxxMap<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 annotationm.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"); } } }); }});跑了下

拿到非接口非抽象类的类对象,遍历所有方法
xxxxxxxxxxcompilationUnit.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 variablesMap<String, String> methodVar = new HashMap<>();// method paramsMap<String, String> paramVar = new HashMap<>();对传入的参数进行保存,后续判断会用到
xxxxxxxxxx// all parameterm.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预编译,continueif (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()而不是executeQuerym.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的实现)