Go语言PWN实战:从栈溢出到ROP链构造的完整解析

1. 项目概述:从一道赛题看Go语言PWN的独特之处

最近在复盘CISCN(全国大学生信息安全竞赛)的题目时,遇到了一道非常有意思的Go语言PWN题,题目名叫“Shellwego”。这道题不仅考察了传统的栈溢出和ROP链构造技巧,更因为其底层是Go语言编译的二进制程序,而带来了许多与常规C/C++程序截然不同的挑战和特性。很多刚接触二进制安全的朋友,一看到Go语言可能就有点发怵,觉得它的运行时、协程调度、内存管理太复杂,无从下手。但我想说,恰恰是这些特性,让Go PWN成为了一块检验我们基本功和理解深度的试金石。通过彻底拆解这道“Shellwego”赛题,我们不仅能掌握一次具体的漏洞利用,更能建立起一套分析Go语言二进制程序的通用方法论。

简单来说,这道题是一个存在栈溢出漏洞的Go语言网络服务程序。攻击者的目标很明确:通过精心构造的输入数据,覆盖函数的返回地址,劫持程序的控制流,最终在服务器上执行任意命令(getshell)。整个过程涉及对Go语言函数调用约定、栈布局、漏洞点定位、ROP Gadget寻找以及最终payload构造的完整分析。相比于传统PWN,你需要额外关注Go的启动过程、 main.main 函数如何被调用、参数如何传递、以及Go运行时特有的内存保护机制(如栈保护、非执行位等)是否被开启或如何绕过。

如果你是一名CTF选手,或者对二进制安全、逆向工程感兴趣,希望通过一个具体案例深入理解Go语言底层的安全机制,那么这篇解析正是为你准备的。我会假设你具备基本的栈溢出原理和ROP概念,但不需要你是Go语言专家。我们将从零开始,一步步还原解题思路,并重点分享那些在官方文档里找不到的“踩坑”经验和调试技巧。

2. 环境准备与逆向分析起点

工欲善其事,必先利其器。分析Go语言二进制程序,工具链的选择和配置至关重要,一些细微的差别可能导致逆向分析效率天差地别。

2.1 工具选型与配置心得

首先,你需要一个能良好解析Go符号的逆向工具。IDA Pro(7.0以上版本)对Go的支持已经比较完善,能够自动识别并恢复大量的Go运行时函数和用户函数名,这能节省大量时间。Ghidra也是一个不错的选择,尤其是其开源免费的特性,但可能需要手动加载Go的特定解析器或脚本(如 golang_renamer.py )来恢复符号。我个人在实战中更倾向于IDA Pro,因为它对Go二进制文件的分析和反编译(F5)通常更稳定、准确。

其次,调试器必不可少。Linux环境下, gdb 是标配,但原生 gdb 对Go协程栈的显示并不友好。这里强烈推荐安装 pwndbg gef 这类增强插件。它们能提供更直观的上下文信息,比如直接显示canary值、识别出栈帧等。对于这道题,使用 pwndbg 能让你在动态调试时一眼看出栈的布局变化。

最后,别忘了 objdump readelf checksec 这些基础工具。 checksec 可以快速查看程序开启了哪些保护机制(如NX, PIE, Canary等),这是我们制定利用方案的第一步。对于Go程序,由于编译器默认行为,PIE(地址空间布局随机化)和NX(堆栈不可执行)通常是开启的,而栈保护(Canary)则不一定,这需要具体分析。

注意:不同版本的Go编译器(如1.16, 1.18, 1.20)在代码生成、函数序言(prologue)和调用约定上可能有细微差别。建议在解题时,尽量使用与题目相同或相近版本的Go环境进行编译测试,以减少因版本差异导致的偏差。你可以通过 strings binary | grep ‘go1.’ 来快速判断大致的编译版本。

2.2 初步逆向与漏洞点定位

拿到“Shellwego”二进制文件后,第一步不是直接扔进IDA,而是先跑起来看看。运行程序,发现它监听某个端口(比如9999),是一个简单的网络服务。用 nc 连上去,尝试发送一些数据,观察回显或崩溃行为,这能给你最直观的感受。

接着用 checksec 检查:

checksec ./shellwego

