本节摘要:要理解 Joern,先理解它脚下的数据结构——代码属性图(Code Property Graph,CPG)。本节从三张「旧地图」讲起:AST 只管结构、CFG 只管流向、PDG 只管依赖,各自都有系统性盲区;然后看 CPG 如何用一个 schema 把三者融合:节点复用 AST 节点集,控制流边与数据依赖边挂在同一批节点上;接着解释「一张图」为什么改变了分析方式——结构、流向、依赖可以在一次遍历里同时询问,并举出三个维度逐级加深的问题实例;再用一个「审阅一段代码要查几次图」的小对照,量化分立图与统一图的效率差;最后与 CodeQL 的代码数据库做同异对比:都是「代码可查询」路线,一个用关系表加逻辑谓词,一个用属性图加遍历 DSL。编译原理术语的出处与深讲见《build-your-own-x》第 09 章《造操作系统与编译器》。
编译器与逆向工程各画过一张地图:
| 地图 | 回答的问题 | 看不见的东西 |
|---|---|---|
| AST 抽象语法树 | 结构:谁包含谁、括号怎么配 | 执行顺序、数据影响 |
| CFG 控制流图 | 流向:从哪执行到哪、跳转关系 | 数据从哪来到哪去 |
| PDG 程序依赖图 | 依赖:谁影响谁(数据与控制依赖) | 结构层级、直观执行序 |
拿一段三行代码看盲区(示意):
# three_maps.txt —— 同一段代码在三种图里的成像 if (auth_ok) ── CFG 看见:两个分支与汇合点 strcpy(buf, input); ── AST 看见:调用表达式嵌套在 if 内 log_access(user); ── PDG 看见:input 影响 buf,user 影响 log_access 的参数
单一地图都答不出安全分析里最常见的那类问题:「input 这个外部数据,是否可能在 auth_ok 为真的路径上流进 strcpy?」——它同时需要依赖(PDG:input 影响什么)、路径(CFG:这条边在不在检查之后)与结构位置(AST:调用发生在哪个函数的哪层块里)。
把三张地图对同一问句的「哑区」排开看更直观:
| 问句 | AST 答 | CFG 答 | PDG 答 | 需要的组合 |
|---|---|---|---|---|
| strcpy 在哪个函数里 | 能 | 绕 | 绕 | AST 单独可答 |
| 检查一定在调用前吗 | 不能 | 能 | 不能 | CFG 单独可答 |
| input 影响 buf 吗 | 不能 | 不能 | 能 | PDG 单独可答 |
| 受控 input 能流进 strcpy 且过检查吗 | 不能 | 不能 | 不能 | 三图联合 |
传统做法是把三张图各自建好,然后在三个数据结构之间来回切换分析逻辑:先在 PDG 上查到数据流候选,再切到 CFG 验证路径,再回 AST 找上下文——每次切换都是一次「导出、对齐、再导入」的工程损耗,而节点编号在三种图里往往还不一致。
CPG 的 schema 思想极其朴素:节点集合直接复用 AST 的节点(函数、调用、赋值、标识符……),再往同一批节点上加挂不同标签的边——AST 边(包含关系)、CFG 边(执行顺序)、PDG 边(数据与控制依赖)。示意图:
# cpg_schema.txt —— CPG:AST 节点为底,三种边并存 ┌────────────┐ │ if 语句节点 │ └─────┬──────┘ AST 边 ┌───────┴────────┐ AST 边 ▼ ▼ ┌──────────┐ ┌────────────┐ │ 条件表达式 │ │ 调用 strcpy │ └────┬─────┘ └──┬─────┬───┘ │ PDG 边 │CFG边 │ AST 边 │ ┌────────────┘ ▼ ▼ ▼ 参数节点 标识符 input ──PDG 边──▶ 参数 buf
三种边并存意味着一个节点的「邻居」天然携带三种语义:往 AST 边走是看结构,往 CFG 边走是看路径,往 PDG 边走是看影响。节点上的属性(源码位置、代码文本、类型信息若有)让每一步遍历的产出都能直接落回源码行——分析结果永远可以说「在第几行」。
Joern 把这个 schema 实现为可落盘、可增量加载的图数据库,并配上 Scala 语言的查询 DSL——它自称 Bug Hunter 的 Workbench(猎虫者的工作台),定位就是给「愿意写几行查询的安全研究员」用的平台。学术源头可以追到代码属性图的原始论文(Yamaguchi 等,2014,把三种图融合用于漏洞模式挖掘),Joern 是这一思想的工程化旗舰。
变化不在数据量,在提问方式。分立的图只能回答单维度问题;CPG 允许把三个维度写进一条遍历里。三个维度逐级加深的真实问题:
第三类问题正是「可达性判定」的雏形——第 06 章里 semgrep 污点模式勉强答、CodeQL 污点查询认真答的问题,在 CPG 里变成一次图遍历:先按结构定位调用点,沿 PDG 边回溯污染源,再用 CFG 边检查路径上是否经过净化检查。
对研究者更重要的差别是可探索性:CodeQL 的查询面向「已想清楚的模式」,一次成稿;CPG 遍历面向「边走边看」的探索,一条查询的输出可以直接变成下一条查询的输入——
# explore_loop.txt —— 探索式分析的滚动循环 查询一:列出全库危险调用 │ 看到输出,挑一个可疑函数 ▼ 查询二:该函数的参数从哪来(PDG 回溯) │ 发现来自入口函数 ▼ 查询三:入口到该函数的路径上有无净化(CFG 检查) │ 结论成型 ▼ 固化成 .sc 脚本,入库复用(第 7.2 节)
逆向工作恰恰是探索密集型的:你几乎从不知道要找什么,直到你看见它。这是「一张图」最深层的价值。
两者同属「把代码变成可查询模型」的路线,工程形态不同:
| 维度 | CodeQL | CPG(Joern) |
|---|---|---|
| 模型形态 | 关系数据库(表与谓词) | 属性图(节点与边) |
| 查询语言 | QL(声明式逻辑) | Scala DSL(图遍历) |
| 风格 | 写清条件,引擎推导 | 亲自遍历,逐步试探 |
| 污点分析 | 引擎级,成熟 | 内置污点引擎,可用 |
| 建库门槛 | 编译型语言要真实构建 | 源码可直接导入,无需构建 |
| 二进制支持 | 不涉及 | 经 Ghidra 前端可导入 x86 |
(语言成熟度以 Joern 官方文档为准:C、C++、Java 很高;JavaScript、Python 与 x86 二进制属于高;Kotlin、PHP、Go、Swift 属于中等——2026-10 官方口径。)
一句话选型直觉:模式已明确、要大规模复跑,用 CodeQL;模式还在成形、要交互式探索、或要碰二进制,用 Joern。第 6.3 节的流水线图可以再添一格:CPG 探索位于「快筛之后、深挖前后皆可」,它的产出(一条想清楚的遍历)既可沉淀成 QL 查询,也可以直接进第 10 章的 AI 验证器清单。
三种「分析姿态」的对照当作本章心法:
| 姿态 | 代表 | 你写什么 | 引擎干什么 |
|---|---|---|---|
| 规则 | semgrep | 模式 | 逐文件匹配 |
| 声明 | CodeQL | 条件 | 全库逻辑推导 |
| 遍历 | Joern | 路线 | 沿边走给你看 |
三种姿态没有高下,只有阶段:探索期用遍历形成假设,稳定期把假设写成声明或规则——第 10 章的 AI 方法论里,模型负责的正是「遍历期的阅读」,引擎负责的正是「稳定期的判定」。
图的道理讲完了,接下来动手:装上 Joern,把一个真实代码库导进去,在 REPL 里亲手写第一遍历——你会发现在图上「问问题」比想象中便宜得多。