这篇文章发表于 1103 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
那篇文章后半部分充斥着一些不太友好的题目,结果导致一些关键的技术并没有得到很好地阐述,理解也相对浅薄。本文就是针对个人觉得欠缺之处进行补充学习的记录。
文字内容大多摘录别人文章,不过是根据个人所需整合然后再表达的,还更新了一些过时的玩意。参考的所有链接都在最后注明了。
里面使用的浏览器全都是当前最新的。
原型链污染
这一切就先得从object讲起。
原型
对象是一个包含相关数据和方法的集合,不像“经典”的面向对象的语言,从构建函数创建的新实例的特征并非全盘复制,而是通过一个叫做原形链的参考链链接过去的。
JavaScript中所有对象都来自 Object
;所有对象从Object.prototype
继承方法和属性,因此当我们通过对象生成某个实例的时候,相应地会带上些属性,比如其中的constructor
(构造函数)和__proto__
属性就是从它的原型上继承来的。
1 | // 直接定义的o |
上面一段内容很无趣,一个Object
对象的constructor
是function Object()
,这很好理解。但请注意区分Object
在不同语境下意思是不同的,别搞混;以及看到{}
请保持敏感(做题的话x)。
我们将注意力集中到原型对象prototype
。下面以这样一道面试题来进行思考:
1 | var F = function(){}; |
问题:1、new这个对象f
的过程是怎样的?2、请问f
中有方法a
还是方法b
?
1、首先会创建一个新对象f
;然后它会被执行[[prototype]]
连接,也就是f.__proto__ = F.prototype
;因为函数体内啥都没有,于是最终返回这个新对象。
2、先运行下试试还是很明智的。
上面这张截图是在firefox里面搞的(chrome里面可能显示的是__proto__
,这个写法其实用在代码里是不规范的,应使用[[prototype]]
,详情见该回答),那个<prototype>
其实是实例对象;而prototype
是其原型对象。也就是说f
中存在的应该是方法a
。
另外,F.prototype === f.constructor.prototype === f.__proto__
三者恒等,也就是说对象的__proto__
等于对象类的prototype
。
理解这些之后,我们基本了解了原型链。接下来看这个:
1 | function Person(name) { |
从上面的例子中我们看到两个实例person1
和person2
共享了一个方法greeting2
,而对于greeting1
则是分开的,因为它并没有定义在prototype
中,自然不会被继承。
但是greeting2
却是可以被攻击的,我们接下来需要思考的是——如何修改person1
实例中的属性,但效果却将person2
中的属性也修改掉。
请仔细看图,我想表达的东西应该也不用多说了,以上的操作并不能够完成污染——如果成功污染了,那么person2.greeting2
应该是"test"
。稍微思考一下,很显然下面就是答案。
应用情境
好了,我们刚刚看到了一个很好的例子,也许你会想是不是一定会用到__proto__
,但其实这只是个好用的payload而已,真正的东西个人理解应该是原型链的设计特点。
1
现在再来个例子,请修改String.SafetifyRegExp
里面的内容:
1 | String.SafetifyRegExp = new RegExp("([^a-zA-Z0-9 \r\n])","gi"); |
我们直接看图:
这里的String
拥有了SafetifyRegExp
属性,因此这里污染String
的prototype
并没有用。直接把这个属性改了就可以。
现在正则已经被换掉了。
2
在上面第一个例子里面的person1
是通过构建函数产生的。那么如果是直接定义产生的又会是什么样的情况呢?比如像下面这样:
1 | person = { |
直接定义的话,其原型直接就是Object
。要攻击的话也是一个道理。
这其实有一定的情境,比如下面这个合并的逻辑。
1 | function merge(target, source) { |
看起来只需要让key=__proto__
就能够产生攻击,但像下面这样的并不会产生问题。
显然这里程序并不认为__proto__
是一个键名,也就是说它并没有进到循环中,为什么呢?
因为它被认为是默认的那个__proto__
了。遍历键名也只能得到[a, b]
。
然而下面这种情况却能够成功:
这是因为JSON解析的情况下,__proto__
会被认为是一个真正的“键名”,而不代表“原型”,所以在遍历o2
的时候会存在这个键。
3
这里说一个经常被我们忽略的小细节。原型链一旦被污染后,一般只有重启相关的js服务才能够防止出现奇奇怪怪的响应。
但是如果我们能够执行运行着js的网页执行js代码,我们可以尝试删除被污染的东西。
当然,这里还是恢复正则比较合适,不过只是举个例子对吧。
前端变量劫持
该部分参考这篇文章。不考虑 iframe
的 sandbox
属性,所有测试都是在不添加任何sandbox
的限制下进行。
iframe跨域的父子页面
当页面存在iframe
的时候,父页面和子页面是可以相互获取到对方的window
对象,然后通过这个window
对象,我们能够获得一些属性:在父子同源的情况下,基本可以获取所有需要的内容;在父子不同源的情况下,iframe
的window
对象大多数的属性都会被同源策略block掉,但frames
和location
相对特殊:
frames
可读,但是不可写。 意味着可以读取不同域的子页面里面的iframe
的window
对象。location
可写,但是不可读。意味着父子可以相互修改彼此的location
。
这两点告诉我们,爷可以修改孙的location,孙也可以修改爷的location。
爷修改孙
就直接运行代码吧。
1 | <!-- localhost:80/index.html --> |
其中8888端口的index.html
如下:
1 | <!-- localhost:8888/index.html --> |
运行之后我们会发现最里面的iframe,也就是孙,被重定向到了必应。
孙修改爷
直接运行代码吧。
1 | <!-- localhost:80/index.html --> |
然后是8888端口的内容:
1 | <!-- localhost:8888/index.html --> |
最后是5555端口,也就是孙的内容:
1 | <!-- localhost:5555/index.html --> |
访问http://127.0.0.1/index.html之后就会被重定向到必应。
id属性
我们知道在浏览器中有如下特点:我们定义的所有全局变量,都被存储在window
对象中,作为window
的属性来被访问的。比如我们打开firefox的新标签页,找到了下面这个一个带着id=root
的标签,于是:
从控制台来看我们似乎能够随意修改这种全局变量,但是真的是这样吗?
1 | <h1>test</h1> |
像这样的脚本是没法覆盖掉test
的值的,虽然我们在控制台里可以改变它;但是我们能够通过这种方式来定义新的变量test2
。
事实上,test
的值会优先取"ddd"
,只要我们在后面将它delete
掉,我们就能够得到<h1 id="test">
这样的结果,正是这个顺序让我们没办法完成覆盖。也就是说,想要利用这种攻击方法,我们就必须保证希望控制的变量没有定义或者在后面被删除了。而且还有一个现象,就是像test2
这样的值是没办法delete
掉的!
另外,我们心中会有疑问,如果两个标签都是同一个id
,那么会有什么样的结果出现呢?(其实在正常的网页里面,我们不应该出现两个相同的id
属性)
1 | <h1 id="test"></h1> |
访问这个页面,在firefox和chrome里面的返回结果是截然不同的,在firefox里面会直接返回<h1 id="test">
,而在chrome里面会得到如图的结果:
这个test
作为一个数组而存在。这一点在文档中有所体现:匹配的元素多于一个时,不同的浏览器表现不同。Firefox 8
表现如同DOM 2
和DOM 4
说明的,返回第一个匹配的元素。而Webkit
浏览器和IE
返回另外一个HTMLCollection
,Opera
返回一个包含所有元素的 NodeList
。
name属性
确切地说,这里主要测试的是iframe
里面的name
属性。
1 | <iframe id="viewer" src="./view.html"></iframe> |
得到了下面这样有意思的结果。
有着name
属性的viewer2
直接返回了一个Window
对象,结合之前的内容,看起来name
应该才是用在攻击上的属性。那我们继续看看两个标签相同的时候会发生什么有趣的现象。
首先看看要是两个标签里面的id
和name
相同会有什么效果,也就是像下面这样:
1 | <iframe id="viewer" src="./view.html"></iframe> |
发现无论在什么浏览器上,以及无论上面两条代码什么顺序,我们都会得到Window
对象的结果——这说明name
优先级高于id
。
那么像下面这种情况又会如何呢?(在正常网页中,name
属性可以重复)
1 | <iframe name="test" src="http://B.com/B.html" ></iframe> |
对于这种情况,firefox和chrome都给出了一致的结果。
也就是只有第一个iframe
的Window
对象。
应用情境
你可能会发现这个漏洞并没有想象中那么神……新版的firefox和chrome似乎都已经结束了它的生命——好吧我们前去下载个古老的版本稍微感受下,我用的是76的。
我们有一个可以控制的域A.com
中有页面A.com/A.html
,用iframe
加载了B.com
的域的页面B.com/B.html
。A.html
无法操作B.html
页面,因为是不同源的,同时B.com/B.html
页面用iframe
加载了一个新的页面C.com/C.html
。
1 | <script> |
在B上(显然这个C就是必应了):
1 | <!-- B 8888/index.html --> |
点击后我们确实得到了一个被劫持的Window
对象。
在这个任务中我觉得需要关注的并不是能不能用,而是这样一个修改内部的iframe
来完成劫持的思路,以及为之后的Dom Clobbering做铺垫。
Dom Clobbering
我们刚刚已经看到了HTML代码能够在js里面发挥一些奇怪的效果,但这其实是本就合理的性质,而且在官方文档里也有所提及,请一定要阅读它。
id和name的一些性质
我们可以知道这其实就是正常的特性,在文档中我们还发现像embed
,form
,img
和object
都能够利用name
属性;而对于id
,它是全局属性(Global attribute),基本上可以写在任何标签里面。再简单总结一下:
- 几乎所有含形如
id=x
(x
原本未定义)的标签都能够成功使得x
被指定为含对应tag的内容 - 含形如
name=x
(x
原本未定义)的embed
,form
,img
和object
标签都能够成功使得x
被指定为含对应tag的内容 - 如果后续操作会将这个
x
强制转化为字符串,那么我们有办法控制这个x
转化后的字符串值(这点我们会在文章*href
*部分中提及)
好了,先从下面这段代码的测试讲起。
1 | <embed id=test1> |
如果你好好看了刚刚给的官方文档,你会发现它说了一句话,因为id
映射到window
上面的API可能会有变化,所以尽量使用document.getElementById()
或者document.querySelector()
来取得这种元素。
仔细看运行结果,你肯定会好奇为什么document.test1
就没有值呢——这一点也是可以在这章节的倒数某段找到原因,如下陈述的元素会被加入到document
里面:
而像test1
这样的并没有满足条件,所以document.test1
呈现出未定义的状态。其他的响应在一开始就在我们的意料之中就不多说了。
(两张截图中的英文很重要)
二级对象控制
我们刚刚已经领悟了id
和name
在一级对象引用上的用法,现在来看看更为高级的二级对象控制。
为快速get main idea,我采用了以下这种代码,并期望能够通过只修改这个文件的HTML代码以成功弹窗,甚至成功执行eval
。
1 | <script> |
这里有两个问题需要解决:
- 我们已经知道控制住某个标签的
id
就能够成功地控制window.test1
,但是下一层的test2
该怎么控制? eval
里面的必须是字符串,如何确保在这样的操作中window.test1.test2
会被自动转化为字符串?
第一个问题比较容易解决,因为每一个<input>
标签的都会添加为它之上的<form>
标签的属性,属性的名字就是<input>
标签中声明的name
属性。
1 | <form id=test1> |
但是它返回的内容是[object HTMLInputElement]
,即便想强制转换为字符串也没能成功。这就是提到的第二个问题。
对于第二个问题,我们需要找到合适的标签,于是使用fuzz。
1 | Object.getOwnPropertyNames(window) |
最后的那个filter
是为了判断这个dom节点对象有没有toString
方法,而且是不是从Object.prototype
上面继承下来的——如果是继承自Object.prototype
,那么很有可能只会返回[object SomeElement]
。
执行完成后会返回两个属性,HTMLAreaElement
(<area>
)和HTMLAnchorElement
(<a>
),我们访问文档会发现因为<a>
和<area>
元素对于hyperlink(超链接)中有着toString
方法,难怪没有继承自Object.prototype
。另外,也只有href
能够创建hyperlink
:The *href* attribute on <a> and <area> elements is not required; when those elements do not have *href* attributes they do not create hyperlinks
。
href
下面就用<a>
标签吧(<area>
标签类似)。我们会很自然地尝试:
1 | <form id=test1> |
但是并不行,test1.test2
是undefined
,因为<input>
元素会变成<form>
的属性,而<a>
标签并不会。这里的解决方法如下(其实去掉<form>
标签也可):
1 | <form id=test1> |
将上述代码放在不同浏览器下运行,发现在firefox下无效果,但是在chrome里面就能够非常好地得到我们想要的结果,因为它有HTMLCollection
。
这就头疼了,有没有是没办法通杀呢?很遗憾我没找到x,所以firefox想要精确控制住形如test1.test2
这种的Dom Clobbering似乎是不太可能的。
哦,这里再补充一个比较重要的知识点,就是在我的前一篇文章中追加练习4 – @SecurityMB系列
章节的第一个练习中,我们还通过fuzz发掘了利用href
和id
去控制形如window.idname.protocol
,window.idname.host
等元素,事实上这个文档告诉我们可以控制的还有像window.idname.username
,window.idname.password
和window.idname.hash
等等,就像下面这样。
1 | <a id=x href="http://Clobbered username:Clobbered Password@a"> |
我们还看到这里的内容被URL编码了,在一些情况下很不利,有什么办法能够消除呢?
在firefox下我们可以这样,
1 | <base href=a:abc><a id=x href="Firefox<>"> |
在chrome下我们可以这样,
1 | <base href="a://Clobbered<>"> |
解决办法
不管怎么说,本小节最开始抛出的问题在chrome环境似乎已经可以通过下面的手段解决:
1 | <a id=test1>click!</a> |
可能是时代在进步,我们发现这个payload已经没办法执行eval
了,原因也很清楚,我们可以看到它有协议塞在前面(比如file://
和http://
)。
不过是有办法绕过并执行eval
的,而且绕过的方式非常无语,多加个字母就好了,甚至连特定的协议都不需要找:
1 | <a id=test1 name=test2 href="xx:alert(123)">click2!</a> |
要是有过滤或者特定白名单机制的话,就改成相应的前缀就好了。
知识点已经讲完了,现在熟悉一下,请在下面代码中添加HTML代码使之弹窗。
1 | <script> |
显然这是要控制住二级对象window.someObject.url
。于是添加如下内容:
1 | <a id=someObject>click!</a> |
一个经典的例子
这里直接上一道题。
1 | <html> |
这题咋看呢?我首先想到了<svg>
标签,但是尝试过后感觉可能是sandbox
有一定的防护效果,那么换条思路,尝试破坏这个对属性的过滤——最直接就是破坏这个循环,比如让这个循环的长度为0。
1 | <form onclick=alert(1)><input id=attributes>Click me |
去掉注释,我们就能够看到输出。
说明这个payload在element
为<form>
的时候,其element.attributes
呈现出未定义的状态,这就导致了循环的直接崩溃,自然没办法去掉其中的onclick
属性。
多级对象控制
先来尝试对三级对象进行控制,在研究这个之前我们需要利用HTML标签之间的关系来构建出层级结构。
通过fuzz找到所有id
或者name
具有父子依赖关系的节点,在控制台运行如下脚本:
1 | var log=[]; |
修改上面的id
为name
得到的输出结果都是一样的,原因之前应该也提过了。
1 | <form id=x><output id=y>I've been clobbered</output> |
这种触发显得有点笨拙,最后一级必须是value
才可以。能否更加普适一点呢?
尝试利用HTMLCollection
想到之前让id
重复出现的技巧,我们可以测试下面这样的代码:
1 | <form id=x></form> |
显然根据之前的经验,firefox并不会接受x.y
这样的内容,于是在chrome上进行测试:
看起来很不错,但在调整相关代码为<a>
标签后这一切还是以失败告终,也就是说我们并不能将它转化为可用的字符串。这种办法似乎行不通。
利用srcdoc任意层数对象引用
使用iframe
的srcdoc
属性就可以创建任意层数的对象引用:
1 | <iframe name=a srcdoc=" |
这个显然也只能用在chrome的情况下,但是对于firefox来说,只要退一步便可以完成三级的控制:
1 | <iframe name=a srcdoc=" |
上面有一个问题,就是必须使用setTimeout
设置一个延迟以保证iframe
加载完毕。这里也可以利用style/link
标签导入一个外部的样式表来创造一个小的延迟。
感觉这里说“多级”其实有点言过其实,本来以为更多的嵌套可以通过URL编码能够写在srcdoc
里面的,可经过测试发现并不能运行。
参考文献
原型链污染:
https://wonderkun.cc/2019/07/18/javascript%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/
https://www.leavesongs.com/PENETRATION/javascript-prototype-pollution-attack.html
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/constructor
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Inheritance_and_the_prototype_chain
https://www.cnblogs.com/polk6/p/4340730.html
前端变量劫持:
Dom Clobbering:
https://wonderkun.cc/2020/02/15/DOM%20Clobbering%20Attack%E5%AD%A6%E4%B9%A0%E8%AE%B0%E5%BD%95/
https://research.securitum.com/xss-in-amp4email-dom-clobbering/
https://segmentfault.com/a/1190000040098609
https://lihuaiqiu.github.io/2020/04/15/DOM-Clobbering-Attack/