前言
最近手上刚好有一个需要做两个方法间可达性分析的需求,且目标是多语言的,因此 tai-e 之类单语言的工具就行不通了。当时试了试常见的多语言分析分析工具,比如 codeql、蚂蚁新出的 YASA 之类的,codeql每种语言的 api 不太一样,兼容性很低,毕竟 codeql 虽然自称是多语言的分析工具,但其实还是对每种语言专门做了一个 AST,代码复用率很低;而 YASA 虽然利用 UAST 对多语言做了大一统,代码复用率比较高,但 YASA 毕竟只是一个面向安全场景的污点分析工具,在程序分析上的能力有点不尽如意;最后问了问好大哥 yulate,给我推荐了 Joern,试了试果然不赖,既好上手又很好用,完美的解决了我当前的需求。
原理浅析
对于多语言静态分析大一统这个问题,不同的工具给出了不一样的解决思路,上面也已经简单介绍过了。
YASA 采用的方式是他们自己提出了一套新的中间表示 UAST(Unified Abstract Syntax Tree),简单来讲,他们尽可能的将不同语言的 AST 用同一种节点兼容,无论是 java、python 还是 go,大家都有 if 条件句等共有的结构,所以都能用 IfStatement 等节点做兼容,但不同语言毕竟有很多不同的差异,像 python、go等语言都有自己独特的特性,因此为了兼容这些特性,YASA 做了一些特定的语言节点,比如 python 的 YieldExpression 或 Go 的 ChanType,这就是整体的思路,在污点传播上也是同理,大家都有变量赋值、方法调用等共有的传播方式,也有一些每种语言特有的传播方式,也需要不断的做兼容。通过主干的通用逻辑尽可能的覆盖所有语言,再辅以每种语言独特的特性做支撑,YASA 的整体原理便是如此。
codeql 依赖于特定语言的提取器和数据库模式,将每种语言转化成对应的 AST 保存在数据库,将程序分析的过程(比如找 Source 到 Sink 的路径)转换成数据库的查询,即 ql 语句,变成查询一条类似 select source where sink = xxx 的语句;WALA/LLVM 的做法较为底层,将不同语言的程序分析问题抽象成底层的 SSA 形式,缺点很明显,就是难以精准捕捉动态语言的高级语义

Joern 选择的方式是利用 CPG(Code Property Graph),全称叫做代码属性图,在2014年的 S&P 上由论文《Modeling and Discovering Vulnerabilities with Code Property Graphs》提出,可以看到 CPG 诞生在网安顶会上,也能想到它的出现也是为了解决安全问题的,简单来说, CPG 把 AST + CFG + PDG 融合成一个“能跑复杂程序语义查询”的统一图模型。
- AST(Abstract Syntax Tree,抽象语法树):AST 是“代码的语法结构”,把源码按语言语法规则解析成一棵树,用于将复杂的原始代码进行抽象
- CFG(Control Flow Graph,控制流图):CFG 是“程序怎么跑”,把代码拆成基本块,用边表示执行顺序,利用 CFG 可以抽象出代码的执行过程
- PDG(Program Dependence Graph,程序依赖图):PDG = 数据依赖 + 控制依赖,数据依赖关注数据是怎么流动的,比如某变量是否来自用户输入,是污点分析的核心;控制依赖关注某条语句是否依赖某个条件是否成立,即哪些判断控制了它是否能够执行
传统的静态分析总归有缺陷:
- AST:语法很清楚,但不知道怎么执行
- CFG:执行路径有了,但不知道数据从哪来
- PDG / DFG:数据依赖有了,但结构上下文丢了
而对于常见的安全问题,比如污点类型的漏洞,我们关注的东西横跨了三者:某个外部输入,是否在某条可执行路径上,未经校验流入了一个危险函数,单用 AST / CFG / DFG,答案都不完整,而通过 CPG 可以完整的获取我们所有需要的信息,在查询中建立复杂的约束:在同一执行路径上是否存在 source →(无 sanitize)→ sink ,这样就很能大程度的解决挖洞的问题
至于为什么利用 CPG 就可以做多语言,理由其实也很简单,只要你能将不同语言的 AST 都统一转换成 CPG,大家当然都能用同一套规则在 CPG 中做查询,我们翻看 joern 的目录,也可以看到很多叫 xxx2cpg 的神秘可执行文件:

