工作一年杂谈

去年毕业到现在刚好工作一年了(离我第一篇博客刚好也一年时间了),聊一些之后学习的方向吧

语言(Swift)

之前有研究过Swift语言类型的内存布局以及函数派发方式

指针

语言是程序员接触最多的一块了。由于现代语言(如Swift,Java, Kotlin等)编译器优化带来的便利性,让程序猿忽略了很多操作系统层面的东西。

其中最明显的就是指针
指针是一个点,是计算机内存区域中的某一个点,平常我们说的寻址寻址,就是指针的值表示的虚拟内存地址,虚拟内存又和实际的物理内存(硬件)有一个映射关系。说到虚拟内存又能说到堆栈,代码区。。。说到代码区又能扯到可执行文件,说到可执行文件,和编译器和语言又有关系。

这也是为什么我们大学学的第一门计算机课程C语言的总要性。因为指针,我们能更好的理解操作系统。

这里我简单的举一个Objective-C和Swift的例子来看下不同吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
var b: Int8?
}

// C++的写法
A* a = (A *)malloc(sizeof(A));
a->b = 2;

// Objective-C的写法
A* a = [A alloc];
a.b = 2;

// Swift的写法
let a = A()
a.b = 2;

(上面demo并不保证过编译)
可以看到从C++到OC到Swift,操作系统的相关的(内存)操作越来越少了。

  1. 先看看C++的写法吧。很明显的可以看到申请了一段内存,这段内存的大小是类型A的大小,得到了一个无类型的指针(指向的就是刚刚申请的堆内存的某个地址),然后将这个指针强转成A类型的指针(其实这也属于编译的优化了,计算机并不知道指针是什么类型,他只是一个地址而已)。
    至于属性的赋值,则是给a指针的指向的内存地址加上属性b在类型A的偏移量的这一段内存进行赋值,而这段内存的大小就是属性b类型的大小

  2. 再来看看Objective-C的语法吧。 [A alloc]函数向堆区申请了一块内存空间,并返回指向这块内存的指针, 并且这个指针还是A类型的指针。可以发现申请内存的大小,转换指针类型这些操作都被alloc函数给隐藏了。
    至于属性的赋值,有以下几个步骤,向a的Setter B发消息(objc_msgSend runtime),执行setter方法,setter方法中对属性赋值的操作可以参照上面C++的赋值操作。而这些被隐藏的操作都是编译器帮我们实现了
    当然上面是OC属性的例子,还可以通过设置成员变量赋值,这样就可以不通过objc _msgSend

  3. 最后来瞅瞅Swift的语法吧。Swift是一门更现代的语言,为我们隐藏了基本上所有的指针操作的细节。当然得益于编译器的强大。A()这个会被编译器翻译成A.allocating _init()->A (这个是Swift中间语言SIL的处理), 在 allocating _init函数中会调用swift _allocObject来申请堆的内存,并且返回A类型(类A的描述(objc),一个struct)的指针。

    至于属性的赋值,可以参考上面Objective-C的操作,但是少了一步 objc _ msgsend的操作, 而是直接调用A.b.setter方法

    A.b 枚举类型 两个字节(关联对象枚举类型),一个是关联对象的值,一个是枚举的值
    在setter方法中,会给关联对象的内存值从0赋值成2,然后再将枚举内存值从1赋值成0

    为了证明我不是瞎扯蛋的,我简单分析下上面Swift代码的执行过程(x86平台)


    从0x100001b34 这行指令开始看起,调用了__allocating _init()方法,返回给rax寄存器一个指向实例A的地址(在堆区),然后将这个地址的第一个word(8个字节)赋值给rcx寄存器(0x100001b47),而这8个字节就是实例A的类对象(描述着A类的信息,在数据段区)的内存地址,接着将类对象地址偏移0x60的地址赋值给rcx寄存器(0x100001b47),而这个地址的值是一个指针,指向的是函数属性b的setter方法(这里就是Swift vtable函数派发方式了)。参数2最后被赋值给edi寄存器(0x100001b4e->0x100001b56->0x100001b5b)。

    这里再复习下王爽的《汇编语言》的CPU读取下一行指令方式(非跳转指令)。pc(cs:ip)寄存器指向着CPU下一行指令的地址,而每行指令也是占用代码段的内存空间的,有个占2个字节,有的占4个字节。下一行指令的地址=当前这行指令+当前这行指令的字节数大小

