news 2026/10/20 18:17:45

lodash 4原型污染漏洞:从线上异常到源码定位与防御实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
lodash 4原型污染漏洞:从线上异常到源码定位与防御实战

前端安全这个领域,平时大家关注最多的就是 XSS、CSRF、越权这类攻击面,相对容易被忽视的其实是“基础库本身”的安全性。一个通用工具库只要存在一个漏洞,影响面往往是全公司所有项目,比某个业务逻辑写错要严重得多。今天要聊的就是这样一个典型:lodash 4 的原型污染漏洞,从线上可疑现象到源码定位、再到最终修复的全过程。

这个内容是给谁看的?一类是日常写业务的前端同学,你可能根本不知道项目里装了什么版本的 lodash;另一类是负责前端基建和安全巡检的同学,需要在漏洞爆发前就把它拦住。我尽量还原这次完整的排查链路,把中间踩过的坑、试过的方案、最后沉淀下来的防御手段都讲清楚。

1. 漏洞全景:为什么 lodash 原型污染能“炸穿”整个应用

1.1 原型污染到底污染了什么

要理解这个漏洞,先得把 JavaScript 原型链这个老底子翻出来。每一个普通对象都有一个隐藏的内部属性[[Prototype]],在浏览器里可以通过__proto__这个访问器去读取和修改它。正常情况下,{}会继承Object.prototype上的方法,比如toString、hasOwnProperty。

原型污染的核心思路就是:如果某个函数允许你修改对象的__proto__属性,并且递归合并时不做过滤,你就能够改写Object.prototype本身。一旦Object.prototype被挂上脏数据,所有继承自它的对象都会“被污染”。

举个最简单的例子:

const obj = JSON.parse('{"__proto__": {"polluted": true}}'); console.log({}.polluted); // undefined,因为 JSON.parse 创建的是自有属性

这里不会触发漏洞,因为JSON.parse创建的是对象自身的__proto__属性,而不是修改原型链。但一旦这个对象经过 lodash 的merge、defaultsDeep这类递归合并函数处理,情况就完全不同:

const lodash = require('lodash'); const payload = JSON.parse('{"__proto__": {"isAdmin": true}}'); lodash.merge({}, payload); console.log({}.isAdmin); // true,原型被污染了!

这就是“原型污染”。攻击者如果能让服务端或客户端代码对不可信数据执行merge,就可以给所有对象注入属性,轻则导致权限绕过、逻辑混乱,重则配合其他漏洞实现远程代码执行。前端场景下最常见的影响是:页面里所有{}实例都凭空多出恶意字段,表单校验失效、状态判断被绕过,甚至导致页面崩溃。

1.2 为什么偏偏是 lodash 4

lodash 4 是使用率极高的版本,尤其是_.merge、_.defaultsDeep、_.set这几个 API,几乎是后端 Node.js 服务和前端数据处理逻辑的标配。这几个函数有一个共同点:都会递归遍历对象,把源对象的 key 和 value 复制到目标对象。

问题在于,lodash 4 早期的实现对 key 的过滤不够严格。它确实屏蔽了constructor这种危险字段,但对__proto__的防护在 4.17.5 之前的版本里是缺失的,或者说只屏蔽了一部分路径。攻击者构造一个嵌套对象,让merge在递归时把__proto__当作普通 key 去赋值,就会直接污染Object.prototype。

更重要的是,lodash 4 生态太庞大了。很多项目的 package-lock.json 里锁定的还是 4.17.4、4.17.2 这种“远古”版本,因为业务代码里只用了cloneDeep、throttle这类安全函数,就忽略了整体版本升级。而攻击者不会管你用的是哪个函数,只要依赖树里有问题版本,就存在被利用的可能。

我这次遇到的场景很典型:某内部管理系统,前端与服务端共用一套数据校验逻辑,服务端骨干代码里用了_.merge合并配置对象。业务方为了省事,把用户传入的筛选条件直接 merge 进配置里,结果就给了攻击者一个天然的入口。

