第1章 · EX-D
传统代码审计的致命误区
1.1危险函数检索法在真实项目里失效
结论:危险函数检索法给出的是候选位置,不是漏洞。它在真实项目里的命中率接近噪声。
该方法的前提假设是"危险函数等于缺陷"。真实缺陷的成立条件是四个同时满足:数据可控、路径可达、过滤缺失、汇聚点有害。函数只是第四项里的一个候选集合,连第四项都不完整。
grep 只能看见字面。它看不见四类真实路径。
第一类,别名调用。函数名是数据,字面里没有这个函数名。
// 源码里没有 eval,没有 system,没有 assert
$func = $_GET['action']; // 值可以是任意已存在的函数名
$func(); // 动态调用,sink 是一对括号
// 源码里没有直接的方法名,只有字符串拼接
Class<?> c = Class.forName("com.app.handler." + name);
c.getMethod(name).invoke(c.getDeclaredConstructor().newInstance());
正则检索在第一个例子里一次都不会命中,因为源码里根本不存在 eval 这个 token。它存在的是"变量加括号"。这类调用在 PHP 编译后落到 ZEND_INIT_FCALL,函数名在运行时才确定;Java 走反射,方法名在字符串里。
危险的是调用能力,不是函数名。 检索表搜的是名字,攻击者用的是能力。
第二类,封装下沉。危险在包装层,业务代码里一个都搜不到。
// 业务层:看起来只是一次普通查询
$this->db->query("select * from t where id = " . $id);
// 底层 Db 类:拼接发生在这里
class Db {
public function query($sql) {
return $this->conn->query($sql); // 收敛点,业务代码看不见
}
}
grep 业务目录,命中 0 次。grep 到 Db 类,命中全部业务——这时命中量太大,反而被当成噪声忽略。
判据是看包装层的参数语义,不是看函数名。 query() 接受字符串,就存在拼接位;接受结构化条件,才谈得上参数化。函数名干净不代表链路干净。
第三类,间接汇聚。写入侧无害,读出侧有害。
用户备注写进数据库时,做的可能是最严格的过滤;危险发生在另一个模块把它读出来的那一刻——导出 CSV 时没做公式转义,后台渲染时没做上下文转义,缓存反序列化时没做类型校验。
危险函数的检索只能覆盖写入侧,而链的终点常在读出侧。 同一份数据被几个模块消费,就有几个收敛点,检索表只认得其中一个。
第四类,配置驱动。代码是白名单比较,漏洞在配置项。
// 代码:严格比较,看起来毫无问题
if (!in_array($ext, $config['allowed_ext'], true)) {
return reject();
}
// 配置:白名单里多了一个后缀,整条链就成立
// config/upload.php
'allowed_ext' => ['jpg', 'png', 'php'],
这段代码静态看是安全的,因为白名单比较本身没有缺陷。缺陷在配置与代码的组合:白名单的值由配置文件决定,配置里多一项,判断就整体失效。
脱离配置文件读代码,等于盲人摸象。 审计白名单时,必须读它的来源,不能只读调用点。
更麻烦的是可达性。现代框架把外部请求收口到统一 router,危险函数会不会被执行,由中间件、路由守卫、参数绑定、容器生命周期共同决定。静态表无法判断可达性,只能判断存在性。
危险函数表还会制造盲区。团队越依赖表,越不看表外路径。定时任务、消息消费、CLI 入口、健康检查、内部回调,这些入口通常无鉴权,也通常不在检索表里。
审计动作:
- grep 结果只写进"候选清单",不写进"漏洞清单"。
- 每个候选必须回答三问:入口在哪、参数是否可控、到汇聚点之间有几层校验。
- 三问无法回答的候选直接丢弃,不留"疑似"。疑似项会在报告里变成假阳性。
深度剖析:为什么静态分析工具(SAST)总会产生海量误报
SAST 的模型是三件东西:抽象语法树、有限的调用图、污点传播。它不理解业务,只理解语法结构。三个结构性缺陷由此而来。
其一,污点源不完整。 工具的"可控输入"集合通常只覆盖框架默认取参方式。参数经 @ModelAttribute 绑定、经 Depends 注入、经 Pydantic 转换、经网关透传、经配置注入,这些值在工具的源集合里往往不存在。源没有标脏,下游一切干净。
其二,传播建模不到。 反射、动态派发、依赖注入容器、AOP 代理、ORM 代理、模板上下文,这六处一断,图就断。图断了之后工具只有两个选择:按干净处理,或按脏处理。前者漏报,后者误报,两个方向都错。
其三,净化判定只看函数名。 工具把"出现在转义函数里"当成净化,不看上下文。htmlspecialchars 对 SQL 无效,addslashes 对宽字节无效,basename 对符号链接无效——工具全按脱脏处理。5.5 里那条"部分脱脏等于未脱脏"的判据,工具一条都不认。
再加两条它做不到的事:没有可达性判断,不看路由是否注册、中间件是否拦截、代码是否在死分支;没有业务语义,分不清"读到一条"与"读到全量",所以严重度算不出来,只能一律标高危。
后果是一条正反馈回路:误报率高,团队建立"批量忽略"的习惯,真阳性被一起忽略。SAST 真正的代价不是漏报,是把"存在"当成了"可达"。
正确用法只有一种:当候选生成器。输出必须过 1.1 的三问,规则只拦新增代码(7.10)。指望它清理存量,结果是规则被静音。
| 常见说法 | 问题所在 |
|---|---|
这里有 eval,高危 | 无可控入口,是死代码 |
| 用了 ORM,没有注入 | order by、like、动态表名仍是拼接 |
加了 addslashes,安全 | 字符集未指定,数字型上下文无效 |
| 有 WAF,打不进来 | WAF 管边界,管不了内部任务与横向 |
| 扫描器报了 200 条 | 200 条都是候选,一条结论都没有 |
1.2单点不等于漏洞:成链五问法
结论:一个可控输入到达一个有缺陷的汇聚点,只是技术可能性。它不是漏洞。
成链五问是「三个问题」在审计场景的展开。一问我能不能多做本不该做的,二问我碰得到什么业务目标,三问我造成的伤害稳不稳。前两问与三个问题同源,第三问是审计特有的——复现稳定性。后两问(证据与整改)是审计场景的增量。
同一套判断,分三处讲,不是三套体系。
把这个状态叫单点。单点是审计的中间产物,不是结论。
靶场与企业环境的差别,全在这个"中间产物"上。
// 靶场形态:语法特征,一眼可辨
eval($_POST[1]);
// 企业形态:同一个 sink,前面隔着三层
public function run(Request $r) {
if (!$this->auth->isAdmin($r)) return; // ① 身份
if (!in_array($r->rule, $this->allowedRules, true)) return; // ② 白名单
if (strlen($r->rule) > 200) return; // ③ 长度
eval($this->compile($r->rule)); // sink 在最后一公里
}
两段代码的 sink 完全相同,成立率可能差一个量级。差别不在 sink,在路径。
| 维度 | 靶场里的 eval | 企业里的 eval |
|---|---|---|
| 入口 | 匿名可达 | 需管理员会话 |
| 前置校验 | 无 | 身份、白名单、长度三层 |
| 可达性 | 默认成立 | 取决于三条判断同时放行 |
| 成立判据 | 执行即成功 | 需证明三条判断均可绕过 |
| 审计产出 | 语法识别 | 链路与证据 |
靶场练的是语法特征,真实审计审的是可达性(Reachability)。 靶场里次次成立的 payload,在企业里可能一次都不成立;反过来,企业里那条白名单只要多放一个规则名,同一个 sink 就必定成立。判断依据是路径,不是函数。
成链五问,逐条过:
- 能否多做本不该做的操作。相比较正常权限,多出来的那部分操作是什么。
- 能否靠近业务目标。碰得到的是数据、账户、资金、审计记录,还是只有内部元信息。
- 能否造成可复现的业务伤害。换环境、换会话、换主体,行为是否稳定成立。
- 有无报文或日志证据。能不能留下可复核的位置与响应。
- 有无整改价值。修一次能不能堵住这一类,而不是只堵这一行。
前四问全过,判技术漏洞。第五问决定定级与交付优先级。
五问不是流程,是减法。 每一问都在砍掉一类推理:砍掉不可达、砍掉无伤害、砍掉不可复现、砍掉无证据。砍到最后还站着的,才是能写进报告的。
假阳性三个来源,对应三种丢弃理由:
- 不可达。代码存在,入口未注册,类未装配,开关关闭,方法被注释。写报告前必须确认调用链上有真实外部请求。
- 被截断。前置中间件、参数绑定、类型强转、签名校验把可控性抹掉了。此时汇聚点再有缺陷也打不到。
- 汇聚点无害。看着危险,实际只写日志、只拼展示串、只在错误分支执行、只操作隔离目录。
判据:同一行为至少在两个不同入口、两次独立会话复现,才算稳定信号。单次成功先怀疑请求构造。
1.3黑白盒链路的统一模型
结论:黑盒给入口,白盒给路径。用黑盒的入口驱动白盒取证,再用白盒结论回黑盒验证。
黑盒给四样:真实入口、真实参数形态、真实响应、真实权限边界。它决定起点,避免在死代码上耗时间。
白盒给四样:数据流、汇聚点、校验层位置、代码意图。它决定终点,避免把"能打通"误判成"能打穿"。
两者是同一条链路的两个视图。统一模型固定五段:
| 段 | 观察对象 | 典型缺陷位置 |
|---|---|---|
| 入口 | 路由注册、参数绑定、鉴权注解、拦截器 | 未挂鉴权的内部接口、参数名可被覆盖 |
| 解包 | 反序列化、格式解析、类型转换 | 弱类型比较、隐式转换、多余的反序列化入口 |
| 清洗 | 过滤函数、白名单、字符集、长度 | 过滤与消费不同层、二次解码、字符集不一致 |
| 汇聚 | SQL、命令、文件、模板、反射、表达式 | 字符串拼接、路径未归一化、表达式求值 |
| 后果 | 返回体、状态码、落库、外部请求 | 数据范围未校验、执行主体未校验 |
任何一段缺失,链路不成立。写"存在 SQL 注入",实际是在说"第二段到第四段成立,第五段未验证"。这种结论不能交付。每段必须标出代码位置或报文位置,缺一段就标"未证实"。