输出可能显示 NX enabled PIE enabled ,但 Stack Canary 大概率是 disabled 的。这是因为在Go语言中,除非显式使用 -race 标志进行竞争检测编译,或者函数非常特殊,否则默认不插入栈保护cookie。这是一个非常重要的突破口,意味着我们可能面对的是一个没有Canary的栈溢出漏洞。

用IDA Pro加载程序。由于是Go语言编译,你会看到入口点并非 main ,而是一个名为 runtime.rt0_go 的函数。这是Go运行时的启动入口,它会进行一系列初始化,最后调用我们编写的 main.main 函数。直接搜索字符串“main.main”,然后跳转过去,这才是我们用户代码的起点。

main.main 函数中,通常会看到网络监听、接受连接、创建新goroutine处理请求的逻辑。我们需要找到处理客户端数据的函数。通常,这个函数里会有一个 read recv 之类的系统调用,或者更常见的是,Go语言封装好的 net.Conn.Read 方法。沿着数据流的传递路径进行跟踪。

关键技巧来了:在IDA的字符串窗口(Shift+F12)搜索一些可能的关键字,如“input”、“read”、“buf”、“error”等,或者直接搜索程序崩溃或打印信息中的字符串,然后交叉引用(Xref)找到使用它们的地方。在这道题中,你可能会发现一个函数,它接收一个连接(conn)作为参数,然后在一个循环中读取数据。仔细看这个读数据的缓冲区是如何定义的。

Go语言的栈帧结构比较规整。一个典型的函数开头会是:

push rbp
mov rbp, rsp
sub rsp, XXh ; 在栈上分配局部变量空间
...

局部变量就会存放在 [rbp-XXh] [rbp] 这个区间。如果你在函数中看到了对某个栈地址进行循环读取操作的代码,并且循环次数或读取长度依赖于用户可控的输入,那么这里就可能是漏洞点。在“Shellwego”中,我们最终定位到一个函数,它使用了一个固定大小的字节数组(比如 [128]byte )作为缓冲区,但却使用了一个从数据包中解析出来的、用户可控的长度值作为 read copy 操作的次数,从而导致了经典的栈缓冲区溢出。

3. Go语言栈溢出利用的核心挑战

找到了溢出点,是不是感觉胜利在望了?别急,对于Go语言程序,直接覆盖返回地址 rip 只是万里长征第一步,前面还有好几座大山需要翻越。

3.1 Go语言的调用约定与栈布局

这是与传统C程序最大的不同之一。在x86-64架构下,C语言使用System V AMD64 ABI调用约定,前六个整数或指针参数依次通过寄存器 rdi, rsi, rdx, rcx, r8, r9 传递,多余的才通过栈传递。而Go语言有自己的调用约定,它 几乎全部通过栈来传递参数和返回值

这意味着什么呢?在Go函数的汇编代码中,你很少会看到像 mov rdi, rax 这样准备参数的指令。相反,调用者函数会在自己的栈帧顶部(也就是当前栈指针 rsp 附近)预留出一块空间,将要传递给被调用函数的参数依次存入这片内存区域,然后执行 call 指令。被调用函数则直接从固定的栈偏移位置去读取这些参数。

这种设计对漏洞利用的影响是巨大的。首先,它使得栈上的内容非常密集,除了返回地址、保存的基址指针( rbp )外,还充斥着大量的函数参数和局部变量。我们在构造溢出数据时,不仅要精准地覆盖返回地址,还要注意不要破坏那些即将被使用的参数,否则程序可能在跳转到我们的gadget之前就因为读取到错误参数而崩溃了。其次,Go语言的栈帧通常比较大(因为协程栈初始就有2KB),且生长方向是向低地址扩展,这需要我们在计算偏移量时格外小心。

3.2 寻找可用Gadget的困境

由于参数通过栈传递,我们常用的 pop rdi; ret 这样的gadget在Go程序中变得几乎无用武之地。因为即使你通过 pop rdi 将栈上的一个值弹到了 rdi 寄存器,Go的函数也不会去读 rdi ,它依然会去读栈上某个固定偏移处的值作为第一个参数。