2. 发现阶段:一次不寻常的线上排查旅程

2.1 线上异常现象:对象属性突然“凭空出现”

整个排查起始于一个非常不起眼的线上疑问。某天下午,运维反馈管理后台的一个列表接口返回值异常,某个用户的数据里多出了一个isAdmin字段,值还是true。这个字段在数据库表结构、接口文档、前端 model 定义里全部不存在。

第一反应是数据库被改了,或者是缓存出现了脏数据。查了一圈,数据库记录正常,缓存也没有异常写入。后来发现,问题校验逻辑在更底层——列表页有个“当前用户是否有权限导出”的判断,代码是这样写的:

if (user.info.isAdmin) { // 允许导出 }

这个user.info是从接口读取的普通对象,理论上根本没有isAdmin字段。但页面却走出了“允许导出”的分支。更诡异的是,随便在浏览器控制台执行({}).isAdmin,返回的竟然也是true。

到这一步基本可以确认:不是某个对象的问题,是所有对象都被污染了。原型链上被注入了一个全局属性,而且这个名字非常有攻击性——isAdmin。

2.2 定位过程:从二分排查到依赖审计

确认是全局原型污染后,排查范围立刻从“业务逻辑”切换到“公共依赖”。我们当时的做法分三步:

第一步,检查所有入口代码里是否有人直接操作__proto__或Object.prototype。全局搜索一圈,业务代码里没有这种危险写操作,基本排除人为写入。

第二步,检查依赖树里是否有已知原型污染漏洞的库。用npm audit扫了一遍,结果直接报警:lodash 版本 4.17.4,存在原型污染漏洞,安全公告编号指向的就是后来大家熟知的 CVE-2018-3721 和后续一揽子问题。npm audit 的提示里写得明白:__proto__属性可被 merge 类函数污染。

第三步,也是最关键的一步:找出到底哪条调用链把用户输入喂给了_.merge。我们用调用栈追踪 + 代码检索双管齐下,最终在一个公共的“参数归一化”函数里发现了嫌疑代码:

function normalizeParams(input) { return _.merge({}, defaultSchema, input); }

这个函数本意是:用默认配置兜底,再用用户输入覆盖,实现参数归一化。结果用户输入里的{"__proto__": {"isAdmin": true}}直接被 merge 进了空对象,一次递归就击穿了整条原型链。

2.3 漏洞代码的实锤分析:merge 内部的递归路径

把 lodash 4.17.4 里merge的实现拆开看,问题点非常清晰。merge内部有一个baseMerge函数,负责把源对象的值复制到目标对象,它的核心逻辑是对每个 key 调用mergeObject。在 4.17.4 里,对 key 的检查依赖一个叫isPrototype的判断,逻辑大致是:如果 key 是__proto__、constructor、prototype就跳过。

但这里有个绕过点:lodash 只检查了“第一层”的 key,没有对嵌套路径中的__proto__做充分防护。攻击者把 payload 改成{"__proto__": {"a": 1}},第一层 key 是__proto__,会被拦截;但如果改成{"a": {"__proto__": {"b": 2}}},递归进入a对象之后,内部再一次进入mergeObject,此时 key 为__proto__,部分版本里的保护逻辑没有被触发。

换句话说,漏洞利用的充分条件是:存在嵌套对象 + 内部 key 包含__proto__,且目标对象不是普通字面量。修复版本的做法就是严格判断对象是否真正拥有__proto__自有属性,而不是依赖“可枚举属性遍历”这种间接判断。

我们当时用最小复现脚本把整个攻击路径跑通以后,问题基本定性:lodash 4 的递归合并函数对嵌套__proto__的防护不充分。接下来进入修复阶段。

3. 修复阶段:从应急补丁到体系化治理

3.1 最直接的修复:升级 lodash 版本,但没有想象中简单

第一步当然是升级。官方在 4.17.5 中首次修复了__proto__污染问题,但后续又被研究者发现新的绕过路径,于是又有了 4.17.11、4.17.12、4.17.15、4.17.19 等多个版本的安全更新。所以我的建议非常明确:不要停在 4.17.5,直接升到 4.17.21 及以上,这是目前最稳的版本之一。

升级过程有几个坑必须提一下:

  • 先用npm ls lodash查看完整的依赖树,你会发现项目里很可能不止一个 lodash。某个小工具库依赖 lodash 4.17.4,你的业务代码依赖 lodash 4.17.20,npm audit依然会报警,因为npm@6只检测直接依赖和顶层间接依赖,嵌套依赖需要先升级顶层依赖的版本。
  • 直接改 package.json 里的版本号不一定会生效,npm 存在缓存和锁文件问题。正确的做法是:删除node_modules和package-lock.json,重新npm install;或者用npm dedupe+npm update lodash组合。用 pnpm 的话,还需要通过pnpm.overrides强制指定版本,否则子依赖仍可能保留老版本。
  • 升级后必须做回归测试,重点覆盖用到merge、defaultsDeep、set的业务。因为新版本对原型相关 key 的过滤行为更严格,个别原本依赖“合并__proto__属性”的代码(这种写法本身就不合法),升级后行为会变化,需要同步调整。

我们这次升级后顺手做了一件事:用package-lock.json的版本约束把 lodash 固定到 4.17.21,同时在 CI 脚本里增加对npm audit的强制检查,high级别以上漏洞直接构建失败。这一步能从流程上拦住未来小版本回退的风险。

3.2 输入校验与安全默认值:临时拦截不能只靠升级

升级是必须的,但说实话,只升版本而不改代码习惯,属于“治标不治本”。攻击者永远在找下一个版本的绕过方式,所以我们同步做了代码层的加固。

第一个加固点:对来自外部的输入做 key 级过滤。我们这个项目的入口是 JSON 格式的请求体,所以在进入merge之前,先对 JSON 进行“反原型污染清洗”:

function sanitize(input) { if (Array.isArray(input)) { return input.map(sanitize); } if (input && typeof input === 'object') { const result = {}; for (const key of Object.keys(input)) { // 直接跳过危险字段,连 constructor.prototype 这种组合也一并拦截 if (['__proto__', 'constructor', 'prototype'].includes(key)) { continue; } result[key] = sanitize(input[key]); } return result; } return input; }

注意我用的是Object.keys而不是for...in,这很关键。Object.keys只返回对象自有的可枚举属性,不会把原型链上的东西带出来,而for...in会遍历到继承的属性,容易把污染源再次引入循环。

第二个加固点:明确“默认值兜底”的原则。原本的normalizeParams把用户输入直接 merge 进默认配置,改成了先通过白名单做字段筛选,再把筛选后的对象 merge 进默认值。这样即使未来再出现类似的__proto__绕过,用户输入中也只包含白名单字段,攻击面从“全属性”缩小到了“固定字段”。

第三个加固点:在服务端入口处统一加一个“对象水位线”检测。简单说,就是每次请求进来先检查Object.prototype的哈希指纹,用Object.keys(Object.prototype).length或随机属性探测法,一旦发现数量异常或出现可疑属性,直接拒绝请求并触发告警。这个检测要放在业务代码之前,避免污染状态在后续请求中被放大。

3.3 纵深防御:把原型污染拦截在开发阶段

代码层的修补只能解决当前项目,要真正做到“以后不再出现同类问题”,还得靠工具链和自动化。

我用了三层手段:

  • 静态扫描:给团队强制启用 ESLint 的no-prototype-builtins规则,同时引入eslint-plugin-security,重点扫描merge、defaultsDeep这类高危函数的调用点。ESLint 的规则不能发现数据流问题,但能拦住最明显的“直接 merge 外部变量”的写法,逼迫开发者在调用前先想清楚数据来源。

  • 依赖审计自动化:除了 CI 里跑npm audit,我还加了snyk test和npm outdated的组合检查。npm audit的问题在于它主要看 npm 官方公告库,延迟有时候比较明显;snyk的漏洞库更新更快,两者结合能覆盖更多场景。审计频率是每次 merge request 触发一次,不只在版本发布时才做。

  • 运行时防御:核心服务启动时,对Object.freeze(Object.prototype)。这招很粗暴但极度有效:冻结之后,任何对Object.prototype的修改都会在严格模式下直接抛错,非严格模式下静默失败。缺点是一旦某些老版本的第三方库也在运行时扩展Object.prototype,会被一并冻结导致功能异常。所以这个手段要评估依赖后再决定是否启用。

我当时在一个对稳定性要求极高的核心服务上启用了Object.freeze,测试跑了两周,没有发现任何兼容性问题。原因很简单:现代业务代码几乎没有合理理由在运行时给Object.prototype挂方法,挂上去的都是历史遗留或攻击行为,该改的改,该报告的报告。

4. 常见问题与排查技巧实录

这段时间处理下来,我整理了五个高频问题和对应的排查方向,贴出来给大家参考:

场景表现排查方向
npm audit报警 lodash 漏洞依赖树里有多个 lodash,版本参差不齐定位顶层依赖,升级顶层包版本;用 overrides 强制统一版本
页面所有对象凭空出现某个属性控制台({}).xxx返回非 undefined全局搜索merge/defaultsDeep调用,检查入参来源
升级后某些对象合并行为变化原本会合并__proto__字段的代码失效这类代码本身就不合规,改为手动删除该字段后再合并
服务端请求处理变慢疑似在循环里触发hasOwnProperty的覆盖检查Object.prototype是否被注入非函数属性,导致继承方法被替换
严格模式下页面直接抛错“Cannot assign to read only property”确认是否启用了Object.freeze(Object.prototype),排查第三方库在运行时扩展原型

在排查这类问题的过程中,有几个很实用的技巧:

  1. 小步验证,快速定位:不要一上来就全项目扫描。先在控制台跑一个最小复现脚本,确定“是否真的污染了原型”,再用调用栈定位是哪个库干的。我们当时就是直接在 Node.js 环境里跑require('lodash').merge({}, JSON.parse('{"__proto__": {"a": 1}}')),输出{}的a属性是否为 1,三分钟就完成了定性。

  2. 看 patch diff 比看公告更有效:官方安全公告会告诉你“修复了原型污染”,但不会告诉你具体怎么修的。去 GitHub 上看 lodash 修复版本的 commit diff,能看到它对baseMerge和isPrototype的改动,这能帮助你判断自己项目里的哪些调用路径仍然处于风险中。

  3. 检查锁文件里的间接依赖:package-lock.json里可能有多个 lodash 记录。用npm ls lodash会把整棵依赖树都列出来,你会惊讶地发现某些小工具的依赖里藏着老版本。这时候最省力的方案是:在 package.json 中增加"overrides": { "lodash": "^4.17.21" },强制所有子依赖统一版本。

我踩过的一个具体坑是:当时想通过直接删除node_modules里的老版本 lodash 来“应急”,结果发现 npm 的 hoisting 机制会把不同版本的 lodash 放到不同的目录层级,删了顶层的不代表删了嵌套的。正确做法永远是改锁文件和版本约束,而不是物理删除文件。

5. 这次修复沉淀下来的三条工程原则

整个事件从发现到彻底修复,前后大概花了两天半。除了解掉线上问题,我更想说的是这次经历给团队留下的几条工程层面的原则,它们比单个漏洞修复更值钱。

第一条:任何合并用户输入的代码,都要默认不信任。以前大家写_.merge({}, defaults, req.body)是顺手的事,觉得“就一个配置合并,能有什么问题”。现在我只允许一种写法:先白名单筛选,再深拷贝合并,最后Object.freeze收尾。可能有人觉得这样代码冗余,但安全本来就是拿复杂性换确定性,这笔账必须算清。

第二条:依赖库版本不是“能跑就行”,而是要跟安全公告保持同步。团队后来定了一条规则:每月第一个工作日,用自动化脚本扫一遍所有依赖的最新安全状态;关键库(lodash、express、webpack 等)如果出现高危漏洞,要在 48 小时内完成升级评估。对旧项目的依赖版本,不要求一步到位全部升到最新,但至少要把高危漏洞清零。

第三条:漏洞修复要留下完整的复盘文档。我们建了一个安全事件档案,记录了漏洞原理、影响范围、检测方式、修复方案和验证过程。这样做的价值在下一次安全巡检时体现得淋漓尽致——新同学不用重新踩坑,照着文档就能复现排查路径并验证修复有效性。

如果你现在正被类似的问题困扰,我的建议很简单:先确认Object.prototype是否被污染,再查 lodash 版本,然后立刻升级并加输入清洗。整个过程不需要猜来猜去,就是一条线走到底。当然,也存在项目里根本没有直接调用 lodashmerge,但依赖树里某个中间层悄然引入的情况,这种情况下npm audit就是你最快的救命稻草。真等线上出现异常再排查,代价永远比提前拦截要大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 18:17:30

AI个性化闭环:从记忆库到反馈机制的大模型应用实践

先聊一个现象:你每天打开手机,各类应用好像越来越“懂”你,推荐的内容总能命中你的口味;可一旦切换到AI对话助手,它却经常把上一轮聊过的话题忘得一干二净,好像从来没认识过你。问题出在哪?在于…

作者头像 李华
网站建设 2026/10/20 18:17:15

腾讯云AI语音识别实战:搭建会议纪要自动化工具

1. 会议记录这件事,到底难在哪里 开会这件事,估计每个职场人都有一肚子话想说。尤其是那种一开就是两三个小时的跨部门对齐会、需求评审会、项目复盘会,开完之后脑子嗡嗡的,还得硬撑着整理会议纪要。我以前就吃过这个亏——会上讨…

作者头像 李华
网站建设 2026/10/20 18:15:46

refin-bin-0.14.tar 文本后处理工具包:日志清洗与规则部署实战

简介:rEFInd 是一款面向 UEFI 固件环境的开源启动管理器,常用于 Linux、macOS 与 Windows 多系统共存的引导切换,可展示图形化启动菜单,支持自定义超时、默认系统与图标,适合具备一定 UEFI 基础的桌面用户、双系统爱好…

作者头像 李华
网站建设 2026/10/20 18:14:09

三相无桥PFC整流器设计实战:从拓扑选型到效率优化

正文说起来有点不好意思,我做三相PFC整流这块,一开始是被人拉着去救火的。当时某公司一台大功率设备的前级整流模块,用着用着就过热,散热片烫得手没法碰,谐波还超标,现场老电工一测电流波形,眉毛…

作者头像 李华
网站建设 2026/10/20 18:13:24

Arduino模糊逻辑避障小车:BLDC驱动与三向感知实战

你手头那辆阈值判断的小车是不是也有这毛病:碰上墙壁就猛打方向,走着走着突然摇头晃脑,遇到墙角直接卡住原地转圈。我前阵子搭了一辆基于 Arduino 和三路传感器的 BLDC 模糊避障小车,把决策从“硬开关”换成了“软油门”之后&…

作者头像 李华
网站建设 2026/10/20 18:12:58

智能助手生活指南:零成本、零代码解决日常难题的提问方法

前阵子帮我妈查异地就医备案的流程,顺手把材料清单、办理窗口、注意事项一起要了,前后不到十分钟。老太太一边看一边念叨:这东西怎么比医院导诊台还明白。后来我发现,身边大部分人不是不会用AI,而是没把智能助手当成一…

作者头像 李华