codeql自定义ql(java)

才发现,发公众号关注图片都会显示违规,csdn,look in my eyes。so求关注微信公众号ToBeHacker,csdn发不了的都发公众号,会不定期更新内容,给我一个把公众号写上简历的机会
在这里插入图片描述
好了,以下是正题:
使用codeql自定义ql规则集,前面部分基本都照搬官方文档,算是笔记,可以重点关注全局污点跟踪,然后根据文章最后面的Sql原生注入示例自行去编写规则集即可

自定义ql(java)

谓词

与函数有所区别,谓词用于描述构成 QL 程序的逻辑关系, 严格来说,谓词的计算结果为一组元组,如:

(1)一元组

predicate isCountry(string country) {
  country = "Germany"
  or
  country = "Belgium"
  or
  country = "France"
}

(2)二元组

predicate hasCapital(string country, string capital) {
  country = "Belgium" and capital = "Brussels"
  or
  country = "Germany" and capital = "Berlin"
  or
  country = "France" and capital = "Paris"
}

谓词定义

谓词大致可分为两种:

  • 不带结果的谓词,如

    predicate isSmall(int i) {
      i in [1 .. 9]
    }
    
  • 带结果的谓词

    int getSuccessor(int i) {
      result = i + 1 and
      i in [1 .. 9]
    }
    

    result是一个关键字,而且,result并不局限于在等式左边,如:

    Person getAChildOf(Person p) {
      p = getAParentOf(result)
    }
    

    result为p的子类。result也可以定义元组中单个值的结果,如:

    string getANeighbor(string country) {
      country = "France" and result = "Belgium"
      or
      country = "France" and result = "Germany"
      or
      country = "Germany" and result = "Austria"
      or
      country = "Germany" and result = "Belgium"
    }
    
    select getANeighbor("France")
    

    最后将会返回 Belgium和Germany

对于无结果的谓词可以这样使用:

import java // 根据目标语言调整
predicate isSmall(int i) {
  i in [1 .. 9]
}
from int i
where isSmall(i)
select i // 

最后会返回所有1-9的整数,而对于有结果的谓词在where会提示需要一个无结果的谓词

谓词递归

string getANeighbor(string country) {
  country = "France" and result = "Belgium"
  or
  country = "France" and result = "Germany"
  or
  country = "Germany" and result = "Austria"
  or
  country = "Germany" and result = "Belgium"
  or
  country = getANeighbor(result)
}
select getANeighbor("Austria")

最后得到结果为Germany

谓词种类

谓词还可以分为三种:

  • 非成员谓词:独立定义,不属于任何类

  • 特征谓词:在类的内部定义,类似构造函数,定义具体的成员,成员类型取决于extends的类型,此处是int类型

    class FavoriteNumbers extends int {
      FavoriteNumbers() {
        this = 1 or
        this = 4 or
        this = 9
      }
    }
    
    from FavoriteNumbers n
    select n
    

    返回1、4、9

  • 成员谓词:在类的内部定义,定义了以下谓词:

    class FavoriteNumbers extends int {
      FavoriteNumbers() {
        this = 1 or
        this = 4 or
        this = 9
      }
      string getName() {   
        this = 1 and result = "one"
        or
        this = 4 and result = "four"
        or
        this = 9 and result = "nine"
      }
    }
    
    from FavoriteNumbers n
    select n.getName()
    

    最后返回one、four、nine,由此可以看出成员谓词更像是用于对类成员进行一定转换

注意,谓词中的元素不能是无限的,除非在外部使用该谓词时做了范围限制,且要使用bindingset做声明,比如:

bindingset[i]
int multiplyBy4(int i) {
  result = i * 4
}

from int i
where i in [1 .. 10]
select multiplyBy4(i)

查询

CodeQl包含两种查询:

  • 警报查询 (problem):突出显示代码中特定位置的问题的查询。
  • 路径查询 (path-problem):描述代码中源和接收器之间的信息流的查询。