因此,在Go语言的ROP链构造中,我们寻找gadget的策略必须转变。我们的目标不再是控制寄存器,而是 直接控制栈上的数据 。我们需要的是能够实现“栈翻转”(stack pivot)的gadget,或者能够将栈指针 rsp 指向我们完全可控的内存区域(比如我们注入的payload所在的缓冲区)的gadget。

一类极其有用的gadget是像 add rsp, XXX; ret 这样的指令序列。它能够直接调整栈指针,跳过一些我们无法精确控制或不想破坏的数据,让 ret 指令从我们期望的新位置取下一条指令地址。另一类更强大的是 xchg mov 指令,能够将 rsp 与其他寄存器(如 rax , rdx )交换或赋值。如果我们能先通过某个漏洞或gadget将一个可控地址(如堆地址或.bss段地址)加载到某个寄存器,再通过 mov rsp, rax; ret 这样的gadget,就能实现栈的完全迁移,从而在一个宽敞、可控的空间里布置复杂的ROP链。

3.3 地址随机化(PIE)与信息泄露

现代CTF赛题和真实环境中的Go程序,几乎100%默认开启PIE。这意味着代码段(.text)的基地址在每次程序运行时都是随机化的。我们虽然能通过逆向分析得到指令相对于基地址的偏移(offset),但不知道程序运行时的实际加载地址(base)。

因此,利用的第一步往往是 信息泄露 。我们需要利用程序的某种功能(比如打印错误信息、回显部分数据),将某个代码指针或库函数地址泄露出来。由于PIE随机化是针对整个模块的,一旦我们泄露了一个地址,就能推算出整个代码段的基地址,进而计算出所有gadget和有用函数(如 system )的实际运行时地址。

在“Shellwego”中,漏洞函数可能在溢出后导致程序崩溃,并将某些栈上或寄存器中的内容作为错误信息返回给客户端。我们需要仔细审计代码,看看有没有这样的机会。例如,如果程序在 read 之后,会将读取的字节数或缓冲区的某个指针用于日志打印,我们或许就能通过精心构造的输入,让这个指针指向一个我们想要泄露的地址(比如 main.main 的地址)。

实操心得:动态调试是解决信息泄露的关键。在gdb中,在疑似泄露点(如调用 fmt.Printf write 系统调用)下断点,观察传入的参数是什么。有时候,泄露可能发生在崩溃时的 panic 处理流程中,Go的 panic 会打印堆栈信息,里面可能包含有价值的地址。多尝试几种崩溃方式,观察输出。

4. 漏洞利用链的详细构造过程

假设我们已经通过某种方式泄露了代码段基地址,并且确认了栈溢出漏洞点和偏移量。接下来就是构造利用链的实战环节。

4.1 计算精确偏移与控制流劫持

首先,我们需要确定从我们输入的缓冲区起始位置,到目标函数返回地址存储位置之间的精确字节偏移。

最可靠的方法是通过模式字符串(pattern)来定位。我们可以使用 pwntools cyclic 功能生成一段长而无重复的字符串,发送给程序触发崩溃,然后程序崩溃时 rip 寄存器的值(或者栈上的值)会指向这个模式字符串的某个部分。再用 cyclic_find 计算这个值在模式串中的位置,就能得到精确偏移。

from pwn import *
context.binary = ‘./shellwego‘
p = process(‘./shellwego‘)
# 或者 remote(‘host‘, port)

# 生成200个字符的模式串
pattern = cyclic(200)
p.sendline(pattern)
p.wait() # 等待崩溃
# 从core dump或崩溃信息中获取rip的值,假设是0x6161616c
rip_value = 0x6161616c
offset = cyclic_find(rip_value) # 计算偏移
print(f“Offset to RIP is: {offset}“)

在Go语言中,由于栈帧较大且结构固定,这个偏移量通常是一个固定值。得到偏移后,我们的payload结构就清晰了: payload = b‘A‘ * offset + p64(target_address)

4.2 构建适用于Go的ROP链

