Joern 入门:代码属性图(CPG)与漏洞变体挖掘的程序分析引擎
一篇面向代码安全研究的技术笔记。重点解释 Joern 如何表示程序、怎样查询代码结构和数据流,以及它为什么适合用作开源组件漏洞变体挖掘的程序分析引擎。
先说结论
Joern是一个开源程序分析平台。它把源代码、部分字节码和二进制程序转换成代码属性图(Code Property Graph,CPG),再通过基于 Scala 的查询语言对图进行遍历。
这套设计主要解决一个问题:漏洞分析经常需要同时理解代码结构、控制条件、函数调用和数据传播,而传统工具往往把这些信息放在不同的分析结果里。
例如,分析一个路径遍历问题时,通常需要回答:
- 用户输入从哪里进入程序?
- 输入经过了哪些函数和转换?
- 程序在哪里拼接或规范化路径?
- 最终哪个文件操作使用了这个路径?
- 路径检查是否覆盖了实际执行条件?
Joern把这些程序信息放进一个可以查询的图结构里。分析人员可以从某个函数或调用点出发,沿着调用关系、语法结构或数据流继续查找。Joern也提供了污点分析相关能力,可以查询指定 Source 是否能够沿数据流到达某个Sink。
对于基于历史安全补丁的漏洞变体挖掘,Joern适合承担程序结构分析和候选路径筛选。补丁语义理解、候选优先级、测试生成和漏洞真实性验证,仍然需要在它之上实现。
官方资料:
1. 为什么需要 Code Property Graph?
先看一个简化的程序:
int handle(char *input) { char *path = normalize(input);
if (is_allowed(path)) { return open_file(path); }
return -1;}如果要分析这段代码,至少会涉及几种不同的信息。
| 分析视角 | 主要回答什么问题 | 例子 |
|---|---|---|
| 抽象语法树(AST) | 代码由哪些语法结构组成? | normalize(input) 是一次函数调用 |
| 控制流图(CFG) | 程序可能按什么顺序执行? | is_allowed(path) 决定是否进入 open_file |
| 数据流 / 数据依赖 | 一个值可能从哪里传播到哪里? | input 影响 path,path 被传给 open_file |
| 调用图(Call Graph) | 函数之间如何调用? | handle 调用 normalize、is_allowed 和 open_file |
不同表示之间的差异,可以用同一段代码直观对比。AST 只保留语法层级,CFG 只保留执行分支与顺序,PDG 则同时标出数据依赖和控制依赖:

从图中可以看出,三种表示回答的是完全不同的问题:AST 关注代码”长什么样”,CFG 关注程序”怎么执行”,PDG 关注”谁影响了谁”,单独使用任何一种都只能得到部分答案。具体到上面这段代码,只看 AST,可以知道 open_file(path)
这次调用存在,却不容易直接回答 path
的值来自哪里;只看数据流,又可能缺少理解某个值位于哪个分支、属于哪个函数的上下文。
代码属性图把多种程序表示组织到同一张带属性的图中,使查询能够在不同视角之间切换。
1.1 CPG 的基本组成
CPG 是一种有向、带标签、带属性的多重图。下图展示了 CPG 如何把 AST、CFG、数据流和调用关系按图层组织在一起:

图中左上角是同一段源码被拆解成语法结构、控制流、数据依赖等不同表示;右上角显示这些表示如何按图层堆叠成 CPG,并在统一图中通过不同类型的节点和边关联起来。可以先记住三个概念:
- Node(节点):表示程序中的实体,例如方法、调用、参数、局部变量、字面量和控制结构。
- Edge(边):表示实体之间的关系,例如语法包含关系、控制流关系或数据依赖关系。
- Property(属性):描述节点或边的细节,例如方法名、源码位置、参数顺序和代码文本。
例如,一个 CALL 节点可以记录调用名称、对应的代码片段和源码位置;一个
METHOD
节点可以记录方法名称、签名等信息。通过边,查询可以从调用点找到其所在方法,也可以进一步追踪参数或相关的数据流。
CPG 的设计重点是把不同程序表示放在一个可查询的结构里。Joern 通过不同的图层(layers / overlays)逐步增加不同抽象层次的信息,而不是要求每次查询都重新解析源码。
1.2 CPG 不是一张普通的调用关系图
把 CPG 理解成”函数 A 调用函数 B”的图会漏掉不少信息。下图把各组成元素并列展开:AST 给出语法结构,CFG 给出执行顺序,PDG 给出依赖关系,Call Graph 给出跨过程调用,程序元数据提供文件与位置信息,它们共同汇入 CPG,再交给 CPGQL 查询:

