一万行代码里找那一个点:代码审计的读码方法
原创
Sink
Sink
船山信安
2026年9月30日 11:58
湖南
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
漏洞挖掘 · VULNHUNT
一万行代码里找那一个点:代码审计的读码方法
代码审计 · 写给打开项目就不知道从哪读起的人
拿到一份源码,最难的不是读懂它,是决定先看哪一行。漏洞类型清单网上一搜一大把,SQL 注入、反序列化、越权、逻辑绕过,条条都背得出来——但没人告诉你:面对一个陌生项目,第一刀该往哪儿下。这篇讲的是方法层,是怎么读,不是读什么。
01别从第一行开始读
clone 下来一个项目,IDE 打开,左边目录树拉不到底。你点开第一个文件,读了三百行,什么都没发现。再点开第二个。三天之后,你记住了它的业务,没记住它的风险。
这是新手最大的时间陷阱:把审计理解成“读完它”。
一万行代码里,值得逐行看的通常不到一千行。剩下九千行是脚手架、是依赖、是重复的增删改查。麻烦在于,那一千行不是均匀铺开的,它集中在特定位置。
所以读码有顺序,而且这个顺序几乎是固定的:入口路由 → 鉴权中间件 → 数据流走向 → 危险函数汇聚点。
第一步只要半小时。找到路由表——PHP 项目常在 routes 目录下,Python 的 Django 在 urls.py,Java 的 Spring 在 Controller 注解里,Go 通常在 router 注册的那几行。把“外部请求能从哪儿进来”抄成一张清单。这张清单就是你后面所有工作的边界。
抄的时候顺手标三列:请求方法、是否需要登录、有没有对应的权限注解。第三列经常是空的,而空的地方往往是作者忘了写,不是真的不需要。
第二步看鉴权。注意,不是看“它有没有鉴权”,而是看“它鉴到了哪一层”。这两个问题差别极大。
示意代码:一段只验“有没有”、不验“是谁”的中间件
def check\_auth(request):
token = request.headers.get(\"Authorization\")
if not token: # 只判断了 token 是否存在
return False
payload = decode(token) # 未校验签名,也未校验过期时间
if payload is None:
return False
return True # 返回放行,但没把 payload 交给下游
这段代码做了三件事,也漏了三件事。做了:取出 token、判空、解码。漏了:没验签、没验过期、没把解码出来的身份往下传。
漏掉的第三条最要命。下游拿不到身份,就只好再信一次请求参数里那个 user\_id。于是“换一个 id 就能看别人订单”这条路,在中间件这一层就被铺好了。
鉴权看完,你的路由清单就能分成两堆:登录后才能进的,和谁都能碰的。先读后者,它的验证成本最低。
还有一类要单独圈出来:不需要登录、但会写数据的接口。第三方回调、导出任务、异步任务的状态上报,都在这类里。它们通常绕开了你在鉴权中间件里刚看过的那套判断。
第三步才是数据流,第四步才是汇聚点。所谓汇聚点,就是命令执行、反序列化、文件读写、查询拼接、模板渲染、跳转目标这几类。一个中型项目里,真正的汇聚点通常只有几十处。
读码从来不是从上往下,而是从两头往中间挤:一头是入口,一头是汇聚点,中间那条路才是你真正要读的东西。
02从终点往回走,而不是从起点往下走
污点追踪只有三个词:source、propagation、sink。
| 环节 | 是什么 | 在项目里长什么样 |
| source 污点来源 | 外部可控的输入起点 | 查询参数、请求体、请求头、Cookie、上传文件名 |
| propagation 传播 | 污点在函数间的搬运与加工 | 赋值、拼接、类型转换、过滤函数、数组传递 |
| sink 汇聚点 | 危险动作真正发生的地方 | 查询执行、命令执行、文件读写、反序列化、渲染、跳转 |
大多数教程教的是正向走:从请求参数出发,跟着变量一路看到它消失。理论上完整,实操上会淹死。
因为起点太多了。一个中型项目里,每一个请求参数、每一个请求头、每一个上传文件名、每一个 Cookie 值都是 source,上千个。而 sink 只有几十个,位置还固定,一次搜索就全部出来了。
反过来走。
在某个 sink 处停下来,看它的参数,问一句这个值从哪儿来。跳到定义。再问一次。一直问到你撞见 request,或者撞见一个硬编码的常量。撞见 request,继续追;撞见常量,这条路就废了,换下一条。
反向走的好处不只是快。更关键的是,每一步你只需要回答一个是非题:这个值能不能被外部控制。是非题比开放题省脑子,省下来的脑子用在别处。
我给自己定过一条规矩:一个汇聚点如果追了五层还没撞见外部输入,就先放下,换下一个。追到第七层还在工具函数里打转,多半是条死路,而人的耐心是会被死路吃掉的。
// 示意代码:两个参数,两条不同的传播路径
func ListOrders(c \*Context) {
status := c.Query(\"status\") // source:外部可控
sort := c.Query(\"sort\") // source:外部可控
sql := \"SELECT id, amount FROM orders WHERE status = '\" + status + \"'\"
if sort != \"\" {
sql += \" ORDER BY \" + safeOrder(sort) // propagation:先过了一个过滤函数
}
rows, err := db.Raw(sql).Rows() // sink
// 省略:错误处理与返回
}
这段里有两个参数。status 直接进了拼接;sort 先过了一个叫 safeOrder 的函数。
传播这一段最该停下来的地方,就是这种自己写的过滤函数。我追过一次,函数名叫 safe\_order,进去一看只有一行——把单引号替换成空。反引号没动,括号没动,空格没动。
所以别凭名字判断。叫 safe 的不一定安全,叫 filter 的可能只过滤了一半。点进去看那几行,成本三十秒。
追传播时你会遇到三种中转:被过滤(去看过滤写得对不对)、被转型(转成整数之后基本就没戏了,可以放弃)、被拼接(继续往上追)。三种里只有第一种值得兴奋。
03先花一小时,判断它值不值得挖
不是每个项目都配得上你的一周。
我的习惯是先花一小时做判断,再决定投不投进去。四个观察点。
| 观察点 | 高价值信号 | 怎么一眼看出来 |
| 自定义代码占比 | 业务代码目录文件多、层级深 | 数一下依赖目录和源码目录的行数比 |
| 框架封装程度 | 自己封装了查询、过滤、校验 | 搜一下项目里有没有自造的数据库类、过滤函数 |
| 改动密度 | 某几个文件被反复修改 | 看提交记录里哪些文件出现的次数最多 |
| 风格是否统一 | 同一件事有两套不同写法 | 取参方式、权限判断方式各搜一遍,数数有几种 |
第一个看自定义代码占比。依赖目录占掉九成的仓库,能读的只有那一成。这一成再去掉自动生成的,可能就剩两千行。两千行,两天能读完。
反过来,业务代码占七成、还带着几万行自己写的基础库的项目,值得你把日历空出来。
第二个看框架封装程度。全程用框架自带查询构造器和自带校验的项目,风险低。一旦发现作者自己写了一个数据库查询的封装、自己做了一套过滤、自己实现了一套签名校验——进去,逐行读。
理由很朴素:自己造的轮子没被压过。框架的查询构造器有几万个项目在天天调,错了早就被人踩出来。作者上礼拜写的那个签名函数,只有他自己在用。
// 示意代码:自己实现的一段签名校验
function verify\_sign($params, $sign) {
ksort($params); // ① 排序规则必须和调用方完全一致
$raw = '';
foreach ($params as $k => $v) {
if ($k === 'sign') continue; // ② 只排除了顶层的 sign 键
$raw .= $k . '=' . $v . '&'; // ③ 若 $v 是数组,这里会拼出什么?
}
$raw .= SECRET;
return md5($raw) === $sign; // $sign 的取值来源,决定这一段的意义
}
这段自己实现的签名校验,有三处值得停下来:排序规则是否和调用方一致、只排除了顶层那一个键、以及某个值变成数组时它会被拼成什么。三处都没有标准答案,全是作者自己拍的。
第三个看改动密度。翻提交记录,看哪些文件被反复改。改得多的地方问题多,但也意味着被别人先挖走的概率高。这个矛盾我到现在也没处理好,只能给个粗暴的建议:新手优先读改得多的,老手相反。
第四个看风格是否统一。同一个项目里出现两套完全不同的取参方式、两套不同的权限判断,说明它至少经过两拨人或者两个时期。那套“另一种写法”通常是后补进去的,而被人后补的地方,往往是因为前面那套出过事。
说到底,这一步估的是一个概率。你估的不是“这里有没有漏洞”,而是“有没有别人跟我一样在找这里”。越小众、越爱自己造轮子、越没人维护的地方,这个概率对你越有利。
04哪些代码读完可以放心跳过
注意力是预算,一天就那么多。
成熟框架的成熟模块。路由分发、依赖注入容器、日志、配置加载,这些被几万双眼睛看过。你不太可能比他们先找到问题。
公开协议的标准实现。令牌的解码、授权的握手、传输层加密,前提是作者没改过它。
第三方依赖与自动生成的代码。接口描述文件的产物、数据模型、脚手架出来的增删改查。
但“跳过”不等于“不看”。标准动作是扫一眼版本号,记下来,去查这个版本有没有已知问题。三十秒,换回一句“这块我可以不用再想了”。
有一类例外必须单独说:作者把依赖改过。认准一个迹象——源码目录里出现了一个本该待在依赖目录里的文件。有人把第三方库复制进来改了几行,那几行就是你要读的全部。
真正要慢读的,是业务逻辑自己拼起来的那一层:订单状态怎么流转、退款金额怎么算、优惠券能不能叠加、权限怎么继承、回调怎么验签、导出文件的路径怎么拼出来。
这层有个共同点——没有标准答案。作者只能自己拍脑袋。
我用一个很土的标准决定快慢:这段代码要是写错了,有没有一个现成的正确版本可以对照?有,就快读;没有,就慢读。照着抄的不会错得离谱,自己想的才会。
05节奏:先画地图,再走三条流到底
上午只干一件事:画地图。目录树过一遍,路由表抄下来,弄清楚哪个目录是控制器、哪个是服务层、哪个管数据访问、哪个是工具函数。不看实现,只看名字和调用关系。
地图画完,挑三条数据流,走到底。
一条挑最常见的,比如登录或者列表查询;一条挑涉及钱的;一条挑涉及文件的。这三类覆盖了大多数严重后果的落点。
“走到底”三个字是关键。从请求进来那一行,到数据库、文件系统或者外部请求出去那一行,中间每一个函数都点开看过。不是扫,是看。
差别在于:扫一遍你只记得函数名,看一遍你才记得它在什么条件下会返回什么。后者才是后面判断“这个分支到底能不能走到”的依据。
为什么必须走到底,而不是到处浅尝一口——因为作者的水平在第一条完整链路里就暴露了。
信号就这些:参数校验是集中在入口做,还是散落在每个函数里自己各做一遍?出错是抛出来,还是捕获之后返回空值?查询是拼接的还是绑定的?权限是每次都查,还是只在入口查一次?
信号出现不一致时,那个用“另一种写法”的文件,优先读。
我有一次就是在第一条链里发现,作者在三个地方用了三种不同的取参方式。搜完之后发现其中一种只在两个文件里出现——那两个文件是后补的,说明作者自己知道前面那两种有问题。这条线索比任何扫描结果都值钱。
三条链走完,你会对这个项目形成一种手感:它的作者是谨慎还是随意,是懂还是抄。这个手感一旦建立,后面搜出来的几百条结果,你扫一眼就知道哪些值得点开。
06工具的位子:起点,不是终点
工具放在哪一步?我的答案是:画完地图之后,走链路之前。
太早用,你连结构都不认识,面对几百条告警只能逐条翻,翻完也不记得;太晚用,你已经有了路径依赖,会下意识跳过工具标出来的、但你自认为没问题的地方。
工具擅长两件事:把项目里所有可能的汇聚点一次性列出来(这件事人做起来最慢,也最容易漏),以及标出那些定义了却从没被调用过的函数。
工具不擅长的是判断。过滤函数是不是真的过滤干净了、权限判断是不是覆盖了所有分支、业务逻辑的先后顺序有没有问题——这些占了真实漏洞的大多数,恰恰也是工具的盲区。
所以我的分工是:工具负责把候选点摆到我面前,我负责下判断。反过来让工具给结论,你会养出一个很难戒掉的坏习惯——把没报警当成安全。
我自己最常用的工具就是一条搜索命令,把所有可疑的汇聚点位置打出来,然后一条一条点开看上下文。土,但快。工具越高级,你越容易把“没报”当成“没问题”。
误报不要跳过。一条一条过。
这句话我说得很认真:过完两百条误报,你对这个项目的理解会超过从头到尾通读一遍源码。每一条误报都在告诉你两件事——作者为什么这么写,以及这个汇聚点在这个项目里的正常用法长什么样。你知道了“正常”,才认得出“不正常”。
具体做法是:跑一次全量,把结果按文件聚类。同一个文件里冒出三十条同类告警,说明那里是一处批量模式,看一条确认模式,剩下二十九条一起划掉。反过来,如果一个文件里只有一条孤零零的告警,那一条要单独看——它往往就是那个“不一致的写法”。
07写不出 PoC 的发现,等于没发现
看到一个疑似点,先别激动,先写最小 PoC。
最小 PoC 只证明一件事:这个位置的值,由我说了算。它不要求完整的利用链,不要求拿到权限,甚至不要求有危害。就是证明可控。
示意:最小 PoC 的骨架,只回答“这个点的值是不是我说了算”
def poc(base\_url):
normal = send(base\_url, {\"status\": \"active\"})
edge = send(base\_url, {\"status\": \"active\" + MARKER}) # MARKER = 一个明显非法的字符
print(normal.status\_code, edge.status\_code)
print(\"标记出现在响应里吗:\", MARKER in edge.text)
两次输出的差异,就是你要的证据。
有差异,再谈危害与修复;没差异,回到调用链重走一遍。
我的体感是,十次“找到了”,大概有八次折在写 PoC 这一步。
折掉的原因通常就三个。你以为上游可控,其实那里有一个转整数的操作;你以为没有防护,其实框架的全局中间件早就做掉了,你少看了一层;你以为这个分支能走到,其实前面有个状态检查,订单不是待支付状态根本进不来。
前两个其实是好事,它们意味着你对这个项目的理解又深了一层。第三个最可惜,但也最好避免:走链路的时候顺手把前置的状态检查记下来。
然后是心态账。
审计是低密度劳动。一天八小时,可能一条有效线索都没有。一周也可能。
低密度还有个副作用:你会开始怀疑自己的方法是不是错的,于是不停换目标、换工具、换方向。这是最费时间的一种状态,比一无所获更费。
这跟能力没关系。你在一万行里找那一个点,密度天生就这么低。把“一整天毫无所获”解读成“我不行”,是这个行业最常见的自我误伤。
把“今天走完了三条链路”当成产出,而不是把“今天挖到一个洞”当成产出。前者你能控制,后者不能。
记笔记。今天排除掉的五十个汇聚点,连同排除理由一起写下来。下周重新回到这个项目时,这五十条就是你的起点。
读得多了你会发现,真正拉开差距的不是谁背的漏洞类型多,而是谁更少把时间花在不该读的那九千行上。
ONE LINE
你不是在读一万行代码,你是在找那一条路。
参考来源
· OWASP Code Review Guide(OWASP 官方代码审查指南,v2.1,2021):把审查拆成“风险暴露点—检查项”的组织方式,以及它对输入校验、认证、会话管理等检查清单的排布逻辑
· OWASP Top 10(2021):官方给出的十大 Web 应用安全风险分类,可用于对照“汇聚点”清单是否覆盖完整
· MITRE CWE(Common Weakness Enumeration,1999 年起持续维护):缺陷类型的标准化编号体系,适合把审计发现映射到统一的语言上
· Google 的 Code Review Developer Guide(google.github.io/eng-practices,2019 年前后公开):虽然面向的是日常评审而非安全审计,但其中“小改动优先、先理解变更意图再读实现”的原则对读码节奏同样适用
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论