现在到了最核心的部分:构建ROP链。我们的目标通常是调用 execve(“/bin/sh“, NULL, NULL) 来获取shell。在开启了NX保护的系统中,我们不能直接执行栈上的shellcode,必须复用程序已有的代码片段。

  1. 寻找系统调用指令 :在64位Linux下, execve 的系统调用号是59(0x3b)。我们需要通过 syscall 指令来触发。在Go语言的二进制文件中, syscall 指令并不少见,因为Go运行时需要用它来进行真正的系统调用。我们可以用ROPgadget这样的工具搜索:

    ROPgadget --binary ./shellwego | grep “syscall“
    

    或者用 objdump 配合 grep 。找到一个 syscall; ret 的gadget地址,记作 gadget_syscall

  2. 设置系统调用参数 :按照x86-64系统调用约定,需要将 rax 设为59, rdi 设为“/bin/sh“字符串的地址, rsi rdx 设为0。但如前所述,Go语言不常用寄存器传参,我们很难直接控制 rdi 。怎么办?

    策略是: 寻找能直接控制栈内容的gadget,然后让 syscall 指令“认为”栈上的布局符合系统调用约定 。但这几乎不可能,因为 syscall 是从特定寄存器读参数的。

    因此,更可行的策略是寻找一个“万能”的gadget,或者一个小型的、能设置寄存器的代码片段。Go运行时库( runtime )或系统库(如 libc ,如果静态链接了的话)里包含许多设置寄存器的指令。我们需要搜索如 pop rax; ret pop rdi; ret pop rsi; ret pop rdx; ret 这样的序列。由于Go二进制文件体积庞大,找到这些基础gadget的概率其实不低。

  3. 布置参数与字符串 :我们需要在内存中找到一个可写且地址已知的区域写入字符串“/bin/sh“。通常可以选择程序的 .data .bss 段(存储未初始化全局变量)。用 readelf -S ./shellwego 查看节区信息,找到 .bss .data 的起始虚拟地址(VMA)。这个地址在PIE开启时也是随机的,但我们可以用泄露的代码基地址加上固定的节区偏移来计算。 假设泄露的代码地址是 leak_addr .bss 段在二进制中的偏移是 0xCAFE00 ,那么 .bss 段的运行时基地址就是 bss_base = leak_addr - (leak_addr的偏移) + 0xCAFE00 。我们需要将这个计算过程自动化到exp中。

  4. 链式调用 :最终的ROP链可能长这样(假设我们找到了所有需要的pop gadget):

    [溢出偏移处开始]
    p64(pop_rdi_ret)        # 1. 将‘/bin/sh‘地址弹入rdi
    p64(bin_sh_addr)        #   参数:字符串地址
    p64(pop_rsi_ret)        # 2. 将0弹入rsi
    p64(0)                  #   参数:argv
    p64(pop_rdx_ret)        # 3. 将0弹入rdx
    p64(0)                  #   参数:envp
    p64(pop_rax_ret)        # 4. 将59弹入rax
    p64(59)                 #   系统调用号
    p64(syscall_ret)        # 5. 执行syscall
    

    但实际情况往往更复杂,可能因为寄存器干扰、栈对齐等问题,需要插入一些 ret (相当于 nop )指令或调整顺序的gadget(如 xchg rdi, rsi; ret )来调整。

4.3 “Shellwego”赛题中的特定链构造

在“Shellwego”这道题中,经过逆向发现,程序本身静态链接了Go运行时和网络库,但没有链接libc。这意味着我们无法使用libc中的 system 函数。同时,寻找完整的 pop rdi; ret 序列可能比较困难。

但是,我们发现了另一个有利条件:程序在初始化时,会将一个全局变量设置为某个libc函数地址(比如用于信号处理的)。虽然不直接使用libc,但这个地址的泄露足以让我们计算出libc的基地址。更重要的是,我们在程序的代码段中找到了一个极其有用的片段:

mov rdx, qword [rsp+0x18]
mov rsi, qword [rsp+0x10]
mov edi, dword [rsp+0x8]
call rax
...
ret

这个片段的功能是:从栈上 [rsp+0x8] [rsp+0x10] [rsp+0x18] 的位置分别读取三个参数到 edi rsi rdx ,然后调用 rax 寄存器指向的函数。这简直是一个为调用 execve 量身定做的“三参数函数调用器”gadget!我们将其命名为 gadget_call_rax