可以看到随着语言的进步,编译器的优化,我们写代码需要考虑的东西少了,但是运行在操作系统上的操作还是不变的。

庆幸的是Swift保留了指针的操作,并且Swift标注库也用到的很多指针操作

编译(swift-llvm)

现代语言编译器非常强大,除了给我们的代码生成机器码之外,还给我们的代码插入了很多额外有效的代码,如

引用计数

类和VTable

最近刚开始看LLVM,感觉还没入门。所以该文这块借助下语言和平台二进制聊聊经过编译后,我们的写的代码变成了啥。
我们平常写Swift(或者其他语言),我个人简单的分为两大块: 类型和函数

类型(包括类,结构体,Swift的协议。。。)的目的是内存分配的描述,计算机可没那么聪明,它可不知道不同的类型该怎么分配内存,都是编译器根据语言的类型生成分配多少内存的指令,获取取多少偏移的内存。类型中属性的set和get只需要通过类型实例指针的偏移操作即可 (当然对于Swift的类来说,除了内存分配描述作用,还有存放着实例方法的地址等信息,并且Swift类实例化成的对象是在堆中的。所以Swift类的信息是回被编译到二进制中的,这也是我们常说的类对象。具体可以看看下面可执行文件的Data区的_ objc_ classlist(这也是著名的逆向工具class-dump的思路来源))

函数。就不言而喻了

这里也随便举个小荔枝,虾扯下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class A {
var b: Int = 4

func test(c: Int) {
print(self.b+c)
}
}

let a = A()

// OC调用
[a test: 4];

// Swift调用
a.test(c: 4)

我们知道OC的[a test]会被编译器翻译成objc_msgSend方法, 第一个参数是实例a, 第二个参数是SEL(选择器,这是一个编译符号(类似self),在这里生成的就是“test:”字符串), 然后就是runtime那一套了,网上很多前辈大佬有很详细的见解了

然后看看Swift的a.test()调用,这个函数会被翻译成Swift中间语言SIL的形式

1
(A) -> (Int) -> Void

可以看到a.test(c: 4)函数被转化成一个柯里化函数,第一个参数就是实例对象a指针,第二个参数就是4. test函数内部的self就是实例对象a指针. 至于取self.a的值,就是我上面说到的根据a指针的偏移量得到内存值.

至于类方法则会被翻译成下面的形式

1
(A.Type) -> (Int) -> Void

函数派发方式文章中我说过类中的实例方法在代码段的地址会被编译到类信息(类对象)中的vtable中,其实对于上面的类方法也是一样,地址也会被编译到类对象的vtable中. 根据这些信息,可以试着替换一下实例方法。demo如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public func swift_getVtableMethod(_ objc_class: AnyClass, methodIndex offset: Int) -> UnsafeMutablePointer<UnsafeMutableRawPointer>? {
let classPointer = Unmanaged.passUnretained(objc_class as AnyObject).toOpaque()

// 属性数量
guard let outCount = malloc(MemoryLayout<UInt32>.size)?.assumingMemoryBound(to: UInt32.self) else { return nil }
defer {
free(outCount)
}
let _ = class_copyIvarList(objc_class, outCount)

// 0x50: other Info; 8: getter method pointer; 8: setter method pointer; 16: type max size
let firstMethodPointer = classPointer.advanced(by: 0x50 + (8+8+16)*Int(outCount.pointee))
return firstMethodPointer.advanced(by: offset*MemoryLayout<UnsafeMutableRawPointer>.size).assumingMemoryBound(to: UnsafeMutableRawPointer.self)
}

