Lyum1dsamaの宝藏之地
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

我在 npm 头部找了 23 轮,只找到 2 个洞 —— 但我把「什么时候不该打」写成了判据

写作时间:2026-10-10 13:08:34

一句话

我花了大约一年里零散的时间,在 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 两条关于"我自己判据写错"的纪律

这是我吃到最狠的两次假阳性,现在成了我的自查清单:

  1. startsWith('..') 不是路径包含性判定 —— 我一版探针因此出了 170 条假阳性, 改成按路径段判断后归零。

  2. 「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)。

读这些代码其实很享受 —— 你能看出作者在想什么。


八、所以我现在会怎么做

如果你也在做类似的批量审计,这是我交完学费后的建议:

  1. 开工前先过 §四 的四问。 任何一条不过,直接换靶 —— 别用"再来一轮看看"骗自己。

  2. 连续 3 轮 0 产出,且每轮原因都是"已有公开记录"或"验证后正确" ⇒ 换线,不要换轴。 换轴会给你"我在推进"的错觉,但分布没变,结果就不会变。

  3. 优先找"隐含不变量",别找"广告出来的安全属性"。

  4. OSV=0 有四种含义,其中一种是「没有可审的东西」(包压根没有安全断言)—— 那不是候选,别记进池子。「没查到」和「没得查」要分开记。

  5. 查到「已修」要追到聚合包锁定的版本 —— "已修"不等于"已修到用户手上"。


九、最后

23 轮,2 个洞。

单看数字,这条线的投入产出很难看。但它给了我三样东西:

  1. 一套能照着做的选靶判据(§三 / §四 / §八)—— 下次不会再白跑 7 轮;

  2. 两条命中都完整跑通了链路**:从选靶、过闸、写报告,到真正送到维护者手上、 并收到"我会调查并修复"的回复。**这件事本身比"找到洞"更接近工程实践。**

  3. 一个我现在会先问自己的问题:

「我是在挖洞,还是在完成一个'我今天挖洞了'的仪式?」

如果你的靶子注定没有,最快的提升不是更努力地挖,而是更早地承认这一点,然后换靶。


(本文涉及的两处上报案例均按负责任披露原则处理:维护者回应或修复发布前不做具体披露。)

avatar

Lyum1dsama

RECOMMENDED

Table of Contents