AST在代码审计领域的实践与局限性

该文章首发于先知社区
https://xz.aliyun.com/t/10287
https://xz.aliyun.com/t/10312

介绍

最近想做一些自动化的代码审计工具,发现存在难点

最初考虑采用纯正则等方式匹配,但这种方式过于严格,程序员编写的代码有各种可能的组合

于是尝试自行实现Java词法分析和语法分析,稍作尝试后发现这不现实,一方面涉及到编译原理的一些算法,另外相比C语言等,Java语言本身较复杂,不是短时间能搞定的,深入研究编译原理背离了做审计工具的目的

后来找到了几种解决方案:Antlr,JavaCC,JDT,javaparser

经过对比,最终选择javaparser项目,该项目似乎是基于JavaCC,核心开发者是effective java的作者。使用起来比较方便,可以简单地以依赖的方式导入

<dependency>
    <groupId>com.github.javaparser</groupId>
    <artifactId>javaparser-symbol-solver-core</artifactId>
    <version>3.23.0</version>
</dependency>

笔者本想采用Golang编写该工具,查找相关资料后发现,Golang本身提供AST库,可以对Golang本身做语法分析,但找不到实现Java语法分析的库(考虑后续复习下编译原理自己尝试)

XSS实例

javaparser最根本的类是CompilationUnit,如果我们想对代码做分析,首先需要实例化该对象

// code是读入的java代码字符串
// 也有其他重载,但这个比较方便
CompilationUnit compilationUnit = StaticJavaParser.parse(code);

给出一段最简单的XSS代码

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 {

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String param = req.getParameter("xss");
        resp.getWriter().write(param);
    }
}

针对于该案例,我们写审计工具的原理

  • 从import来看,比如有request和response,以证明这是HttpServlet
  • 从类来看,必须继承自HttpServlet才能证明这是Servlet
  • 如果req.getParameter得到的值被write了,认为这是XSS

验证导包

关于验证导入包的情况,简单做了一个方法

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];
}

如果要验证请求和相应包的导入情况

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文件中不一定只有一个类

compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
        // 不是接口且不是抽象类
        .filter(c->!c.isInterface()&&!c.isAbstract()).forEach(c->{
            System.out.println(c.getNameAsString());
        });

// 输出
// Demo

进一步,我们需要判断该类是否继承自HttpServlet

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");
            }
        });

只有得到类节点,才可以继续遍历抽象语法树拿到方法等信息

获得方法

遍历得到方法节点,并且拿到具体的请求和响应参数名称

之所以要拿到方法参数名,是为了做进一步的追踪

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保存的原因

可以加上参数校验

if (params.get("request") != null && !params.get("request").equals("") ||
        params.get("response") != null && !params.get("response").equals("")) {
    return;
}

获取所有的赋值表达式,确定是否调用了req.getParameter这样的参数

并且参考上文的方式使用map保存这个参数结果,用于后续校验

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

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方式

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代码复杂一些,看看审计的效果

public class Demo extends HttpServlet {

    @Override
    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做了审计,实际上无论从广度还是深度,都有巨大的工作量:

  • 广度:SQL注入,XXE,反序列化,文件上传,CSRF等漏洞的审计
  • 深度:如果代码对Servlet做了一定的封装,或者需要跨多个java文件分析
  • 可控参数的追踪:从controller层传入参数到返回,这个参数经历了些什么

简单写了个输出html的页面:

SQL注入实例

审计案例

JDBC

从最简单的JDBC原生SQL注入来看,怎样的语句是存在注入的?

1.不使用prepareStatement而使用createStatement

  1. 调用了executeQueryexecuteUpdate方法
  2. SQL语句进行了拼接操作
  3. 拼接的地方应该是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));
}

其实还应该有多种情况,但其他方式解析方式类似

问题不在于如何解析,而在于考虑多种情况必然出现疏漏,无法完整地覆盖

比如有两个问题待解决,这里就是局限性:

  1. 传入的如果不是String,而是DTO包装类,在拼接的时候get属性如何处理
  2. 在拼接的时候如果调用了其他Util,应该如何处理

除了JDBC原生,注意到SpringJdbcTemplateJPA也存在问题,但审计和分析原理大同小异。同样存在局限性:无法考虑到所有的编写情况

// 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开发常用的框架,这里先看注解形的审计规则

  1. 某类是接口类型(interface)并且有注解Mapper
  2. 参数必须有Param注解并且类型要求是String
  3. 方法注解必须是Select等,并且value内包含了${}(其实Mybatis这里是一个值得讨论的点,并不是说有了一定存在注入,也不是说有#一定安全。存在一些复杂的问题,但目前先粗略地认为只要有那么就是有漏洞的,可以参考大佬文章MyBatis 和 SQL 注入的恩恩怨怨

局限性:

  1. 如果传入的是一个包装类,然后#{}${}取的是类属性如何处理
  2. 如何从controller->service->mapper这一个流程进行追踪
@Mapper
public interface CategoryMapper {
    @Select("select * from category_ where name= '${name}' ")
    public CategoryM getByName(@Param("name") String name);
}

Mybatis XML

使用注解方式的Mybatis是最常见的手段,原理类似上文,对${}做检查,简单的规则可以总结如下:

  1. 解析XML找到mapper标签下的select等标签
  2. 如果select标签内容匹配到${}认为存在漏洞

问题以及局限性:

  1. 如果传入的是包装类
  2. mapper标签其实并不是必须,因为Spring可以配置扫描包路径
  3. 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存在较多的缺点,难以处理的缺点:

  1. 无法完全覆盖语句编写方式:虽然我这里处理了几种常规的方式,然而远远少于实际的可能性

  2. 无法从源头确认参数可控:难以实现调用关系与数据流动的分析

解决

一种方式是基于字节码和Java Code之间的代码,又被称为IR,可以有效地分析数据流动。另一种方式是使用ASM,从字节码本身触发,直接解析字节码,进而得到调用关系与数据流动(参考gadget inspector的实现)

将在后文介绍如何实现一个完整的流程分析