- 教程
【免费下载链接】jstips
This is about useful JS tips!
本文基于 jstips 仓库
_posts/es_ES/javascript/2016-02-11-preventing-unapply-attacks.md(Tip #42,作者 emars)整理扩充。它讲解了一种名为 "unapply attack" 的原型污染手法——外部代码通过改写Array.prototype.concat等内置方法,窃取并篡改bindpolyfill 闭包中绑定的参数,从而让依赖 polyfill 的应用崩溃。读完本文,你将掌握这种攻击的完整复现代码、攻击的底层生效原理,以及使用Object.freeze冻结内置原型(Object.prototype、Array.prototype、Function.prototype)这一防御方案,同时理解为什么它只能算缓解措施、而非真正的安全边界。
什么是 Unapply 攻击
JavaScript 的内置原型(builtin prototypes)是全局可写的:任何代码都可以在运行时改写Object.prototype、Array.prototype、Function.prototype上的方法。本 tip 指出,一旦外部代码改写了内置原型,它就可以通过重写代码来暴露(expose)和修改(change)原本被闭包捕获的绑定参数,进而破坏那些依赖 ES5 polyfill 方法才能正常工作的应用——这是一个可能造成严重破坏的问题。
"Unapply" 这个名字暗示了攻击的核心动作:正常流程是fn.apply(thisArg, args)把参数"应用"到函数上,而攻击者则试图反向操作——把已经通过闭包绑定(bind)进去的参数从函数内部"剥离"出来。
攻击复现:bind polyfill 与 unapplyAttack
原文档给出了两段关键代码。第一段是一个典型的bindpolyfill:
// example bind polyfill function bind(fn) { var prev = Array.prototype.slice.call(arguments, 1); return function bound() { var curr = Array.prototype.slice.call(arguments, 0); var args = Array.prototype.concat.apply(prev, curr); return fn.apply(null, args); }; }它做的事情是:
- 调用
bind(fn, a, b, ...)时,用Array.prototype.slice.call(arguments, 1)把除fn之外的参数截取为prev数组,并通过闭包保存; - 返回的
bound函数在被调用时,再把本次传入的参数截取为curr; - 通过
Array.prototype.concat.apply(prev, curr)把预绑定参数prev和运行时参数curr拼接成完整的args; - 最后
fn.apply(null, args)执行原函数。
注意第 3 步:拼接动作依赖Array.prototype.concat这个内置方法,而prev这个闭包变量会作为concat的接收者(this)被传入。
第二段是攻击函数:
// unapply-attack function unapplyAttack() { var concat = Array.prototype.concat; Array.prototype.concat = function replaceAll() { Array.prototype.concat = concat; // restore the correct version var curr = Array.prototype.slice.call(arguments, 0); var result = concat.apply([], curr); return result; }; }攻击分两步:
- 先用
var concat = Array.prototype.concat把原始的concat方法缓存到局部变量; - 再给
Array.prototype.concat赋上一个新的replaceAll函数,其中内部立即把Array.prototype.concat恢复为原始版本(restore the correct version),然后用缓存的原始concat以[]为接收者、仅携带运行时参数curr完成拼接。
攻击为什么会让 bind 崩溃
关键差异在于replaceAll内部使用的是concat.apply([], curr),接收者是空数组[],而不是polyfill 闭包里的prev。于是:
- 在
unapplyAttack()执行之后,任何一个bind出来的函数第一次被调用时,其内部的Array.prototype.concat.apply(prev, curr)拿到的已经是被替换的replaceAll; replaceAll内部把concat恢复到原始版本,但调用时接收者被换成了[],绑定的prev数组被直接丢弃(discards theprevarray from the bind);- 结果拼接出的
args只剩下curr,甚至因为prev缺失导致参数结构错乱——原文档明确指出:使用 unapply 攻击后,紧随其后的第一次.concat调用就会抛出错误("the first concat call following using the unapply attack will throw an error")。
换句话说,攻击者不需要知道prev的具体内容,只要在关键时刻替换一次concat,就能让所有基于闭包绑定参数的功能失效——这正是"重写代码以暴露和修改绑定参数"的含义。
防御方案:Object.freeze 冻结内置原型
Object.freeze()能让对象变为不可变(immutable):既不能新增属性,也不能删除或重新定义属性,现有属性的值也不可写。把这个能力用在原型对象上,就相当于给内置原型上了一把锁,任何后续的覆盖写入都会被静默拒绝(非严格模式下)或抛出TypeError(严格模式下)。
原文档给出的完整防御代码是一个立即执行函数(IIFE):
(function freezePrototypes() { if (typeof Object.freeze !== 'function') { throw new Error('Missing Object.freeze'); } Object.freeze(Object.prototype); Object.freeze(Array.prototype); Object.freeze(Function.prototype); }());逐行解读:
if (typeof Object.freeze !== 'function') { throw new Error('Missing Object.freeze'); }:先做能力检测。Object.freeze是 ES5 引入的 API,在非常老旧的环境中可能不存在,此时直接抛出错误,避免静默失败;Object.freeze(Object.prototype):冻结Object.prototype,防止所有对象的原型链顶层被改写;Object.freeze(Array.prototype):冻结Array.prototype,直接封死concat、slice等数组方法的替换路径;Object.freeze(Function.prototype):冻结Function.prototype,阻止apply、call、bind等函数方法被篡改。
三段冻结互补:Object.prototype管"所有对象的公共祖先",Array.prototype管"攻击最常见的落点(concat/slice)",Function.prototype管"函数调用机制本身"。
建议在应用启动、任何第三方脚本加载之前尽早执行这段代码,让"冻结先于污染"。部署方式很简单:把它放到入口脚本的顶部,或在script标签中以立即执行形式加载。
深入 Object.freeze:不可变对象的三个层次
jstips 仓库中另一篇 tip(_posts/en/javascript/2018-11-25-creating-immutable-objects-in-native-javascript.md)系统介绍了原生 JavaScript 制造不可变对象的三个 API,可以帮助你理解为什么选freeze而不是seal或preventExtensions:
| API | 阻止新增属性 | 阻止删除属性 | 阻止重定义描述符 | 阻止修改属性值 |
|---|---|---|---|---|
Object.preventExtensions() | ✅ | ❌ | ❌ | ❌ |
Object.seal() | ✅ | ✅ | ✅ | ❌ |
Object.freeze() | ✅ | ✅ | ✅ | ✅ |
Object.preventExtensions()只是禁止向对象添加新属性(且不可逆);Object.seal()在此基础上禁止删除属性和重定义属性描述符,但属性值仍可写;Object.freeze()是三者中约束最彻底的:加上属性值不可写(non-writable),这也是冻结原型所必需的能力——否则攻击者虽然无法替换concat,却仍可能改写其内部的默认行为。
可以用Object.isFrozen(Object.prototype)、Object.isFrozen(Array.prototype)等方法来验证冻结是否生效。
另外,结合仓库中 _posts/en/javascript/2016-01-07-use-strict-and-get-lazy.md 这篇 tip 的提醒:在严格模式("use strict")下,对只读属性(如被冻结原型上的方法)执行赋值会直接抛出TypeError错误,把"静默失败"变成"响亮失败",更便于在开发阶段第一时间发现原型被篡改的迹象。
重要提醒:冻结不是真正的安全边界
原文档在结尾给出了冷静而重要的限定(英文原文见 _posts/en/javascript/2016-02-11-preventing-unapply-attacks.md):
- 虽然这个概念被称为 "unapply attack"(因为某些代码能够访问通常不在其作用域内的闭包),但把它当作安全特性(security feature)来看待是错误的;
- 原因之一是:如果攻击者本身就拥有代码执行能力,他完全可以在冻结发生之前就扩展原型——冻结只能封住"冻结之后"的改写,封不住"冻结之前"的污染;
- 原因之二是:攻击者即便不修改原型,也仍然可以利用各种语言特性(如
eval、with、动态作用域)去读取所有作用域内的变量; - 相对更强的隔离方案是ECMA 模块(ES Modules):模块系统提供基于 realm 的隔离(realm-based isolation),比冻结原型的防御力度大得多;但它依然无法彻底解决第三方脚本带来的问题(第三方脚本往往需要直接访问全局环境,天然难以完全隔离)。
因此,正确的姿势是把Object.freeze冻结内置原型当作纵深防御中的一环(让意外污染和常规攻击更难成功),而不是可以替代代码审查、沙箱隔离或模块边界的"万能安全锁"。
在 jstips 仓库中的对应资源
本文对应的原始 tip 在仓库中提供了四种语言版本,内容互为镜像,可以对照阅读:
- 西班牙语(本篇依据):_posts/es_ES/javascript/2016-02-11-preventing-unapply-attacks.md
- 英语(原始版本):_posts/en/javascript/2016-02-11-preventing-unapply-attacks.md
- 简体中文:_posts/zh_CN/javascript/2016-02-11-preventing-unapply-attacks.md
- 繁体中文:_posts/zh_TW/javascript/2016-02-11-preventing-unapply-attacks.md
该 tip 在项目 README.md 的 Tips 列表中登记为第 42 条("42 - Preventing Unapply Attacks"),属于javascript分类。相关延伸阅读还包括上文提到的不可变对象专题(Tip #74)与严格模式专题(Tip #07),它们共同构成了理解"原型安全 + 不可变性"的完整知识链。
- 教程
【免费下载链接】jstips
This is about useful JS tips!
相关推荐
预防 Unapply 攻击:冻结 JavaScript 内置原型,抵御原型链篡改(jstips 第 42 期)
预防 Unapply 攻击:冻结 JavaScript 内置原型,抵御原型链篡改(jstips 第 42 期) 本文源自 jstips 开源仓库( README
教程mini-vue中的响应式对象冻结:Object.freeze处理
mini vue中的响应式对象冻结:Object.freeze处理 在开发响应式应用时,你是否遇到过需要保护对象不被意外修改的场景?是否想过如何在mini vu
前端教程minikube v1.24.0 time-to-k8s 基准测试解读:从零到成功部署 Kubernetes 的耗时测量与对比
minikube v1.24.0 time to k8s 基准测试解读:从零到成功部署 Kubernetes 的耗时测量与对比 minikube 项目在发布每个
教程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考