安全的本质永远绕不开“对抗”这个主题,基于对日常攻防对抗的思考和作为研究员不断探索的天性。
蒋浩天先生为我们带来了《基于Qemu/kvm硬件加速下一代安全对抗平台》,虚拟化等技术带给传统攻防全新的思考维度,让我们看到了站在上帝模式下的降维打击。随着后续行为监控和武器库的完善,相信此工具可以帮广大安全人员解决安全对抗的疑难杂症。
下面就让我们来回顾2021看雪第五届安全开发者峰会上《基于Qemu/kvm硬件加速的下一代安全对抗平台》的精彩内容。
蒋浩天:字节跳动无恒实验室云安全专家,曾任职于360、腾讯,现就职于字节跳动无恒实验室。
拥有6年一线安全对抗经验。擅长内核安全开发、虚拟化安全、沙箱检测,是某沙箱的核心作者之一。擅长逆向分析、二进制漏洞攻防、游戏安全对抗、嵌入式,bios开发。
以下为速记全文:
大家好,接下来由我给大家分享《基于Qemu/kvm硬件加速下一代安全对抗平台》。分享者:蒋浩天,来自字节跳动无恒实验室。我的研究方向为:二进制安全、漏洞攻防、游戏安全、沙箱检测、虚拟化安全,喜欢研究一些底层方向。将安全能力下沉,开启安全对抗的上帝模式。
我们的议题分为大概几个部分,分别是:
安全对抗思考
虚拟化技术
Ark工具设计思路
调试器设计思路
平台介绍(KVM-Based security platform)
后续展望
01安全对抗思考
首先,先来说一下:安全对抗思考。我们在安全对抗中会经常遇到那些棘手的问题呢?
由于很多黑产模块商业化。导致对抗异常激烈,商业化模块具备各种对抗功能,当恶意样本,黑产工具集成了商业化模块后,会具备很强的对抗能力,例如说:
1、反调试,公开插件已经无法绕过。
2、反沙箱,反ark工具,一旦检测到沙箱环境,行为监控工具,ark工具等常用的安全工具,样本停止工作。
3、机器封禁,当它检测到你在分析它,将会进行机器封禁,导致此机器无法运行此样本。
4、样本很紧急,老板让你今晚出报告。
目前来说,业界有哪些优秀的解决方案?
第一个解决方案,也是目前比较流行的解决方案,就是模拟执行,例如unicorn。模拟执行具备一定的优势,例如它可以模拟执行一段shellcode且是轻量级的。也有一些劣势,例如需要模拟一个可以执行的环境,这个环境想要模拟非常全面,工程量也是很大的。它的致命缺点是效率比较低,且环境模拟不全。无法模拟D3D,一些依赖于D3D的软件无法正常工作运行。例如说:游戏无法运行,游戏外挂就不能正常工作,你就无法分析它。
第二个比较主流的解决方案就是硬件调试器,此方案优点比较多:效率高,支持d3d。劣势是:第一,需要额外购买硬件并且价格比较高;第二,携带不方便,不方便给小伙伴展示高端调试技巧;第三,对于用户态环境识别不是非常好。
这两种解决方案为什么比较好用?
当前的这些问题,其实我综合分析一下,站在我的角度来看:windbg依赖于windows的调试子系统,gdb依赖unix的ptrace,所以就会存在很多可检测特征。
我理解来说:硬件调试器和模拟执行这两类工具抽象一点来看,更像一个站在CPU内部的工具,可以精准的控制每一行指令的执行,监控或修改指令执行结果。它不依赖于系统的调试机制,所以很多反调试方法对其无效。
通俗一点说:站在了一个至高点,实现了降维打击。
我们继续思考:模拟执行这项技术最开始用于虚拟机,来虚拟一个操作系统,虚拟机本身具备模拟执行的能力。
Unicorn和虚拟机有什么关联?经过查阅资料,unicorn的代码大部分是移植的QEMU中的代码。由于虚拟机模拟执行操作系统时,性能非常低,所以模拟执行逐渐被抛弃。硬件虚拟化成为了主流。启用硬件虚拟化的虚拟机,不再需要模拟执行了,效率得到了非常大的提升。
虚拟机一般都具备一个virtualJTAG接口,这个接口在模拟执行和硬件虚拟化两种模式中,实现原理不同。VirtualJTAG接口不能区分进程上下文,不能调试用户态程序,而且不能识别windows内核。
这时突发奇想,我们是否可以基于虚拟化来开发一套类似的工具?
是否可以对虚拟机进行二次开发,开发一个支持windows系统,支持用户态和内核态的调试器呢?并且将各种安全工具,ark工具,调试,都集成到一起,形成平台化。
这样,我们也是站在了一个制高点,形成一个降维打击。以后做安全对抗就非常方便了。我们的目标是:将安全工具运行在物理机,对虚拟机进行控制。在开发之前,就可以想到一些优略势,首先说优势:1、不依赖于操作系统机制。例如调试机制。2、不需要在虚拟机中安装模块,安全工具无法被检测。3、直接在物理机对虚拟机进行环境扫描,权限更高且不会被检测。4、可以监控虚拟机中敏感内存的读写执行。
但同时也会存在一些劣势:1、数据解析工作量大,适配繁琐。2、不能调用 api。一些常规操作变得异常复杂,例如锁,同步。
02虚拟化技术
接下来介绍一下虚拟化技术,先来介绍几个名词:对应的翻译在后面都列出来了,大家可以先看一下,因为后续讲解过程中可能会用到。
由于模拟执行,性能非常低,随着技术的发展,硬件虚拟化成为了主流。所以我们需要看一下硬件虚拟化的原理:
如图我们调用vmxon指令后,cpu进入虚拟化模式。分别为guest模式和host模式。通过VMExit和VMEntry事件来进行驱动。
例如说执行VMLAUNCH指令后,此时就会有一个VMEntry事件,虚拟机从host模式,进入到guest模式,此时虚拟机就开始执行了。
当虚拟机运行过程中,遇到了一些特殊事件比方说执行了in out 指令,就会产生一个VMExit 事件,此时CPU将会从guest模式切换到host模式。
在host模式下,对in out指令进行虚拟化处理。处理完成之后,在返回到guest,让虚拟机继续执行。
接下来需要介绍qemu kvm的架构图:
首先最底层,是硬件层,例如说CPU,GPU, 内存,磁盘,网卡等硬件设备。再硬件层之上,是linux内核层,例如说vmlinux,设备驱动,kvm等,其中kvm就处于这一层。再上一层,就是linux用户层,在这一层,运行了很多linux程序,服务,守护进程等相关的东西。Qemu就处于这一层,qemu以一个进程的形式存在。
Qemu基于kvm开启硬件虚拟化。所以说图中的两个qemu虚拟机都运行在kvm之上。在qemu虚拟机内部,运行着虚拟机的操作系统,分为虚拟机的内核层,和虚拟机的用户层。
根据上面的架构图得知,虚拟机是运行在qemu进程的上下文的。那么qemu进程,是否包含虚拟机的内存数据呢?
我们在创建虚拟机时,需要设置虚拟机内存的大小。虚拟机在启动时,首先要为虚拟机申请物理内存,代码如下。
通过此代码反映出一个问题。虚拟机的内存,其实就是对应这个mmap的内存,在qemu进程的上下文中。大家可以从代码中看到,这个mmap函数的调用。
如上图可以看到,虚拟机启动后,用户态会存在一个qemu的进程。我们的问题是:如何对这块内存进行识别,解析出虚拟机中操作系统的数据?想要完成这项工作,我们必须要完全了解虚拟机中是如何实现内存的虚拟化的。
我们先看一下虚拟化模式和非虚拟化模式的内存地址翻译流程区别:
对比发现,多了一层地址翻译,需要把虚拟机中的物理地址,再次进行翻译,翻译成物理机的物理地址。也就是GPA 翻译成HPA。
我们首先需要简单回顾一下X86架构下内存翻译流程:
逻辑地址到线性地址的翻译,我们在保护模式下,地址访问都是段寄存器加逻辑地址来进行访问的。
例如说,TI位为0。我们需要在gdt中寻址。根据段寄存器的index字段,来访问gdt表。找到对应的项,例如说,cs段寄存器的index 为2,对应GDT的第3项,获取到对应的段描述符之后,P位为1表示有效。
判断段描述符的DPL和段寄存器的RPL是否满足一系列的权限检查。如果检查全部通过。将得到段对应的base。大家可以看到,这些段的base都是0。拿到段的base之后,用base 加上逻辑地址偏移,既可以计算出对应的线性地址。
接下来我们说一下从线性地址到物理地址的转换,线性地址到物理地址的转换需要通过页表来完成。由这张图,我们可以找到每一级索引的位置:
先说一下4K页面的转换,CR3寄存器是页表寄存器。存放了页表的首地址,首先需要对线性地址进行拆分,把每一级的索引都划分出来,偏移也要划分出来。最高级索引,索引CR3指向的目录,拿到对应的项之后,这里面存放的也是一个物理地址。然后用第二级索引进行索引,以此类推,直到索引完成,最后拿索引到的物理地址,加上offset,即可完成线性地址到物理地址的转换。
接下来看一下2M页面的转换,他跟4K页面的转换基本原理一致,只是说索引级别少了。Offset变长了。
再看一下从GPA到HPA的翻译过程,先将GPA 进行拆分,划分好每一级的索引,和offset。根据eptp指向的页表,进行一级一级的索引,跟前面的页表翻译原理类似。
接着看一下虚拟机的内存虚拟化实现的整体架构,如图:
1、在虚拟机中,GVA先通过页表,翻译成GPA。
2、通过EPT,将GPA翻译成HPA.
Host模式下:1、在宿主机中,HVA通过宿主机的页表翻译成HPA。2、Qemu通过memory slot机制,管理这个GPA 到HPA的映射。
如图,黄色的方块,就代表虚拟机的一块内存,这块内存在guest中,访问对应的GVA可以访问到。同时,在host中,访问对应的HVA也可以访问到,因为都是对应的同一块物理内存。
前面我们介绍了X86架构下的地址翻译过程,我们还需要了解一下qemu是如何管理内存的。
由于qemu在启动时,mmap了一块内存,这块内存用于虚拟机的物理内存。由于内存的惰性分配,和效率优化,虚拟机需要对他进行管理。
Qemu有两种内存管理方式,第一种是树形管理,具备一个更好的管理视图。第二种是平坦类型管理,用于和kvm交互。
我们先说一下树形管理:Qemu的内存,最顶端是一个address space 结构体,不同类型的内存由不同的address space来表示。所有的address space通过链表,连接在一起。
在address space结构体内部,有一个root字段,指向一个memory region结构体,这个字段是内存树的根节点。
Memoryregion下面会有子节点,有一些子节点的ram_block是为空的,他表示一个别名的引用。其中alias offset 就是指向引用的memory region对应的这个虚拟机物理内存的偏移。有一些memory region的ram_block是有值的,如果不为空,里面会有一个host字段,这里指向的是虚拟机物理内存对应的hva。
虽然说qemu对虚拟机的内存有自己的管理体系。但是想要实现内存虚拟化,必须按照cpu的规则来进行ept映射。才能够生效。所以qemu还有一套平坦内存管理视图,用于和kvm进行交互。实现基于硬件的内存虚拟化。
如图所示,每一个memory region经过generatememory topology函数调用后,都会生成一个Flatview结构体,这个结构体会插入到一个flat_views的一个hashmap中,方便下次快速查找。
这个FlatView内部,存在一个ranges字段,这个字段是一个指针,指向Flat range的数组。每一个flat range 结构体管理着一块虚拟机的物理内存,标志着kvm中EPT如何对虚拟机物理内存进行虚拟化。
例如说flat range里有一个addr字段,Addr是一个addrrange 类型,里面包含一个start,和size。这里面的start存放的是一个gpa。Qemu通过调用kvm的接口来完成ept映射。kvm会根据调用参数,和上述的X86架构的规则来完成ept的映射。建立GPA和HPA的映射关系。从而实现了内存的虚拟化。到目前位置,qemukvm的内存虚拟化,我们大概介绍完成。
03ark工具设计
有了前面的知识体系,我们想实现ark的功能,已经有了一些希望。我们就有了初步的想法。虚拟机中的物理内存是qemu启动中mmap出来的一块内存。所以我们读写这块内存,其实就是在读写虚拟机的物理内存。
所以第一步,我们要先完成HVP ßà GPA转换。我们可以加载一个内核模块,从而获取到EPT的页表。拿到页表后,我们就可以将GPA翻译成HPA。然后再从HPA转换成HVA。
但是这样有一个缺点,想要从HPA 翻译成GPA 就比较麻烦了,需要遍历ept的表,效率非常低。
KVM为了完成这个GPA到HVA的互相翻译,实现了一个remap机制。我们通过解析kvm的remap 可以完成这个步骤,也可以通过qemu的两种内存管理模型来完成这项工作。
我们完成了上述功能后,我们就具备了读取任意GPA的能力了。但是现在的操作系统都是分页的,就跟前面所讲述的一样,通过页表进行了映射。
所以说我们具备了GPA的任意地址读写能力还是不够的。我们还需要能够读取任意地址GVA才行。我们要想具备GVA的读写能力,我们就需要一个页表。有了页表,我们才能够自己翻译内存,找到GVA和GPA的对应关系。
现在的操作系统,每一个进程的用户态内存都是隔离的。内核态的内存是相同的。每一个进程都有一个独立的的页表,存放在进程控制块中。所以说我们要想能够读取任意进程的任意GVA,我们就需要先拿到所有进程的页表。但是我们想要拿到所有进程的页表,我们就需要拿到所有进程控制块信息。
由于进程控制块是存放在内核地址空间的,进程的内核态地址空间都是相同的,所以说,我们只要拿到任意一个进程的cr3,即可通过解析页表,来实现一个读写内核GVA的能力。有了这个能力,我们才能进行下一步操作。
问题来了,我们如何获得虚拟机中任意进程的cr3寄存器?
我们如何获取一个cr3寄存器的值呢?我们这里尝试了几种方法:
1、通过vmread指令,读取vmcs中的guest cr3。
2、通过对vmexit handler进行hook。
3、Kvm运行时,会把cr3 保存在某个位置。
如图,只要设置kvm_valid_regs的值, kvm在运行过程中就会自动把所有寄存器保存下来。经过调试发现,默认情况下,这个字段为0。所以我们需要自己将他置为1,之后我们就可以去读到cr3寄存器了。
有了cr3之后,我们自己写一套页表翻译的代码,将GVA转换成GPA。我们就可以具备读写内核地址空间GVA能力了。我们需要适配各个模式,例如32位模式,PAE模式,64位模式,4k页面模式,2M页面,1G页面等多种情况。
现在,我们只具备了读写内核地址空间任意GVA,但是我们还不能读写任意进程用户态的任意内存。
如前面所述,每一个进程都拥有一个自己的页表。而这个页表存放在进程控制块中。而进程控制块在内核地址空间中。
所以接下来我们应该找到所有的进程控制块。进程控制块由操作系统内核进行管理,例如说windows平台下,在ntoskrnl模块中,我们可以通过解析PspCidTable, PsActiveProcessHead等来识别出所有的进程控制块。
但是我们目前无法知道这些数据结构所在的地址。所以我们当务之急,我们需要找到ntoskrnl模块的首地址在哪里?
如何找到它,其实方法很多,可以通过IDT中的中断向量的handler来进行定位,或者msr寄存器等等诸多方法。
例如说:我们首先在物理机读取到虚拟机的IDT表。获取异常向量的handler。我们知道,异常向量的handler,肯定是位于ntoskrnl模块中。
第一种方法采用暴力搜索,向上搜做mz文件头,肯定能搜到。还是不够优雅。
第二种方法采用符号解析,我们通过解析内核符号,来获取handler在模块中的位置,Ntoskrnl首地址 =handler 地址 – 偏移;这样我们就能够获取到内核模块首地址了。
我们既然要用到符号信息,那么我们就得具备解析符号的能力。我们的代码运行在linux平台,我们如何解决符号解析的问题?
对于linux虚拟机,我们解析elf文件的debug info信息,这里面是一个dwarf结构,开源,第三方库比较多。所以很好解决。
但是windows pdb符号如何解析呢。我们知道在windows平台下解析pdb,调用com接口即可,非常简单。但是我们运行在linux平台上,是无法调用windows提供的api的。
微软最近几年公开了pdb相关的格式和代码。但是非常复杂,如果我们自己去解析,将耗费非常多的时间。于是我们想到了其他方法:
1、硬编码?放弃,维护很困难。
2、Llvmpdbutil,解析成功,但是对于win10 的符号信息解析崩溃,不知道现在llvm有没有修复这个问题。
3、Wine?我们最终采用了wine的方式,开发一个windows平台的符号解析程序,然后运行在wine环境下,来完成pdb解析。
这样,我们就解决了符号解析的问题。
我们现在具备了符号解析的能力,知道内核模块的首地址,并且具备对内核地址空间任意地址读写的能力。我们就可以基于这些能力实现一个ark的工具,例如说:
解析PspCidTable,PsActiveProcessHead读取所有的进程信息;解析PsLoadedModuleList读取所有的内核模块信息等等,方法非常多,可以实现的功能也非常多,而且老前辈写过很多文章,这里就不一一讲解了。
当我们解析出所有的进程控制块的时候,我们就可以拿到进程对应的页表。通过解析进程对应的页表,我们就具备读写任意进程任意空间GVA的能力了。
此时有一个潜在的问题,由于内存分页的机制问题,会把不常用的内存交换到磁盘上。
这种情况我们就无法读取这块内存了,这个问题我们后续进行讲解。
经过我们不懈努力,我们已经具备一个ark初级的功能,例如:可以读取到虚拟机中的所有进程;每一个进程中,加载的模块信息;可以读取到虚拟机中所有的内核模块等等。这些都是调试器不可缺少的能力。
04调试器设计
接下来我们介绍一下调试器的设计。
传统的虚拟机,在未开启硬件虚拟化加速时,采用的模拟执行。实现一个调试接口比较简单。采用了硬件虚拟化后,虚拟机不再需要模拟执行,具备了更高的性能。
虚拟机本身提供了一个virtual JTAG调试接口。只是在模拟执行和硬件虚拟化两种模式下原理不同。并且不支持windows虚拟机,不能区分用户态和内核态。
如果我们对virtual JTAG调试接口进行改造,二次开发。我们是否可以能实现windows 内核态,用户态调试呢?
我们首先需要深入分析一下virtual JTAG的实现原理。我们以软件断点为例,会调用kvmarch insert sw breakpoint 函数来插入一个断点,我们看这个代码实现,我们发现,就是保存了原来的位置的字节码,然后替换成了cc。
把内存改写成一个0xcc,就能实现断点功能?看似平平无奇,其实在kvm_update_guest_debug中存在着奥秘。
我们首先得介绍一下intel硬件虚拟化中的一个特性,exception bitmat特性。
他是一个32位的字段,每一位对应一个异常。当对应的位为1时,虚拟机中,产生对应的异常,就会产生vmexit事件。从而host可以进行捕获,并进行虚拟化。
kvm_update_guest_debug 函数,看名字就可以猜到,会和kvm进行交互。我们分析到最后,到最关键的位置,发现:最终通过一个vmcs_write32函数,更新了vmcs的ExceptionBitmap,使得vcpu对guest中int3 异常具备拦截能力。
虚拟机中执行代码,如果遇到int3指令,会产生vmexit,进入host模式,首先由宿主机接管,宿主机将int3指令封装成一个事件,投递给qemu中内置的gdbserver来处理。
接着我们整体梳理一下virtualJTAG流程:
1、Vcpu遇到int 3,会产生异常,如果exception bitmap中第四位为1,则产生vmexit事件,并切换到host模式。
2、执行kvm中注册的vmexit handler。
3、Handler将此次的这个vmexit的事件封装成一个结构体,投递给qemu。4、Qemu把此事件交给内置的gdb server。
5、Gdb server 会和gdb client进行交互。
6、Gdb server 收到请求后,返回到vmexit handler中。
7、Vmexit handler中调用vmresume指令,产生vmentry事件,虚拟机恢复执行。
这个virtual jtag方案大概是这个原理。
我们通过分析发现,virtualJTAG之所以不能调试用户态内存,不支持windows,根本原因在于这个gdbserver不能识别windows 的结构体,
无法解析虚拟机中的所有进程,所有模块等等系统信息。
所以我们要做的事情,就是帮它来完成这个功能。于是我们就有了实现一个基于virtualJTAG的调试接口的思路的。
我们通过我们自己的ark,来识别所有的进程,进程中所有的模块。我们的ark,具备对虚拟机任意进程,任意内存的读写能力,我们对想调试的位置,把内存改写成int3。通过ExceptionBitmap 的能力,拦截int3异常。后续的处理流程交给qemu内置的gdbserver。
我们对内置的gdb server进行了修改,来完成了这个功能。有个问题是:是否可以采用硬件断点?其实采用硬件断点也是可以的,我们需要在kvm中对dr寄存器操作进行拦截,由于精力有限,所以并未实现。
其实不止如此,还有非常多的细节问题,例如说:
我们采用软件断点的方法,最终还是改写了内存,容易被检测到,怎么办?
用户态的内存,可能会被交换到磁盘,如何解决?
对于系统动态库,copy_on_write 如何解决?
Cpu 缓存问题?等等
有感兴趣的可以私下交流。我们需要把重点放在第一个问题上。用调试器下断点时还是把内存改写成了int 3,如何才能不被检测到?
我们要做的一件事情就是隐藏int3断点。保证不被检测。
通过ept特性,对虚拟机的内存进行hook,来欺骗虚拟机,使其无法感知到内存被修改。
如图所示,左边这部分时正常状态下的内存翻译流程,和权限,例如说,GPAàHPA的翻译,此页面具备读写执行三个权限。
右边这部分时我们hook之后的状态。我们对原始内存页面进行复制,将权限进行拆分,分为读写权限对应原始内存,和执行权限对应复制的内存,对复制的内存改写int3。
当guest 进程读写这块内存的时,读取到的是原始页面。当guest 进程执行这块内存的时候,将翻译成另一个已修改的另一个页面,执行的是修改成int 3的内存页面。
通过此方法,我们欺骗进程,让其无法检测到我们修改了内存。
我们通过eptMTF 等特性,进行了一些其他功能的扩展,例如说指令trace,内存读写监控。
05平台介绍(KVM-Based security platform)
通过上述介绍,我们的安全对抗平台,大概原理想必大家都已经理解。
现在我们介绍一下我们安全对抗平台的整体架构:
1、首先我们具备一个linux内核模块,用于patchkvm,读写kvm中的关键数据,hookkvm 的关键函数。
2、我们需要对qemu进行二次编译,将我们自己实现的调试相关的代码移植到qemu去。使其具备调试能力。
3、Manager模块,分为几个子模块。虚拟机管理功能;符号解析功能,支持windows,支持linux;鼠标键盘模拟功能,虚拟机屏幕图片查找功能;Hypervisorsdk,用于和内核模块,qemu调试模块交互。
5、Python引擎,用于将目前的大部分功能通过python接口的形式导出,降低开发门槛。方便安全人员参与开发
6、还开发了一个简易的ui。
Python插件,来实现一些强力的功能,例如antirootkit,武器库,调试器等功能。
这是我们目前导出的部分python接口。例如说,获取所有进程,获取所有内核模块。获取符号信息。读写GPA,读写GVA,EPT hook, 内存读写执行监控等。
开发者,知道内核模块地址,进程控制块信息,然后通过解析符号的方法,可以轻松的开发其他的功能。
例如上面代码,大家可以通过非常简单的几行代码,就可以获取到虚拟机中的所有进程,所有内核模块等信息。
只需要在python中,importhypervisor_engine,即可。
接下来是视频演示:通过视频可以观察,我们的工具在运行过程中,不会出现虚拟机卡顿情况,而且不需要在虚拟机安装任何程序。
如果采用传统方法,对working set 进行对抗,比较麻烦。通过我们平台提供的能力,几行代码就可以搞定。
如图,非常简单,我们直接定位到内核的PsWatchEnabled,直接进行修改,关闭working set的信息收集。
调试能力展示,大家可以看到,我们在linux宿主机,直接调试虚拟机中的进程。
右边是被调试程序的ida截图,大家可以看一下汇编指令,和地址。左边是在物理机通过gdb,调试虚拟机中的这个demo程序。可以观察,汇编指令,和地址都是对应的。
调试能力展示,大家可以看到,我们在linux宿主机,直接调试虚拟机中的进程。
06后续展望
1、武器库完善,后续希望把 ark 中的常用功能全部移植,实现一个完整的 ark。
2、通过 hook 虚拟机内核,完善行为监控工具,将沙箱检测能力放在host中。
3、后续尝试支持 arm 架构,通过 arm 服务器,运行安卓系统,完成对安卓系统的支持。
4、希望此工具可以帮广大安全人员解决安全对抗的疑难杂症
最后是这次议题相关的一些参考文献。那么今天我分享的议题就到这里,谢谢大家!
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2021-10-27 19:22
被Editor编辑
,原因: