定位

前面几篇讲的是”人工怎么看代码找漏洞”,本篇讲的是怎么让机器帮你找——自动化审计体系的核心原理: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 实际怎么用(零基础操作演示):

  1. 把 PHP 项目源码目录拖进 Seay;
  2. 点”自动审计”,它会按内置危险函数库(eval、system、include、unserialize、file_get_contents 等)全项目正则匹配;
  3. 得到一张命中列表,每条是”文件 + 行号 + 命中的危险函数”;
  4. 从这里开始就是纯人工:双击跳到该行,肉眼往回翻——这个变量哪来的?是 $_GET 还是写死的常量?中间有没有 intval()、addslashes()?
  5. 确认可控且无有效过滤,才构成漏洞。

可以看到第 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 把”漏洞模式”变成了用户自己可写的查询,这是它革命性的地方。

工作流程(面试要能说全)

  1. 抽取建库:编译型语言(Java/C/C++/Go/C#)由 extractor 在编译过程中记录 AST、类型、数据流信息,生成关系型数据库(snapshot/TRAP);解释型语言(JS/Python)直接抽取源码。所以编译型语言必须能编译成功,这是 CI 集成的硬性前提。
  2. 写查询:用 QL(声明式逻辑语言,语法近似 Datalog/SQL)描述漏洞模式。
  3. 跑分析:引擎在数据库上执行查询,输出结果。

一条真实感的数据流查询长这样(看懂结构即可,不要求会写):

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 查反向流,定位链路断在哪一跳,再在断点补模型。

③ 误报怎么处理? 三招,按推荐顺序:

  1. 收紧查询:给查询加 sanitizer 条件、限定 source 类型——治本,一次修改全员受益;
  2. 行内抑制注释:// codeql[query-id]——只压这一条,适合确认过的个别误报;
  3. 在 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、SemgrepBurp 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 插桩),区别在目的和部署位置:

维度RASPIAST
目的防护:实时阻断攻击测试:发现漏洞交给开发修
部署生产环境测试环境
判定视角攻击特征 + 调用栈上下文污点数据流是否到达 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
    }
}

数据流分析,按编号走一遍:

  1. 输入从哪进来:doGet 里两次 req.getParameter(...),kw 和 order 都是 HTTP 参数,攻击者完全可控。
  2. 经过什么处理:Servlet → Service → Dao 三层透传。关键差异在 Service 层:kw 走了 escape()(单引号翻倍),order 没有任何净化。
  3. 到达哪个危险函数:UserDao.find 里两者都被 + 拼进 SQL,交给 createStatement().executeQuery() 执行。
  4. 防线为什么失效/生效,两条路径分开判:
    • 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 证据,误报靠规则压;深埋点全链路追踪但拖累高并发应用。工程取舍:测试深埋换检出率,生产浅埋+高置信规则换稳定,埋点字典随框架版本维护。