因此 CPG 不是单一 AST 的替代品,而是把结构、控制和依赖关系放进同一张图的组织方式。它还可以表示:
- 方法内部的语法树;
- 条件分支与控制流;
- 局部变量和表达式;
- 调用点及参数;
- 数据依赖与污点传播;
- 根据分析需求扩展的额外节点、属性和边。
这使同一个查询可以先定位某种调用,再查看调用参数、所在方法和周围控制条件。如果进一步启用了相应的数据流分析,还可以检查某个值是否可能传播到目标调用。
需要留意,CPG 的精度取决于语言前端、图层、调用解析和数据流语义。图中没有表示出来的关系,不会因为图结构统一了就自动变得可知。
2. Joern 的处理流程是什么?
从使用者角度看,可以把 Joern 的处理过程理解为下面几个步骤。
把这条链路展开,可以从四个角度理解:源码经由语言前端变成多种程序表示、这些表示汇总为 CPG、CPG 的节点与边如何组织、以及最终如何用于 Source 到 Sink 的数据流分析。

图中四块依次对应:源码经前端生成 CPG 的整体流程、同一段代码在 AST/CFG/数据流三种表示下的差异、CPG 的节点与边结构示例,以及基于 CPG 的 Source→Sink 数据流分析。右下角那部分值得留意:它把 name 参数经 new File(...)、getCanonicalPath()、startsWith(...) 直到 f.mkdir() 的路径完整画了出来——这正是查询阶段要定位的东西,不是单个危险调用,而是一条可解释的传播路径。
第一步:前端读取目标程序
Joern 通过不同的前端处理不同语言。例如,Java、C/C++、Python 和 JavaScript 使用各自的前端。前端负责解析代码,并生成后续图构建所需的表示。
Joern 的一项实用特性是,它可以在部分构建环境不完整的情况下尝试解析代码。不过,“可以解析”不代表所有类型信息、动态调用和框架语义都已经被完整恢复。对漏洞研究而言,解析成功只是起点。
第二步:构建 CPG
前端生成的程序表示被整理成 CPG。后续查询不需要每次重新扫描所有源码文本,而是从图中的节点、边和属性出发。
第三步:增加分析信息
不同分析会需要不同的图层。例如,查询函数调用和查询数据流所依赖的信息并不完全相同。Joern 提供了对应的分析 Pass 和数据流功能。
官方文档中,数据流示例会使用 run.ossdataflow 生成 OSS data-flow
layer。具体需要运行哪些 Pass,要根据目标语言、Joern
版本和当前查询来决定。
第四步:通过查询语言检索结果
Joern 的交互式 Shell 使用基于 Scala 的查询领域专用语言(Domain-Specific
Language,DSL),官方文档通常称为 CPGQL。它以 cpg
作为查询入口,通过一系列遍历步骤定位节点、筛选属性、追踪关系并输出结果。
第五步:解释结果并验证
查询命中只说明图中存在符合查询条件的结构或路径。它本身不等于漏洞成立。分析人员还需要检查上下文、安全约束和可触发条件;条件允许时,再使用测试验证实际行为。
3. CPGQL 怎么用?
刚开始不需要先学完整个 Scala。先掌握 Joern 查询里最常见的操作:从 cpg
出发,选择节点类型,筛选属性,沿关系继续查询,最后输出结果。
3.1 列出方法
cpg.method.name.l可以按下面的方式理解:
cpg:当前载入的代码属性图。method:选择图中的方法节点。name:读取方法名属性。l:执行遍历并将结果收集为列表。
这个查询适合检查前端识别出了哪些方法。对于大型项目,可以先用它了解当前 CPG 是否包含预期的代码。
3.2 查看某类调用
cpg.call.name("exec").code.l这个查询会寻找名称为 exec
的调用点,并返回其代码文本。它可以作为简单的危险 API
检索起点,但不能直接当成命令注入检测器。
原因很直接:同名方法可能有不同语义;即使找到了相关调用,也还要继续确认参数来源、调用上下文和防护逻辑。
也可以查看调用点所在的方法:
cpg.call.name("exec").method.name.l3.3 查看调用参数
cpg.call.name("exec").argument.code.l这条查询能帮助检查调用传入了哪些表达式。实际分析时,还可以进一步筛选参数位置、所在方法或源码文件。
需要注意,不同语言前端对调用名称、构造器和语法结构的表示可能不同。正式编写查询前,应先在目标仓库中检查相关节点的
name、code 和源码位置等属性。
3.4 查询语句是如何组合的?
CPGQL 的一个常见模式是:
cpg.<节点类型>.<筛选条件>.<关系遍历>.<属性输出>.l这只是帮助理解的示意形式,不是每个查询都必须严格遵循的语法模板。
例如,查询通常从 method、call、identifier 等节点类型开始,随后使用
name(...)、filter(...) 等条件缩小范围,再通过
method、argument、callIn、callOut 等遍历继续查询。
更多示例参见 Joern Traversal Basics 和 Calls。
4. 数据流分析:Source 如何到达 Sink?
漏洞变体分析里经常需要追踪不可信输入。这里最常用的概念是:
- Source:数据进入程序或变得可被攻击者控制的位置,例如命令行参数、HTTP 参数或文件内容。
- Processing:数据经过的函数调用、赋值、拼接、转换和检查。
- Sink:使用这些数据执行敏感操作的位置,例如文件访问、SQL 查询或命令执行。
可以用一条路径描述这个问题:
外部输入 ↓参数读取 ↓字符串拼接 / 转换 ↓安全检查 ↓敏感调用分析目标是确认:从 Source 到 Sink 是否存在数据流,以及路径上有哪些转换和控制条件。
需要说明的是,Source 到 Sink 的可达性只是候选条件。数据确实能抵达敏感操作,并不等于漏洞成立;路径上是否存在规范化、前缀检查或权限判断,还需要结合控制流一起看。
4.1 Joern 的 reachableBy 与 reachableByFlows
Joern 提供了两个常见的数据流查询步骤:
reachableBy:查询某个目标是否能够从指定的 Source 接收到数据,并返回相关来源。reachableByFlows:进一步返回数据流路径,便于检查数据经过了哪些节点。
官方快速入门使用了一个小型 C 程序 X42。其中,main 的参数 argv
被传给 strcmp。可以用下面的查询检查这个数据流:
def source = cpg.method.name("main").parameterdef sink = cpg.call.name("strcmp").argument
sink.reachableBy(source).l如果想查看路径,可以执行:
sink.reachableByFlows(source).p这个例子展示了如何从函数参数出发,检查数据是否能够流向某个调用的参数。它本身不代表发现了安全漏洞;这里的
strcmp 只是演示用的目标调用。
在实际项目中,通常需要先明确 Source 和 Sink 的定义,再根据语言和框架设置相应的语义。必要时还要计算所需的数据流图层,例如:
run.ossdataflow具体执行步骤请以当前 Joern 版本和目标语言的官方文档为准。
4.2 为什么数据流结果可能不够精确?
考虑一个函数调用:
result = helper(userInput, constantValue);如果分析器不知道 helper
的语义,就可能无法准确判断两个参数中哪些会影响返回值。为了避免漏掉路径,某些分析可能保留更宽泛的传播关系,结果就会出现额外路径。
这会带来两类问题:
- 误报:查询发现了数据传播路径,但实际安全条件已经阻止了危险行为。
- 漏报:分析缺少必要的函数或框架语义,没有识别出真实传播路径。
Joern 支持定义自定义数据流语义,显式描述特定调用的参数与返回值之间如何传播。对大型开源组件而言,这类语义配置可能是提高分析精度的重要工作。
5. Joern 为什么适合漏洞变体挖掘?
先把研究任务拆开。假设已经有一个真实的路径遍历修复补丁,需要寻找其他代码中的相似缺陷。
5.1 从补丁中提取安全约束
补丁分析需要回答:
- 修复前,外部输入如何影响文件路径?
- 修复后增加了什么校验或规范化逻辑?
- 这项检查保证了什么安全属性?
- 哪些调用路径必须遵守这项约束?
这一步主要涉及补丁差异分析和安全语义理解。Joern 可以提供代码结构、调用关系和数据流查询能力,但不会自动知道补丁背后的完整业务意图。
5.2 把安全假设转换为程序查询
假设分析人员确认:问题与不可信输入传播到文件操作有关,而且缺少必要的路径约束。
接下来可以用程序分析定位:
- 外部输入相关的节点;
- 文件操作相关的调用点;
- 两者之间可能的数据流;
- 相关函数和控制条件;
- 是否存在已知的规范化或验证函数。
Joern 在这一步负责检索程序证据。安全约束应该来自补丁、文档和人工审核,不能只根据某个危险函数名称推断。
5.3 在其他代码中搜索候选变体
候选检索可以先从同一个仓库开始,确认方法能否工作,再扩展到多个仓库或版本。
对于每个候选,至少记录:
- 对应的源码位置;
- Source、Processing 和 Sink;
- 与种子补丁相似的代码结构;
- 可能存在的安全检查;
- 仍未确定的触发条件。
这份证据可以作为后续测试生成器的输入。它能帮助系统选择应该验证哪个输入、哪个调用路径以及哪个安全约束。
5.4 最后进行独立验证
静态查询的结果应当进入单独的验证阶段:
- 固定目标版本和构建环境;
- 根据候选路径设计测试;
- 在隔离环境中执行测试;
- 比较修复前后的行为,或比较预期安全属性与实际结果;
- 记录可重复的证据;
- 由独立验证逻辑决定是否确认该问题。
整体流程可以表示为:
Joern 主要覆盖其中的程序表示、查询和候选路径分析部分。补丁语义理解、跨仓库任务调度、候选排序和动态验证,是研究系统需要额外实现的模块。
6. 安装和跑通第一个查询
官方安装文档列出了预编译版本和从源码构建两种方式。预编译版本通常更适合初次体验;如果后续需要开发前端、Pass 或插件,再考虑从源码构建。
6.1 安装预编译版本
当前官方安装文档列出的前置条件是 JDK 19;更新版本的 JDK 可能可用,但应以目标 Joern 版本的测试情况为准。
mkdir joern && cd joern
curl -L \ "https://github.com/joernio/joern/releases/latest/download/joern-install.sh" \ -o joern-install.sh
chmod u+x joern-install.sh./joern-install.sh --interactive安装脚本默认会将 Joern 安装到用户目录下的
~/bin/joern。实际安装路径以安装器输出为准。
官方文档:Installation。
6.2 导入一个代码仓库
启动 Joern:
joern在交互式 Shell 中导入本地仓库:
importCode(inputPath = "/path/to/repository", projectName = "demo")将路径替换成实际仓库路径。首次导入可能需要一段时间,具体耗时取决于项目大小、语言前端和机器资源。
导入后,先执行几个基础查询:
cpg.method.name.lcpg.call.name.lcpg.file.name.l如果返回的结果和项目内容明显不符,先检查路径、语言前端和解析日志,不要立即开始编写漏洞查询。
6.3 检查资源消耗
大型仓库的 CPG 构建和后续分析可能消耗较多内存。官方安装文档支持通过 JVM 参数调整堆内存,例如:
joern -J-Xmx8G8G
只是命令示例,实际值需要根据机器可用内存和项目规模确定。导入阶段可能会启动额外
JVM,因此不能只按交互式 Shell 的内存估算总消耗。
对于大型仓库,建议先在小型样本上确认前端和查询正确,再逐步扩大规模。
7. 使用 Joern 时需要注意的限制
7.1 解析成功不代表程序语义完整
Joern 的前端能够处理多种语言,部分情况下也能在构建环境不完整时完成解析。但动态调用、反射、框架注入、宏和依赖代码仍可能影响分析结果。
因此,漏洞报告需要保留解析状态、目标版本和证据位置,避免把缺失的分析信息当作不存在漏洞的证明。
7.2 数据流路径不等于漏洞
从用户输入到文件操作存在一条数据流,并不必然意味着路径遍历漏洞成立。路径可能已经被规范化,也可能在更早的位置经过了校验。
需要结合控制流、安全约束和实际测试判断。Joern 能帮助检查程序结构,不能代替完整的安全论证。
7.3 跨语言统一表示不意味着所有语言的分析精度相同
Joern 官方文档列出的语言包括 C/C++、Java、JavaScript、Python、x86/x64、JVM Bytecode、Kotlin、PHP、Go、Ruby、Swift 和 C# 等。不同前端的成熟度与语义建模能力有所差异,具体应查看当前版本的 官方概览 和 Frontends 文档。
同一条查询在不同语言中也可能需要调整。例如,Java 构造器、Python 动态调用和 C 函数调用的代码表示并不完全相同。
7.4 版本升级可能影响脚本
Joern 的查询 API、底层图存储和部分类型名称会随着版本迭代发生变化。官方仓库的变更记录提到,Joern v4.0.0 将底层图存储迁移到 flatgraph。
如果研究项目需要长期复现实验,建议固定 Joern 版本、运行环境和查询脚本,并为关键查询保存回归样例。不要把依赖内部实现的代码当成长期稳定接口。
8. 结合漏洞变体研究,应该怎样学习 Joern?
如果目标是做开源组件漏洞变体挖掘,建议按下面的顺序学习。
- 先掌握 CPGQL 基础。 能查询方法、调用点、参数和源码位置。
- 理解 AST、CFG、调用关系和数据流。 对同一个小型程序分别查询这些信息,观察每种表示能回答什么问题。
- 学习
reachableBy和reachableByFlows。 用官方示例验证 Source 到 Sink 的路径查询。 - 研究自定义数据流语义。 了解函数摘要如何影响数据流结果和误报。
- 从单个真实补丁开始。 手工确定根因,再尝试用 Joern 查询同类代码路径。
- 加入自动验证。 将候选路径关联到测试用例、测试结果和复现证据。
- 最后扩展跨仓库。 先证明查询在一个仓库中有效,再研究不同仓库的语言版本、依赖和框架差异。
学习材料可以从以下官方文档开始:
- Quickstart
- Traversal Basics
- Calls
- Data-Flow Steps
- Custom Data-Flow Semantics
- CPG Slicing
- Developing Plugins
9. 总结
Joern 的核心价值是把程序结构、调用关系和数据流放进可查询的代码属性图里。对于漏洞研究,它提供了一套可以重复使用的程序分析能力:先定位代码结构,再追踪调用和数据传播,最后把分析结果整理成候选路径。
对于基于历史补丁的漏洞变体挖掘,建议先选一个漏洞类别和一组经过人工核实的修复案例,提取明确的安全约束,再用 Joern 检索相似代码路径。候选问题必须经过独立验证,才能计入有效发现。
从工程角度看,最值得优先掌握的是三件事:
- 如何从 CPG 中定位目标函数和调用点;
- 如何查询 Source 到 Sink 的数据流;
- 如何将查询结果与补丁差异、测试用例和复现证据关联。
这三部分掌握后,就可以开始实现自己的补丁驱动查询和候选验证流程,而不必一开始就修改 Joern 的底层实现。
参考资料
- Joern GitHub:https://github.com/joernio/joern
- Joern 官方文档:https://docs.joern.io/
- Code Property Graph:https://docs.joern.io/code-property-graph/
- Code Property Graph Specification:https://cpg.joern.io/
- Installation:https://docs.joern.io/installation/
- Quickstart:https://docs.joern.io/quickstart/
- Traversal Basics:https://docs.joern.io/traversal-basics/
- Calls:https://docs.joern.io/cpgql/calls/
- Data-Flow Steps:https://docs.joern.io/cpgql/data-flow-steps/
- Custom Data-Flow Semantics:https://docs.joern.io/dataflow-semantics/
- Frontends:https://docs.joern.io/frontends/
- CPG Slicing:https://docs.joern.io/cpg-slicing/
- Developing Plugins:https://docs.joern.io/extensions/
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!









京公网安备11011402057358号