这需要在编写好的ql文件的元数据中指定(这非常重要):

/**
 * @name 原生sql注入
 * @description 使用jdbc进行sql语句的执行,由于构造sql时传入不可信数据导致问题存在.
 * @kind path-problem
 * @id java/sqli-jdbc
 * @problem.severity error
 * @security-severity 7.5
 * @precision high
 * @tags security
 */

一般的查询就是select或from+where+select,可以通过as关键字指定列名:

from int x, int y
where x = 3 and y in [0 .. 2]
select x, y, x * y as product
xyproduct
300
313
326

通过desc或asc关键字按列排序:

from int x, int y
where x = 3 and y in [0 .. 2]
select x, y, x * y as product order by y asc

但除此之外,还有一个查询谓词

query int getProduct(int x, int y) {
  x = 3 and
  y in [0 .. 2] and
  result = x * y
}

最后返回一个

xyresult
300
313
326

查询谓词同样可以在类中使用以定义成员:

query int getProduct(int x, int y) {
  x = 3 and
  y in [0 .. 2] and
  result = x * y
}

class MultipleOfThree extends int {
  MultipleOfThree() { this = getProduct(_, _) }
}

from MultipleOfThree m
select m

最后返回的实际上是getProduct的result

类型

QL中,每个变量必须有类型声明,这些类型分为原始类型和自定义类型。

  • 原始类型包括float、boolean、int、string、date(日期)

  • 自定义类型一般由以下部分组成

    • 成员谓词
    • 特征谓词(类似构造函数)
    • 字段

    如:

    class SmallInt extends int {
      SmallInt() { this = [1 .. 10] }
    }
    
    class DivisibleInt extends SmallInt {
      SmallInt divisor;   //字段
      DivisibleInt() { this % divisor = 0 }//特征谓词
    
      SmallInt getADivisor() { result = divisor }//成员谓词
    }
    from DivisibleInt i
    select i, i.getADivisor()
    

    继承使用extends

抽象类型

多重继承

class Two extends OneTwo, TwoThree {}

有关继承需要注意的是,当在同一文件内调用一个父类,其子类会自动被调用,如果子类存在问题,就会导致父类无法正常执行

拓展子类型

final class FinalOneTwoThree = OneTwoThree;

在继承该类型后,无法通过override覆写成员谓词

非拓展子类型

class Bar instanceof Foo {
  string toString() { result = super.fooMethod() }
}

通过instanceof声明为Foo的子类型,然后通过super调用父类的成员谓词

模块

模块的名称可以是任何标识符以大写或小写字母开头

显示定义模块

module Example {
  class OneTwoThree extends int {
    OneTwoThree() {
      this = 1 or this = 2 or this = 3
    }
  }
}

隐式定义模块

每个查询文件(扩展名 .ql)和库文件(扩展名 .qll)都隐式定义一个模块

导入模块

import <module_expression1> as <name>
import <module_expression2>

或者通过以下方式导入:

module Sets = QlBuiltins::InternSets<int, int, getAValue/1>;

内置模块QlBuiltins

  • BigInt:任意范围证书
  • InternSets:集合
  • EquivalenceRelation:等价关系

签名

参数化模块使用签名作为其参数的类型系统。签名分为三类: 谓词签名类型签名模块签名

公式

  • 比较

    <expression> <operator> <expression>
    
    名字operator
    大于>
    大于或等于>=
    小于<
    小于或等于<=
  • 相等检查

    要使用 = 比较两个表达式,至少其中一个表达式必须具有类型。如果两个表达式都有一个类型,则它们的类型必须兼容 。

    要使用 != 比较两个表达式,两个表达式都必须具有类型。这些类型也必须兼容 。

  • 类型检查

    <expression> instanceof <type>
    
  • 范围检查

    <expression> in <range>
    
  • 检查是否存在

    exists(<variable declarations> | <formula>)
    

    如果不存在则是not exists

  • forall检查

    forall(<variable declarations> | <formula 1> | <formula 2>)
    

    如果 < 公式 2>< 公式 1> 的所有值都成立,则它成立。

  • 逻辑连接词

