☰
从CTF到实战:undefsafe原型链污染攻击链与防御
2026/10/20 12:30:22 网站建设 项目流程

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 undefsafe@2.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调用导出接口触发模板渲染,读取flag
5从响应中提取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)做配置对象从根上断掉原型链
依赖锁定undefsafe>=2.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这类库的旧版本。有的话,升个级,或者加个过滤。这个动作花不了多少时间,但能挡掉一大类风险。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询