那么利用链就简化为:

  1. 将字符串“/bin/sh“的地址写入 .bss 段。
  2. execve 的函数地址(来自libc)放入 rax 。我们需要一个 pop rax; ret 的gadget。
  3. 在栈上布置好三个参数: rdi (字符串地址)、 rsi (0)、 rdx (0)。注意这里 edi 是32位,但地址通常在高位,不过如果地址在低32位范围内(即小于4GB),也是可行的。否则需要寻找其他gadget。
  4. 将栈指针调整到合适的位置,然后跳转到 gadget_call_rax

如何写内存呢?我们发现程序中存在一个类似 memcpy 的函数,其参数同样通过栈传递。我们可以通过ROP链调用这个 memcpy ,将“/bin/sh“字符串从我们的payload中复制到 .bss 段。这就需要我们控制栈来布置 memcpy 的三个参数(目标地址、源地址、长度)和返回地址。

最终的payload布局变得像“俄罗斯套娃”:第一层ROP链调用 memcpy 写字符串;执行完后返回到第二层ROP链,设置 rax 和三个参数;最后跳转到 gadget_call_rax

5. 动态调试与利用脚本编写

理论分析得再完美,也需要动态调试来验证和调整。

5.1 使用GDB/Pwndbg调试Go程序

pwndbg 附加到运行中的“Shellwego”进程:

gdb -p $(pidof shellwego)

或者在启动时调试:

gdb ./shellwego
run

Go程序在收到信号(如段错误)时,默认行为是打印堆栈跟踪并退出,这不利于我们观察崩溃现场。可以在gdb中设置 handle SIGSEGV nostop noprint pass ,让gdb接管SIGSEGV信号,这样程序崩溃时会停在非法指令处,方便我们查看寄存器和内存。

关键断点设置:

  • 在漏洞函数(如 main.handleConnection )的读数据指令处下断点,观察输入数据如何被存入栈中。
  • 在函数返回指令( ret )处下断点,单步执行,观察 rip 是否被成功覆盖为我们预设的地址。
  • 在ROP链中的第一个gadget地址处下断点,确认控制流成功跳转。

调试时,多用 pwndbg stack 命令查看栈内容,用 context 命令查看当前上下文。特别注意栈指针 rsp 的变化,确保它始终指向我们期望的、可控的数据区域。

5.2 编写稳健的Exploit脚本

利用脚本(exp)的编写要追求稳健和自动化。使用 pwntools 库是标准做法。

#!/usr/bin/env python3
from pwn import *

context.binary = ‘./shellwego‘
context.log_level = ‘debug‘ # 调试时开启,可以看到发送接收的详细数据
context.terminal = [‘tmux‘, ‘splitw‘, ‘-h‘] # 方便分屏调试

def leak_address():
    # 构造触发信息泄露的payload
    # 例如,发送特定数据使程序打印出一个指针
    # p.recvuntil(b‘some prompt:‘)
    # p.send(b‘A‘ * offset_to_leak + b‘%p‘) # 假设有格式化字符串漏洞
    # leaked = int(p.recvline().strip(), 16)
    # return leaked
    pass

