目录
1. 漏洞源码
2. 进行绕过
2.1 使用老方法进行绕过
注入失败原因:
2.2 分析注入方式
2.3 使用svg进行绕过(方式一)
2.3.1 了解什么是dom树,以及dom树的构建。
2.3.3 分析img方法失败的原因
可以在页面上添加以下代码来测试这一点。
2.3.4 使用svg劫持innerhtml
2.3.5 分析双svg标签成功原因
触发流程
实验
注意一点:
2.4 使用details标签延迟响应绕过(方式一)
2.4.1 事件触发流程
2.4.2 实验验证
分析:
2.4.3 再次更改源码进行details标签测试
分析:
注意:
2.5 DOM clobbering 绕过(方法二)
2.5.1 DOM clobbering(dom破坏)
分析:
2.5.2 只进行一次dom破坏查看注入情况
2.5.3 总结:
2.5.4 注意一点
3. 漏洞总结
Document
payload:
使用此payload的原因具体看上一篇文章—《dom型xss》
这里可以看到payload中的元素全部被删除,是因为这里定义了一个名为attrs的数组专门用来删除注入语句里的元素,语句中的元素首先会逐个进入“attrs”数组,然后会在数组中被逐个删除,这样就保证了注入语句中的元素不会出现偶数元素被保留的情况。
方式一、 在过滤语句之前就执行payload,然后payload才进入过滤语句被过滤,此时已经没有实际意义。
方式二、 使过滤语句不能过滤payload中的弹窗语句。
2.3.2.1 dom树的概念:
DOM 是文档对象化模型(Document Object Model)的简称。DOM Tree 是指通过 DOM 将 HTML 页面进行解析,并生成的 HTML tree 树状结构和对应访问方法。借助DOM Tree,我们能直接而且简易的操作HTML页面上的每个标记内容
DOM技术被Internet Explorer 5.0及以上版本的浏览器所支持,它采取一种非常直观且一致的方式将HTML文档进行模型化处理,并借此提供访问、导航和操作页面的简易编程接口。通过DOM技术,我们不仅能够访问和更新页面的内容及结构,而且还能操纵文档的风格样式。DOM由W3C组织所倡导,这样,大多数浏览器都将最终支持这项技术。
2.3.2.2 dom树的构建:
我们知道JS是通过DOM接口来操作文档的,而HTML文档也是用DOM树来表示。所以在浏览器的渲染过程中,我们是最关注的就是DOM树是如何构建的。
首先在解析一份文档时,先由标记生成器做词法分析,将读入的字符转化为不同类型的Token,然后将Token传递给树构造器处理;接着标识识别器继续接收字符转换为Token,如此循环。实际上对于很多其他语言,词法分析全部完成后才会进行语法分析(树构造器完成的内容),但由于HTML的特殊性,树构造器工作的时候有可能会修改文档的内容,因此这个过程需要循环处理。
在树构建过程中,遇到不同的Token有不同的处理方式。具体的判断是在HTMLTreeBuilder::ProcessToken(AtomicHTMLToken*token)中进行的。AtomicHTMLToken是代表Token的数据结构,包含了确定Token类型的字段,确定Token名字的字段等等。Token类型共有7种,kStartTag代表开标签,kEndTag代表闭标签,kCharacter代表标签内的文本。所以一个会被解析成3个不同种类的Token,分别是kStarTag、kCharacter和kRendTag。在处理Token的过程中,还有一个InsertionMode的概念,用于判断和辅助处理一些异常情况。
在处理Token的时候,还会用到HTMLElementStack,一个栈的结构。当解析器遇到开标签时,会创建相应元素并附加到其父节点,然后将Token和元素构成的Item压入该栈。遇到一个闭标签的时候,就会一直弹出站直到遇到对应元素构成的item为止,这也是一个处理文档异常的办法。比如
1
1
而当处理script的闭合标签是,除了弹出相应item,还会暂停当前的DOM树构建,进入JS的执行环境。换句话说,在文档中的script标签会阻塞DOM的构造。JS环境里对DOM操作又会导致回流,为DOM树构造造成额外影响。
当html中存在js语句时会优先处理js语句然后再继续生成dom树。
我们通过断点进行调试:
将过滤的代码注释以后,注入payload并打断点调试一下
payload:
可以发现即使代码已经执行到最后一步,但在没有退出JS环境以前依然还没有弹窗。
此时再点击单步调试就会来到我们的代码的执行环境了。此外,这里还有一个细节就是appendChild
被注释并不影响代码的执行,证明即使img元素没有被添加到DOM树也不影响相关资源的加载和事件的触发。
那么很明显,alert(1)
是在页面上script标签中的代码全部执行完毕以后才被调用的。这里涉及到浏览器渲染的另外一部分内容: 在DOM树构建完成以后,就会触发DOMContentLoaded
事件,接着加载脚本、图片等外部文件,全部加载完成之后触发load
事件。
同时,上文已经提到了,页面的JS执行是会阻塞DOM树构建的。所以总的来说,在script标签内的JS执行完毕以后,DOM树才会构建完成,接着才会加载图片,然后发现加载内容出错才会触发error
事件。
window.addEventListener("DOMContentLoaded", (event) => {
console.log('DOMContentLoaded')
});
window.addEventListener("load", (event) => {
console.log('load')
});
测试注入语句
//通过标签打印出img
那么失败的原因也就跟明显了,由于JS阻塞DOM树,一直到JS语句执行结束后,才可以引入img,此时img的属性已经被sanitizer清除了,自然也不可能执行事件代码了。
总结下整个执行流程就是:
最先执行的js ---> for循环用来删除我们标签属性的 ---> js把dom树阻塞住了,js先执行(img所有的属性先删除了)---> js执行结束 ---> 恢复DOM树的加载 ---> DOMCONTENTloaded ---> 执行img(此时img已经没有任何属性了)
总结失败原因:
img和其他payload的失败原因在于sanitizer执行的时间早于事件代码的执行时间,sanitizer将恶意代码清除了。
poyload:
弹窗成功,但查询后发现
想法:怀疑如方式一中所提到的svg弹窗语句在未进入过滤语句之前就执行了payload,然后payload才进入过滤语句被过滤。
继续使用断点调试的方法检测双svg标签,不过这一次我们将断点打到过滤语句之前进行调试。
神奇的事情发生了,直接弹出了窗口,点击确定以后,调试器才会走到下一行代码。而且,经检查这个地方如果只有一个
,那么结果将同img一样,直到script标签结束以后才能执行相关的代码,这样的代码放到挑战里也将失败(测试单个svg时要注意,不能像img一样注释掉appendChild
那一行)。那为什么多了一个svg套嵌就可以提前执行呢?带着这个疑问,我们来看一下浏览器是怎么处理的。
上文提到一个叫HTMLElementStack的结构用来帮助构建DOM树,它有多个出栈函数。其中,除了Pop All以外,大部分出栈函数最终会调用到PopCommon函数。这两个函数代码如下:
void HTMLElementStack::PopAll() {
root_node_ = nullptr;
head_element_ = nullptr;
body_element_ = nullptr;
stack_depth_ = 0;
while (top_) {
Node& node = *TopNode();
auto* element = DynamicTo(node);
if (element) {
element->FinishParsingChildren();
if (auto* select = DynamicTo(node))
select->SetBlocksFormSubmission(true);
}
top_ = top_->ReleaseNext();
}
}
void HTMLElementStack::PopCommon() {
DCHECK(!TopStackItem()->HasTagName(html_names::kHTMLTag));
DCHECK(!TopStackItem()->HasTagName(html_names::kHeadTag) || !head_element_);
DCHECK(!TopStackItem()->HasTagName(html_names::kBodyTag) || !body_element_);
Top()->FinishParsingChildren();
top_ = top_->ReleaseNext();
stack_depth_--;
}
当我们没有正确闭合标签的时候,如,就可能调用到PopAll来清理;而正确闭合的标签就可能调用到其他出栈函数并调用到PopCommon。这两个函数有一个共同点,都会调用栈中元素的FinishParsingChildren函数。这个函数用于处理子节点解析完毕以后的工作。因此,我们可以查看svg标签对应的元素类的这个函数。
void SVGSVGElement::FinishParsingChildren() {
SVGGraphicsElement::FinishParsingChildren();
// The outermost SVGSVGElement SVGLoad event is fired through
// LocalDOMWindow::dispatchWindowLoadEvent.
// IsOutermostSVGSVGElement 是否是最外层的svg,如果是直接return
if (IsOutermostSVGSVGElement())
return;
// finishParsingChildren() is called when the close tag is reached for an
// element (e.g. ) we send SVGLoad events here if we can, otherwise
// they'll be sent when any required loads finish
SendSVGLoadEventIfPossible();
}
这里有一个非常明显的判断IsOutermostSVGSVGElement,如果是最外层的svg则直接返回。注释也告诉我们了,最外层svg的load事件由LocalDOMWindow::dispatchWindowLoadEvent触发;而其他svg的load事件则在达到结束标记的时候触发。所以我们跟进SendSVGLoadEventIfPossible进一步查看。
bool SVGElement::SendSVGLoadEventIfPossible() {
if (!HaveLoadedRequiredResources())
return false;
if ((IsStructurallyExternal() || IsA(*this)) &&
HasLoadListener(this))
DispatchEvent(*Event::Create(event_type_names::kLoad));
return true;
}
#先决条件 在于svg不能最外层 onload 必须保证不是最外层。
这个函数是继承自父类SVGElement的,可以看到代码中的DispatchEvent(*Event::Create(event_type_names::kLoad));确实触发了load事件,而前面的判断只要满足是svg元素以及对load事件编写了相关代码即可,也就是说在这里执行了我们写的οnlοad=alert(1)的代码。
总结:
如果只有一个
我们将过滤的代码注释,并添加相关代码来验证这个事件的触发时间。
window.addEventListener("DOMContentLoaded", (event) => {
console.log('DOMContentLoaded')
});
window.addEventListener("load", (event) => {
console.log('load')
});
测试的代码
这里通过打印顺序可知,最内层的svg先触发,然后是第二层的svg,之后dom树构建完成,最后外层的svg触发。
套嵌的svg之所以成功,是因为当页面为root.innerHtml
赋值的时候浏览器进入DOM树构建过程;在这个过程中会触发非最外层svg标签的load
事件,最终成功执行代码。所以,sanitizer执行的时间点在这之后,无法影响我们的payload。
这是用火狐浏览器进行的弹窗测试,弹窗失败。这里可以看到不仅弹窗语句被过滤,就连
断点调试结果:
可以看到data中接收到了双层的
payload:
测试失败,这个payload有时可行,有时不行。所以,这里也值得探讨一下。
首先触发代码的点是在DispatchPendingEvent
函数里
void HTMLDetailsElement::DispatchPendingEvent(
const AttributeModificationReason reason) {
if (reason == AttributeModificationReason::kByParser)
GetDocument().SetToggleDuringParsing(true);
DispatchEvent(*Event::Create(event_type_names::kToggle));
if (reason == AttributeModificationReason::kByParser)
GetDocument().SetToggleDuringParsing(false);
}
而这个函数是在ParseAttribute
被调用的
void HTMLDetailsElement::ParseAttribute(
const AttributeModificationParams& params) {
if (params.name == html_names::kOpenAttr) {
bool old_value = is_open_;
is_open_ = !params.new_value.IsNull();
if (is_open_ == old_value)
return;
// Dispatch toggle event asynchronously.异步调度事件
pending_event_ = PostCancellableTask(
*GetDocument().GetTaskRunner(TaskType::kDOMManipulation), FROM_HERE,
WTF::Bind(&HTMLDetailsElement::DispatchPendingEvent,
WrapPersistent(this), params.reason));
....
return;
}
HTMLElement::ParseAttribute(params);
}
ParseAttribute
正是在解析文档处理标签属性的时候被调用的。注释也写到了,分发toggle事件的操作是异步的。可以看到下面的代码是通过PostCancellableTask
来进行回调触发的,并且传递了一个TaskRunner
。
TaskHandle PostCancellableTask(base::SequencedTaskRunner& task_runner,
const base::Location& location,
base::OnceClosure task) {
DCHECK(task_runner.RunsTasksInCurrentSequence());
scoped_refptr runner =
base::AdoptRef(new TaskHandle::Runner(std::move(task)));
task_runner.PostTask(location,
WTF::Bind(&TaskHandle::Runner::Run, runner->AsWeakPtr(),
TaskHandle(runner)));
return TaskHandle(runner);
}
跟进PostCancellableTask
的代码则会发现,回调函数(被封装成task)正是通过传递的TaskRunner
去派遣执行。
清楚调用流程以后,就可以思考,为什么无法触发这个事件呢?最大的可能性,就是在任务交给TaskRunner
以后又被取消了。因为是异步调用,而且PostCancellableTask
这个函数名也暗示了这一点。
可以做一个实验来验证,修改一部分源码,将sanitizer部分延时执行
Document
payload:
重新执行结果:
火狐结果:
弹窗成功,但依旧没有弹窗语句。
因为details标签的toggle事件是异步触发的,由于改变后的源码中延迟了2秒,导致弹窗语句的进程执行完毕之后才被过滤函数过滤掉,这就是为什么弹窗成功却不存在弹窗语句的原因。未改变前的源码没有延迟,所以无法进行异步执行,而被直接过滤掉。
更改后的源码:
Document
payload:
执行结果:
弹窗成功,但这里的源码中不存在弹窗语句,同时没有创建的
details标签被移除表明其是黑名单的一员,这也是我一开始无法理解为何这个payload能成功执行的原因。但现在我们理清楚调用流程以后,可以有一个大胆的猜测:正是因为details在黑名单里,所以被移除以后其属性没有被直接修改,所以事件依然在队列中没有被取消。
所以我们可以得到结论,details标签的toggle事件是异步触发的,并且直接对details标签的移除不会清除原先通过属性设置的异步任务 ,弹窗可正常执行。
这里的火狐浏览器弹窗失败。
对于安全来说DOM clobbering主要是用来进行DOM型的XSS攻击,其可以篡改JS函数原本的属性恶意插入一些XSS代码到页面的JS中去,它的特征就是利用了元素配置id或name属性后可以使用包括document、window、自己名称的形式进行访问。其可以对document的属性进行恶意的替换。从而影响dom树的结构造成破坏。也是这一特性让它有了DOM COLBBERING这个名号。
想要了解具体的DOM clobbering(dom破坏)知识点请查看本人之前的博客 “DOM clobbering(dom破坏)”:DOM clobbering(DOM破坏)
这里不再赘述我们继续看漏洞
payload:
弹窗成功,且源码中存在弹窗语句。
payload分析:
// 这里就是主要进行dom破坏的部分,通过建立标签其中的id=attributes就是使用标签对过滤语句中的attributes属性进行劫持,替换掉下面过滤代码中的el.attribute属性为img
payload主要是通过dom破坏替换掉下面过滤代码中的el.attribute属性为img,使过滤时只能对替换后的img进行过滤,无法过滤form表单中的弹窗语句。
那么这里为什么要进行两次dom过滤呢?
只进行一次dom破坏的payload:
可以看到浏览器此时弹出了报错信息,说el.attributes不是一个可迭代对象,为什么会报错呢?因为只有可迭代对象才能进入for循环,此时的img只替换了一个el.attributes不能被迭代,所以会弹出说el.attributes不是一个可迭代对象,所以说这里至少需要两个标签的dom破坏,才会出现迭代对象,才能进入for循环被过滤掉。
两个img
的id
设置为attributes
就是为了形成一个可迭代对象,替换掉下面过滤代码中的el.attribute
属性,让它只能删除img
标签。而使得我们的form
标签逃逸出过滤器。
又是我们的火狐浏览器立大功
火狐浏览器弹窗失败,我们查看直接注入后的源码发现,form表单中的弹窗语句被火狐的事件监听器中的对焦功能模块监听,导致注入语句被划分开无法形成完整的注入语句,从而导致弹窗失败。(此为个人分析如有问题请指正)
成功形成了弹窗
而且注入的弹窗代码完整不存在事件监听器,也可能是事件监听器中的代码被重新进行了拼接,形成了完整的弹窗注入语句。
对于DOM XSS,我们是通过操作DOM来引入代码,但由于浏览器的限制,我们无法像这样root.innerHTML = "" 直接执行插入的代码,因此,一般需要通过事件触发。通过上面的例子,可以发现依据事件触发的时机能进一步区分DOM XSS:
1. 立即型,操作DOM时触发。嵌套的svg可以实现
2. 异步型,操作DOM后,异步触发。details可以实现
3. 滞后型,操作DOM后,由其他代码触发。img等常见payload可以实现
从危害来看,明显是1>2>3,特别是1,可以直接无视后续的过滤操作。但是随着浏览器的版本更新。svg的处理流程似乎也有所变化。比如火狐就不支持svg这样的写法进行绕过。同时我们也拥有dom破坏这样的方法,但也要注意是否会被浏览器的事件监听器监听破坏。