原型污染
使用不可信的数据,通过调用不安全的递归函数来暴露默认原型
原型污染:基础
什么是原型污染?
原型污染是一种针对JavaScript运行时的注入攻击。通过原型污染,攻击者可以控制对象属性的默认值,从而篡改应用程序的逻辑并可能导致服务被拒绝,甚至在某些极端情况下远程执行代码。
现在,你是不是满脑子充满了各种疑问。到底什么是“在运行时改写对象的属性”?它如何影响应用程序的安全?而且,更重要的是,我如何保护我的代码免受这种攻击?
关于本文
原型污染可以很复杂,所以本文将分三部分进行介绍。
- 使用原型污染危害易受攻击的API。
- 了解更多有关JavaScript原型的知识以及原型污染是如何工作的。
- 如何修复和防止应用程序中的原型污染。
事实上,原型污染漏洞在许多流行的JavaScript库中都被发现过并修复了,包括jQuery、lodash、express、minimist、hoek等等。在jQuery中发现原型污染时,当时有74%的网站都在使用jQuery,听起来有多可怕!
原型污染攻击示例
让我们来演示一下在真实场景中原型污染是如何进行攻击的。假设一个名为startup.io的公司决定发布一个API,允许用户通过app来管理公司的数据。
不幸的是,由于开发过程中时间紧任务急,startup.io的工程师们根本来不及考虑API的安全问题,以至于他们忽略了安全扫描报告中发现的所有安全漏洞。于是,随着该API紧急发布,其中包含了许多bug和安全漏洞——其中之一就是原型污染。
对startup.io来说这显然是一个坏消息,但对攻击者来说却再好不过了,他们可以通过这些bug和安全漏洞对API进行攻击。让我们来看看startup.io发布的API中的两个endpoints:
- 通过HTTP POST请求https://api.startup.io/users/:userId,使用userId更新用户的数据
- 通过HTTP GET请求https://api.startup.io/users/:userId/role,获取指定用户当前分配的角色(admin或者user)
易受攻击的API
让我们尝试通过篡改应用程序的逻辑来将我们提升到管理员的权限。之后,我们再尝试以拒绝服务的方式来搞垮整个API。
所有的示例都假设我们已经获得了应用授权,为了便于可读,我们省略了所有的HTTP授权header。
我们发送一个有效的请求,来看看那个HTTP POST的endpoint是如何工作的。我们从API提供的文档中可以了解到,这个endpoint允许我们修改显示在用户个人资料页面上的“about”部分的内容。我们打算将这部分内容修改成“Database sanitization expert”。
复制并粘贴下面的内容到终端然后执行:
curl -H "Content-Type: application/json" -X POST -d '{"about": "Database sanitization expert"}' https://api.startup.io/users/1337
我们应该会收到一个JSON格式的响应,其中存储了有关该用户的数据,我们可以看到“about”的内容被更新了:
{ name: "Robert", surname: "Tables", about: "Database sanitization expert" }
接下来,让我们发送另一个请求,来看看那个HTTP GET的endpoint是如何工作的。
复制并粘贴下面的内容到终端然后执行:
curl -X GET https://api.startup.io/users/1337/role
我们应该会得到一个JSON数据,其中包含该用户默认分配的角色。
攻击1:失败的尝试
现在我们知道API是如何工作的了,让我们看看能否修改用户的角色并将其设置为admin。我们试着通过POST请求直接将role改成admin。
复制并粘贴下面的内容到终端然后执行:
curl -H "Content-Type: application/json" -X POST -d '{"role": "admin"}' https://api.startup.io/users/1337 && curl -X GET https://api.startup.io/users/1337/role
我们应该得到以下输出:
{ role: "user" }
显然,这种简单的尝试未能奏效。role的值依然是user。不过,我们的尝试并不止于此!
攻击2:使用原型污染提升权限
通过前面的内容,我们发现原型污染可以允许我们改写应用程序中在任何对象上定义的任何属性的值。也许我们可以借用这个漏洞来更改角色?让我们再试一次——但是这次我们在要设置的属性前添加了神奇的__proto__前缀。
复制并粘贴下面的内容到终端然后执行:
curl -H "Content-Type: application/json" -X POST -d '{"about": {"__proto__": {"role": "admin"}}}' https://api.startup.io/users/1337 && curl -X GET https://api.startup.io/users/1337/role
然后我们得到:
{ role: "admin" }
哈哈!通过向后端发送这样一个神奇的内容{"about": {"__proto__": {"role": "admin"}}},我们成功地将自己的权限提升为管理员。
不过,等一下,这个神奇的__proto__前缀到底是什么?为什么它在这里能起作用?别担心,接下来我们会详细讨论它。但先让我们把这个有bug的API给整瘫痪掉。
攻击3:搞垮整个API
在前面的攻击中,我们设法将role的值改成任何我们想要的内容。但是,JavaScript函数不是也作为属性存储在它们各自的对象上吗?那么我们可以使用相同的方式来改写一个函数吗?
让我们试一下看看!在JavaScript中哪个函数最有可能被其它程序调用?答案是toString函数!让我们试着将该函数改写成一段毫无意义的内容,改成一段程序员的笑话怎么样?
复制并粘贴下面的内容到终端然后执行:
curl -H "Content-Type: application/json" -X POST -d '{"about": {"__proto__": {"toString": "Two bytes meet. The first byte asks: Are you ill? The second byte replies: No, just feeling a bit off."}}}' https://api.startup.io/users/1337
我们应该得到以下输出:
500 Internal Server Error
API挂掉了。看来我们完全可以改写一个函数!
究竟发生了什么?稍后我们会深入研究这其中的代码。现在,我们已经得知我们能够改写toString方法,就像前面我们对role属性所做的那样。当JavaScript运行时,toString()总是被当作一个函数来调用,但是当我们修改之后它就不再是一个函数了(现在它是一段笑话,是一个字符串),因此整个web服务器挂了,返回500错误。
原型污染工作原理
JavaScript中的原型是什么?
为了便于理解我们上面的攻击过程,我们需要首先解释一下什么是JavaScript原型。
当我们在JavaScript中创建一个空对象时(例如,const obj = {}),此时所创建的对象已经具有了一些属性和方法,例如toString方法。你是否想过这些属性和方法来自于哪里?答案就是原型。
许多面向对象语言,例如Java,都基于类来创建对象。每个对象都属于一个类,这些类按照父子层级的结构组织在一起。当我们在一个对象上调用toString方法时,底层运行库将会在该对象所属的类中查找toString方法的定义。如果没有找到,则在父类中进行查找,一直查到最顶层的类。
相反,JavaScript是一种基于原型的面向对象编程语言,每个对象都链接到一个“prototype”(原型)。当我们在对象上调用toString方法时,JavaScript首先查看该对象上是否定义了这个方法,如果没有,则在对象的原型上进行查找。
普通的JavaScript对象
const a = {}; console.log(typeof a.__proto__); // Output: object
来自原型上的属性
const a = {}; a.__proto__.someFunction = function () { console.log("Hello from the prototype!") }; a.someFunction(); // Output: Hello from the prototype!
共享默认的原型
const a = {}; const b = new Object(); console.log(a.__proto__ === b.__proto__); // Output: true
在共享的原型上设置属性
const a = {}; const b = new Object(); a.__proto__.x = 1337; console.log(b.x); // Output: 1337
有关原型污染的解释
总之,如果我们修改了被多个对象共享的原型,那么所有对象都会受到影响!这些对象甚至不需要处于同一作用域或其它相关的范围中。记住,绝大多数对象默认都共享同一个原型——所以如果我们修改了其中一个对象的原型,其它的对象也会被改变!
如果有人恶意修改(或者污染)了被多个对象共享的原型怎么办?事实上,这就是前面我们对start.io公司的API所做的操作。记住,我们发送给服务器的内容是:
{"about": {"__proto__":"{"role": "admin"}}}
{"about": {"__proto__": {"toString": "Two bytes meet. The first byte asks: Are you ill? The second byte replies: No, just feeling a bit off."}}}
通过发送一个特定的HTTP POST请求,我们污染了默认共享原型上的role和toString属性。要了解这种攻击是如何工作的,我们可以看下GET和POST的HTTP请求处理程序代码:
一种原型污染攻击,黑客向后端服务器发送恶意数据,然后通过一个不安全的合并函数将该数据与对象进行合并操作
1 async function updateUser(userId, requestBody) { 2 const userData = await db.loadUserData(userId); 3 merge(userData, requestBody); 4 5 log("Saving userData " + userData.toString()); 6 await db.saveUserData(userId, userData); 7 return userData; 8 } 9 10 async function getRole(userId) { 11 const userPermissions = await db.loadUserPermissions(userId); 12 13 let role = "user"; 14 if (userPermissions.role) { 15 role = userPermissions.role; 16 } 17 18 return { role }; 19 } 20 21 /** 22 * Sets or updates all attributes of the source object on the target object. 23 * 24 * For example if `target` is {a: 1, b: 2} and `source` is {a: 3, c: 4}, 25 * after calling this function `target` becomes {a: 3, b: 2, c: 4}. 26 */ 27 function merge(target, source) { 28 for (const attr in source) { 29 if ( 30 typeof target[attr] === "object" && 31 typeof source[attr] === "object" 32 ) { 33 merge(target[attr], source[attr]) 34 } else { 35 target[attr] = source[attr] 36 } 37 } 38 }
- 第1行,updateUser方法用来处理HTTP POST请求。参数requestBody的值是我们要发送给服务器的数据。
- 第2行,从数据库获取指定user的数据。
- 第3行,merge函数将requestBody对象的所有属性合并到userData对象中。这里就是原型被污染的地方,我们将深入探讨一下merge函数。
- merge函数的定义在第27行。target是userData,source是我们通过HTTP Post请求发送给服务器的数据:
target: { "about": "Database sanitization expert" ...}
source: { "about": { "__proto__": { "role": "admin" } } }
- 第28行,遍历source的所有属性,第一个属性是about。
attr: "about"
- 第33行,about属性在target和source中都存在,于是我们通过递归调用merge函数。
- 代码回到第27行,此时target和source的值为:
target: "Database sanitization expert"
source: { "__proto__": { "role": "admin" } }
- 第28行,遍历source的所有属性,此时第一个属性是__proto__。
attr: "__proto__"
- 第33行,属性__proto__在target和source中都存在,所以我们再次通过递归调用merge函数。这一步是问题的关键所在,因为target.__proto__是被大多数对象默认共享的原型!
- 代码再一次回到第27行,此时target和source的值为:
target: 默认原型
source: { "role": "admin" }
- 第28行,遍历source的所有属性,第一个属性是role。
attr: "role"
- 由于属性role没有在target中定义,所以代码会走到第35行,通过语句target[attr] = target.role将{"role": "admin"}设置给target。啊哈!我们成功地通过一段恶意代码污染了全局原型。现在,对于所有共享默认原型的对象来说,属性role的值即为"admin"。
- 代码回到第3行,现在默认原型已经被role="admin"污染了。
- 然后,第10行,我们通过HTTP GET请求查询我们分配给用户的的角色。
- 第11行,对象userPermissions用来接收从数据库返回的值,在本例中它是一个空对象({},因为指定的userId根本不存在),并且共享了默认原型。
- 第15行,由于userPermissions是一个空对象,所以它没有role属性。正常情况下,role默认为"user"。但是,由于我们通过role属性污染了原型,userPermissions.role等同于userPermissions.__prop__.role,即"admin"。
- 第18行,{role: "admin" }被作为HTTP GET请求的返回值。
减少原型污染
方案1:在通过递归设置对象的属性时使用安全的开源库
在startup.io公司的案例中,merge函数的作用是将一个对象的所有属性更新到另一个对象中。正如我们在上面的代码分析过程中所看到的那样,merge函数以递归的方式将第二个参数的所有属性合并到第一个参数中——甚至包含那些不可信的内容,如__proto__。
并不是只有合并两个对象的功能才会使源代码可能受到原型污染攻击——任何其它以递归调用的方式对嵌套的属性进行设置的函数都有可能受到攻击。在JavaScript生态中,其它常见的例子包括:深拷贝(如lodash中的cloneDeep方法),设置嵌套的属性(如lodash中的set方法),或者以递归的方式“压缩”属性的值来创建一个新对象(如lodash中的zipObjectDeep方法)。
在以递归的方式设置嵌套的属性时,需要始终确保将那些不可信的内容排除在外。不要自己实现!即使最优秀的程序员也会很容易犯错。我们应该使用如lodash这样的开源库,它非常受欢迎,并且拥有出色的社区支持和及时的安全更新。
一个避免原型污染的例子,黑客尝试发送一条恶意数据来攻击服务器,在使用安全的合并函数后,阻止了对原型的影响
1 import safeMerge from 'lodash.merge'; 2 3 async function updateUser(userId, requestBody) { 4 const userData = await db.loadUserData(userId); 5 safeMerge(userData, requestBody); 6 7 await db.saveUserData(userId, userData); 8 return userData; 9 }
想知道哪些库是可信的,可以使用Snyk Advisor,它提供了给定package的受欢迎程度、社区支持和安全性等信息。除此之外,也可以使用漏洞扫面工具来检查你要使用的开源库,如Snyk,它会告诉你使用的库中所有发现的安全漏洞,并帮助你如何轻松地解决这些问题。
事实上我们很难做到完全避免原型污染攻击。在实现递归合并函数的过程中,lodash的开发人员确保值为__prop__的键不会从一个对象复制到另一个对象。不幸的是,后来发现原型污染也可以通过其它的属性产生,例如constructor.prototype(查看这篇文章以了解lodash的开发人员如何修复这个问题)。因此我们得到的教训是,要正确处理用户的输入是非常困难的,我们应该尽可能地使用那些经过实践检验过的库来帮助我们完成工作。
方案2:创建没有prototype的对象:Object.create(null)
另一种避免原型污染的方法是在创建新对象时考虑使用object.create()方法,而不是通过对象字面量{}或者构造函数new Object()来创建。这样,我们可以通过传递给Object.create()的第一个参数直接设置所创建对象的prototype。如果参数的值为null,那么所创建的对象就没有prototype,因此就不会产生原型污染。
1 async function updateUser(userId, requestBody) { 2 const userData = await db.loadUserData(userId); 3 4 const saveToDatabase = Object.create(null); 5 merge(saveToDatabase, userData); 6 merge(saveToDatabase, requestBody); 7 8 await db.saveUserData(userId, saveToDatabase); 9 return saveToDatabase; 10 }
方案3:阻止对prototype的任何修改:使用Object.freeze()
JavaScript提供了Object.freeze()方法,我们可以使用它来阻止对对象属性的任何修改。由于prototype也是一个object,所以我们可以freeze它。我们可以使用Object.freeze(Object.prototype)来冻结默认原型,这样可以防止对象的默认原型受到污染。
或者,你也可以安装nopp包,它会自动冻结所有常见的对象原型。
1 // call once in ‘main.js’ or similar 2 Object.freeze(Object.prototype); 3 4 async function updateUser(userId, requestBody) { 5 const userData = await db.loadUserData(userId); 6 merge(userData, requestBody); 7 8 await db.saveUserData(userId, userData); 9 return userData; 10 }
如何减少原型污染?
当需要通过递归在对象上设置嵌套属性时,使用流行的开源库可以减少代码库中的原型污染漏洞。使用Snyk Advisor检查你要使用的库,并确保通过Snyk扫描后没有安全漏洞。为了进一步强化代码,可以使用Object.create(null)方法来避免使用prototype,或者使用Object.freeze(Object.prototype)来阻止对共享原型的任何修改。
更多有关原型污染的内容:
最后,如果你想对原型污染有更深入的了解,请阅读由Olivier Arteau撰写的有关原型污染的详细报告。他在很多常见的JavaScript库中发现并披露了许多原型污染漏洞。
原文地址:Prototype Pollution