def exploit():
    if args.REMOTE:
        p = remote(‘靶机IP‘, 端口)
    else:
        p = process(‘./shellwego‘)
        # 如果本地调试,可以在这里attach gdb
        # gdb.attach(p, gdbscript=‘‘‘
        # break *0x555555555555
        # continue
        # ‘‘‘)

    # 1. 信息泄露阶段
    code_base = leak_address() - 0x123456 # 减去泄露点的固定偏移
    log.success(f“Code base: {hex(code_base)}“)

    # 计算所有gadget和地址的运行时地址
    gadget_pop_rax = code_base + 0xaaaaa
    gadget_mov_rdx_from_stack = code_base + 0xbbbbb # 那个三参数调用器
    bss_addr = code_base + 0xca000
    memcpy_plt = code_base + 0xddddd

    # 2. 构造payload
    offset = 136 # 之前计算出的到返回地址的偏移

    # 第一段:调用memcpy,将‘/bin/sh‘写入.bss段
    # 我们需要在栈上布置memcpy的参数:rdi=dest(bss), rsi=src(我们payload中的字符串地址), rdx=len(8)
    # 由于参数通过栈传,我们需要布置栈帧。
    # 假设我们控制返回地址后,rsp指向的位置是我们可以连续布置数据的地方。
    payload = b‘A‘ * offset
    payload += p64(gadget_pop_rax)   # 返回地址:跳转到pop rax; ret
    payload += p64(memcpy_plt)       # pop rax会将这个值弹入rax,现在rax=memcpy地址
    payload += p64(gadget_mov_rdx_from_stack) # 接下来执行这个gadget,它会从栈上读参数并call rax
    # gadget_mov_rdx_from_stack期望的栈布局:[ret_addr] [edi] [rsi] [rdx] ... 它从[rsp+8]开始读
    # 所以我们在它之后布置参数
    payload += p64(0xdeadbeef)       # 这个地址是call rax之后的返回地址,我们先随便填
    payload += p64(bss_addr & 0xffffffff) # edi (低32位目标地址)
    payload += p64(0)                # 填充,因为edi是4字节,但栈是8字节对齐
    payload += p64(???如何提供字符串地址???) # rsi (源地址,这是一个难题!)
    payload += p64(8)                # rdx (长度)

    # 这里遇到一个难题:字符串“/bin/sh“的地址在哪里?
    # 它就在我们的payload里!但我们需要知道它在内存中的运行时地址。
    # 这通常需要另一个信息泄露,或者利用栈地址相对固定的特性进行爆破。
    # 在“Shellwego”中,我们发现栈地址可以通过之前的信息泄露获得,或者利用程序本身的某些输出。

    # 更常见的做法是分两步:
    # 第一步ROP链:将字符串写入已知地址(如.bss)。
    # 第二步ROP链:设置参数并调用execve。
    # 我们需要一个“栈翻转”gadget(如add rsp, XXX; ret)来连接这两步。

    p.sendline(payload)
    p.interactive()

if __name__ == ‘__main__‘:
    exploit()

编写exp是一个不断调试、修改、再调试的过程。经常遇到的问题包括:栈对齐问题(64位Linux要求栈在 call 指令前16字节对齐)、地址包含空字节导致输入截断、以及最重要的——栈地址预测不准。

5.3 常见问题与避坑指南

  1. 栈对齐问题 :如果跳转到某些libc函数(如 system )时发生崩溃,很可能是栈没有对齐到16字节。解决方案是在ROP链中插入一个只包含 ret 指令的gadget(通常有很多)。这个 ret 不改变栈内容,但会让 rsp 减8,从而调整对齐。
  2. 地址中的空字节 :如果目标地址是 0x7fxxxxxx 这样的形式,其内存表示中可能包含 0x00 字节(空字节)。这在使用 strcpy 之类的函数时会导致截断。解决方法是寻找不包含空字节的替代地址,或者使用 read 等函数逐字节写入。
  3. onegadget的尝试 :在libc中,存在一些称为“onegadget”的特殊地址,跳转到那里可以直接执行 execve(“/bin/sh“, NULL, NULL) ,前提是满足某些寄存器约束(如 rsp+0x30 为NULL, rsp+0x70 为NULL等)。在拥有libc地址的情况下,可以尝试这些onegadget,比构造复杂的ROP链更稳定。用 one_gadget 工具可以查找。
  4. Go runtime的干扰 :Go程序有复杂的垃圾回收和调度机制。在长时间运行的ROP链中,可能会触发goroutine调度或内存操作,导致意外崩溃。因此,利用链应尽可能短小精悍,尽快完成 execve 调用。
  5. 远程与本地环境差异 :本地调试成功的exp,打到远程服务器可能失败。原因包括libc版本不同(导致偏移不同)、网络延迟导致数据粘包、或者服务器有连接超时限制。exp中应加入健壮的错误处理和重试逻辑,并确保所有地址偏移都是从远程泄露动态计算的,而不是写死的。

通过“Shellwego”这道题,我们深入走完了Go语言PWN的完整流程:从环境准备、逆向分析、漏洞定位,到理解Go特有的ABI挑战、设计绕过PIE和NX的ROP链,最后通过动态调试完成利用。这个过程比传统C程序PWN更曲折,但也更有挑战性和学习价值。它强迫你去理解程序更深层的运行机制,而不仅仅是套用模板。掌握这套方法后,你再面对其他Go语言的二进制安全题目,甚至是分析一些真实的Go语言服务端程序时,都会更有底气和思路。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值