1. 从一道CTF题拆解原型链污染的真实攻击面第一次看到“undefsafe原型链[网鼎杯 2020 青龙组]notes”这个组合的时候我正坐在工位上啃一个Node.js项目的依赖树。当时第一反应是undefsafe这个库名太熟了几乎每个用lodash风格做配置合并的项目里都能翻到它的身影。而网鼎杯那道notes题恰好是把原型链污染从“理论漏洞”变成“可拿flag的实际利用链”的经典案例。这两者放在一起其实指向的是同一个核心问题——JavaScript对象原型链的继承机制在特定代码路径下会变成攻击者写入任意属性的通道。这篇文章想聊的不是单纯复现某道题而是把原型链污染这件事从原理、触发条件、利用手法到防御方案完整地捋一遍。如果你平时写Node.js后端、做前端工具链、或者对CTF Web方向感兴趣这篇内容应该能帮你把“原型链污染”从一个模糊的概念变成手里能用的武器和盾牌。我会尽量用从业者之间交流的方式来讲不堆术语该给代码给代码该说坑说坑。先明确一下讨论范围原型链污染Prototype Pollution本质上是攻击者通过可控的输入修改了JavaScript对象原型上的属性导致所有继承自该原型的对象都受到影响。undefsafe是一个用于安全读取嵌套对象属性的小工具库但它在特定版本中存在可以通过__proto__等键名污染原型的缺陷。而网鼎杯2020青龙组的notes题则是一个典型的“污染→绕过→RCE”的完整利用场景。两者结合能让我们看清从漏洞点到利用链的全貌。2. 原型链污染的核心原理与undefsafe的缺陷定位2.1 JavaScript原型链继承机制回顾要理解原型链污染得先把JavaScript的对象模型说清楚。每个对象都有一个内部属性[[Prototype]]指向它的原型对象。当我们访问一个对象的属性时如果对象自身没有这个属性引擎就会沿着原型链往上找直到找到或者到达null。这个机制本身是语言特性没有问题。问题出在如果攻击者能控制某个赋值操作的“键名”让这个键名变成__proto__或者constructor.prototype那么原本只想给一个普通对象加属性的操作就会变成给Object.prototype加属性。举个例子假设有这样一个合并函数function merge(target, source) { for (let key in source) { if (typeof target[key] object typeof source[key] object) { merge(target[key], source[key]); } else { target[key] source[key]; } } return target; }如果传入的source是JSON.parse({__proto__: {isAdmin: true}})那么key就是__proto__target[key]就是Object.prototype于是Object.prototype.isAdmin被设成了true。之后任何空对象{}的isAdmin都会是true。这就是最基础的原型链污染。2.2 undefsafe为什么会被卷入原型链污染undefsafe的定位是“安全地读取深层嵌套属性”它的API长这样undefsafe(obj, a.b.c)如果中间某一层不存在它不会抛错而是返回undefined。这个库在早期版本里内部实现用了递归赋值来“创建缺失的中间对象”。问题就出在这个“创建”过程。我翻过undefsafe的源码核心逻辑大致是这样的它把路径按.拆开然后逐层往下走如果发现某一层不是对象就把它设成一个空对象继续往下。关键在于它没有对路径中的__proto__、constructor、prototype这些敏感键做过滤。也就是说如果你调用undefsafe(obj, __proto__.polluted)它会在obj上找__proto__发现存在就是Object.prototype然后继续往下最终在Object.prototype上设置polluted属性。这个缺陷在undefsafe 2.0.3之前的版本里是存在的。后来社区报了CVE作者在2.0.3版本里加了键名黑名单把__proto__、constructor、prototype都挡掉了。但现实情况是很多老项目锁定了旧版本或者间接依赖了旧版本所以这个风险依然在。2.3 网鼎杯notes题如何把污染变成利用链网鼎杯2020青龙组的notes题我印象很深因为它不是那种“污染完就结束”的题而是要求你把污染转化成实际的信息泄露或者代码执行。题目是一个Node.js写的笔记应用用户可以创建、查看、删除笔记。后端用了undefsafe来读取用户配置里的嵌套字段比如user.settings.theme这种。攻击路径大致是这样的用户提交的笔记内容会被解析成JSON然后通过undefsafe合并到用户配置对象里。由于undefsafe没有过滤__proto__攻击者可以提交{__proto__: {role: admin}}污染Object.prototype.role。之后应用里任何检查if (user.role admin)的地方都会因为原型链继承而通过。但这只是第一步题目还要求读取flag文件所以需要进一步污染到能影响文件读取路径或者模板渲染的属性。具体来说题目里有一个“导出笔记”的功能它会根据用户配置里的outputPath来决定写文件的位置。攻击者可以污染Object.prototype.outputPath把它设成/flag或者某个可读路径然后触发导出就能把flag内容带出来。这个链条的关键在于污染的属性必须恰好被后续代码路径读取到而且不能覆盖掉对象自身已有的同名属性。注意原型链污染的成功率取决于目标代码是否在污染之后、在关键判断之前没有给对象自身设置同名属性。如果对象自身有role属性那么原型上的role就不会被用到。3. 手工复现undefsafe污染与notes题利用链3.1 环境搭建与依赖版本确认要复现这个链条第一步是确认undefsafe的版本。你可以用npm ls undefsafe看当前项目里的版本或者直接看package-lock.json。如果版本低于2.0.3那就有戏。我一般会建一个干净的目录手动装一个旧版本mkdir proto-lab cd proto-lab npm init -y npm install undefsafe2.0.2然后写一个最简单的测试脚本const undefsafe require(undefsafe); const obj {}; undefsafe(obj, __proto__.polluted, yes); console.log({}.polluted); // 输出 yes如果控制台打出yes说明污染成功。这一步是后面所有利用的基础必须先确认。3.2 构造污染payload并观察属性继承实际攻击中payload往往不是直接写__proto__而是嵌套在JSON里。比如notes题里用户提交的笔记数据可能是这样的{ title: test, content: hello, config: { __proto__: { outputPath: /flag, role: admin } } }后端用undefsafe把config合并到用户对象时就会触发污染。这里有个细节如果后端用的是JSON.parse那么__proto__会被当成普通字符串键不会自动触发原型修改。只有当你用for...in或者Object.assign这类操作去遍历赋值时才会真正写到原型上。undefsafe的内部递归赋值恰好做了这件事。我实测下来污染生效后新建的空对象会继承这些属性const user {}; console.log(user.role); // admin console.log(user.outputPath); // /flag但要注意如果代码里用了Object.create(null)创建的对象它是没有原型的污染对它无效。所以利用的时候要确认目标对象是不是普通对象。3.3 从污染到文件读取的完整链路notes题里读取flag的环节我复盘的时候发现它依赖了一个模板引擎的配置。具体来说应用用了一个简单的字符串替换来生成导出内容而替换的模板路径来自user.templatePath。攻击者可以污染Object.prototype.templatePath指向/flag然后触发导出。由于模板引擎会读取该路径的文件内容并渲染flag就被带出来了。整个链路的步骤可以整理成下面这个表步骤操作目的1提交含__proto__的JSON触发undefsafe污染2污染role为admin绕过权限检查3污染templatePath为/flag控制文件读取路径4调用导出接口触发模板渲染读取flag5从响应中提取flag完成利用这个表看起来简单但每一步都有坑。比如第2步如果应用在检查权限之前已经给user.role赋了默认值那污染就不生效。第3步如果模板引擎对路径做了白名单校验也走不通。所以实际利用的时候需要先读源码找到那些“从原型链上取属性且没有自身赋值”的代码点。实操心得污染的属性名要尽量选那些应用里“只读不写”的配置项。如果一个属性在对象初始化时就被显式赋值了那原型上的同名属性永远没机会被访问到。4. 常见问题排查与防御方案速查4.1 污染不生效的几种典型原因我在复现和实际测试中遇到过不少“明明payload对了但就是没反应”的情况排下来主要有这几类对象自身已有同名属性比如user.role在登录时就被设成了user那原型上的admin就被遮蔽了。解决办法是找一个应用里没有显式初始化的属性。使用了Object.create(null)这种对象没有原型链污染对它无效。常见于一些注重安全的库。JSON解析后没有遍历赋值如果后端只是JSON.parse然后直接存数据库没有用undefsafe或者类似的合并逻辑污染不会发生。undefsafe版本已修复2.0.3及以上版本会直接拦截__proto__这时候需要找其他污染点比如constructor.prototype。排查的时候我一般会在本地起一个调试环境在undefsafe的赋值处打断点看键名到底是什么目标对象是什么。这样最直接。4.2 防御原型链污染的四个实操层面防御这件事得从代码、依赖、运行时三个层面一起下手。我整理了一个速查表层面措施说明代码合并前过滤敏感键对__proto__、constructor、prototype做黑名单代码用Object.create(null)做配置对象从根上断掉原型链依赖锁定undefsafe2.0.3或者换用lodash的_.get等安全替代运行时冻结Object.prototypeObject.freeze(Object.prototype)可阻断污染其中Object.freeze(Object.prototype)这一招最省事但要注意它会影响一些依赖原型扩展的库。我一般只在关键服务里用而且会先跑一遍回归测试。另外如果你在用JSON.parse可以加一个reviver函数来过滤__proto__JSON.parse(input, (key, value) { if (key __proto__ || key constructor) { return undefined; } return value; });这个reviver会在解析阶段就把敏感键丢掉比事后过滤更彻底。4.3 从CTF题到真实项目的迁移思考notes题这种CTF场景在真实项目里其实有对应的影子。比如很多配置中心、功能开关系统会允许用户提交JSON来覆盖默认配置。如果后端用了不安全的合并逻辑攻击者就能污染全局配置把某个功能开关打开或者把日志路径改到敏感目录。我见过一个案例某内部系统允许用户自定义主题结果攻击者污染了Object.prototype.theme导致所有用户的页面都加载了恶意脚本。所以我的建议是凡是涉及“用户输入合并到对象”的地方都要过一遍原型链污染的检查清单。具体来说问自己三个问题输入里的键名可控吗合并逻辑会遍历赋值吗合并后的对象会被后续代码当作配置读取吗三个都是“是”那就得警惕了。5. 写在最后的一些个人体会原型链污染这个漏洞类型我最大的感受是它特别“隐蔽”。它不像SQL注入那样有明显的报错也不像XSS那样能直接看到弹窗。它往往是悄无声息地改了一个属性然后在某个意想不到的地方触发。undefsafe这个库本身很小小到很多人不会去读它的源码但正是这种“小透明”依赖成了攻击链里的关键一环。网鼎杯那道notes题我后来自己又复现了两遍每次都能发现新的细节。比如第一次我只做到了权限绕过第二次才把文件读取的链路打通。这个过程让我意识到CTF题的价值不在于拿flag而在于它逼着你去读源码、去理解运行时行为、去思考每一步的因果关系。这种思维方式放到真实项目的安全审计里同样管用。如果你也在做Node.js相关的开发或者安全测试我建议你花半个小时把自己项目里的依赖树翻一遍看看有没有undefsafe、lodash.merge、deep-extend这类库的旧版本。有的话升个级或者加个过滤。这个动作花不了多少时间但能挡掉一大类风险。
阅读完成 · 觉得有帮助?