前言:为什么从 Soot 逃向 Tai-e

我做这个工具要应对的场景就是到客户内网做白盒审计项目:可以提供部署包、但是断网也不能用ai、在客户电脑安装个东西也很麻烦。这就需要一个传统手艺来帮忙快速发现一些问题。直接做一个解析字节码的工具jar带进去也很方便。

​

其实这个功能和tabby很像,但是我不想带数据库,这种场景最好直接预设好source、sink跑一次输出txt格式的调用链,然后人工对照检查。

​

最开始用了经典的soot来作,然后就发现问题了,即便是跑个靶场应用也要半个小时起步,而且经常出现可选依赖缺失,真实项目根本跑不起来

经典崩溃长这样:

java.lang.RuntimeException: Failed to apply jb to
<com.zaxxer.hikari.metrics.dropwizard.CodaHaleMetricsTracker: void (...)>

Caused by: This operation requires resolving level HIERARCHY
but com.codahale.metrics.Gauge is at resolving level DANGLING

这个问题后来我也在 Tai-e 提了 issue(#217)。

但是,我要说但是了 Tai-e发了0.5.4:新版把这个问题解决了,而且不得不说,跑起来是真快啊!

​

从零跑通一个 Tai-e 污点分析

Tai-e 的 taint 分析本质是:PTA 先跑出一张调用图,再拿一组 source/sink/transfer 规则在这张图上找从污染源到危险点的路径。规则全部在外部一个 taint-config.yml 里,跟你自己的 Java 代码没关系。这决定了后面每样东西该往哪放。

第二个:Tai-e 默认只从 main 出发。对于我们审的web应用来说需要写个插件,把controller这些方法注册成入口。

先记住我们的输入输出长什么样:

  • 输入①:classpath(业务 jar 和依赖库分开给)
  • 输入②:taint-config.yml(source / sink / transfer 全在这)
  • 输入③:一个负责注入入口的插件
  • 输出:一条条 TaintFlow,从 source 到 sink 的调用路径

下面按顺序走一遍。

第一步:区分classpath

第一步不是写分析代码,是把 classpath 分清楚。因为我们的内存是有限的,必须把业务代码和依赖给他区分开

Tai-e 有两种路径:--app-class-path(要分析的业务代码)和 --class-path(只参与类型解析的依赖库)

这一块做的工作是:扫描目标里所有 jar,用包名推断出业务包(比如 com.xxx.app),业务 jar 进 --app-class-path,其他依赖进 --class-path。关键是不加 --input-classes all,这样 applicationClasses() 里只会出现真正的业务类,分析范围一下就小下来了。

这个包名推断我目前做的还有点瑕疵,只会取最高概率的一个,但是有碰到过业务代码在两个包路径下的项目。这个需要改rules.yaml,

scan_packages: 
  - "com.example"
  - "org.example2"

把人工发现的新包名加上再跑一次。

第二步:source 从哪来,怎么提取

source 分两类,对应 taint-config 里的 kind:

  1. param source:入口方法的第 i 个参数。Web 场景就是 Controller 方法签名里的参数
  2. call source:某个方法调用的返回值,比如 request.getParameter(),kind: call, index: result

提取 pipeline 是这样的:

  • 先用 ASM 扫字节码注解——Spring 的 @RequestMapping、JAX-RS 的 @Path、Servlet 的 web.xml 映射——得到一张路由表
  • 路由表里的方法签名,每个参数生成一个 param source
  • rules.yaml 里手工配的方法型 source,转成 call source

实际生成的 sources 长这样(摘自我们真实跑出来的 taint-config):

sources:
- method: "<xxx.RuleEngineController: ... runGroovyScript(...)>"
  kind: "param"
  index: 0
- method: "<javax.servlet.http.HttpServletRequest: java.lang.String getParameter(java.lang.String)>"
  kind: "call"
  index: "result"

有一个坑点:Tai-e 不直接支持注解型 source(比如 @RequestParam)。要么自己预处理成方法签名,要么写自定义 TaintConfigProvider。我们是靠入口推导覆盖的——Controller 参数本来就全被标成 param source 了,注解在路由提取阶段已经消费掉了。

这里衍生出一个实战功能:很多项目用注解区分权限,比如 @AnonymousValidator、@IgnoreAuth。既然 source 是从路由表来的,那路由提取阶段就可以做注解过滤——只提取带指定注解的方法,得到的 api.txt 就只剩可未授权访问的接口。这相当于把"鉴权判断"提前到 source 定义这一步:

# 只提取标注了 AnonymousValidator 的接口
java -jar xxx.jar -m api --filter-annotation AnonymousValidator /path/to/app.jar

支持多关键词,是"或"的关系,类注解和方法注解都会看。这样跑完,生成的source也就是api.txt就只会有未授权的接口,省掉大量人工筛鉴权的时间。

如果鉴权没这么直接,就只能先-m api提取,然后用目录扫描把提取的路径都跑一遍,再人工删减api.txt。

第三步:sink 怎么定义,为什么还要预扫描

用户侧我们维护一份可读的 rules.yaml,带漏洞类型和严重度,方便改。Tai-e 不吃这个格式,所以要翻译成 taint-config 的 sinks。

sink 的关键是方法签名 + index:

  • 有参 sink:<java.sql.Statement: ResultSet executeQuery(java.lang.String)>,index: 0
  • 零参接收者 sink:<java.io.ObjectInputStream: Object readObject()>,index 必须写 base,不写就是废的
  • 多参数按需逐个标,比如 Method.invoke 我们标参数 1

翻译之外,我们加了一道 ASM 预扫描:对目标字节码先扫一遍,看配置的 sink 里哪些在调用点上被引用过,只把这些写进配置。这招一举两得:

  1. 避免 Cannot find sink method——sink 类可能根本不在 classpath 上,这全靠 phantom 机制兜底
  2. 配置精简,没被调用的 sink 不会产生干扰另外别漏了 transfers。在 only-app 模式下,库方法体根本不分析,MultipartFile.getInputStream()、StringBuilder.append() 这种链会断。必须用 transfer 显式续上,不然污点传到一半就丢了。

第四步:把 source/sink 和入口塞给 Tai-e

规则怎么进 Tai-e?一个 pta 配置串搞定:

pta=cs:ci;only-app:true;taint-config:/path/taint-config.yml;implicit-entries:true;plugins:[com.example.MyEntryPointPlugin,com.example.LibraryBridgePlugin]

入口怎么进调用图(本文最关键的一步):写一个 Plugin,在 onStart() 里 solver.addEntryPoint(...),把路由方法注册成 PTA 入口。核心代码长这样:

public class EntryPointPlugin implements Plugin {
    public void onStart() {
        for (String sig : entrySignatures) {
            JMethod m = resolve(sig);
            if (m == null || m.isAbstract() || m.isNative() || m.getDeclaringClass().isPhantom()) {
                continue; // 没有 IR 的方法,直接注入会在 getIR() 上炸
            }
            solver.addEntryPoint(new EntryPoint(m, new DeclaredParamProvider(m, solver.getHeapModel())));
        }
    }
}

两个坑必须记住:

  • abstract / native / phantom 方法没有 IR,直接注入会炸 getIR(),注入前先过滤
  • 同一个 Java 方法可能映射多个 HTTP path,要按 JMethod 身份去重,否则重复注入

第五步:跑起来,把结果捞出来

启动代码路径(Tai-e 0.5.4 嵌入式用法):

Options options = Options.parse(args);          // --app-class-path / --class-path / -a pta=...
WorldBuilder wb = options.getWorldBuilderClass().getConstructor().newInstance();
wb.build(options);                               // 建 World
new AnalysisManager(plan).execute();             // 跑 pta + taint

结果怎么拿是个麻烦事:Tai-e 的 SourcePoint/SinkPoint 是 package-private,对外不开放;而且分析结果跑完就被清了,晚一步就没了。

我们的做法:在 pascal.taie.analysis.pta.plugin.taint 包里塞一个极小的桥接类,插件在 onFinish() 回调里、结果被清空之前,把 TaintFlow 转成可序列化的快照——source/sink 签名、语句索引、行号。后续的过滤、打分、报告,全靠这份快照。

还有个内存问题,虽然比较而言太阿其实吃内存不算严重,比之前soot攒的牛多了。我们最后把 Tai-e 挪到独立子 JVM 里跑,主进程只做协调。子 JVM 带资源预算:-Xmx 限制、超时强杀、-XX:+ExitOnOutOfMemoryError、OOM 时落 heap dump 和 GC log,结果走 JSON 回传。崩了也只是"这个批次失败",不影响已经扫出来的东西。

别忘了:链条会悄悄断掉

跑通主流程之后,拿已经做过知道有洞的项目去测覆盖度,经常会发现有些该报的洞没报。也就是断链了。

第一种断法:****only-app 把库跳掉了。 only-app:true 是常用配置,只分析应用代码、跳过库。副作用是库方法返回值的 points-to 集合被跳过:

Runtime rt = Runtime.getRuntime();  // rt 的 points-to 是空的
rt.exec(cmd);                       // 找不到接收者类型,调用边建不出来

一段真实可达的 RCE,就这么静默丢了。这不是框架的 bug,是 only-app 这个取舍自带的断点,最容易被误判成"Tai-e 不行"。

解法是写一个 LibraryBridgePlugin,在 onNewCallEdge 里给一小组工厂方法的返回值补合成对象——Runtime.getRuntime、DriverManager.getConnection、DocumentBuilderFactory.newInstance 这类返回值会被当 sink 接收者的。白名单不大,但要手工维护。

第二种断法:调用边在,污点却传不动。 这多半是值流断了——污点经过一个中间方法就消失,因为那个方法没被建模。我们的 taint-config 里攒了一堆 transfers 就是补这些洞的,按原因分三类:

  1. 库方法体不分析:MultipartFile.getInputStream()、IOUtils.toByteArray() 这种,只能显式声明 base→result / 0→result
  2. 接口方法只有声明没有实现体:java.util.List.get、java.util.stream.Stream.map/collect 这类,虚拟调用分派不到具体实现,直接按声明签名给 transfer
  3. 入口参数是整体对象(DTO/bean) :Controller 收的是一个对象而不是字符串,污点在字段里,只能靠 getter 冒出来,所以动态生成 RuleEngine.getScript() 这类 base→result

从我们真实生成的 taint-config 里截几段:

transfers:
- method: "<java.lang.StringBuilder: java.lang.StringBuilder append(java.lang.String)>"
  from: "0"
  to: "base"
- method: "<org.springframework.web.multipart.MultipartFile: java.io.InputStream getInputStream()>"
  from: "base"
  to: "result"
- method: "<java.util.List: java.lang.Object get(int)>"
  from: "base"
  to: "result"
- method: "<org.hzero.boot.platform.rule.entity.RuleEngine: java.lang.String getScript()>"
  from: "base"
  to: "result"

维护这些 transfer 是个问题,目前都是扫已知漏洞的项目通过没覆盖到的漏洞来补,直到它能发现。

诚实说边界:Spring @Autowired 注入的 bean 还是建模不了,接收者字段连 points-to 都没有,调用边根本不会产生。这个目前无解。

​

小结:Tai-e 给的是"分析内核",把最难的部分给做了,我做的也就是外围的一些工作。classpath、路由提取、规则翻译、入口注入、结果捕获、进程隔离。

​

漏洞覆盖实证

以某项目为例,20.874秒就跑完了这个20个jar的系统,如果把scan_package增加到两个包名(它就是我说的那个业务代码多包名的系统),时间会变成39秒多。这也是难以置信的高速了!

从结果来看,成功覆盖到了一处任意文件读取、一处groovy脚本执行。

误报也是个问题,PTA 一次扫出 23423 条 source→sink 流,经过精确去重、fan-in 聚合、按 sink 强度和是否落在业务包内做压制之后,最后只有 6257 条进主报告,其实还是挺多的

这是太阿的日志

有考虑引入sca,这样对于第三方依赖从版本就能判断打不了的就可以不看了,但是还没实现,目前只是简单的按漏洞类别区分到不同的结果文件。

后记

这篇文章,包括工具都有ai的深度参与,能用ai的项目也基本直接开始agent、skill向ai许愿式挖洞了。

作为旧时代的残党,很难说新时代还需不需要一个懂点传统手艺的老船员。