只要你闲得慌,为你想兼容的语言做好 AST->CPG 的转换,就可以方便的兼容新的语言,而 codeql 这么久了还没兼容 php,就是因为 codeql 的跨语言兼容其实做的很不好,不但不开源,而且对于每种语言都有自己的解析逻辑,对于 joern 而言,你只需要做好 AST->CPG 的转换,其他工作都能用统一的引擎做查询,而 codeql 还得做一次又一次的适配
实战演练
joern 的环境搭建其实很简单,开发者甚至直接打包好了,就这个 joern-cli:
https://github.com/joernio/joern/releases

解压出来就长这样,windows 用 xxx.bat,linux 直接用可执行文件:

经过我的观察,js 和 python 做源码到 CPG 转换的这一步似乎已经直接打包在 joern.bat 里面了,所以我们可以直接用 importCode("代码地址") 这一步导入源码, joern 会自动做 CPG 转换,然后你就可以开始愉快的开始查东西了;但对于一些可能是后来才兼容进来的语言,比如 java、go,目录里是直接提供了 javasrc2cpg.bat ,所以我们还得先手动转成 CPG 然后做导入
这里我随便找了一个 java 项目:https://gitee.com/opencc/JFlow,我本地的源码版本可能和线上有点不一样,大家以学习为主即可,我们的目标就是查出来下面这个典型的 SQL 注入漏洞代码:
public final Object Demo_SFTable_EmpsByDeptNo(String token, String deptNo)
{
//根据token登录.
try
{
Port_GenerToken(token);
DataTable dt = DBAccess.RunSQLReturnTable("SELECT No,Name FROM Port_Emp WHERE FK_Dept='" + deptNo + "' ORDER BY No");
dt.Columns.get(0).ColumnName = "No";
dt.Columns.get(1).ColumnName = "Name"; //这里是故意处理, 用于测试是否可以转化为标准格式的字典.
return Return_Info(200, "执行成功", bp.tools.Json.ToJson(dt));
}
catch (RuntimeException ex)
{
return Return_Info(500, "执行失败", ex.getMessage());
}
}

很显然,参数 deptNo 被直接拼接在了 SQL 语句里然后直接做了查询,存在一目了然的 SQL 注入,那么就让我们用 Joern 来自动化的发现这类污点型漏洞吧!
首先我们需要将原始的 java 项目转成 CPG:
D:/BaiduNetdiskDownload/joern-cli/joern-cli/javasrc2cpg.bat -J-Xmx8092m D:/BaiduNetdiskDownload/jflow/jflow-web --output temp_cpg.bin.zip

接下来在当前路径下应该可以看到一个 temp_cpg.bin.zip,这就是我们的 CPG了:

然后输入 joern.bat 的路径加载进 joern 的页面:

接着用 importCpg("./temp_cpg.bin.zip") 引入 CPG(对于 js 和 python 可以直接用 importCode("代码地址") 导入源码,joern 会自动做 CPG 的转换)

接下来就可以对它进行查询了,对于传统的污点分析问题,我们需要三步:设置 Source、设置 Sink、执行查询,joern 中提供了一个叫做 reachableByFlows 的接口可以方便的做可达性分析,首先定义 Source,这里我做的限制是 所有位于 @GetMapping / @PostMapping 方法中的 String 类型方法参数:
val src = cpg.method.parameter.where(_.typeFullNameExact("java.lang.String")).where(_.method.annotation.name(".*GetMapping|.*PostMapping"))
接着定义 Sink,这里我们只需要找到存在 RunSQLReturnTable 调用的节点即可:
val sink = cpg.call.name("RunSQLReturnTable")
最后一步进行查询,判断 Source 是否真的可以流向 Sink:
sink.reachableByFlows(src).p

可以看到一下查出来了很多东西,当然,有些执行路径是有洞的,有些是没洞的,下面这个就是我们想查的路径,可以很明显的看到参数 deptNo 通过拼接被 RunSQLReturnTable 执行了,存在显而易见的 SQL 注入:

我们可以用类似 cursor 的 vibe coding 工具让 LLM 做路径验证,joern 的输出中有极为详细的调试信息,对于 LLM 而言可以很精确的验证执行路径中是否真的存在漏洞:

上面的这套 CPG+LLM 的组合拳思路来自于 LLMxCPG ,虽然现在 LLM 很发达,但适合传统工具做的事也应当拿传统工具做, LLM 强在分析和研判,我们应当让它做它适合的事,即判断某个路径是否真的存在漏洞,对于查询路径这种脏活累活,还是适合 joern、codeql 这类传统程序分析工具。一个好消息是,joern 是多语言通用的,LLM 也是多语言通用的,因此 LLM+CPG 的思路也能沿用到多语言的漏洞分析场景上,通过同一套固定的规则即可在不同语言上做精确的安全分析