一句话
我花了大约一年里零散的时间,在 npm 下载量头部的包上做了 23 轮白盒审计。 只找到 2 个能上报的洞。
听上去像个失败。但这 23 轮真正的产出是另一件事:
我把「什么时候不该打」变成了可以照着做的判据。
对做漏洞挖掘的人来说,这可能比多找几个洞更值钱 —— 因为时间才是唯一的成本。
一、先说结论:一条我认为没人明说的「结构性张力」
我把 23 轮的战绩摊开看,发现每次"又白跑一轮"的原因都是同一件事:
这两个条件近乎互斥。
一个漏洞要"值得报",得同时满足「没人审过」和「真的有人用」。 但在 npm 的下载量分布里,这两件事是反相关的 —— 下载量就是审计强度的代理变量。
于是:
在「高下载量 + 找老代码」这条路上,产出率会趋零。 这不是方法问题,是分布问题。
这一条我是在连续 7 轮 0 产出之后才敢写下来的。在那之前,我一直以为是自己的判据不够好。
二、我试过的六种「破法」,以及它们为什么都没成
7 轮里我换了 6 个轴。这一节是我交的学费:
尝试
结果
换判据:从「自称安全」→「维护状态一致性」
✅ 命中 1 条,但此后同法不再出
换子类:锁租约 / 限流时间窗 / 重试退避
0(4 个最强候选全被已公开的 issue/PR 挡下)
换方向:只找「失败即放行」的组件
0(3 个假设全被自己证伪)
换方向:只找「攻击者可直接触发」的
0(4 个假设全证伪;同族的 CVE 已全部修完)
换判据:只找「隐含不变量」
0(2 个候选逐行读完,都是对的)
换轴:按代码模式全生态扫
0(模式命中 12/60,真阳性 0)
换轴:审计新版本引入的新代码
0(读到的每个改动方向都是"加固")
最后一行是最让我意外的:
我读了十几二十个头部包最近发布的新版本,改动方向无一例外都是加固 —— 换严格正则、修资源泄漏、给 RSA 加模数长度校验、新代码是校验密集型。
npm 头部的发布流本身就在持续加固。
所以「审计最新代码」这条本来最漂亮的破法,也失效了。
三、那我是怎么找到那 2 个的:隐含不变量
把两次命中放在一起看,它们的共性非常清楚:
卖点是什么
隐含的是什么
命中 A
"whitelisted operations"(白名单机制)
不变量的实现藏在 registry 的语义里
命中 B
"idempotent"(幂等)
从来没人提过"冲突检测的完整性"
而所有 0 命中的轮次,靶子有一个共同点:它的安全属性是"广告出来的"。
⭐ 判据:不要找"宣称自己安全"的组件 —— 那是最卷、被审得最彻底的赛道。 要找"安全属性是隐含的、没被宣称的"组件:作者压根没把安全当卖点, 但代码里有一条必须成立的假设。
⛔ 别用安全词捞池(secure / prevent / protect / guard / sanitize / escape)—— 那正好捞到最卷的那一池。 ✅ 改用"不变量词":unique / monotonic / exact / lossless / deterministic / sortable / atomic / collision …
我实测过筛选效率:不变量词的命中率和安全词差不多(40% vs 25~41%),但指向的赛道完全不同。
最实用的一句问法是:
「这个包的正确性,依赖哪一条它自己没写出来的假设?」
沿着这句话往下问,方向就出来了:并发的顺序假设、序列化的往返假设、 货币单位的精度假设、标识的唯一性假设、时间戳的单调性假设。
四、我现在开工前会过的「四问」
这四个问题是我从 23 轮里提炼出来的。按顺序过,任何一条不过就直接换靶。
① 下载量够吗?("真实用户"的量化代理)
npm 有个很好用的接口:api.npmjs.org/downloads/point/last-month/<包名>。 比 star 可靠得多。 我的经验线是 ≥10 万/月才考虑。
② 失败方向是「多放行」吗?
这一条救过我。我遇到过这样一个包:5400 万/月下载、休眠三年、零公告、本地可测、 判据全都对 —— 我读进去,真找到 bug 了:调一次它的公开 API,就会永久关掉它自己的内部刷新。
但我没报。 因为它 fail-closed —— 失败方向是"卡死"(自身可用性),不是"放行"。 而且触发点是应用自己调用的,攻击者控制不了。
「有真 bug」≠「是安全问题」。
反过来说,只有失败方向是"多放行"(绕过 / 双持有 / 重复执行 / 返回陈旧许可), 才通常是安全问题。
③ 防护对象是「攻击者」吗?
我还花时间看过一个熔断/重试/降级库。它天然会 fail-open,很诱人。
但它不是安全控件 —— 它防的是「故障」,不是「攻击者」。 即便它有 bug,也只是健壮性问题。直接不烧。
④ 公开记录干净吗?
活跃维护的包,先去翻 issue/PR(含 closed) —— 我那一轮四个最强候选, 全部因为已经有人提过同类问题而被排除,一个没剩。
⭐ 反过来:休眠包反而更值得看。 上面那个有 bug 的包就是休眠三年的。
五、把方法做成工具(可复现)
这些都是普通脚本,但组合起来省了很多时间:
5.1 选靶 + 公开记录三查
一个候选丢进去,自动查 OSV + GitHub issues + GitHub PR(含 closed) 三路。 (只查 OSV 会把"已有人报过"误判成"新发现"。)
5.2 按代码模式扫(两段式)
难点是:npm 搜索是按"描述"匹配的,不按"代码"匹配。 所以必须两段:
我试过的模式之一是「算了 HMAC 却从不做常量时间比较」。 命中 12/60,但真阳性 0 —— 因为绝大多数命中要么是 HMAC 实现(它本来就不比较), 要么是纯签名器(只签不验)。
⭐ 一个反向观察很有说服力:真正的签名校验器,反而都用了 timingSafeEqual。 我人工通读了三个高下载量的(一个 1.34 亿/月、一个 5.93 亿/月、一个 3.32 亿/月)—— 全部写对。
5.3 审计「新版本引入的新代码」
思路是:新代码同时满足「没人审过」+「立刻有几百万人用」—— 正好是前五轮一直找不到的那个非对称性。
⚠️ 这里踩过一个坑:初版判据没排除打包产物。 某个构建工具命中了 3966 次 —— 点开全是单个 chunk 文件(+38236 行,把 node_modules 全打进去了)。 加个行数阈值 / 路径过滤就解决了。
六、踩过的坑(有些跟挖洞无关,但会让你白干)
6.1 Sent 只证明「发出了」,不证明「送达」
我发出过一封安全报告,5 秒后就被退信了。如果我只检查"已发送"文件夹,就会以为 "我已经联系过厂商了" —— 而这个结论是错的。
判送达的唯一办法:查收件箱有没有退信。
6.2 给 Gmail 发安全报告,别附可执行脚本
同一封报告,带着一个 .mjs 脚本附件 → 被退: 552 5.7.0 This message was blocked because its content presents a potential security issue。 换成只附 .md,立刻送达,并被维护者读到。
附件优先级:
.md/.txt✅ > 正文内联纯文本 ✅ ≫.js/.mjs❌
6.3 两条关于"我自己判据写错"的纪律
这是我吃到最狠的两次假阳性,现在成了我的自查清单:
-
startsWith('..')不是路径包含性判定 —— 我一版探针因此出了 170 条假阳性, 改成按路径段判断后归零。 -
「payload 出现在输出里」≠「它被执行了」 —— 我拿"输出里有没有 INJECTED"当判据, 而 payload 本身就会被打印出来 ⇒ 44/45 假阳性。 改成 canary 副作用判据(让 payload 去
touch一个文件,再查文件在不在),真相才翻转。
配对纪律:
全是 0,先怀疑工具;一片命中,先怀疑判据。
6.4 「危险的默认值」≠ 漏洞
我找到过一个库,它的默认配置让某个白名单机制很容易被绕过。 但去查了官方文档 —— 文档明确写着这就是该配置的含义。
那是"危险的默认值",是文档化的设计,不是漏洞。报出去只会掉自己的信誉。
同理还有两类,我现在都会先问一句「攻击者到底能做什么?失败让我多放行了吗?」: parser 之间的行为不对称(校验路径照样要过签名验证,伪造不了)、 不可达的边界(需要天文数字次调用才可能触发的那种)。
七、一些被验证「确实写对了」的代码(这部分是夸)
顺手说几个我读完觉得写得好的,也顺便提醒自己:头部生态的加固是真的在发生。
-
某签名库:先比长度、再逐字节 XOR 累加,常量时间比较写得很干净。
-
某数据库驱动新增的近千行 token/鉴权代码:缓存键里放的是
sha256(clientSecret)(不同凭据不共享条目)、过期就擦除、并发用单飞 +finally里做身份校验防误删、 token 端点强制 https、参数白名单归一。 -
某 JWT 库的一次大重构:把一堆散落的类型检查收进一个
validate.js, 收紧原型链判断、给 RSA 加了模数长度下限、把底层异常吞掉后按"验证失败"处理(fails-closed)。
读这些代码其实很享受 —— 你能看出作者在想什么。
八、所以我现在会怎么做
如果你也在做类似的批量审计,这是我交完学费后的建议:
-
开工前先过 §四 的四问。 任何一条不过,直接换靶 —— 别用"再来一轮看看"骗自己。
-
连续 3 轮 0 产出,且每轮原因都是"已有公开记录"或"验证后正确" ⇒ 换线,不要换轴。 换轴会给你"我在推进"的错觉,但分布没变,结果就不会变。
-
优先找"隐含不变量",别找"广告出来的安全属性"。
-
OSV=0有四种含义,其中一种是「没有可审的东西」(包压根没有安全断言)—— 那不是候选,别记进池子。「没查到」和「没得查」要分开记。 -
查到「已修」要追到聚合包锁定的版本 —— "已修"不等于"已修到用户手上"。
九、最后
23 轮,2 个洞。
单看数字,这条线的投入产出很难看。但它给了我三样东西:
-
一套能照着做的选靶判据(§三 / §四 / §八)—— 下次不会再白跑 7 轮;
-
两条命中都完整跑通了链路**:从选靶、过闸、写报告,到真正送到维护者手上、 并收到"我会调查并修复"的回复。**这件事本身比"找到洞"更接近工程实践。**
-
一个我现在会先问自己的问题:
「我是在挖洞,还是在完成一个'我今天挖洞了'的仪式?」
如果你的靶子注定没有,最快的提升不是更努力地挖,而是更早地承认这一点,然后换靶。
(本文涉及的两处上报案例均按负责任披露原则处理:维护者回应或修复发布前不做具体披露。)
