目录
DOM树的构建
源码
img失败的原因
解法一 --- svg成功的原因
触发流程
实验
解法二 --- details标签
事件触发流程
实验验证
总结
首先在解这道题的开始连了解下什么是DOM树,以及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树构造造成额外影响。
可以看出这是个明显的DOM XSS,用户的输入会构成一个新的div元素的子节点,但在插入body之前会被移除所有的属性。
了解完上述的内容后,我们可以看看为什么svg可以成功,而img为什么失败。
先来找一下失败案例的原因,看看是哪里触发了img 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')});
测试结果:
那么失败的原因也就跟明显了,由于JS阻塞DOM树,一直到JS语句执行结束后,才可以引入img,此时img的属性已经被sanitizer清除了,自然也不可能执行事件代码了。
总结下整个执行流程就是:
最先执行的js ---> for循环用来删除我们标签属性的 ---> js把dom树阻塞住了,js先执行 ---> img所有的属性先删除了 ---> js执行结束 ---> 恢复DOM树的加载 ---> DOMCONTENTloaded ---> 此时img已经没有任何属性了
payload
继续使用断电调试svg payload为何成功。
在root.innerHtml = data断下来后,点击单步调试。
然而神奇的事情发生了,直接弹出了弹窗,点击确认以后,调试器才会走到下一行代码。而且,这个地方如果只有一个
上文提到一个叫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_--;
}
当我们没有正确闭合标签的时候,如
void SVGSVGElement::FinishParsingChildren() {SVGGraphicsElement::FinishParsingChildren();// The outermost SVGSVGElement SVGLoad event is fired through// LocalDOMWindow::dispatchWindowLoadEvent.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 finishSendSVGLoadEventIfPossible();
}
这里有一个非常明显的判断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)的代码。
总结
svg之所以可以成功实现,原因是,当遇到外层的svg时直接返回了,但是遇到内层svg时,且存在onload事件则直接执行,使用到的是DispatchEvent(*Event::Create(event_type_names::kLoad));函数。
我们可以将过滤的代码注释,并添加相关代码来验证这个事件的触发时间。
window.addEventListener("DOMContentLoaded", (event) => {console.log('DOMContentLoaded')});window.addEventListener("load", (event) => {console.log('load')});
同时,我们将注入代码也再套嵌一层
可以看到结果不出所料,最内层的svg先触发,然后再到下一层,而且是在DOM树构建完成以前就触发了相关事件;最外层的svg则得到等到DOM树构建完成才能触发。
payload
details存在有时可行,有时又不行的问题,现在我们进行探讨下
首先触发代码的点是在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部分延时执行。
const data = decodeURIComponent(location.hash.substr(1));;const root = document.createElement('div');root.innerHTML = data;setTimeout( () => {for (let el of root.querySelectorAll('*')) {let attrs = [];for (let attr of el.attributes) {attrs.push(attr.name);}for (let name of attrs) {el.removeAttribute(name);}} document.body.appendChild(root)} , 2000)
修改代码前
执行失败
修改代码后
可以看到,确实成功执行了事件代码。
那么回头来想一下,为什么测试Tui的时候直接成功,却在修改前的挑战代码中失败?看一下Tui的处理这部分内容的相关代码。tui.editor/htmlSanitizer.ts at 48a01f5add76cb6eedb29cceb95f765164d69649 · nhn/tui.editor · GitHub
export function sanitizeHTML(html: string) {const root = document.createElement('div');if (isString(html)) {html = html.replace(reComment, '').replace(reXSSOnload, '$1');root.innerHTML = html;}removeUnnecessaryTags(root);leaveOnlyWhitelistAttribute(root);return finalizeHtml(root, true) as string;
}
sanitizeHTML函数是处理用户输入的部分。比起挑战的代码,这里多了正则过滤,移除黑名单标签(removeUnnecessaryTags),不过不会移除所有标签而是留下了部分白名单标签(leaveOnlyWhitelistAttribute)。最神奇的部分来了,details标签也是黑名单的一员,这也是我一开始无法理解为何这个payload能够成功执行的原因。但现在我们清楚调用流程以后,可以有一个大胆的猜测:正是因为detalils在黑名单里,所以被移除以后其属性没有被直接修改,所以事件依然在队列中没有被取消。
再进行一个实验来验证,对挑战的代码做一些修改,增加移除标签的代码。
const data = decodeURIComponent(location.hash.substr(1));;const root = document.createElement('div');root.innerHTML = data;let details = root.querySelector("details")root.removeChild(details)for (let el of root.querySelectorAll('*')) {let attrs = [];for (let attr of el.attributes) {attrs.push(attr.name);}for (let name of attrs) {el.removeAttribute(name);}}
成功执行了代码
得出结论,details标签的toggle事件是异步触发的,并且直接对details标签的移除不会清除原先通过属性设置的异步任务。
对于DOM XSS,我们是通过操作DOM来引入代码,但由于浏览器的限制,我们无法像这样root.innerHTML = ""直接执行插入的代码,因此,一般需要通过事件触发。通过上面的例子,可以发现依据事件触发的时机能进一步区分DOM XSS:
1、立即型,操作DOM时触发。嵌套的svg可以实现
2、异步型,操作DOM后,异步触发。details可以实现
3、滞后型,操作DOM后,由其他代码触发。img等常见payload可以实现
从危害来看,明显是1>2>3,特别是1,可以直接无视后续的sanitizer操作
上一篇:SQL 进阶刷题笔记