这段代码的目的就是获取到类信息(类对象)在内存的地址,然后通过类对象的内存布局原则,计算出函数偏移值,类对象起始地址+偏移值 得到一个新的地址,这个地址的前面8个字节的值是一个指针,指向的就是函数在代码段的地址。所以这段代码返回的是指针类型的指针(即指向指针的指针)

可以用这段代码实验下看看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class A {
// 如果某个属性只写getter方法,那么得到的指针就得向前偏移8个字节了(少了一个setter函数指针的大小)
var b: Int8?
var c: UInt16?
var d = "Tanner Jin"

// vtable index: 0
init() {
}

// vtable index: 1 (so this why class method can override)
class func test() {
print("a_classMethod_test")
}

// not compile to vtable
static func test1() {

}

// vtable index: 2
func test2() {
print("a_instance_test2")
}
}

extension A {
// not compile to vtable, so this why extension method can't override
func test3() {
print("a_instance_test3")
}
}

class B {
// vtable index: 0
func b_method() {
print("b_method")
}
}

if let b_method = swift_getVtableMethod(B.self, methodIndex: 0),
// 可以将1替换成2再看看效果
let a_class_test = swift_getVtableMethod(A.self, methodIndex: 1) {

swap(&b_method.pointee, &a_class_test.pointee)
B().b_method()
}

我们知道Swift或者OC类的继承设计是一个单向链表结构,每个类对象的第8个字节到第16个字节的值(指针)指向的是他的父类,这也是函数或者属性能被继承的原因,因为这些信息都在类结构(类对象)中。顺着链表就能找到super的函数或者属性的信息了

下面的第一张图是swift-llvm Mach-O部分的源码图,第二张是objc的源码图。可以猜测,编译期,根据llvm的class64_t结构将类的描述(信息)写入到二进制,运行时再根据objc(runtime)的objc _class结构将类信息从二进制加载到内存中,那么我们接下来就来看看可执行文件二进制吧


可执行文件(Mach-O, Dyld)

一个进程运行的几乎所有信息都包括在可执行的二进制文件中,并且二进制的信息和虚拟内存息息相关(因为在dyld加载过程中,会将磁盘二进制的数据映射到虚拟内存中,当然虚拟内存只是逻辑上的地址,之后内核还会将虚拟内存空间映射到物理内存空间)
其实这部分以及有很多优秀的书籍和博客了,并且我也是通过AloneMonkey的《iOS应用逆向与安全》和飞虫的Mach-O文件格式以及dyld源码才对这方面了解一些

下面就是某个二进制的Mach-O图, 其中的 1 表示文件偏移(即从二进制的第0个字节开始到这里的偏移字节数),2就是具体的二进制数据
还有就是二进制可以分为几个大段(segment), 每个段下面可以区(section)和其他段信息
我个人目前对段的设计理解是,为了虚拟内存的权限,即同一段的数据拥有着相同的权限。区则为了是具体的功能划分

一个二进制可能包含两个架构的数据,这里我只聊聊64位的,可以和下面操作系统的的虚拟内存对应着看
其实可以只看Load Commands下面的信息,这里是dyld加载器所需要的信息,而Load Commands后面则是信息对应的具体数据。dyld通过这些信息将具体的数据通过mmap函数映射到虚拟内存, 这是因为这些信息包括这具体数据的文件偏移以及加载到虚拟内存的地址(没算上vm _slide),以及数据的大小,刚好填充了mmap函数的参数

如下图是 __Text(text section)的加载信息

Section Name: 表示section(区)信息
Segment Name: 表示segment(段)信息,也就是代表TEXT段下面的 text区信息,这是代码区的信息。 这样dyld加载器就知道怎么处理这个描述了
Offset: 表示这个代码区的具体数据在二进制中的文件偏移(具体位置)
Size: 则是代码区的数据大小
Address: 表示这代码区的数据要加载到虚拟内存中的位置