codeql java

适用于java和Kotlin的库

标准 Java/Kotlin 库中最重要的类可以分为五大类:

  1. 用于表示程序元素(例如类和方法)的类
  2. 用于表示 AST 节点的类(例如语句和表达式)
  3. 用于表示元数据(如注释和注释)的类
  4. 用于计算指标的类(例如圈复杂度和耦合)
  5. 用于导航程序调用图的类
程序元素

这些类表示命名的程序元素:包 (Package)、编译单元 (CompilationUnit)、类型 (Type)、方法 (Method)、构造函数 (Constructor) 和变量 (Variable

类型
  • PrimitiveType 表示一种原始类型 ,即 booleanbytechardoublefloatintlongshort 之一;QL 还将 void<nulltype>文本的类型)分类为基元类型。
  • RefType 表示引用 (即非基元) 类型;它又有几个子类:
    • Class 表示 Java 类。
    • Interface 表示 Java 接口。
    • EnumType 表示 Java 枚举类型。
    • Array 表示 Java 数组类型。
  • TopLevelType 表示在编译单元的顶层声明的引用类型。
  • NestedType 是在另一个类型中声明的类型。
泛型

变量

Field 表示 Java 字段。

LocalVariableDecl 表示局部变量。

Parameter 表示方法或构造函数的参数。

AST

AST,抽象语法树,即语句(类 Stmt)和表达式(类 Expr)。

ExprStmt 都提供了用于探索程序的抽象语法树的成员谓词:

  • Expr.getAChildExpr 返回给定表达式的子表达式。
  • Stmt.getAChild 返回直接嵌套在给定语句中的语句或表达式。
  • Expr.getParentStmt.getParent 返回 AST 节点的父节点。

语句通常不直接产生值,而表达式则会产生值

元数据

主要是注释,比如查看所有构造函数的Annotation注释

import java

from Constructor c
select c.getAnAnnotation()

查找所有私有字段的Javadoc注释:

import java

from Field f, Javadoc jdoc
where f.isPrivate() and
    jdoc = f.getDoc().getJavadoc()
select jdoc
度量

总共有六个这样的类:MetricElementMetricPackageMetricRefTypeMetricFieldMetricCallableMetricStmt。每个相应的元素类都提供了一个成员谓词 getMetrics,该谓词可用于获取委托类的实例,然后可以在该实例上执行度量计算。

查找圈复杂度大于 40 的方法:

import java

from Method m, MetricCallable mc
where mc = m.getMetrics() and
    mc.getCyclomaticComplexity() > 40
select m
调用图

可以使用谓词 Call.getCallee 来找出特定调用表达式引用的方法或构造函数,比如:

import java

from Call c, Method m
where m = c.getCallee() and
    m.hasName("println")
select c

查询从未调用的方法和构造函数:

import java

from Callable c
where not exists(c.getAReference())
select c

数据流

数据流存在以下概念:

  • source:输入节点
  • sink:输出节点
  • sanitizer:净化函数(全局数据流)

只有当source和sink同时存在,并且从source到sink的链路是通的,数据流才存在

本地数据流

本地数据流是单个方法或可调用范围内的数据流。

首先导入:

import semmle.code.java.dataflow.DataFlow

DataFlow 模块定义了 Node 类,表示数据可以流经的任何元素。 节点分为表达式节点 (ExprNode) 和参数节点 (ParameterNode)。通过成员谓词 asExprasParameter 可以获取当前节点的表达式和参数

全局数据流

通过实现特征 DataFlow::ConfigSig 并应用模块 DataFlow::Global<ConfigSig> 来使用全局数据流库

import java
import semmle.code.java.dataflow.DataFlow

module MyFlowConfiguration implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) {
    ...
  }

  predicate isSink(DataFlow::Node sink) {
    ...
  }
}

module MyFlow = DataFlow::Global<MyFlowConfiguration>;

