☰
jstips 第 42 期:预防 Unapply 攻击——用 Object.freeze 冻结内置原型
2026/10/8 14:14:20 网站建设 项目流程
  • 教程

【免费下载链接】jstips

This is about useful JS tips!

项目地址:https://gitcode.com/gh_mirrors/js/jstips
点击查看免费下载

本文基于 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); }; }

它做的事情是:

  1. 调用bind(fn, a, b, ...)时,用Array.prototype.slice.call(arguments, 1)把除fn之外的参数截取为prev数组,并通过闭包保存;
  2. 返回的bound函数在被调用时,再把本次传入的参数截取为curr;
  3. 通过Array.prototype.concat.apply(prev, curr)把预绑定参数prev和运行时参数curr拼接成完整的args;
  4. 最后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; }; }

攻击分两步:

  1. 先用var concat = Array.prototype.concat把原始的concat方法缓存到局部变量;
  2. 再给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!

项目地址:https://gitcode.com/gh_mirrors/js/jstips
点击查看免费下载
上一篇:Windows热键侦探:终极指南帮你找回被"偷走"的快捷键
下一篇:终极文档下载神器:3步免费下载百度文库、原创力文档等30+平台内容

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询