1
(* mmap)(void* addr, size_t len, int prot, int flags, int fd, off_t offset)

dyld加载器通过上面这个函数可以将二进制文件的数据映射到虚拟内存

addr: Address + vm_ slide(虚拟内存随机偏移,在运行时产生)
len: Size
prot: 这个信息在__ TEXT(段)中,表示这块内存的权限(可读可写,可执行,具体可以参考下面的虚拟内存)
fd: 文件描述符,通过open该二进制文件路径函数获得
offset: Offset

__PAGEZERO

虚拟内存的权限: VM_ PROT_NONE(不允许访问)
这个段为虚拟内存提供空指针信息, 包括对应虚拟内存的起始地址以及大小

__TEXT

虚拟内存的权限: VM_ PROT_ READ | VM_ PROT_ EXECUTE(可读可执行)

存放这代码相关的数据,包括我们写的代码( text),寻找符号( stub_ helper)的代码

__DATA

虚拟内存的权限: VM_ PROT_ READ | VM_ PROT_ WRITE(可读可写)

存放着class对象(_ objc_ classlist, protolist等),以及符号指向的数据(懒加载和非懒加载指针)

__LINKEDIT

虚拟内存的权限: VM_ PROT_ READ (可读)
这个段里大部分都是链接库和符号表的信息,根据这些信息可以加载依赖的动态库和符号绑定(fishhook原理)

LC_ LOAD _ DYLIB: 依赖动态库信息(主要是路径和版本信息)

LC_ DYLD_ INFO_ ONLY (Dynamic Loader Info数据信息)

这里包括了4部分信息内容

  1. Rebase Info: 将DATA段的指针数据重新绑定(上面我们知道,内存地址数据的映射会偏移vm_ slide,所有需要设置偏移)
  2. Binding Info: DATA段的Non-Lazy Symbol Pointer区数据绑定依赖的信息(即符号的信息)
  3. Lazy Binding Info: DATA段的Lazy Symbol Pointer区数据绑定依赖的信息
  4. Export Info: 这个是动态库暴露出来符号的信息(如Swift中pulic或者open关键字),包括函数,类对象等

LC_ SYMTAB (Symbol Table和String Table数据信息)

LC_ DYSYMTAB (Dynamic Symbol Table数据信息)

操作系统(iOS/MacOS)

进程

由于涉及到虚拟内存的创建,就扯下和虚拟内存有关的

首先一个进程的创建是由父进程fork出来的,也就是说进程是从父进程复制出来的,这个复制包括了资源的复制。文件具体可以看下xun内核源码的kern_fork.c文件的fork系列函数

可以看看进程的结构(在proc_internal.h文件中的proc 结构体),部分截图如下。属性太多,一个屏幕放不下…


当然还有很多其他属性,如task(corresponding task),vm_shm(sysV shared memory)等等…

简单看看其中几个属性

  1. p_ pid(进程唯一标识,不多说了吧)
  2. p_ stat(进程状态), 可以看看下面几个进程状态的关系图

虚拟内存

这块是我们打交道最多的一块地方了。无论是可执行文件加载后的各个段,区的映射,还是运行过程中的堆栈的利用,我们无时无刻不在和内存打交道

  1. shared vm
  2. vm_region(部分连续的虚拟内存空间,每个进程有多个)
  3. vm_protection(虚拟内存权限)

空指针区(PAGEZERO)

0x0 - 0x100000000 (64位)

代码相关区(Text)

数据相关区(Data)

链接库相关区(LINKEDIT)

堆区(Heap)

栈区(Statck)

每个线程都有自己的栈空间,在线程中执行某个方法时,生成的局部变量,都会创建在栈中(编译器会将alloca操作隐藏),在通过pop指令再将变量数据出栈

栈还有两个非常重要的寄存器,rbp和rsp寄存器
rbp用于

内核区(Kernel)