使用:

from DataFlow::Node source, DataFlow::Node sink
where MyFlow::flow(source, sink)
select source, "Data flow to $@.", sink, sink.toString()

全局数据流和全局污点跟踪的区别在于,全局数据流关注数据的明确流动路径,且中途没有发生修改,但污点跟踪不是,因此全局污点跟踪一般会得到更多的数据

污点跟踪

本地污点跟踪通过包含非值保留流步骤来扩展本地数据流。

首先导入:

import semmle.code.java.dataflow.TaintTracking

使用:

TaintTracking::localTaint(DataFlow::parameterNode(p), DataFlow::exprNode(call.getArgument(0)))

类似数据流,但实际上污点跟踪会追踪所有影响call.getArgument(0)的入参

流源

RemoteFlowSource(在 semmle.code.java.dataflow.FlowSources 中定义)表示可能由远程用户控制的数据流源

import java
import semmle.code.java.dataflow.FlowSources

module MyFlowConfiguration implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) {
    source instanceof RemoteFlowSource
  }

  ...
}

module MyTaintFlow = TaintTracking::Global<MyFlowConfiguration>;

常用函数

  • 直接获取函数的返回类型

    m.getDeclaringType()
    

    m为Method类型

  • 对返回类型进行类型判断

    m.getDeclaringType().hasQualifiedName("java.sql", "Statement")
    

    检查返回类型是不是java.sql.Statement

  • 获取注解所在函数或类

    annotation.getParent()
    

    筛选所在为类:

    from Controller c, Class clazz
    where c.getParent()=clazz
    select c, c.getParent()
    

    筛选所在为函数:

    from MappingAnnotation a, Method m
    where m =a.getParent()
    select m
    
  • 获取函数所在类

    from Method method
    method.getDeclaringType()
    
  • 限制构造函数类型

    school.getDeclaringType().hasQualifiedName("com.example.demo.entity", "School")
    

    school为Constructor类型

  • 限制为调用了fileReader函数

    call.getCallee() = fileReader
    

    fileReader是Constructor类型,call是Call类型

  • 限制参数是公共参数

     DataFlow::localFlow(DataFlow::parameterNode(p), DataFlow::exprNode(call.getArgument(0)))
    

    调用的函数的第一个参数来自公共参数p,p为Parameter类型

ApiSourceNode

import java
import semmle.code.java.dataflow.FlowSources

from ApiSourceNode node
select node

会枚举所有Api的入口,哪怕某个入参实际没有被使用

image-20250702020513890

枚举入参的信息:

import java
import semmle.code.java.dataflow.FlowSources

from ApiSourceNode node
select node.asParameter(),  node.asParameter().getType(), node.asParameter().getName()

包括asParameter、参数的类型以及参数名

枚举Node对应的Method
import java
import semmle.code.java.dataflow.FlowSources

from ApiSourceNode node, Method method
where method = node.getEnclosingCallable()
select method
枚举属于Controller且属于node的部分
import java
import semmle.code.java.dataflow.FlowSources


