定位
前面几篇讲的是”人工怎么看代码找漏洞”,本篇讲的是怎么让机器帮你找——自动化审计体系的核心原理:SAST 分层原理、AST、污点分析、符号执行、CodeQL、SCA,以及灰盒体系(IAST 主/被动、RASP 原理与对抗、埋点深浅)。
这一篇的知识密度高,但请记住一条主线:所有自动化审计技术,本质上都是在回答同一个问题——“用户输入能不能流到危险函数?” 各种技术的区别只在于回答这个问题的方式和精度。对应题库四.20-23、46、48-49、51-59。
先修知识
读这篇之前,先确保下面这些词你都认识(每个一句话白话解释):
- 源代码 / 字节码:源代码是人写的代码(
.java、.php);字节码是源代码编译后的中间产物(Java 的.class),机器味更重但还能分析出结构。 - 编译:把人能看懂的源代码翻译成机器能执行的东西的过程。Java 编译成字节码,C 编译成机器码。
- 危险函数(Sink):一行代码真正”出事”的地方,比如
executeQuery(执行 SQL)、Runtime.exec(执行系统命令)。类比:厨房里那把菜刀。 - 输入入口(Source):不可信数据进入程序的地方,比如
request.getParameter()(取用户提交的参数)。类比:快递包裹进你家的门。 - 污点 / 污点传播:把用户输入想象成沾了红漆的手,它摸过的变量、拼过的字符串都会”沾漆”——污点分析就是追踪这摊红漆最后有没有碰到菜刀。
- 白盒 / 黑盒 / 灰盒:白盒=能看到源码(像看图纸验房);黑盒=只能从外面打(像试驾时只踩油门看反应);灰盒=程序跑着,但肚子里装了个窃听器(像试驾时副驾坐了个观察员)。
- 插桩(Instrumentation):不改源码,在程序运行时往关键位置”塞探针”,让程序经过那里时顺便汇报一声。类比:在流水线的传送带上装传感器。
- javaagent:JVM 官方提供的”外挂入口”,启动时加一个
-javaagent:xxx.jar参数,这个 jar 就能在类加载时改写字节码。IAST 和 RASP 在 Java 世界基本都靠它实现。 - WAF:架在应用前面的流量防火墙,只看 HTTP 请求长什么样,看不到应用内部发生了什么。
- CI/CD:代码从提交到上线的自动化流水线,提交→构建→测试→部署全自动。本篇多次提到”工具进不进得了 CI/CD”。
- CVE / 漏洞库:CVE 是全球统一的漏洞编号体系(如 CVE-2021-44228 就是 Log4Shell);NVD 等漏洞库记录”哪个组件哪个版本受哪个 CVE 影响”。
SAST 原理分层
SAST 是什么
SAST(静态应用安全测试):不运行程序,直接对源码/字节码做”纸上分析”,找出可疑代码。
- 是什么:一句话——验房师不搬进去住,只拿着图纸和结构计算书找隐患。程序一行不跑,工具靠”读代码”推断哪里可能出事。
- 为什么需要它:人工审计一个几十万行的项目要几个月,而且人会累、会漏。开发者写代码时最常见的错误(拼 SQL、
exec用户输入)模式非常固定,适合机器地毯式排查。 - 代价:纸上推演不等于真实发生——SAST 觉得”理论上可达”的路径,运行时可能根本走不到(比如前面有个前置校验),所以误报天然偏高。
五层能力阶梯
SAST 不是一个技术,而是一族技术。按”对代码理解得多深”由浅到深分五层,层次越高,回答”用户输入是否可控”的能力越强,但实现成本也越高:
| 层次 | 技术 | 产出 | 能力边界 |
|---|---|---|---|
| L0 文本/词法匹配 | 正则、token 匹配 | 危险函数出现位置 | 不理解语法,误报漏报都高;只能做”检索辅助” |
| L1 语法分析 | 词法→语法分析生成 AST | 代码结构化树 | 能区分”字符串里的 exec”和”函数调用 exec”,能精确匹配节点 |
| L2 控制流 | 在 AST 上构建 CFG(控制流图) | 基本块+跳转边 | 能分析分支、循环、调用顺序 |
| L3 数据流/污点 | 在 CFG 上做到达定值、污点传播 | Source→Sink 路径 | 能回答”用户输入是否流到危险函数”,跨过程分析是分水岭 |
| L4 路径敏感/符号执行 | 符号值+路径约束+SMT 求解 | 可到达路径上的精确条件 | 最精确,但受路径爆炸限制 |
用一个生活类比理解这五层——假设你在审查一本小说里”凶手有没有碰到凶器”:
- L0 正则:Ctrl+F 搜”刀”这个字。搜到”刀叉牛排”也报警,搜不到”利器”就漏报。
- L1 AST:看懂句子成分,知道”他拿起了刀”里”刀”是宾语、是名词,不是菜名。
- L2 控制流:看懂情节发展顺序,知道”拿刀”发生在”进厨房”之后。
- L3 污点:追踪”刀”这个道具在全书人物手里怎么流转——凶手接过的刀最后出现在案发现场,证据链成立。
- L4 符号执行:推演所有剧情分支,算出”在什么样的具体条件下,凶手一定会拿到刀”。
面试核心对比:纯规则匹配 vs 数据流分析
这是 SAST 部分最高频的考题,务必吃透:
- 纯正则只能告诉你”这里有
executeQuery”,回答不了三个关键问题:参数是不是用户可控的?中间有没有被过滤?这条路径运行时走不走得到? - 数据流分析能给出完整的 Source→Sink 证据链(“参数从
getParameter进来,过了一个无效的 escape,到达executeQuery”),但建模成本高,遇到反射、回调、框架约定(Spring 的 AOP、动态代理)容易断链——链路一断,工具就瞎了。
Fortify vs Seay:两条技术路线的对比
学审计工具绕不开这两个典型代表,一个是”重型语义引擎”,一个是”轻量检索辅助”:
| 维度 | Fortify SCA(商用,现属 OpenText) | Seay 源代码审计系统(法师开发,主要面向 PHP) |
|---|---|---|
| 引擎 | 源码翻译成中间表示(NST),再跑多分析器:Dataflow、Control Flow、Semantic、Structural、Configuration、Buffer 等 | 正则匹配危险函数/特征,再向上文本回溯变量来源 |
| 规则 | source / passthrough / sink / cleansing 四类规则包(rulepack),数据流分析器据此传播污点 | 内置危险函数库 + 可自定义正则 |
| 优势 | 真正理解语义,能跨函数跨文件给出完整污点路径,漏报相对低 | 轻量、快、对 PHP 项目开箱即用,适合人工审计辅助 |
| 局限 | 反射、动态代理、框架回调(Spring/AOP)、多态导致断链;误报仍需人工 audit;规则包不开源 | 不理解语法语义:字符串拼接绕过正则即漏报;跨函数/跨文件/面向对象回溯基本靠人;无法判断 sanitizer 有效性 |
| 产物 | FPR 报告 + Audit Workbench 审计界面 | 命中列表,靠人逐条回溯确认 |
Warning
记住两句话,面试直接背:
- Fortify 报”高危”不等于漏洞成立——它默认保守传播(宁误报不漏报);
- Seay 没报不等于没有——正则没匹配到拼接变体就漏了。 两者都只负责定位”嫌疑点”,定性永远靠人工数据流确认。这就是为什么说自动化工具是审计员的放大镜,而不是替代品。
Seay 实际怎么用(零基础操作演示):
- 把 PHP 项目源码目录拖进 Seay;
- 点”自动审计”,它会按内置危险函数库(
eval、system、include、unserialize、file_get_contents等)全项目正则匹配; - 得到一张命中列表,每条是”文件 + 行号 + 命中的危险函数”;
- 从这里开始就是纯人工:双击跳到该行,肉眼往回翻——这个变量哪来的?是
$_GET还是写死的常量?中间有没有intval()、addslashes()? - 确认可控且无有效过滤,才构成漏洞。
可以看到第 4 步完全靠人,这就是 L0 工具的定位:它帮你把”几万行代码里找危险函数”从几天压缩到几秒,但确认工作一分不少。
AST:代码的结构化表示
AST 是什么
AST(抽象语法树,Abstract Syntax Tree):语法分析的结果——把源码从一串字符变成一棵树,每个节点是一个语言结构(方法调用、二元表达式、赋值),叶子是标识符和字面量。
- 生活化类比:语文课的”句子成分分析”。“小明在操场打篮球” → 主语:小明,状语:在操场,谓语:打,宾语:篮球。AST 就是对代码做的成分分析,而且做成了一棵树,程序可以顺着树枝精确找到任何一个成分。
- 和 CST 的区别:CST(具体语法树)保留所有语法细节(括号、分号都在树上);AST 丢弃这些纯语法细节,只保留语义结构。审计用的是 AST——你关心”这是个拼接表达式”,不关心程序员括号写在哪。
例如这行经典的 SQL 拼接漏洞代码:
String sql = "SELECT * FROM user WHERE id = " + id;它的 AST 主干:
VariableDeclaration
├── type: String
└── initializer: BinaryExpr(+) ← 审计关注点:拼接
├── left: StringLiteral("SELECT * FROM user WHERE id = ")
└── right: NameExpr(id) ← 污点追踪的起点之一
注意看这棵树给了我们什么正则给不了的信息:
- 右边是
NameExpr(id)——一个变量,不是字面量。数据流分析接下来就会问:id这个变量是谁赋的值?追到request.getParameter("id"),污点链就连上了。 - 如果代码写的是
String tip = "不要用 + exec 拼接 SQL";,正则搜exec会误报,但 AST 里这只是一个 StringLiteral 节点,不是方法调用,L1 以上的工具直接无视。
生成/操作 AST 的主流工具链
| 工具 | 特点 |
|---|---|
javac Tree API(com.sun.source.*) | JDK 自带,JavacTask 可在编译早期拿到带类型标注的 AST,准确度最高但 API 偏底层 |
| JavaParser / Eclipse JDT / Spoon | 纯 Java 库,无需完整编译,适合审计工具二次开发 |
| ANTLR | 文法驱动(.g4)生成任意语言的 lexer/parser,适合自定义 DSL 审计 |
| tree-sitter | 增量解析、容错强(代码写一半也能出树),编辑器/GitHub 代码导航在用,适合对不完整代码做快速结构匹配 |
怎么选:自己写审计小工具,首选 JavaParser(Java 项目)或 tree-sitter(多语言/代码可能不完整);需要类型信息(“这个 dao 到底是哪个类的”)才上 javac Tree API 或 JDT。
污点分析三要素与动态污点追踪
污点分析是什么
- 是什么:一句话——给不可信数据贴标签,追踪标签在程序里的传播路径,看标签最后有没有贴到危险函数上。类比:疾控中心做流调——确诊病例(Source)去过哪(传播路径),接触了谁(哪个函数被污染),最后有没有进 ICU(Sink)。
- 为什么会出现漏洞(从开发者动机讲):开发者拿用户输入拼 SQL,动机通常很朴素——“就查个数据,拼一下字符串多省事,预编译还得写
?和setString,麻烦”。他不知道危险,或者觉得”这个参数前端有校验/用户不会乱填”。污点分析就是专门抓这种”图省事拼字符串”的行为。 - 审计时怎么找:先背熟三要素,然后在代码里做”双向奔赴”——从 Source 往下推(正向),或从 Sink 往上追(反向)。实务上反向更常用:Sink 数量有限,先把所有 Sink 列出来,逐个往回追到 Source。
三要素(必背)
- Source(源头):不可信输入入口。Web 侧典型:
request.getParameter、getHeader、getCookies、@RequestParam、$_GET/$_POST、文件上传、反序列化入口。凡是从”程序外面”进来的数据,一律按不可信处理。 - Sanitizer(净化器):净化函数。如参数化查询、
ESAPI.encoder()、HTML 转义。判定点是”对当前 Sink 类型是否有效”——SQL 转义对 XSS 无效,intval()对数字型注入有效但对字符串路径无效。面试最爱考”这个过滤函数到底管不管用”。 - Sink(汇聚点):危险函数。
Statement.executeQuery、Runtime.exec、new File(path)、response.getWriter().write、ObjectInputStream.readObject。
动态污点追踪
静态污点分析是”看图纸推演”;动态污点追踪换个思路——在运行时给数据真打标签:Source 处打标,标签随拷贝、拼接、函数调用在内存中传播,Sink 处检查数据是否带标。类比:不猜快递有没有沾红漆,直接在包裹上涂荧光粉,最后在厨房用紫外灯一照就知道。
典型实现:
- Phosphor:JVM 级动态污点追踪,通过 javaagent + ASM 给 JVM 类插桩增加污点字段,无需改源码;
- TaintDroid:Android 平台经典研究项目,在 Dalvik 虚拟机层面做污点追踪,变量级/方法级/消息级粒度(是 Android 4.x 时代的成果,思路影响至今)。
优缺点对比:
- 优点:路径精确——它看到的是真实跑过的路径,不存在”理论可达但实际走不到”的误报方向问题;
- 缺点:性能开销大(每个变量多带一个标签字段),且隐式流难以追踪——比如
if (secret > 100) x = 1,x的值间接泄露了secret的信息,但x并没有拷贝secret的数据,标签传不过去。
符号执行与约束求解
符号执行是什么
- 是什么:一句话——不给程序具体输入,给符号(α、β),让程序”空跑”,把每条路径上的条件攒成方程组,最后解方程反推出”什么样的输入能走到危险点”。类比:不用一辆辆车去试哪条路线会掉进坑里,而是把地图建成方程,直接算出”车速>80 且雨天”时必掉坑。
- 为什么用它:数据流分析能告诉你”这条链理论上通”,但说不清”到底什么输入能触发”。符号执行能把触发条件精确算出来,是 SAST 的精度天花板。
看一段最小示例,逐行看约束怎么攒:
x = input() // x = α(符号,不是具体值)
if (x > 10) // 走进这个分支,路径约束 += "α > 10"
if (x + 5 < 0) // 再进这个分支,路径约束 += "α + 5 < 0"
danger() // 要想到达这里,需要同时满足 α > 10 且 α + 5 < 0把约束 α > 10 ∧ α + 5 < 0 交给 SMT 求解器(Z3,微软出品;同类还有 CVC5),求解器一算:无解——danger() 是死代码,不可达。这条路径被剪掉,不用报。这就是符号执行比纯数据流精确的地方:数据流说”链是通的”,符号执行说”但条件自相矛盾,实际走不通”。
工程局限与代表工具
- 路径爆炸:每遇到一个分支,路径数翻倍,代码一多就是指数级;循环展开后更失控(一个
while理论上无限条路径)。缓解手段:启发式路径搜索、约束缓存/合并、限制求解时间、与模糊测试结合(concolic/白盒 fuzzing,如微软 SAGE)。 - 代表工具:KLEE(LLVM bitcode)、angr(二进制)、Symbolic PathFinder(Java 字节码,基于 Java PathFinder)。
- 面试定位(背这句):符号执行是 SAST 精度天花板,但工程上只能用于小规模关键路径验证(比如验证一个补丁是否修干净了),全项目扫描仍靠数据流分析。
CodeQL:把代码变成可查询的数据库
CodeQL 是什么
- 是什么:一句话——把整个项目的代码抽成一个数据库,然后用类似 SQL 的查询语言(QL)去”查”漏洞。理念叫 “Code as data”(代码即数据)。类比:图书馆把所有书的内容录入检索系统,你不再逐架翻书,而是写一条检索条件”找出所有’用户输入不加过滤直接拼进 SQL’的书页”。
- 为什么会出现这类工具:传统 SAST 的漏洞规则是工具厂商写死在引擎里的,你想查一个自己项目特有的模式(比如”我们内部框架的
UnsafeQuery.run()不允许接getParameter”)基本没门。CodeQL 把”漏洞模式”变成了用户自己可写的查询,这是它革命性的地方。
工作流程(面试要能说全)
- 抽取建库:编译型语言(Java/C/C++/Go/C#)由 extractor 在编译过程中记录 AST、类型、数据流信息,生成关系型数据库(snapshot/TRAP);解释型语言(JS/Python)直接抽取源码。所以编译型语言必须能编译成功,这是 CI 集成的硬性前提。
- 写查询:用 QL(声明式逻辑语言,语法近似 Datalog/SQL)描述漏洞模式。
- 跑分析:引擎在数据库上执行查询,输出结果。
一条真实感的数据流查询长这样(看懂结构即可,不要求会写):
import java
import semmle.code.java.dataflow.TaintTracking
// 语义级描述:任何 RemoteFlowSource 不经 sanitizer 到达 QueryInjectionSink 即告警关键点:查询里描述的是语义模式(source 类型、sink 类型、sanitizer 条件),不是字符串匹配。引擎基于 DataFlow::Node 和 TaintTracking 配置自动做全局污点传播(现代版本是 TaintTracking::Global 模块),跨函数跨文件的路径它自己推。
三个工程问题(面试高频)
① 能否进 CI/CD? 能,而且是主要落地形态:
- GitHub code scanning(GitHub Action)原生集成,PR 上自动跑;
- 本地/自建 CI 用 CodeQL CLI:
database create(建库,编译型语言要保证构建成功)→database analyze(跑查询),结果输出 SARIF 标准格式,可以喂给任何漏洞管理平台。
② 断链怎么办? 第三方库没有源码,污点流进库里就断了。解法叫 MaD(Models as Data):用 YAML 给第三方库补充模型——声明哪个方法是 source、哪个是 sink、参数在方法内部怎么传递(summary)。框架回调导致的断链可以写自定义 source。排查断链的思路也很固定:先把 sink 反过来当 source 查反向流,定位链路断在哪一跳,再在断点补模型。
③ 误报怎么处理? 三招,按推荐顺序:
- 收紧查询:给查询加 sanitizer 条件、限定 source 类型——治本,一次修改全员受益;
- 行内抑制注释:
// codeql[query-id]——只压这一条,适合确认过的个别误报; - 在 code scanning 后台标记 false positive——最省事,但注意别误改共享查询影响别的项目。
SCA:软件成分分析
SCA 是什么
- 是什么:一句话——SAST 查”你自己写的代码”,SCA 查”你引用的第三方依赖”。类比:食品质检——SAST 是检查你的菜谱和厨艺,SCA 是检查你买的配料里有没有被召回的批次(“这批次的 Fastjson 1.2.24 有公开 RCE 漏洞,你货架上有”)。
- 为什么需要它:现代项目 70% 以上的代码来自第三方依赖(Log4j、Fastjson、Spring……)。开发者图省事引入一个库,往往不知道这个版本带洞,更不知道自己传递引入(A 依赖 B,B 依赖有洞的 C)了什么。Log4Shell 爆发时,无数企业连”我用没用 Log4j”都答不上来——SCA 就是干这个的。
原理与工具
- 原理:解析依赖声明与锁定文件(
pom.xml、build.gradle、package-lock.json、Pipfile.lock、go.sum),还原含传递依赖的完整依赖树,把”组件名+版本”与漏洞库(NVD、GitHub Advisory、厂商私有库)的受影响版本区间比对,命中即报。 - 代表工具:OWASP Dependency-Check、Snyk、Trivy(也能扫镜像)、国内墨菲安全等。
关键局限:依赖存在 ≠ 漏洞可达
这是 SCA 面试的核心考点:
- 项目里引了 Log4j 2.14 不等于 Log4Shell 可利用——得代码里真的走到触发点(比如真的把用户输入交给
logger.error()打印)才行; - 高级 SCA 会结合调用链做 reachability(可达性)分析:只有存在从你的代码到漏洞函数的调用路径才报高危,其余降级——这是降误报的关键手段;
- 私有组件、改名重打包的组件,名字对不上,需要指纹匹配(文件哈希、类结构哈希)来识别。
SAST / DAST / IAST 对比
三者不是竞争关系,是互补关系。先用一个类比建立直觉——还是验车:
- SAST = 只看设计图纸和零部件清单审车,车不用造出来;
- DAST = 车造好了,从外面踹门、撞墙做碰撞测试,不拆车;
- IAST = 碰撞测试时车里装满了传感器,每一次撞击内部哪个零件受力、怎么变形都录得清清楚楚。
| 维度 | SAST(白盒) | DAST(黑盒) | IAST(灰盒) |
|---|---|---|---|
| 输入 | 源码/字节码 | 运行中的应用 URL | 运行中的应用 + 插桩 agent |
| 是否需要可运行环境 | 否 | 是 | 是 |
| 检出依据 | 静态数据流可达 | 发包看响应特征 | 运行时真实数据流 |
| 误报 | 高(理论可达≠实际可达) | 低(响应证据确凿) | 低(运行时真实路径) |
| 漏报 | 低(覆盖全代码,含未暴露接口) | 高(爬虫覆盖率决定上限,藏深的接口测不到) | 取决于流量覆盖 |
| 定位能力 | 精确到行 | 只到 URL/参数 | 精确到行 + 调用栈 |
| 对反射/动态加载 | 弱 | 无感(测的是运行时) | 天然支持 |
| 典型工具 | Fortify、CodeQL、Semgrep | Burp Scanner、AWVS、Xray | 洞态 IAST(DongTai)、Contrast |
记法:SAST 胜在覆盖全、DAST 胜在证据实、IAST 两者兼顾但要运行环境。所以实战组合是 SAST 地毯式排嫌疑 + DAST/IAST 做运行时再确认。
主动 IAST vs 被动 IAST
IAST 内部再分两派,区别在”攻击流量谁来造”:
| 维度 | 主动 IAST | 被动 IAST |
|---|---|---|
| 工作方式 | DAST 扫描器主动发攻击 payload,agent 在内部观测 payload 的数据流走向,双向验证 | 不主动发包,只观测功能测试/日常流量中的真实数据流 |
| 脏数据问题 | 有:攻击 payload(' or 1=1--、../../../etc/passwd)会被业务逻辑正常处理并落库,污染测试数据,需清理 | 无:不产生额外流量,适合直接挂生产镜像环境观测 |
| 覆盖率 | 由扫描器决定,可对单接口高强度测试 | 由测试用例决定,功能没测到的接口就是盲区 |
| 结论 | 检出率上限高,但必须隔离环境跑 | 零侵入零脏数据,检出率是功能测试覆盖率的子集 |
Warning
面试答”主动 IAST 最大的工程问题”就是脏数据:payload 通过业务校验写入数据库后,可能造成后续功能异常、报表污染,甚至触发下游风控。所以主动 IAST 必须跑在可重置的测试环境。被动 IAST 反过来——它的天花板不是技术,是你们测试用例的覆盖率,功能测试没点过的接口它就是瞎的。
RASP:运行时应用自保护
RASP 是什么
- 是什么:一句话——把防护逻辑直接注入应用运行时内部,在危险函数被调用的那一瞬间,拿着完整上下文(参数+调用栈)做判定,在应用进程里直接阻断攻击。类比:WAF 是小区门口查证件的保安(只看进出的包裹外表);RASP 是贴身保镖(站在主人旁边,谁递刀他按谁的手)。
- 为什么会出现:WAF 有两个先天缺陷——只看到加密/编码后的流量外表,容易被变形绕过;不知道应用内部上下文,分不清正常请求和攻击。RASP 站在应用内部,这两个缺陷都不存在。
- 审计/防守视角怎么用:理解 RASP 的插桩原理,你才能评估”它覆盖了哪些 Sink、哪些路径是漏的”——这既是防守部署的检查项,也是红队绕过的突破口(见后文”对抗”一节)。
javaagent 字节码 hook 全流程(Java RASP 标准链路)
下面五步是 Java RASP 的标准实现路径,每一步都给了代码,逐行带读:
第 1 步:JVM 启动参数挂 agent:
java -javaagent:/opt/rasp/agent.jar -jar app.jar第 2 步:JVM 在任何业务类加载前,先回调 agent 的入口方法 premain:
public class RaspAgent {
// JVM 启动时最先调用这里,比 main() 还早
public static void premain(String args, Instrumentation inst) {
// 关键动作:注册一个"类转换器"
// 之后 JVM 每加载一个类,都会先问这个转换器"要不要改?"
inst.addTransformer(new HookTransformer());
}
}第 3 步:ClassFileTransformer.transform() 在每个类加载时拿到原始字节码。匹配到目标类(比如 java.lang.ProcessBuilder)就交给 ASM 框架改写。下面这段是 RASP 的”心脏”,逐行注释:
// classBytes = JVM 正在加载的类的原始字节码
ClassReader cr = new ClassReader(classBytes);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
cr.accept(new ClassVisitor(Opcodes.ASM9, cw) {
@Override
public MethodVisitor visitMethod(int acc, String name, String desc,
String sig, String[] ex) {
// JVM 挨个方法问我们,name 是当前方法名
MethodVisitor mv = super.visitMethod(acc, name, desc, sig, ex);
if (name.equals("start")) { // 找到目标:ProcessBuilder.start()
// AdviceAdapter 能在方法入口/出口插入我们自己的字节码
return new AdviceAdapter(Opcodes.ASM9, mv, acc, name, desc) {
@Override
protected void onMethodEnter() {
// 在 start() 方法的第一行之前插入一句:
// HookHandler.checkCommand(this.command);
// ——先拿命令参数去判定,判定为攻击就 throw SecurityException 阻断
// 原始命令根本没机会执行
}
};
}
return mv;
}
}, 0);看懂这段就懂了 RASP 的本质:不改 jar 包一个文件,在类加载进内存的瞬间做了”换芯手术”——ProcessBuilder.start() 的第一句代码被神不知鬼不觉地换成了安全检查。
第 4 步:按同样套路,把插桩点铺满所有危险 Sink:Runtime.exec / ProcessBuilder.start(命令执行)、Statement.execute*(SQL)、ObjectInputStream.readObject(反序列化)、文件读写、JndiManager.lookup(JNDI 注入,Log4Shell 的命门)等。
第 5 步:判定。RASP 相比 WAF 的杀手锏是调用栈:它能区分”业务正常调用 exec 做图片压缩”和”反序列化 gadget 链触发的 exec”——看调用栈里有没有 ObjectInputStream 就知道。这个信息流量层的 WAF 永远拿不到。
OpenRASP 原理
OpenRASP 是百度 2017 年开源的 RASP 实现,架构就是上面那套:javaagent + ASM 在 JVM 层 hook 各类危险函数。它的设计亮点是把”hook 工程”和”检测逻辑”解耦:
- hook 点触发后,把上下文(参数、调用栈、请求信息)交给 JavaScript 编写的检测插件判定(JVM 内嵌 JS 引擎执行);
- 插件可热更新——改检测规则不用重启应用,运维友好;
- 判定结果分 log / block 两种动作,新规则先 log 观察再开 block,防止误杀业务;
- 覆盖类型:命令执行、SQL 注入、文件操作、反序列化、XXE、SSRF 等。
RASP 与 IAST 的区别
它俩底层是同一套技术(javaagent 插桩),区别在目的和部署位置:
| 维度 | RASP | IAST |
|---|---|---|
| 目的 | 防护:实时阻断攻击 | 测试:发现漏洞交给开发修 |
| 部署 | 生产环境 | 测试环境 |
| 判定视角 | 攻击特征 + 调用栈上下文 | 污点数据流是否到达 sink |
| 输出 | block/log 安全事件 | 漏洞报告(含代码行、调用栈) |
| 共同点 | 都是 javaagent 插桩,都拿运行时真实上下文,误报都远低于流量层方案 |
一句话记:同一把刀,IAST 拿它解剖(找漏洞),RASP 拿它防身(拦攻击)。
对抗 RASP 的思路(检测与绕过,客观介绍)
学攻防要两边都会。攻击方怎么探测 RASP 存在:
- 读
RuntimeMXBean.getInputArguments(),启动参数里有-javaagent就暴露了; - 故意触发一次危险调用,看异常堆栈里有没有 hook 类名(比如带
rasp字样的包名); - 枚举已加载的
ClassFileTransformer。
绕过层面的客观分类(每条附防守对策):
- 走未覆盖的等价路径:hook 了
Runtime.exec/ProcessBuilder但没 hook 底层 native 方法ProcessImpl.forkAndExec时,反射直达;或通过 JNI/自定义 native 库执行命令(对策:hook 点下沉到 native 边界,native 方法是绕不过去的最后一道门); - 参数变形骗过判定:把命令写进脚本文件再执行脚本、用通配符/编码/环境变量展开,让 hook 处看到的入参”看起来正常”——hook 处看到
bash /tmp/a.sh,判定器不知道a.sh里是什么(对策:判定逻辑做归一化+栈溯源,看调用来源而不是只看参数); - 利用判定逻辑缺陷:白名单进程配置过宽、插件只配了 log 没配 block 等运维失误;
- 干扰 agent 本身:通过 Attach API 加载对抗 agent 重转换已插桩的类,把探针”摘掉”;高权限下甚至直接卸载(对策:禁用 attach、做 hook 完整性自检)。
Warning
防守侧的核心结论(面试收尾用):RASP 不是银弹。hook 点覆盖率、判定插件质量、agent 自身防护三者决定实际水位。纵深防御里它只是一层,答出”RASP 可被对抗”比吹它多强更显专业。
灰盒埋点深浅对检出率/性能的影响
“灰盒埋点”指 IAST/RASP agent 在字节码里插桩的密度与层级——探针装多少、装多深。这是一对矛盾的工程取舍:
| 埋点策略 | 检出率 | 漏报面 | 性能/稳定性 |
|---|---|---|---|
| 浅:只 hook 高危 sink(exec、SQL) | 只能确认”危险调用发生”,丢失 source 端证据,无法还原完整污点链 | 无法区分业务正常调用与攻击,误报靠规则硬压 | 开销小,可长期挂生产 |
| 深:source/传播/sink 全链路埋点(HTTP 入口→字符串操作→JDBC/命令) | 能还原完整污点链,检出率上限最高 | 依赖埋点字典完整性,框架新入口没覆盖照样漏 | 开销与埋点密度正相关,深埋点对高并发应用有可见延迟,还有 agent 兼容风险 |
用流水线的类比:浅埋点=只在出厂口设一个质检员(知道有坏品出去了,不知道哪道工序造的);深埋点=每道工序都装传感器(能精确定位到工序,但传感器本身拖累产线速度)。
工程取舍(背结论):测试环境用深埋点换检出率,生产环境用浅埋点+高置信规则换稳定性;埋点字典(哪些类哪些方法是 source/sink/传播点)要随框架版本持续维护——Spring 出了新版本,字典不更新就是新盲区。
案例带读:一条跨过程污点链,三种工具各自看到什么
这是本篇最重要的案例,把前面所有概念串起来。代码是一个”用户搜索”功能,分三个类。先逐行读代码:
@WebServlet("/user") // 对外暴露的接口:GET /user?kw=xx&order=xx
public class UserServlet extends HttpServlet {
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String kw = req.getParameter("kw"); // ← ① Source:kw 直接来自用户,完全可控
String order = req.getParameter("order"); // ← ① Source:order 同样完全可控
UserService svc = new UserService();
try {
svc.search(kw, order); // ← ② 两个参数原封不动传给下一层
} catch (SQLException e) {
throw new ServletException(e);
}
}
}
class UserService {
UserDao dao = new UserDao();
void search(String kw, String order) throws SQLException {
dao.find(escape(kw), order); // ← ③ kw 过了一道 escape;order 没做任何处理
}
// 净化函数:把单引号 ' 替换成两个单引号 ''(SQL 字符串转义的标准写法)
private String escape(String s) { return s.replace("'", "''"); }
}
class UserDao {
List<User> find(String kw, String order) throws SQLException {
String sql = "SELECT * FROM user WHERE name LIKE '%" + kw // kw 拼在引号内
+ "%' ORDER BY " + order; // ← ④ Sink:order 裸拼在 ORDER BY 后
return jdbc.createStatement().executeQuery(sql); // ← ④ 用 Statement 执行拼接 SQL
}
}数据流分析,按编号走一遍:
- 输入从哪进来:
doGet里两次req.getParameter(...),kw和order都是 HTTP 参数,攻击者完全可控。 - 经过什么处理:
Servlet → Service → Dao三层透传。关键差异在 Service 层:kw走了escape()(单引号翻倍),order没有任何净化。 - 到达哪个危险函数:
UserDao.find里两者都被+拼进 SQL,交给createStatement().executeQuery()执行。 - 防线为什么失效/生效,两条路径分开判:
kw路径:拼接位置在'...'引号内部,单引号被翻倍后无法闭合字符串,escape对 MySQL LIKE 场景基本有效(前提是数据库没设NO_BACKSLASH_ESCAPES之类改变转义语义的模式)——这条路径可降级;order路径:拼接位置在ORDER BY后面,这是标识符/表达式位置,本来就不在引号里,escape那种”防引号”的思路在这毫无意义,何况它根本没被调用——注入成立。
为什么 ORDER BY 不能参数化? 这是高频追问。预编译占位符 ? 只能绑”值”,不能绑”列名、排序方向、表达式”这些 SQL 结构成分——ORDER BY ? 在语法上就不允许。开发者图省事直接拼,是这类注入最经典的成因。正确修法是白名单:if (!Arrays.asList("id","name","time").contains(order)) order = "id";
payload 逐段拆解(时间盲注,验证 order 注入):
?order=(IF(SUBSTR(DATABASE(),1,1)='s',SLEEP(5),1))
| 片段 | 作用 |
|---|---|
( ... ) | 把整个东西包成一个合法表达式,ORDER BY 后面允许接表达式 |
IF(条件, A, B) | MySQL 三元函数:条件真返回 A,假返回 B |
DATABASE() | 取当前数据库名,假设结果是 shop |
SUBSTR(...,1,1) | 取第 1 个字符,即 's' |
='s' | 猜解判断:首字符是不是 s |
SLEEP(5) | 猜对了就睡 5 秒——响应明显变慢,攻击者据此知道”猜中了” |
1 | 猜错了返回 1,正常排序,响应无延迟 |
把 1,1 换成 2,1、3,1 逐字符猜,就能拖出库名——这就是盲注。整个过程页面不报错、不回显数据,只有响应时间泄露信息。
三种工具的视角(本案例的点题之处):
- Seay(L0 正则+回溯):在 DAO 层命中
executeQuery和+拼接,列入嫌疑清单;向上文本回溯能看到order来自方法参数,但跨三个类追到getParameter基本靠人翻——它给你的是”嫌疑点清单”,确认全靠你自己。 - Fortify(L3 数据流分析器):能自动给出
getParameter → search → find → executeQuery完整路径,并把escape识别为 cleansing(若规则库收录),kw路径降级,order路径照常报高危。 - CodeQL:同 Fortify,且可以用 QL 精确表达”
ORDER BY位置拼接 + 无 sanitizer”这个具体模式,在查询层就把kw路径过滤掉,误报进一步降低。
同一个漏洞,三种工具看到三个世界——这就是为什么要懂原理:你只会用工具,工具断链你就跟着瞎;你懂原理,工具只是帮你跑腿。
防御与修复(体系建设层面)
- 工具组合而非单选:SCA 管依赖、SAST 管自有代码、DAST/IAST 管运行时验证、RASP 管生产兜底——四层各管一段,谁也别想包打天下。
- SAST 落地三件套:规则基线(先只开高置信规则把 CI 跑通,别一上来全量规则轰炸开发)→ 误报运营(专人 triage,别把原始报告直接扔给开发自己看)→ 自定义规则(把业务框架的 source/sink 喂给引擎,减少断链)。
- 灰盒落地节奏:IAST 挂测试环境跟随功能测试跑,主动模式限定可重置环境;RASP 上生产先 log-only 观察一段再开 block。
- 度量看漏报闭环:每一例线上漏洞都要回溯”为什么工具没拦住”,沉淀为新规则/新埋点——工具水位是靠运营喂出来的,不是买来的。
面试题眼速答
| 题眼 | 一句话答案 | 展开两三句 |
|---|---|---|
| SAST 原理分层 | 词法/语法→AST→CFG→数据流/污点四层;纯规则匹配只有前两层,数据流分析才能给 Source→Sink 证据链 | 分层本质是对代码理解深度的递进:正则连语法都不懂,AST 懂结构,CFG 懂执行顺序,数据流懂”值从哪来到哪去”,符号执行能精确算出触发条件。纯规则答不出”输入是否可控、是否被过滤”,这是它和数据流分析的本质差距。 |
| Fortify 与 Seay 原理区别 | Fortify 翻译中间表示+多分析器做语义级 source→sink 数据流;Seay 正则匹配危险函数+人工文本回溯 | Fortify 把源码转成 NST 中间表示,Dataflow/Control Flow 等六个分析器协同,能跨函数跨文件给完整污点路径,但遇反射/框架回调会断链。Seay 不理解语义,拼接变体绕过正则即漏报,价值在于把”全项目找危险函数”压缩到秒级,确认靠人工。 |
| AST 是什么、怎么生成 | 语法分析产出的语义结构树(丢弃括号分号等细节) | 每个节点是语言结构(方法调用、表达式),叶子是标识符和字面量。生成工具:javac Tree API(带类型标注最准)、JavaParser/JDT(免完整编译)、ANTLR(自定义语言)、tree-sitter(容错强适合不完整代码)。 |
| 污点分析三要素 | Source(不可信输入)/Sanitizer(净化,须对当前 sink 类型有效)/Sink(危险汇聚点) | 动态污点在运行时给数据打标签随流传播,代表 Phosphor(JVM)、TaintDroid(Android Dalvik)。Sanitizer 有效性判定是考点:SQL 转义防不住 XSS,intval() 只对数字型注入有效。动态污点精确但开销大,且追不了隐式流。 |
| 符号执行 | 符号代替具体输入执行,路径约束交 Z3 求解反推触发输入 | 每个分支把条件加入路径约束,矛盾路径直接剪掉,是精度天花板。瓶颈是路径爆炸(分支指数增长+循环展开失控),工程上只用于小规模关键路径验证,代表工具 KLEE、angr、Symbolic PathFinder。 |
| CodeQL 原理 | 编译期抽取代码成关系型数据库,用声明式 QL 写漏洞模式查询 | 编译型语言 extractor 依赖真实构建(CI 里必须能编译),解释型直接抽源码。能进 CI/CD(GitHub code scanning / CLI 输出 SARIF)。断链用 MaD YAML 模型补第三方库 source/sink/summary;误报三招:收紧查询、行内注释 // codeql[id]、后台标记。 |
| SCA 原理 | 解析 pom/lock 还原含传递依赖的依赖树,与 CVE 漏洞库比对版本区间 | 与 SAST 互补:SAST 查自写代码,SCA 查第三方依赖。关键局限是”依赖存在≠漏洞可达”,高级工具做 reachability 可达性分析降误报;改名重打包组件要靠哈希/类结构指纹匹配。 |
| SAST/DAST/IAST 优缺点 | SAST 覆盖全但误报高;DAST 误报低但覆盖受爬虫限制;IAST 插桩拿运行时数据流,误报低+定位到行 | SAST 不需要运行环境但不懂运行时行为;DAST 证据确凿但藏深的接口爬虫摸不到;IAST 用 agent 换”运行时真实数据流+精确到行+调用栈”,代价是要可运行环境、覆盖率随流量、有性能开销。 |
| 主动 vs 被动 IAST | 主动=扫描器发 payload+agent 观测,检出高但产生脏数据必须隔离环境;被动=只观测正常流量零脏数据 | 主动 IAST 的 payload(' or 1=1--)会被业务正常处理落库,污染测试数据甚至触发下游风控,这是它最大工程问题。被动 IAST 检出率是功能测试覆盖率的子集——测试没点到的接口就是盲区。 |
| RASP 原理 | -javaagent→premain→ClassFileTransformer→ASM 在危险函数入口插桩,运行时拿参数+调用栈判定阻断 | 杀手锏是调用栈:能区分业务正常调用和反序列化 gadget 触发的调用,这是流量层 WAF 拿不到的信息。OpenRASP 用 JS 插件解耦 hook 工程与检测逻辑,规则热更新不重启。 |
| RASP 与 IAST 区别 | 同是插桩,RASP 上生产做实时防护阻断,IAST 在测试环境做漏洞发现 | RASP 可被对抗:走未 hook 的等价路径(native 方法)、参数变形、干扰 agent(Attach API)。所以 RASP 只是纵深防御的一层,hook 覆盖率+插件质量+agent 自防护决定实际水位。 |
| 灰盒埋点深浅影响 | 埋点越深污点链越完整检出率越高,但性能开销与兼容风险越大 | 浅埋点(只 hook sink)只能确认危险调用发生,丢 source 证据,误报靠规则压;深埋点全链路追踪但拖累高并发应用。工程取舍:测试深埋换检出率,生产浅埋+高置信规则换稳定,埋点字典随框架版本维护。 |