ctfer

SSTI

2026-09-28 #dev

Q1:

注入一个cat /flag,为什么要写成lipsum.globals[‘os’].popen(‘cat /flag’).read()

在模板里从 Jinja2 默认能访问到的对象出发,沿着 Python 对象模型一路找到 os 模块,再执行命令并把输出读回来。

拆开看:

1
{{ lipsum.__globals__['os'].popen('cat /flag').read() }}

1. 为什么不能直接写 os.popen(...)?

在 Jinja2 模板上下文里,默认并没有把 Python 的 os 模块暴露成变量。你直接写:

1
{{ os.popen('cat /flag').read() }}

模板引擎会认为 os 是一个未定义变量,通常报错或渲染为空。SSTI 要做的就是:从模板里能访问到的对象,找到 Python 全局命名空间,再拿到危险模块。

2. lipsum 是什么?

lipsum 是 Jinja2 自带的一个全局函数,用来生成 Lorem Ipsum 文本。默认情况下,Jinja2 模板里可以直接调用:

1
{{ lipsum() }}

所以它是一个模板里“天然可访问”的 Python 函数对象。

3. __globals__ 是什么?

在 Python 中,函数对象有一个属性 __globals__,它返回这个函数定义时所在模块的全局命名空间字典。

lipsum 定义在 Jinja2 的 jinja2.utils 模块里,而这个模块顶部 import os,所以:

1
lipsum.__globals__['os']

就相当于拿到了 jinja2.utils 模块里的 os 模块。

换句话说:

1
2
3
4
lipsum.__globals__
# 是 jinja2.utils 模块的全局变量字典
lipsum.__globals__['os']
# 从中取出 os 模块

4. 为什么用 ['os'] 而不是 .os?

因为 __globals__ 是一个字典。字典取键可以用:

1
d['os']

也可以用属性方式:

1
d.os

但方括号更明确,而且当键名不是合法标识符时必须用方括号。这里写 ['os'] 就是标准的字典取值。

5. 为什么要 .popen(...).read()?

os.popen('cat /flag') 会通过 shell 执行命令,并返回一个类似文件的对象。命令输出在管道里,必须调用 .read() 才能把输出读成字符串。

如果不加 .read():

1
{{ lipsum.__globals__['os'].popen('cat /flag') }}

模板最终渲染出来的可能是类似:

1
<os._wrap_close object at 0x...>

而不是 /flag 的内容。

如果换成 os.system('cat /flag'),它返回的是退出码,命令输出通常直接写到服务器进程的标准输出,不一定进入 HTTP 响应,所以 SSTI 里更常用 popen(...).read()。

6. 整条链路的含义

1
2
3
4
5
6
7
8
9
10
lipsum
→ Jinja2 默认全局函数,模板可直接访问
lipsum.__globals__
→ 函数定义所在模块的全局命名空间
lipsum.__globals__['os']
→ 从全局命名空间里拿到 os 模块
os.popen('cat /flag')
→ 执行命令并拿到管道对象
.read()
→ 读取命令输出,作为模板渲染结果回显

所以它不是固定咒语,而是:找一个模板里能访问到的 Python 函数,用它的 __globals__ 跳到 Python 全局空间,再找 os、subprocess、__builtins__ 等危险对象。

不同环境里可用的“跳板”可能不同,比如 cycler、joiner、namespace、Flask 的 config、url_for 等,都可能被拿来找 __globals__。lipsum 只是因为 Jinja2 默认提供、比较常用。

防御上,根本办法是不要把用户输入当模板渲染,使用 render_template 传变量,启用沙箱,限制模板全局对象,并让 Web 进程最小权限运行。以上仅用于授权测试或 CTF 场景。

评论
也许会发生一些好事!
分享

评论