private class Mapping extends Annotation{
    //出现额外mapping注解在此补充,类似注解只能出现在方法上面
    Mapping(){
        this.getType().getQualifiedName() in 
        ["org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.RequestMapping",
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"]
    }
}

from ApiSourceNode node, Method m
where m = node.getEnclosingCallable() and
  exists(Mapping mapping | m = mapping.getParent())
select m
枚举属于node但不属于Controller的部分

以下代码实际起不到筛选作用:

import java
import semmle.code.java.dataflow.FlowSources


private class Mapping extends Annotation{
    //出现额外mapping注解在此补充,类似注解只能出现在方法上面
    Mapping(){
        this.getType().getQualifiedName() in 
        ["org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.RequestMapping",
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"]
    }
}

from ApiSourceNode node, Method m
where m = node.getEnclosingCallable() and
  not exists(Mapping mapping | m = mapping.getParent())
select m
枚举属于Controller中但不属于node的部分
import java
import semmle.code.java.dataflow.FlowSources


private class Mapping extends Annotation{
    //出现额外mapping注解在此补充,类似注解只能出现在方法上面
    Mapping(){
        this.getType().getQualifiedName() in 
        ["org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.RequestMapping",
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"]
    }
}

from Mapping mapping , Method m 
where m = mapping.getParent() and not exists(ApiSourceNode node | m = node.getEnclosingCallable())
select m

标准库

https://codeql.github.com/codeql-standard-libraries/java/

现有查询ql参考

https://codeql.github.com/codeql-query-help/java/

示例

获取spring映射url
//梳理spring接口
import java
import semmle.code.java.Annotation

private class Controller extends Annotation{
    //出现额外Controller注解在此补充,类似注解只能出现在类上面
     Controller(){
        this.getType().getQualifiedName() in [
            "org.springframework.web.bind.annotation.RestController",
            "org.springframework.web.bind.annotation.Controller",
            "org.springframework.stereotype.Controller", 
            // "org.springframework.web.bind.annotation.RequestMapping" # 注意特殊场景:即定义在RequestMapping上而非Controller上
        ] 
     }

    
     string getPath(){
        if exists(this.getStringValue("value"))
        then result =this.getStringValue("value") 
        else (
            if exists(this.getStringValue("path"))
            then result = this.getStringValue("path")
            else result = ""
            )
     }
}

private class Mapping extends Annotation{
    //出现额外mapping注解在此补充,类似注解只能出现在方法上面
    Mapping(){
        this.getType().getQualifiedName() in 
        ["org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.RequestMapping",
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"]
    }

    //获取注解指定参数的值,这个值可能是字符串也可能是array,因此通过Expr返回
    //可以尝试获取value(path)、params、consumes、produces、method
    Expr getAnnotationValue(string name){
        result = this.getValue(name)
    }

}

private class MyClass extends Class{
    MyClass(){
        exists(Class clazz |  this = clazz)
    }

    string getController(){
        exists(Controller controller| this = controller.getParent() and result = controller.getPath())
    }


}

private class MyMethod extends Method{
    MyMethod(){
        exists(Method method |  this = method)
    }

    //由于映射的url可能存在多个,即可能为数组,因此返回Expr
    Expr getUrlMappingExpr(){
        exists(Mapping mapping| 
            mapping.getParent()=this
        and(
            if exists(mapping.getAnnotationValue("value"))
            then result = mapping.getAnnotationValue("value")
            else result = mapping.getAnnotationValue("path"))
        )
    }
}

from MyClass clazz, MyMethod method
where clazz = method.getDeclaringType()
select clazz, clazz.getController(), method, method.getUrlMappingExpr() 
方法追踪
import java


from MethodCall m 
where m.getMethod().toString()="parse" or m.getMethod().toString()="parseObject"
select m.getCaller(),m.getParent(),m.getArgument(0)

这是一个最简单的定位fastjson解析对象位置的一个ql文件

#[0][1][2]
1mainparse(…)js
2mainparseObject(…)payload
  • 第一列:调用该方法的函数位置
  • 第二列:被调用方法所处位置
  • 第三列:方法的第一个入参
本地数据流
import java
import semmle.code.java.dataflow.DataFlow

from Constructor school, Call call, Expr src
where
 school.getDeclaringType().hasQualifiedName("com.example.demo.entity", "School") and
  call.getCallee() = school and
  DataFlow::localFlow(DataFlow::exprNode(src), DataFlow::exprNode(call.getArgument(0)))
select src

明确要分析的是com.example.demo.entity.School类的构造函数,有关DataFlow部分的含义则是存在从源表达式(src)到构造函数第一个参数(call.getArgument(0))的局部数据流。简化一下:

import java
import semmle.code.java.dataflow.DataFlow

from Call call, Expr src
where
  DataFlow::localFlow(DataFlow::exprNode(src), DataFlow::exprNode(call.getArgument(0)))
select src, call.getArgument(0)

如果代码是:

public static void main(String[] args) {
        String[] a = new String[]{"attachment1.pdf", "attachment1.exe", "attachment1.exe", "attachment2.exe", "attachment1.pdf"};
        FileValidateAndCalculate(a);

    }

则src(表达式)是new String[]{"attachment1.pdf", "attachment1.exe", "attachment1.exe", "attachment2.exe", "attachment1.pdf"};call.getArgument(0)FileValidateAndCalculate(a)中的a,简而言之,该ql在追踪局部变量a,但由于是在追踪所有被调用函数的第一个入参,因此需要做一些限制来简化结果。

fastjson(局部污点追踪)
import java
import semmle.code.java.dataflow.DataFlow
import semmle.code.java.dataflow.TaintTracking
import semmle.code.java.StringFormat

from MethodCall call, Expr src, Parameter p
where call.getMethod().toString() in ["parse", "parseObject" ] and
DataFlow::localFlow(DataFlow::exprNode(src), DataFlow::exprNode(call.getArgument(0))) 
and TaintTracking::localTaint(DataFlow::parameterNode(p), DataFlow::exprNode(call.getArgument(0)))
select p as input, call.getParent() as expr, call.getArgument(0) as sink

p是危险函数入参,expr是注入点所在表达式,call.getArgument(0)为注入点

原生sql注入(全局)
/**
 * @name 原生sql注入
 * @description 使用jdbc进行sql语句的执行,由于构造sql时传入不可信数据导致问题存在.
 * @kind path-problem
 * @id java/sqli-jdbc
 * @problem.severity error
 * @security-severity 9.2
 * @precision high
 * @tags security
 */

import java
import semmle.code.java.dataflow.TaintTracking
import semmle.code.java.security.PathSanitizer
private import semmle.code.java.dataflow.ExternalFlow
private import semmle.code.java.dataflow.FlowSources
private import semmle.code.java.security.Sanitizers

private class Mapping extends Annotation{
    //出现额外mapping注解在此补充,类似注解只能出现在方法上面
    Mapping(){
        this.getType().getQualifiedName() in 
        ["org.springframework.web.bind.annotation.PostMapping",
            "org.springframework.web.bind.annotation.RequestMapping",
            "org.springframework.web.bind.annotation.GetMapping",
            "org.springframework.web.bind.annotation.PutMapping",
            "org.springframework.web.bind.annotation.DeleteMapping",
            "org.springframework.web.bind.annotation.PatchMapping"]
    }
}

private class SqliSource extends DataFlow::Node{
  SqliSource()  {
    exists(Method m | m = this.getEnclosingCallable() and
  exists(Mapping mapping | m = mapping.getParent()))
  }
}

private class SqliSink extends DataFlow::Node {
    SqliSink(){
        exists(MethodCall call|(call.getMethod().getDeclaringType().hasQualifiedName("java.sql", "Statement") 
or call.getMethod().getDeclaringType().hasQualifiedName("java.sql", "Connection") ) and
        call.getMethod().toString()in["executeQuery", "executeUpdate","execute", "prepareStatement"]
        and this.asExpr() = call.getArgument(0))
    }

}

module SqlJdbcConfig implements DataFlow::ConfigSig {
  predicate isSource(DataFlow::Node source) { source instanceof SqliSource  }

  predicate isSink(DataFlow::Node sink) { sink instanceof SqliSink }

}


module SqlJdbcFlow = TaintTracking::Global<SqlJdbcConfig>;
// module SqlJdbcFlow = DataFlow::Global<SqlJdbcConfig>;
import SqlJdbcFlow::PathGraph

from SqlJdbcFlow::PathNode source, SqlJdbcFlow::PathNode sink
where SqlJdbcFlow::flowPath(source, sink)
select sink.getNode(), source, sink, "sql-injection"

参考

  • https://codeql.github.com/docs/ql-language-reference/types/#defining-a-class

  • https://geekdaxue.co/read/loulan-b47wt@rc30f7/kgidug

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值