Chenyu's Blog

Something valued really in my life


  • Home

  • About

  • Archives

  • Sponsorship

简易内核实现笔记(一)

Posted on 2020-09-06 | In Computer Science
Words count in article: 7.5k | Reading time ≈ 30

简易内核实现笔记(一) ——开启操作系统前的准备

BIOS

在计算机电源打开的一瞬间,x86架构的CPU处于实模式下,所谓的实模式就是8086CPU运行的模式,x86家族的CPU为了做到向下兼容,全部默认开机时运行在8086的模式下,在实模式中,所有的地址都是物理地址,寄存器大小都是16位,寻址采用20位地址线,由段地址左移4位+偏移地址实现。

在实模式背景下,第一行代码的位置是0xf000:0xfff0,也就是0xffff0,这一行代码的指令是jmp f000:e05b,这个跳转的地址就是BIOS的第一行代码地址,随后BIOS就会进行硬件自检,在没有问题后就会执行最后一行代码jmp 0x7c00跳转到主引导程序MBR处。

MBR

MBR占512字节,正好是一个硬盘扇区的大小,在这512字节的程序中,MBR的任务就是把加载器载入内存中执行:

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
;主引导程序 
;------------------------------------------------------------
%include "boot.inc"
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

; 清屏
;利用0x06号功能,上卷全部行,则可清屏。
; -----------------------------------------------------------
;INT 0x10 功能号:0x06 功能描述:上卷窗口
;------------------------------------------------------
;输入:
;AH 功能号= 0x06
;AL = 上卷的行数(如果为0,表示全部)
;BH = 上卷行属性
;(CL,CH) = 窗口左上角的(X,Y)位置
;(DL,DH) = 窗口右下角的(X,Y)位置
;无返回值:
mov ax, 0600h
mov bx, 0700h
mov cx, 0 ; 左上角: (0, 0)
mov dx, 184fh ; 右下角: (80,25),
; 因为VGA文本模式中,一行只能容纳80个字符,共25行。
; 下标从0开始,所以0x18=24,0x4f=79
int 10h ; int 10h

; 输出字符串:MBR
mov byte [gs:0x00],'1'
mov byte [gs:0x01],0xA4

mov byte [gs:0x02],' '
mov byte [gs:0x03],0xA4

mov byte [gs:0x04],'M'
mov byte [gs:0x05],0xA4 ;A表示绿色背景闪烁,4表示前景色为红色

mov byte [gs:0x06],'B'
mov byte [gs:0x07],0xA4

mov byte [gs:0x08],'R'
mov byte [gs:0x09],0xA4

mov eax,LOADER_START_SECTOR ; 起始扇区lba地址
mov bx,LOADER_BASE_ADDR ; 写入的地址
mov cx,4 ; 待读入的扇区数
call rd_disk_m_16 ; 以下读取程序的起始部分(一个扇区)

jmp LOADER_BASE_ADDR + 0x300

;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_16:
;-------------------------------------------------------------------------------
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ;备份eax
mov di,cx ;备份cx
;读写硬盘:
;第1步:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数

mov eax,esi ;恢复ax

;第2步:将LBA地址存入0x1f3 ~ 0x1f6

;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al

;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f ;lba第24~27位
or al,0xe0 ; 设置7~4位为1110,表示lba模式
mov dx,0x1f6
out dx,al

;第3步:向0x1f7端口写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al

;第4步:检测硬盘状态
.not_ready:
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。

;第5步:从0x1f0端口读数据
mov ax, di
mov dx, 256
mul dx
mov cx, ax ; di为要读取的扇区数,一个扇区有512字节,每次读入一个字,
; 共需di*512/2次,所以di*256
mov dx, 0x1f0
.go_on_read:
in ax,dx
mov [bx],ax
add bx,2
loop .go_on_read
ret

times 510-($-$$) db 0
db 0x55,0xaa

可以看到MBR的代码分为两部分,第一个部分就是在窗口打印”1 MBR”这几个字符,这是通过向段起始0xb800处的内存写入字符实现的。在实模式下,这个地址就是显存的位置。第二部分就是写入loader,也就是函数rd_disk_m_16,在这个函数里,cx寄存器储存的是要读的磁盘扇区个数。相关的宏定义如下:

宏LOADER_START_SECTOR就是0x2,表示我们要向磁盘第三个扇区(第一个是0x0)读loader,LOADER_BASE_ADDR就是loader被写入的地址0x900。

在加载完loader之后,MBR的使命就结束了,最后一条命令jmpLOADER_BASE_ADDR+0x300就是跳转到loader的第一条命令去执行loader。

Loader

我们的loader就负责做四个事情:

  • 加载全局描述符表
  • 进入保护模式
  • 创建页表,展开虚拟地址空间
  • 加载操作系统内核

保护模式

所谓的保护模式就是可以寻址32位(4GB)的模式,而’保护’二字指的就是在这个模式下CPU为程序执行提供了一些内存的保护措施,这个措施就是通过全局描述符表来实现的。为了开启保护模式,我们要做3件事:

  • 加载全局描述符表
  • 打开A20 Gate
  • 修改控制寄存器CR0第一位为1

全局描述符表

全局描述符表就是一个表,存储着段描述符,所谓的描述符就是关于内存段的一些信息,CPU会根据这些信息做出相应的措施,所谓的全局就是指这个表不是局部的。一个描述符占了64位8字节,每位的意义如下:

descriptor
descriptor

由于一些向下兼容的原因,一些东西是不连续的,但不妨碍我们理解:

  • 段界限:一个段的最大大小是20位,如果索引超过段界限CPU会触发异常。
  • G:段界限的粒度,如果G为0就代表粒度是1位,对应到段界限就是20位1MB。G为1就代表粒度为4KB,对应到段界限就是4GB,因此实际的段界限大小等=粒度大小*段界限-1
  • 段基址:顾名思义,不用说了
  • D/B:一个用来兼容80286保护模式的位,表示有效地址和操作数的位数。D为0表示16位,D为1表示32位(所以对我们不用80286的就没什么用)
  • L:为1表示64位代码段,0表示32位
  • AVL:available字段,这个available是对于用户来说的,不是硬件,所以是可以随便用的
  • P:用于指示段是否存在于内存中,用到这个段时如果它不存在,就会触发CPU的异常,然后跳转到异常处理程序中把它加载到内存中。
  • DPL:表示特权级,特权级一共4级,从高到低为0,1,2,3。
  • S:为1表示系统段,0表示非系统段
  • type:段的类型,这三位对于系统段和非系统段有不同的定义:
descriptor-type
descriptor-type

A20 Gate

实模式能够寻址的空间是1MB 20位,要进入保护模式的32位寻址,就要去除20位寻址的限制,这个限制被称为A20 Gate,打开A20 Gate的方法就是将端口0x92的第一个位置写为1:

1
2
3
in al,0x92
or al,0000_0010B
out 0x92,al

而进入保护模式的方法就是将控制寄存器CR0的第0位写为1:

1
2
3
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

因此,进入保护模式的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
;-----------------   准备进入保护模式   -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

;----------------- 打开A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 加载GDT ----------------
lgdt [gdt_ptr]

;----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt

虚拟地址空间

在进入保护模式之后,我们所访问的32位地址仍然是物理地址,虚拟地址为我们提供了一层抽象,使得每个进程都可以在32位地址空间中运行,我们只需要通过页表将它们映射到物理地址即可,这样写程序就不用再自己去管地址从哪里开始了。

页表

页表是虚拟地址与物理地址的映射关系,由于将来每个操作系统下的进程,包括操作系统自己都是在32位虚拟地址空间中运行的,因此每个进程都需要有自己的页表,我们将物理地址分页,每个页占有4kB的大小,一个页表项就占32位4字节,检索4GB的虚拟内存空间总共需要1M个页表,在内存中占4MB,这个大小显然是无法接受的,因此我们再创建一个页表的页表,也就是页目录表,一个页目录项也是32位4字节,因此一个页目录项也可以索引4kB的空间,那么检索4GB的虚拟地址空间只需要4GB/4kB/4kB=1024个页目录,只需要4096个字节就可以了,这样的开销就可以接受。

对于1024个页目录,我们需要10位地址来进行索引,这10位地址就是虚拟地址中的高10位,我们将这10位地址4就是对应页表的偏移地址,再加上页目录表的起始地址就得到了对应页表所在的物理地址,一个页表中有1024个页,因此检索它也需要10位地址,这10位地址就是虚拟地址中的中间10位,我们用这中间10位地址4就得到了所在页的偏移地址,加上前面得到的页表物理地址就得到了对应页所在物理地址,这个页中存储的就是真实物理地址的偏移量,再加上最低12位虚拟地址就得到了对应的真实物理地址了。

因为每个页表项都是4字节,因此它们的值里面低12位全是0,因此为了避免浪费就要往里面加一些关于页表的安全信息:

page
page

其中:

  • P:该页存在于物理地址中
  • R/W:读写权限,0表示只读,1表示可读可写
  • US:普通用户/超级用户位,为1表示在普通用户级,普通用户在特权级3
  • PWT:通写位,1表示处于通写模式,表示改该页是高速缓存
  • PCD:打开使用高速缓存
  • A:访问位,如果CPU访问过该页,就会把它置为1,之后的操作系统我们会将它置为0,通过count置为1的次数就能判断它是否常常被使用,是就将这个页存入缓存中
  • D:脏页位,CPU对一个页进行写操作时,就会把这个位置为1,仅对页表项有效
  • G:global位,若为global,那么这个页表就会一直在高速缓存TLB中保存
  • AVL:软件的可用为,CPU不会管,怎么用就是软件定义的了

对页表的初始化我们要有一个约定,也就是4GB的虚拟地址空间中,高1GB是只有操作系统内核才能访问的区域,因此在初始化页表时我们要将内核区的页表和普通页表分开,并且为了减小开销在未来将所有进程的内核页表通用,所以完整的loader代码如下:

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
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
   %include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4

DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4

VIDEO_DESC: dd 0x80000007 ; limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4 ; 此时dpl为0

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的空位(slot)
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0 ; 同上

; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_buf times 244 db 0
ards_nr dw 0 ;用于记录ards结构体数量

loader_start:

;------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------

xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;ards结构缓冲区
.e820_mem_get_loop: ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
mov ecx, 20 ;ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ;若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ;记录ARDS数量
cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

;------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx
mov ecx, 0x10000 ;0x10000十进制为64KB
mul ecx ;32位乘法,默认的被乘数是eax,积为64位,高32位存入edx,低32位存入eax.
add esi,eax ;由于此方法只能测出4G以内的内存,故32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi ;edx为总内存大小
jmp .mem_get_ok

;----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
;int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax,0x0000FFFF

;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
mov [total_mem_bytes], edx ;将内存换为byte单位后存入total_mem_bytes处。


;----------------- 准备进入保护模式 -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

;----------------- 打开A20 ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;----------------- 加载GDT ----------------
lgdt [gdt_ptr]

;----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt

[bits 32]
p_mode_start:
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp,LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax

; ------------------------- 加载kernel ----------------------
mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ; 从磁盘读出后,写入到ebx指定的地址
mov ecx, 200 ; 读入的扇区数

call rd_disk_m_32

; 创建页目录及页表并初始化页内存位图
call setup_page

;要将描述符表地址及偏移量写入内存gdt_ptr,一会用新地址重新加载
sgdt [gdt_ptr] ; 存储到原来gdt所有的位置

;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000 ;视频段是第3个段描述符,每个描述符是8字节,故0x18。
;段描述符的高4字节的最高位是段基址的31~24位

;将gdt的基址加上0xc0000000使其成为内核所在的高地址
add dword [gdt_ptr + 2], 0xc0000000

add esp, 0xc0000000 ; 将栈指针同样映射到内核地址

; 把页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

; 打开cr0的pg位(第31位)
mov eax, cr0
or eax, 0x80000000
mov cr0, eax

;在开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr] ; 重新加载

;;;;;;;;;;;;;;;;;;;;;;;;;;;; 此时不刷新流水线也没问题 ;;;;;;;;;;;;;;;;;;;;;;;;
;由于一直处在32位下,原则上不需要强制刷新,经过实际测试没有以下这两句也没问题.
;但以防万一,还是加上啦,免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel ;强制刷新流水线,更新gdt
enter_kernel:
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT ; 用地址0x1500访问测试,结果ok


;----------------- 将kernel.bin中的segment拷贝到编译的地址 -----------
kernel_init:
xor eax, eax
xor ebx, ebx ;ebx记录程序头表地址
xor ecx, ecx ;cx记录程序头表中的program header数量
xor edx, edx ;dx 记录program header尺寸,即e_phentsize

mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件42字节处的属性是e_phentsize,表示program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件开始部分28字节的地方是e_phoff,表示第1 个program header在文件中的偏移量
; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 若p_type等于 PT_NULL,说明此program header未使用。
je .PTNULL

;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size)
push dword [ebx + 16] ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
mov eax, [ebx + 4] ; 距程序头偏移量为4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ; 压入函数memcpy的第二个参数:源地址
push dword [ebx + 8] ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
call mem_cpy ; 调用mem_cpy完成段复制
add esp,12 ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header
loop .each_segment
ret

;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
;输入:栈中三个参数(dst,src,size)
;输出:无
;---------------------------------------------------------
mem_cpy:
cld
push ebp
mov ebp, esp
push ecx ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; size
rep movsb ; 逐字节拷贝

;恢复环境
pop ecx
pop ebp
ret


;------------- 创建页目录及页表 ---------------
setup_page:
;先把页目录占用的空间逐字节清0
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

;开始创建页目录项(PDE)
.create_pde: ; 创建Page Directory Entry
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 此时eax为第一个页表的位置及属性
mov ebx, eax ; 此处为ebx赋值,是为.create_pte做准备,ebx为基址。

; 下面将页目录项0和0xc00都存为第一个页表的地址,
; 一个页表可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(3)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的0xc0000000~0xffffffff共计1G属于内核,0x0~0xbfffffff共计3G属于用户进程.
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的地址

;下面创建页表项(PTE)
mov ecx, 256 ; 1M低端内存 / 每页大小4k = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; 属性为7,US=1,RW=1,P=1
.create_pte: ; 创建Page Table Entry
mov [ebx+esi*4],edx ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址
add edx,4096
inc esi
loop .create_pte

;创建内核其它页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; 此时eax为第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P ; 页目录项的属性RW和P位为1,US为0
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 范围为第769~1022的所有目录项数量
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret


;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_32:
;-------------------------------------------------------------------------------
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax ; 备份eax
mov di,cx ; 备份扇区数到di
;读写硬盘:
;第1步:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al ;读取的扇区数

mov eax,esi ;恢复ax

;第2步:将LBA地址存入0x1f3 ~ 0x1f6

;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3
out dx,al

;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f ;lba第24~27位
or al,0xe0 ; 设置7~4位为1110,表示lba模式
mov dx,0x1f6
out dx,al

;第3步:向0x1f7端口写入读命令,0x20
mov dx,0x1f7
mov al,0x20
out dx,al

;;;;;;; 至此,硬盘控制器便从指定的lba地址(eax)处,读出连续的cx个扇区,下面检查硬盘状态,不忙就能把这cx个扇区的数据读出来

;第4步:检测硬盘状态
.not_ready: ;测试0x1f7端口(status寄存器)的的BSY位
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88 ;第4位为1表示硬盘控制器已准备好数据传输,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready ;若未准备好,继续等。

;第5步:从0x1f0端口读数据
mov ax, di ;以下从硬盘端口读数据用insw指令更快捷,不过尽可能多的演示命令使用,
;在此先用这种方法,在后面内容会用到insw和outsw等

mov dx, 256 ;di为要读取的扇区数,一个扇区有512字节,每次读入一个字,共需di*512/2次,所以di*256
mul dx
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax,dx
mov [ebx], ax
add ebx, 2
; 由于在实模式下偏移地址为16位,所以用bx只会访问到0~FFFFh的偏移。
; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
; 故程序出会错,不知道会跑到哪里去。
; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16位,处于32位模式时,
; 也会认为要执行的指令是32位.
; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数,
; 32位模式下用32字节的操作数)时,编译器会在指令前帮我们加上0x66或0x67,
; 临时改变当前cpu模式到另外的模式下.
; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.

loop .go_on_read
ret

注意,在内核页表中,我们仍置US位为U,是因为内核的加载程序是运行在用户特权下的。由于我们目前只需要1MB的物理内存,每个页能映射4kB,因此只需要创建256个普通页表项,普通页表目录也只需要一个,并且这1MB的内存中,虚拟地址就等于物理地址。另外,第一个内核页表目录也指向256个普通页表项,因为我们需要让内核在这1MB的物理内存下被加载运行,之后的那1GB内核虚拟内存的页表和页表目录创建时就把P位置为0,表示他们不存在于内存中。

在加载完页表之后,我们就可以把控制寄存器CR0的第31位置为1,表示让CPU开启虚拟寻址模式,然后重新将全局描述符表加载到内核区域,再将内核加载到内核区的内存中就可以运行操作系统内核了。

载入内核程序

在载入内核之前,首先我们要了解ELF文件格式,ELF的E和L就是executable and linkable的缩写,一个ELF文件在链接或者执行视图中可以分段(segment)或者分节(section):

elf
elf

elf的header是一个数据结构,用来记录这个ELF文件的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 32位elf头 */
struct Elf32_Ehdr
{
unsigned char e_ident[16];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
};

其中,e_indent[16]功能如下:

e16-indent
e16-indent

e_type占2字节,表示elf目标文件类型,一共有下面几种:

elf目标文件类型 取值 意义
ET_NONE 0 未知目标文件格式
ET_REL 1 可重定位文件
ET_EXEC 2 可执行文件
ET_DYN 3 动态共享目标文件
ET_CORE 4 core文件,即程序崩溃时其内存映像的转储格式
ET_LOPROC 0xff00 特定处理器文件的扩展下边界
ET_HIPROC 0xffff 特定处理器文件的扩展上边界

其余的字段意义如下:

字段 大小(字节) 意义
e_machine 2 支持的硬件平台
e_version 4 表示版本信息
e_entry 4 操作系统运行该程序时,将控制权转交到的虚拟地址
e_phoff 4 程序头表在文件内的字节偏移量。如果没有程序头表,该值为0
e_shoff 4 节头表在文件内的字节偏移量。若没有节头表,该值为0
e_flags 4 与处理器相关的标志
e_ehsize 2 指明 elf header 的字节大小
e_phentsize 2 指明程序头表(program header table )中每个条目(entry)的字节大小
e_phnum 2 指明程序头表中条目的数量。实际上就是段的个数
e_shentsize 2 节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小
e_shnum 2 指明节头表中条目的数量。实际上就是节的个数
e_shstrndx 2 指明 string name table 在节头表中的索引 index

在加载程序中,我们需要做的就是将内核按照编译好的虚拟地址将各个段复制到对应的位置,然后jump到内核的运行入口(链接时可用指定)去开启内核,然后loader的生命历程就结束了。

附录

虚拟机bochs的安装与配置

这里使用2.6.2版本,下载地址: https://sourceforge.net/projects/bochs/files/bochs/2.6.2/

下载源代码文件之后解压进入目录,然后配置:

1
2
3
4
5
6
7
8
./configure \
--prefix=/*你的安装目录*/ \
--enable-debugger \
--enable-disasm \
--enable-iodebug \
--enable-x86-debugger \
--with-x \
--with-x11

然后make,如果报错说

1
2
Makefile:179: recipe for target 'bochs' failed
make: *** [bochs] Error 1

就再makefile里找到LIBS =,尾部加上-lpthread,注意这里不要再configure,否则makefile会被覆盖,再make,make install就可以了

进入bochs的目录,然后配置文件bochsrc.disk:

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
# Configuration file for Bochs
# 设置Bochs在运行过程中能够使用的内存: 32 MB
megs: 32

# 设置真实机器的BIOS和VGA BIOS
# 修改成你们对应的地址

romimage: file=*bochs的目录*/share/bochs/BIOS-bochs-latest
vgaromimage: file=*bochs的目录*/bochs-2.6.2/share/bochs/VGABIOS-lgpl-latest

# 设置Bochs所使用的磁盘
# 设置启动盘符
boot: disk

# 设置日志文件的输出
log: bochs.out

# 开启或关闭某些功能,修改成你们对应的地址
mouse: enabled=0
keyboard:keymap=*bochs的目录*/share/bochs/keymaps/x11-pc-us.map

# 硬盘设置
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14

# 增加gdb支持,这里添加会报错,暂时不需要
# gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0

然后运行bin/bximage,创建一个60M的虚拟硬盘,遇到选项全部回车,然后问size的时候填个60,然后把让你复制的这一行:ata0-master: type=disk, path="hd60M.img", mode=flat, cylinders=121, heads=16, spt=63复制到配置文件中,记得path改成绝对路径,bochs不认识相对路径

之后运行bochs就完事了

glibc malloc 源码分析

Posted on 2020-08-14 | In Computer Science
Words count in article: 5k | Reading time ≈ 23

glibc malloc 源码分析

linux给了我们两种类型的系统调用来申请动态内存,分别是brk()和mmap(),malloc()仅仅是在这二者之上做了一些其他的事情而已,这里从源代码来剖析一下glibc malloc都做了什么。源代码是glibc v2.32版本。

chunk

‘chunk’指的就是malloc分配内存的最小单元,我们来看下它的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*
This struct declaration is misleading (but accurate and necessary).
It declares a "view" into memory allowing access to necessary
fields at known offsets from a given base. See explanation below.
*/

struct malloc_chunk {

INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */

struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;

/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
};

首先INTERNAL_SIZE_T其实就是无符号整数size_t:x86-64 linux下,32位操作系统为4字节32位,64位操作系统为64位8字节,用下面的这个宏定义:

1
2
3
#ifndef INTERNAL_SIZE_T
# define INTERNAL_SIZE_T size_t
#endif

这里面的4根指针注释上都强调了只有在free了后才使用,因此使用中的chunk是长这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    chunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of previous chunk, if unallocated (P clear) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of chunk, in bytes |A|M|P|
mem-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| User data starts here... .
. .
. (malloc_usable_size() bytes) .
. |
nextchunk-> +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| (size of chunk, but used for application data) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Size of next chunk, in bytes |A|0|1|
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

这里注意,mchunk_size最后面的3个bit是用来表示这个chunk的一些信息的,意义如下:

  • A表示NON_MAIN_ARENA,记录当前 chunk 是否不属于主线程,1 表示不属于,0 表示属于。
  • M表示IS_MAPPED,记录当前chunk是否由mmap分配的,1表示是,0表示不是。
  • P表示PREV_INUSE,记录物理上相邻的前一个chunk是否正在使用,1表示正在使用,0表示没有。

接下来就是一些chunk的宏函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef MALLOC_ALIGNMENT
#define MALLOC_ALIGNMENT (2 * SIZE_SZ)
#endif

/* conversion from malloc headers to user pointers, and back */

#define chunk2mem(p) ((void*)((char*)(p) + 2*SIZE_SZ))
#define mem2chunk(mem) ((mchunkptr)((char*)(mem) - 2*SIZE_SZ))

//#define offsetof(s,m) ((size_t)&(((s*)0)->m))
/* The smallest possible chunk */
#define MIN_CHUNK_SIZE (offsetof(struct malloc_chunk, fd_nextsize))

/* The smallest size we can malloc is an aligned minimal chunk */

#define MINSIZE \
(unsigned long)(((MIN_CHUNK_SIZE+MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK))

其中:

  • chunk2mem(p):偏移2*SIZE_SZ到用户真正使用的数据区

  • MIN_CHUNK_SIZE:chunk的最小size。在CTF wiki的引用里面MIN_CHUNK_SIZE的定义是:

    1
    #define MIN_CHUNK_SIZE (offsetof(struct malloc_chunk, fd_nextsize))

    这里offsetof()函数返回的是一个size_t,大小为结构成员相对于结构开头的偏移量,在64位操作系统下,MIN_CHUNK_SIZE是32,32位操作系统下是16。

  • MINSIZE:申请最小的堆内存大小,展开后和MIN_CHUNK_SIZE一样。(虽然是个无关紧要的细节,但我没看懂为什么要定义相同的MIN_CHUNK_SIZE和MINSIZE)

检查对齐的宏:

1
2
3
4
5
6
7
8
/* Check if m has acceptable alignment */

#define aligned_OK(m) (((unsigned long)(m) & MALLOC_ALIGN_MASK) == 0)

//SIZE_SZ = sizeof(size_t)
#define misaligned_chunk(p) \
((uintptr_t)(MALLOC_ALIGNMENT == 2 * SIZE_SZ ? (p) : chunk2mem (p)) \
& MALLOC_ALIGN_MASK)

这里可以看出,申请内存大小必须是2*SIZE_SZ的整数倍,否则也会给你对齐到整数倍。

然后是把malloc请求的size转换为对应chunk的size宏和对request size做检查的宏:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* pad request bytes into a usable size -- internal version */

#define request2size(req) \
(((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? \
MINSIZE : \
((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)

/* Check if REQ overflows when padded and aligned and if the resulting value
is less than PTRDIFF_T. Returns TRUE and the requested size or MINSIZE in
case the value is less than MINSIZE on SZ or false if any of the previous
check fail. */
static inline bool
checked_request2size (size_t req, size_t *sz) __nonnull (1)
{
if (__glibc_unlikely (req > PTRDIFF_MAX))
return false;
*sz = request2size (req);
return true;
}

接下来是对chunk做一些操作的宏,从命名和定义就可以看出具体用途:

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
/* size field is or'ed with PREV_INUSE when previous adjacent chunk in use */
#define PREV_INUSE 0x1
/* extract inuse bit of previous chunk */
#define prev_inuse(p) ((p)->mchunk_size & PREV_INUSE)
/* size field is or'ed with IS_MMAPPED if the chunk was obtained with mmap() */
#define IS_MMAPPED 0x2
/* check for mmap()'ed chunk */
#define chunk_is_mmapped(p) ((p)->mchunk_size & IS_MMAPPED)
/* size field is or'ed with NON_MAIN_ARENA if the chunk was obtained
from a non-main arena. This is only set immediately before handing
the chunk to the user, if necessary. */
#define NON_MAIN_ARENA 0x4
/* Check for chunk from main arena. */
#define chunk_main_arena(p) (((p)->mchunk_size & NON_MAIN_ARENA) == 0)
/* Mark a chunk as not being on the main arena. */
#define set_non_main_arena(p) ((p)->mchunk_size |= NON_MAIN_ARENA)
/*
Bits to mask off when extracting size

Note: IS_MMAPPED is intentionally not masked off from size field in
macros for which mmapped chunks should never be seen. This should
cause helpful core dumps to occur if it is tried by accident by
people extending or adapting this malloc.
*/
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
/* Get size, ignoring use bits */
#define chunksize(p) (chunksize_nomask (p) & ~(SIZE_BITS))
/* Like chunksize, but do not mask SIZE_BITS. */
#define chunksize_nomask(p) ((p)->mchunk_size)
/* Ptr to next physical malloc_chunk. */
#define next_chunk(p) ((mchunkptr) (((char *) (p)) + chunksize (p)))
/* Size of the chunk below P. Only valid if !prev_inuse (P). */
#define prev_size(p) ((p)->mchunk_prev_size)
/* Set the size of the chunk below P. Only valid if !prev_inuse (P). */
#define set_prev_size(p, sz) ((p)->mchunk_prev_size = (sz))
/* Ptr to previous physical malloc_chunk. Only valid if !prev_inuse (P). */
#define prev_chunk(p) ((mchunkptr) (((char *) (p)) - prev_size (p)))
/* Treat space at ptr + offset as a chunk */
#define chunk_at_offset(p, s) ((mchunkptr) (((char *) (p)) + (s)))
/* extract p's inuse bit */
#define inuse(p) \
((((mchunkptr) (((char *) (p)) + chunksize (p)))->mchunk_size) & PREV_INUSE)
/* set/clear chunk as being inuse without otherwise disturbing */
#define set_inuse(p) \
((mchunkptr) (((char *) (p)) + chunksize (p)))->mchunk_size |= PREV_INUSE
#define clear_inuse(p) \
((mchunkptr) (((char *) (p)) + chunksize (p)))->mchunk_size &= ~(PREV_INUSE)
/* check/set/clear inuse bits in known places */
#define inuse_bit_at_offset(p, s) \
(((mchunkptr) (((char *) (p)) + (s)))->mchunk_size & PREV_INUSE)
#define set_inuse_bit_at_offset(p, s) \
(((mchunkptr) (((char *) (p)) + (s)))->mchunk_size |= PREV_INUSE)
#define clear_inuse_bit_at_offset(p, s) \
(((mchunkptr) (((char *) (p)) + (s)))->mchunk_size &= ~(PREV_INUSE))
/* Set size at head, without disturbing its use bit */
#define set_head_size(p, s) ((p)->mchunk_size = (((p)->mchunk_size & SIZE_BITS) | (s)))
/* Set size/use field */
#define set_head(p, s) ((p)->mchunk_size = (s))
/* Set size at footer (only when chunk is not in use) */
#define set_foot(p, s) (((mchunkptr) ((char *) (p) + (s)))->mchunk_prev_size = (s))

Bin

前面也说了,chunk是用户通过malloc申请内存的最小单元,glibc malloc不会在一个chunk free了以后马上将内存还给操作系统,而是创建了一些数据结构来接管他们,以节省再次申请时的时间,由于时间空间局部性的存在,这种设计一般来说是能起作用的(当然对于生命周期很长的程序这样的设计可能导致一大堆内存释放不掉)。在内部为了管理这些free chunk,glibc使用了一种叫bins的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define NBINS 128
typedef struct malloc_chunk *mbinptr;

/* addressing -- note that bin_at(0) does not exist */
#define bin_at(m, i) \
(mbinptr) (((char *) &((m)->bins[((i) - 1) * 2])) \
- offsetof (struct malloc_chunk, fd))

/* analog of ++bin */
#define next_bin(b) ((mbinptr) ((char *) (b) + (sizeof (mchunkptr) << 1)))

/* Reminders about list directionality within bins */
#define first(b) ((b)->fd)
#define last(b) ((b)->bk)
/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2];

这里就能看出来,bins是一个chunk指针组成的数组,而前面chunk的数据结构定义中也有指向前一个free chunk和后一个free chunk的指针(如果这个chunk也是free的话),也就是说,bins实际上就是一个管理free chunk的链表数组。在bins中,如果两个free chunk是物理相邻的,两个chunk就会合并以减少内存碎片,相似地,如果在bins里找不到malloc要的size大小的chunk,那么就会从大chunk中分割出一个符合size要求的chunk来,这个步骤后面的代码会看到。

bins又细分为fastbin,smallbin,largebin和unsortedbin,free chunk会根据一些规则被分到这4个组里。

Fast Bin

首先,小size的chunk在free后如果物理相邻就会被合并,但很多程序常常会申请和释放小内存块,要是每次malloc或者free小块都会进行合并和分割就会导致程序变慢,为了照顾着一些很常用的小块内存,fast bins就出现了:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct malloc_chunk *mfastbinptr;
//indexing
#define fastbin(ar_ptr, idx) ((ar_ptr)->fastbinsY[idx])

/* offset 2 to use otherwise unindexable first 2 bins */
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
/* The maximum fastbin request size we support */
#define MAX_FAST_SIZE (80 * SIZE_SZ / 4)

#define NFASTBINS (fastbin_index (request2size (MAX_FAST_SIZE)) + 1)
/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS];

NFASTBINS展开以后是10,而根据fastbin_index的定义(这个定义中的-2显然受到了MIN_CHUNK_SIZE的约束),0和1的index不存在,因此fastbin最多cache 8个chunk,根据这个宏可以推出每个index对应的chunk size大小:

index 32位系统(SIZE_SZ=4) 64位系统(SIZE_SZ=8)
0 不存在 不存在
1 不存在 不存在
2 16 32
3 24 48
4 32 64
5 40 80
6 48 96
7 56 112
8 64 128
9 72 144

注意,在fast bins中的free chunk是LIFO的,使用单向链表实现,fast bins能fast也是基于时间空间局部性。在malloc申请一个chunk时,首先就会在fast bins中查找有没有适合的size,如果没用才会进行后面的操作:

1
2
3
/* Set if the fastbin chunks contain recently inserted free blocks.  */
/* Note this is a bool but not all targets support atomics on booleans. */
int have_fastchunks;

BTW,fast bins中的chunk会被标记为使用中,即链表中chunk的PREV_INUSE都会被设置为1,为了防止被合并。

Small bin

顾名思义,small bins就是包含着小size chunk的bins:

1
2
3
4
5
6
7
8
9
10
11
12
#define NBINS             128
#define NSMALLBINS 64
#define SMALLBIN_WIDTH MALLOC_ALIGNMENT
#define SMALLBIN_CORRECTION (MALLOC_ALIGNMENT > 2 * SIZE_SZ)
#define MIN_LARGE_SIZE ((NSMALLBINS - SMALLBIN_CORRECTION) * SMALLBIN_WIDTH)

#define in_smallbin_range(sz) \
((unsigned long) (sz) < (unsigned long) MIN_LARGE_SIZE)

#define smallbin_index(sz) \
((SMALLBIN_WIDTH == 16 ? (((unsigned) (sz)) >> 4) : (((unsigned) (sz)) >> 3))\
+ SMALLBIN_CORRECTION)

从源码中可以看出,一个chunk是small还是large,是由宏MIN_LARGE_SIZE决定的,这个size在64位操作系统上是1024,在32位系统上是512,小于它的被定义为small chunk,大于等于的是large chunk。

而根据smallbin_index(sz)的indexing规则,32位系统下(SMALLBIN_WIDTH != 16)为sz/8,64位下为sz/16,在已知sz必须和2 * SIZE_SZ(64位操作系统为16,32位操作系统为8)对齐的情况下,那么我们就能反推出small chunk总共的index数量为1024/16=64,正好和NSMALLBINS对上了!

然后我们就可以得到不同small bin的index下,每个size的chunk的一一对应关系,注意MIN_CHUNK_SIZE规定了最小的size,因此index是1和0的情况是不存在的,所以实际情况上,small bins有62个index!

index 32位系统(SIZE_SZ=4) 64位系统(SIZE_SZ=8)
2 16 32
3 24 48
4 32 64
x 24x 28x
63 504 1008

fast bin是和small bin的范围有重合的,实际上,fast bins就是small bins的cache。

Large Bin

搞懂了small bins,large bins就很简单了,前面说过,比MIN_LARGE_SIZE大的chunk都称为large bins,它们是如何indexing的就看源代码怎么定义的了:

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
#define largebin_index_32(sz)                                                \
(((((unsigned long) (sz)) >> 6) <= 38) ? 56 + (((unsigned long) (sz)) >> 6) :\
((((unsigned long) (sz)) >> 9) <= 20) ? 91 + (((unsigned long) (sz)) >> 9) :\
((((unsigned long) (sz)) >> 12) <= 10) ? 110 + (((unsigned long) (sz)) >> 12) :\
((((unsigned long) (sz)) >> 15) <= 4) ? 119 + (((unsigned long) (sz)) >> 15) :\
((((unsigned long) (sz)) >> 18) <= 2) ? 124 + (((unsigned long) (sz)) >> 18) :\
126)

#define largebin_index_32_big(sz) \
(((((unsigned long) (sz)) >> 6) <= 45) ? 49 + (((unsigned long) (sz)) >> 6) :\
((((unsigned long) (sz)) >> 9) <= 20) ? 91 + (((unsigned long) (sz)) >> 9) :\
((((unsigned long) (sz)) >> 12) <= 10) ? 110 + (((unsigned long) (sz)) >> 12) :\
((((unsigned long) (sz)) >> 15) <= 4) ? 119 + (((unsigned long) (sz)) >> 15) :\
((((unsigned long) (sz)) >> 18) <= 2) ? 124 + (((unsigned long) (sz)) >> 18) :\
126)

// XXX It remains to be seen whether it is good to keep the widths of
// XXX the buckets the same or whether it should be scaled by a factor
// XXX of two as well.
#define largebin_index_64(sz) \
(((((unsigned long) (sz)) >> 6) <= 48) ? 48 + (((unsigned long) (sz)) >> 6) :\
((((unsigned long) (sz)) >> 9) <= 20) ? 91 + (((unsigned long) (sz)) >> 9) :\
((((unsigned long) (sz)) >> 12) <= 10) ? 110 + (((unsigned long) (sz)) >> 12) :\
((((unsigned long) (sz)) >> 15) <= 4) ? 119 + (((unsigned long) (sz)) >> 15) :\
((((unsigned long) (sz)) >> 18) <= 2) ? 124 + (((unsigned long) (sz)) >> 18) :\
126)

#define largebin_index(sz) \
(SIZE_SZ == 8 ? largebin_index_64 (sz) \
: MALLOC_ALIGNMENT == 16 ? largebin_index_32_big (sz) \
: largebin_index_32 (sz))

#define bin_index(sz) \
((in_smallbin_range (sz)) ? smallbin_index (sz) : largebin_index (sz))

看一下这个嵌套三元表达式的宏就知道,large bins一共分为了6组,每组的index个数可以从移位算符得出,算一算就可以知道它们一一对应到表里,glibc内存管理ptmalloc源码分析里有完整的数据,这里给出CTF wiki的比较简短的总结:

组号 index个数 公差
1 6 64
2 16 512
3 8 4096
4 4 32768
5 2 262144
6 1 不限制

Unsorted Bin

源码定义如下:

1
2
/* The otherwise unindexable 1-bin is used to hold unsorted chunks. */
#define unsorted_chunks(M) (bin_at (M, 1))

可见unsorted bin定义在了bins的第一个index下,因此unsorted bin只是一个链表。

Top Chunk

glibc对top chunk定义和描述如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
Top

The top-most available chunk (i.e., the one bordering the end of
available memory) is treated specially. It is never included in
any bin, is used only if no other chunk is available, and is
released back to the system if it is very large (see
M_TRIM_THRESHOLD). Because top initially
points to its own bin with initial zero size, thus forcing
extension on the first malloc request, we avoid having any special
code in malloc to check whether it even exists yet. But we still
need to do so when getting memory from system, so we make
initial_top treat the bin as a legal but unusable chunk during the
interval between initialization and the first call to
sysmalloc. (This is somewhat delicate, since it relies on
the 2 preceding words to be zero during this interval as well.)
*/

/* Conveniently, the unsorted bin can be used as dummy top on first call */
#define initial_top(M) (unsorted_chunks (M))

根据注释描述,所谓的top chunk就是位于可用堆内存地址最高位的chunk,它不属于任何bin,只有当没有chunk可用时它才会向系统去申请扩展heap的可用区域。为防止被合并,top chunk的prev_inuse始终为1。初始情况时,unsorted chunks用作top chunk。

现在总结一下,宏NBINS告诉我们bins一共有108个入口,small bins有62个,large bins一共有63个,加起来125个bin,根据bin的indexing宏bin_at的定义,bin[0]和bin[127]是不存在的,因此bin[1]就是top chunk,也是unsorted bin,加起来总共126个。

BTW,bins的定义是:

1
mchunkptr bins[NBINS * 2 - 2];

也就是实际size有NBINS * 2 - 2一共254个mchunkptr,而chunk实例的是6个mchunkptr的大小,这怎么存的下呢?但我们注意到,bins中的chunk是头节点,那么chunk中的mchunk_prev_size和mchunk_size是没有意义的!而fd_nextsize和bk_nextsize只有large chunk才会用到,那么出于节省内存的想法,我们只需要2个mchunkptr,总共需要的就是126*2=254个mchunkptr的大小,正好对上了!

bins
bins

总之,glibc的malloc使用的内存管理方法就是链表数组的内存池,和gnu C++远古版本std allocator是相同的思想,因此在后面版本的gnu C++里面默认allocator都是封装malloc,如果看了侯捷的STL源码剖析,别被书里推崇无比的内存池allocator误导了。

其他核心结构

malloc_state

定义如下:

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
struct malloc_state
{
/* Serialize access. */
__libc_lock_define (, mutex);

/* Flags (formerly in max_fast). */
int flags;

/* Set if the fastbin chunks contain recently inserted free blocks. */
/* Note this is a bool but not all targets support atomics on booleans. */
int have_fastchunks;

/* Fastbins */
mfastbinptr fastbinsY[NFASTBINS];

/* Base of the topmost chunk -- not otherwise kept in a bin */
mchunkptr top;

/* The remainder from the most recent split of a small request */
mchunkptr last_remainder;

/* Normal bins packed as described above */
mchunkptr bins[NBINS * 2 - 2];

/* Bitmap of bins */
unsigned int binmap[BINMAPSIZE];

/* Linked list */
struct malloc_state *next;

/* Linked list for free arenas. Access to this field is serialized
by free_list_lock in arena.c. */
struct malloc_state *next_free;

/* Number of threads attached to this arena. 0 if the arena is on
the free list. Access to this field is serialized by
free_list_lock in arena.c. */
INTERNAL_SIZE_T attached_threads;

/* Memory allocated from the system in this arena. */
INTERNAL_SIZE_T system_mem;
INTERNAL_SIZE_T max_system_mem;
};

这里就可以看到,前面所说的bins是malloc_state的一部分,因此一个malloc_state的实例就是一个分配区域,下面一一说明这些变量:

flags是一些标志位,它的用途从后面的宏定义就可以看出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
NONCONTIGUOUS_BIT indicates that MORECORE does not return contiguous
regions. Otherwise, contiguity is exploited in merging together,
when possible, results from consecutive MORECORE calls.

The initial value comes from MORECORE_CONTIGUOUS, but is
changed dynamically if mmap is ever used as an sbrk substitute.
*/

#define NONCONTIGUOUS_BIT (2U)

#define contiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) == 0)
#define noncontiguous(M) (((M)->flags & NONCONTIGUOUS_BIT) != 0)
#define set_noncontiguous(M) ((M)->flags |= NONCONTIGUOUS_BIT)
#define set_contiguous(M) ((M)->flags &= ~NONCONTIGUOUS_BIT)

这里就是用flags的第2位来判断MORECORE是否返回了连续的虚拟地址空间,0为是,1为否。实际上MORECORE就是系统调用sbrk(),只是经过了重重包装:

1
2
3
4
5
6
7
8
9
10
11
12
#define MORECORE         (*__morecore) 
void * __default_morecore (ptrdiff_t);
void *(*__morecore)(ptrdiff_t) = __default_morecore;
void *
__default_morecore (ptrdiff_t increment)
{
void *result = (void *) __sbrk (increment);
if (result == (void *) -1)
return NULL;

return result;
}

have_fastchunk用来表示这个分配区域是否有fast chunk。以前版本的glibc malloc是用flags的第1位来判断是否有fast chunk的,定义宏的方法和contiguous是一样的,但我看的这个版本是在malloc_state定义了一个have_fastchunk来判断,感觉新的版本有点浪费内存资源了。

fastbinsY,前面已经说过了,存储fast chunk链表指针的数组。

top,指向bins top chunk的指针。

last_remainder,指向chunk的指针, 分配区上次分配 small chunk 时,从一个 chunk 中分 裂出一个 small chunk 返回给用户, 分裂后的剩余部分形成一个 chunk,last_remainder 就是 指向的这个 chunk 。

bins:前面说过了,存储chunk的链表指针数组,分为unsorted bin,fast bin,small bin, large bin,bin[0]不存在,bin[1]是unsorted bin。

binmap:一个int数组,关于它的用途,可以看下面部分的代码:

1
2
3
4
5
6
7
8
9
10
11
/* Conservatively use 32 bits per map word, even if on 64bit system */
#define BINMAPSHIFT 5
#define BITSPERMAP (1U << BINMAPSHIFT)
#define BINMAPSIZE (NBINS / BITSPERMAP)

#define idx2block(i) ((i) >> BINMAPSHIFT)
#define idx2bit(i) ((1U << ((i) & ((1U << BINMAPSHIFT) - 1))))

#define mark_bin(m, i) ((m)->binmap[idx2block (i)] |= idx2bit (i))
#define unmark_bin(m, i) ((m)->binmap[idx2block (i)] &= ~(idx2bit (i)))
#define get_binmap(m, i) ((m)->binmap[idx2block (i)] & idx2bit (i))

这里可以看出BINMAPSIZE的值是128/32=4,我们知道int是32位,那么binmap实际上就是一个128位的buffer,这些宏就是在定义每一位对应每一个bins的映射关系,ptmalloc就使用这些位来标记对应bin中是否有free chunk。

next:一根指向下一个分配区的指针。

next_free:一根指向下一个free分配区的指针。

system_mem:记录当前分配去已经分配的内存大小。

max_system_mem:记录当前分配去最大能分配的内存大小。

malloc_par

定义如下:

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
struct malloc_par
{
/* Tunable parameters */
unsigned long trim_threshold;
INTERNAL_SIZE_T top_pad;
INTERNAL_SIZE_T mmap_threshold;
INTERNAL_SIZE_T arena_test;
INTERNAL_SIZE_T arena_max;

/* Memory map support */
int n_mmaps;
int n_mmaps_max;
int max_n_mmaps;
/* the mmap_threshold is dynamic, until the user sets
it manually, at which point we need to disable any
dynamic behavior. */
int no_dyn_threshold;

/* Statistics */
INTERNAL_SIZE_T mmapped_mem;
INTERNAL_SIZE_T max_mmapped_mem;

/* First address handed out by MORECORE/sbrk. */
char *sbrk_base;

#if USE_TCACHE
/* Maximum number of buckets to use. */
size_t tcache_bins;
size_t tcache_max_bytes;
/* Maximum number of chunks in each bucket. */
size_t tcache_count;
/* Maximum number of chunks to remove from the unsorted list, which
aren't used to prefill the cache. */
size_t tcache_unsorted_limit;
#endif
};

各个变量的意义如下(摘自ptmalloc源码剖析):

trim_threshold字段表示收缩阈值,默认为 128KB,当每个分配区的 top chunk 大小大于 这个阈值时,在一定的条件下,调用 free 时会收缩内存,减小 top chunk 的大小。由于 mmap 分配阈值的动态调整,在 free 时可能将收缩阈值修改为 mmap 分配阈值的 2 倍,在 64 位系 统上, mmap 分配阈值最大值为 32MB,所以收缩阈值的最大值为 64MB,在 32 位系统上, mmap 分配阈值最大值为 512KB,所以收缩阈值的最大值为 1MB。 收缩阈值可以通过函数 mallopt()进行设置。

top_pad 字段表示在分配内存时是否添加额外的 pad,默认该字段为 0。 mmap_threshold 字段表示 mmap 分配阈值,默认值为 128KB,在 32 位系统上最大值为 512KB, 64 位系统上的最大值为 32MB,由于默认开启 mmap 分配阈值动态调整,该字段的 值会动态修改,但不会超过最大值。

arena_test 和arena_max 用于 PER_THREAD 优化,在 32 位系统上 arena_test默认值为 2, 64 位系统上的默认值为 8, 当每个进程的分配区数量小于等于 arena_test 时,不会重用已有 的分配区。为了限制分配区的总数,用 arena_max 来保存分配区的最大数量,当系统中的分 配区数量达到 arena_max,就不会再创建新的分配区,只会重用已有的分配区。 这两个字段 都可以使用 mallopt()函数设置。

n_mmaps 字段表示当前进程使用 mmap()函数分配的内存块的个数。 n_mmaps_max 字段表示进程使用 mmap()函数分配的内存块的最大数量,默认值为 65536,可以使用 mallopt()函数修改。 max_n_mmaps字段表示当前进程使用 mmap()函数分配的内存块的数量的最大值,有关 系 n_mmaps <= max_n_mmaps 成立。 这个字段是由于 mstats()函数输出统计需要这个字段。 no_dyn_threshold 字段表示是否开启 mmap 分配阈值动态调整机制,默认值为 0,也就 是默认开启mmap 分配阈值动态调整机制。 pagesize 字段表示系统的页大小,默认为 4KB。 mmapped_mem 和 max_mmapped_mem 都用于统计mmap 分配的内存大小,一般情况 下两个字段的值相等, max_mmapped_mem用于mstats()函数。 max_total_mem 字段在单线程情况下用于统计进程分配的内存总数。 sbrk_base字段表示堆的起始地址。

References:

[1]. https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/heap_structure-zh

[2].glibc内存管理ptmalloc源码分析

the art of software security assessment Chap6. translate

Posted on 2020-07-21 | In Computer Science
Words count in article: 39.6k | Reading time ≈ 149

7.2 第六章 关于C语言方面的问题(C Language Issues)

“终有一天你将会明白”

—— Neel Mehta ,X-Force互联网安全系统高级研究员

7.2.1 概论

当你正在检查软件并覆盖潜在的安全漏洞时,理解编程语言底层如何实现数据类型以及运算的细节,以及这些细节会如何影响到执行流(execution flow)非常重要。审计员在汇编层面上检查应用程序二进制能够明确地看出数据是怎样存储以及更改的,以及对数据块操作的确切含义。然而,当你在源代码的层面上对应用程序进行审计时,一些细节就显得抽象并且不那么显然了。这种抽象能够导致一些软件中存在微妙的漏洞,这些漏洞在很长时间内不会被注意并修正。一个彻底的审计者应该熟悉源代码使用的语言的底层实现,并且熟悉这些实现细节会如何在边缘情况或者特殊情况下导致安全方面的问题。

本章将会对C语言细微的细节进行探讨,这些细节能够对应用程序的安全以及鲁棒性产生不利的影响。特别地,本章将会讨论原始数据类型(primitive types)的存储,算术溢出以及下溢的情况,类型转换问题,符号位扩展以及截断。你也会看到一些C语言有趣的细微差别, 包括来自某些操作符和其他通常不被重视的行为导致的意外结果。虽然本章的重点是C语言,但很多原则也可以应用到其他语言上。

7.2.2 C语言背景

本章会明确地讨论C语言的特性并且使用从C语言标准中定义的各种术语。你不必去查阅相关标准文献来配合食用,不过本章会明确地使用最新发布的C99标准草案(ISO/IEC 9899:1999),关于C99标准,可以在下面链接找到: www.open-std.org/jtc1/sc22/wg14/www/standards.

标准草案附带的C Rationale文档也很有用, 有兴趣的读者可以看看Peter Van der Linden的优秀图书Expert C Programming, Kernighan与Ritchie写的The C Programming Language。如果你对购买ISO标准的最终版本或旧的ANSI标准感兴趣的话,两者都在ANSI组织的网站出售 (www.ansi.org).

虽然这一章包含了一个最近的标准,但是内容针对的是目前C的主流使用,特别是ANSI C89/ISO 90标准。因为我们要讨论底层的安全细节,所以会添加关于跨版本C变更相关的任何情况的注释。

在讨论标准时,偶尔会用到术语“未定义的行为”(undefined behavior)和“实现定义的行为”(implementation-defined behavior)。未定义行为是错误行为:编译器不需要处理的条件,因此会产生未指定的结果。实现定义的行为是由底层实现决定的行为。应该以一种一致的、合乎逻辑的方式来处理它,处理它的方法应该文档化。

7.2.3 数据存储概述

在深入研究C的细节之前,应该回顾一下C类型的基础知识,特别是它们的存储大小、值范围和表示。本节从一般的角度解释类型,探索诸如二进制编码、二进制补码算法和字节顺序约定等细节,并以一些常见和未来实现的实用观察作为结束。

C标准将对象定义为执行环境中的数据存储区域;它的内容可以表示值。每个对象都有一个相关联的类型:一种解释存储在该对象中的值并赋予其意义的方法。在C标准中定义了许多类型,但本章主要关注以下内容 :

  • 字符类型。有三种字符类型,分别是char, signed char, unsigned char。所有三种类型都在存储中占用1个字节,不管char类型是否是带有符号的。大多数系统当前都默认char是带富豪的,尽管编译器的标志(flags)通常能够更改这个行为
  • 整数类型。有4中标准的带符号整数类型,包括short int, int ,long int, long long int。每个标准带符号整数类型都对应了不带符号的整数类型,并且对应的存储大小都相同。(注:long long int是在C99标准时才出现的新类型)
  • 浮点型。有三种实数浮点型和三种复数浮点型。实数浮点型是float, double, long double,三种复数型是float_Complex, double_Complex, long double_Complex。(注:复数类型是C99标准时才出现的新类型。)
  • 位字段(bit fields)。位字段是对象中的特定位数。 位字段可以是带符号的和无符号的,取决于它们的声明。 如果没有给出符号类型说明符,则位字段的符号依赖于实现。

注

位字段可能对于一些程序员来说并不熟悉,因为它们通常不会出现在网络代码(network code)或低层级代码之外。 下面是一个关于位字段的例子:

1
2
3
4
5
6
7
8
9
struct controller
{
unsigned int id:4;
unsigned int tflag:1;
unsigned int rflag:1;
unsigned int ack:2;
unsigned int seqnum:8;
unsigned int code:16;
};

controller结构中有很多数。id表示一个4位的无符号整数,tflag,rflag表示1位,ack是两位,seqnum是8位,code是16位。 这种结构的成员很可能被布局成内存中一个32位区域内的连续位。

从抽象的角度来看,每个整数类型(包括字符类型)都表示了一个占不同大小空间的整数,编译器可以将它们映射到合适的由底层架构决定(underlying architecture-dependent)的数据类型。每个字符会占用1字节的存储(尽管1字节可能不一定等于8位).sizeof(char)会一直是1,你也可以一直使用无符号字符的指针,sizeof和memcpy()来检查和操作其他类型的实际内容。 其他整数类型具有一定范围的值,它们必须能够表示这些值,并且它们之间必须保持一定的关系(例如long不能比short小), 但除此之外,它们的实现很大程度上取决于它们的架构和编译器。

带符号整数类型能够表示正数和负数,但无符号类型只能表示正数。每个带符号整数类型都有一个对应的无符号整数类型,并且占用相同的存储空间。无符号整数类型有两种不同的位:数位(value bits)包含对象值实际的2进制表示,填充位(padding bits) 是可选的,且标准未指定。带符号整数除了数位和填充位还有额外的一个位:符号位。 如果符号位在有符号整数类型中是清晰的,则其对值的表示与该值在相应的无符号整数类型中的表示相同。 换句话说,不管它是存储在整型还是无符号整型中,正42的底层位模式都应该是相同的。

整数类型有精度和宽度。精度是整型使用的值位数。宽度是类型用于表示其值的位数,包括值和符号位,但不包括填充位。对于无符号整数类型,精度和宽度是相同的。对于有符号整数类型,宽度比精度大1。

程序员能够用多种方式调用不同的类型。对于整数类型,例如short int,程序员通常可以省略int关键字,因此关键字signed short int, signed short, short int和short表示同一数据类型。一般地,如果signed和unsigned关键字被省略了,那么数据类型就会被假定为带符号类型。但是,这种假设对于char类型不成立,因为它是否为带符号取决于具体实现。(通常,char类型是带符号的,如果你想100%确定一个带符号的字符类型,你可以在声明时直接使用signd char。)

C语言还通过typedef 支持丰富的类型别名系统。 因此,程序员通常在指定已知大小和表示形式的变量时做一些约定。例如在UNIX和网络编程中,int8_t, uint8_t, int32_t, u_int32_t就很受程序员欢迎。它们分别表示8位带符号整数,8位无符号整数,32位带符号整数,32位无符号整数。 Windows程序员倾向于使用BYTE、CHAR和DWORD等类型,它们分别对应为8位无符号整数、8位带符号整数和32位无符号整数 。

二进制编码

(这里作者想要cover到所有架构的东西,以至于很多地方为了叙述严谨而导致很啰嗦,建议这一部分参考主要讲述Intel架构的CSAPP —by译者)

无符号整数值通过纯粹的二进制形式进行编码,也就是二进制的计数系统。每个位是0或者1,表示这一位所对应的2的指数所贡献的值。将一个表示为二进制的正数转换为十进制,只需要将第n位对应的数乘以2n−1再全部加起来就可以了,例如下面的例子:

\[00011011=24+23+21+20=27\]

\[00001111=23+22+21+20=15\]

\[00101010=25+23+21=42\]

相近地,将一个十进制表示的正数转换为二进制,只需要不断将它除以2直到结果为0,然后取结果的余数从最高位开始作为对应的数就可以了,例如下面的例子: $$ 55=32+16+4+2+1\

=25+24+22+21+20\

=00110111\

37=32+4+1\

=25+22+20\

=00100101 $$

有符号整数使用符号位以及数位和填充位。C标准给出了三种可能的算术方案,因此也给出了符号位的三种可能解释:

  • 符号和幅度(sign and magnitude)数的符号在符号位中存储,1代表负数0代表正数。数的幅度保存在数位中。这种方案对于人类来说简单易读易理解,但对于计算机来说很难处理,因为它们不得不明确地对于算术操作去比较幅度和符号。
  • 二进制反码(ones complement)同样,符号位中1代表负数0代表正数。正数值能够直接从数位中读取。然而,负数值不行,首先要将整个数都取反。在二进制反码中,取反的意思就是将所有位的0和1反转。为了找出一个负数的值,你必须先将它们的位都转回来。这个系统对于计算机来说更好一些,但仍然还有一些在加法上问题,以及就像符号和幅度表达的方法一样,对于两个这样的值会造成歧义:正0和负0。
  • 二进制补码(twos complement)符号位中1作为负数0作为正数。可以从正数值中直接从数位中读取大小,但无法从负数中的数位中直接读取。首先也需要将所有数位反转。在二进制补码中,反转操作意味着所有数位0和1反转,然后再加上1。这种方法对于机器来说能够非常好地工作,并且移出了会存在两个0的歧义。

(关于反码和补码为什么能在机器上很好地工作,本质上是一些数学性质上的优势,和本书给出的定义相反,从线性空间的角度去考量二进制补码和反码会显得非常直观与显然,关于这部分细节可以参考CSAPP的第二章相关部分——by译者)

正数通常在内部被表示为二进制补码,特别是在现代计算机中。就像前面提到的,二进制补码将正数编码为标准的二进制编码形式。正数值的范围取决于数位的位数。一个使用二进制补码的8位带符号整数有7个数位和1个符号位,7个数位能够表示从0到127。二进制补码将所有的负数值按照前面所述的方式编码,范围从-128到-1。也就是说,8位带符号整数能够表示-128到127的整数。

对于算术来说, 符号位放置在数据类型的最重要位中。 一般地,一个宽度为x的带符号补码能够表示的数范围为−2x−1到2x−1−1。下面的表显示了不同典型宽度补码能表示的数的范围。

8位 16位 32位 64位
最大值(带符号) -128 -32768 -2147483648 -9223372036854775808
最小值(带符号) 127 32767 2147483647 9223372036854775807
最大值(无符号) 0 0 0 0
最大值(无符号) 255 65535 4294967295 18446744073709551615

就先前面所述的,补码将所有数位反转并加1,下面的表给出了-15的补码表示:

1
0000 1111 15的标准二进制表示1111 0000 反转所有的位0000 0001 加11111 0001 -15的二进制补码1101 0110 一个不知道具体值的负数的二进制补码0010 1001 反转所有位0000 0001 加10010 1010 42的二进制形式,因此最初的值是-42
位的次序

在现代架构中,有两种将数位字节排序的约定:大端(big endian)和小端(little endian)。这些约定会使用于大于1个字节的数据类型,例如short和int型。在大端架构中,字节在内存中从最大位的字节开始到最小位的字节结束。小端在内存中的排布方式相反。例如,你有一个4位的整数,它的值位1234。在二进制中,它的值是11000000111001。这个值位于内存地址500的位置。在大端机器中,它会在内存中如此放置:

1
2
3
4
地址 500: 00000000
地址 501: 00000000
地址 502: 00110000
地址 503: 00111001

在小端机器中,它会这样放置:

1
2
3
4
地址 500: 00111001
地址 501: 00110000
地址 502: 00000000
地址 503: 00000000

因特尔机器是小端方式的,但是RISC机器,例如SPARC是大端方式的。一些机器能够同时应对两种约定。

常见的实现

从实践的角度讲,对于现代的,32位补码机器, 关于C的基本类型及其表示,你能说些什么 ?一般地,整数型不会带有填充位,所以你不需要考虑它。任何数都是用补码来表示的。一个字节会有8位的长度。字节的排序也有不同,在Intel机器上是小端方式,在RISC上是大端方式。

char类型默认为带符号类型并且占用1个字节。short类型占用2字节,int类型占用4字节。long型占4字节,long long型占8字节。在知道了整数是使用补码编码的,以及它们底层中占用的空间大小,得到它们能表示的最大和最小值就很简单了,下面的表总结了一些常见的整数类型数据在32位机器上所占空间大小与范围。

类型 长度(按位算) 最小值 最大值s
signed char 8 -128 127
unsigned char 8 0 255
short 16 -32,768 32,767
unsigned short 16 0 65,535
int 32 -2,147,483,648 2,147,483,647
unsigned int 32 0 4,294,967,295
long 32 -2,147,483,648 2,147,483,647
unsigned long 32 0 4,294,967,295
long long 64 -9,223,372,036,854,775,808 9,223,372,036,854,775,807
unsigned long long 64 0 18,446,744,073,709,551,615

当不久的将来64位机器更加普及时会怎样? 下面的列表描述了一些目前正在使用或已经被提议的类型系统:

  • ILP32 int,long,指针为32位,和所有32位机器的标准一样
  • ILP32LL int, long,指针为32位,新的类型long long为64位。long long是C99标准中新增的,标准中规定它要有不小于64位大小,但它不悔更高任何语言的基础特性。
  • LP64 int, long,指针都为64位。int型更改为了64位, 这对该语言具有相当重要的意义。
  • ILP64 long和指针为64位,也就是指针和long类型从32位变成了64位
  • LLP64 指针和新的数据类型long long为64位。int和long型仍为32位。

下面的表简单总结了这些类型对应系统的大小:

类型 ILP32 ILP32LL LP64 ILP64 LLP64
char 8 8 8 8 8
short 16 16 16 16 16
int 32 32 32 64 32
long 32 32 64 64 64
long long N/A 64 64 64 64
pointer 32 32 64 64 64

如你所见,常见的数据类型的大小中,ILP32模型是32位平台上大多数编译器所遵循的。 LP64模型是为64位平台生成代码的编译器的真实标准。正如你在本章后面学到的,int类型是C语言的基本单元;许多东西都在幕后从它转换而来。由于int数据类型在表达式计算中非常依赖,LP64模型是64位系统的理想选择,因为它不会改变int数据类型;因此,它在很大程度上保留了预期的C类型转换行为。

7.2.4 算术边界条件

你已经了解了C的基本整数类型的最小和最大可能值是由它们在内存中的底层表示决定的。上面的表给出了32位补码体系结构的典型范围。 所以,现在你可以探索当你尝试跨越这些边界时会发生什么。对变量进行简单的算术运算,如加法、减法或乘法,可能会导致无法在该变量中保存值。看一下这个例子

1
unsigned int a;a=0xe0000020;a += 0x20000020;

你知道a可以没有任何问题地赋值为0xE0000020;无符号32位整数地最大值为4,294,967,295或者0xFFFFFFFF。然而,当0x20000020和0xE0000000相加后,理论上的结果值0x100000040无法被变量a容纳。当一个算术运算的结果值大于最大可能的表示数值时,我们就称他为数字溢出条件。

下面是一个有点不一样的例子:

1
2
3
unsigned int a;
a=0;
a-=1;

这个程序将a减去1,a的初始值为0,因此运算结果理论上为-1,但它不能被a容纳因为它小于了a的最小可能值0.这个结果被称为数字下溢条件。

注

数值溢出条件在安全编程文献中也称为数值溢出(numeric overflows)、算术溢出(arithmetic overflows)、整数溢出(integer overflows)或整数环绕(integer wrapping) 。数值下溢条件可称为数值下溢(numeric underflows)、算术下溢(arithmetic underflows)、整数下溢(integer underflows)或整数环绕。具体来说,可以使用术语“环绕一个值(wrapping around a value)”或“在0以下环绕(wrapping below zero)”。

(为什么叫wrapping,因为无论是underflow还是overflow都是从一个边界(上界或者下界)跳到另一个边界,就像一个闭环一样,至于为什么是闭环,主要是CPU的ALU在进行补码加法运算时会将溢出位舍去,详见CSAPP第二章的整数运算小节 —by译者)

尽管这些条件在实际代码中似乎并不常见或无关紧要,但它们实际上经常发生,而且从安全角度来看,它们的影响可能非常严重。算术运算的错误结果会破坏应用程序的完整性,并经常导致其安全性的损害。在代码块早期出现的数字溢出或下溢可能导致一系列微妙的级联错误(series of cascading faults);不仅单个算术操作的结果受到污染,而且后续使用该污染结果的每个操作都会引入一个攻击者可能会产生意外影响的点。

注

尽管数值环绕在大多数编程语言中很常见,但它在C/ C++程序中是一个特殊的问题,因为C要求程序员执行低级任务,而这些低级任务由更抽象的高级语言自动处理。这些任务,如动态内存分配和缓冲区长度跟踪,通常需要运行一些容易受到攻击的计算。攻击者通常通过操纵长度计算来利用算术边界条件,以便分配足够的内存。如果发生这种情况,程序以后就会冒在已分配空间的边界之外操作内存的风险,这通常会导致可利用的情况。另一种常见的攻击技术是绕过保护敏感操作(如内存副本)的长度检查。本章提供了几个例子,说明如何下溢和溢出条件导致可利用的漏洞。通常,审计人员在检查代码时应该注意算术边界条件,并确保考虑到这些细微的、层叠的缺陷可能带来的影响。

警告

我们在示例中尝试使用int和unsigned int类型,以避免代码受到C默认类型提升的影响。这个主题在本章后面“类型转换”中会提到,但现在,请注意,当你在C语言的算术表达式中使用char或short时,它会在算术执行之前被转换成int。

无符号整数边界

在C规范中,无符号整数被定义为服从模运算规则(参见“模运算”侧栏)。对于一个使用X位存储空间的无符号整数,该整数的算术运算以2X为模进行。例如,对8位无符号整数的运算以28或256为模进行。再看看这个简单的表达式 :

1
2
3
unsigned int a;
a=0xE0000020;
a+=0x20000020;

加法运算的模为232,或者4,294,967,296 (0x100000000)。加法的结果为0x40,也就是(0xE0000020 + 0x20000020)取0x100000000的模。

另一种概念化它的方法是将数字溢出结果的额外位视为截断。如果以二进制方式进行计算0xE0000020 + 0x20000020,将得到以下结果:

1
2
3
  1110 0000 0000 0000 0000 0000 0010 0000
+ 0010 0000 0000 0000 0000 0000 0010 0000
= 1 0000 0000 0000 0000 0000 0000 0100 0000

真实得到的a的结果是0x40,二进制形式为 0000 0000 0000 0000 0000 0000 0100 0000。

(其实Intel的CPU就是这么做的,ALU在做加法运算发生溢出时,溢出的进位会被舍掉,传到overflow flag里,详见CSAPP第二章—by译者)

模运算

模运算是计算机科学中广泛使用的一种运算系统。“X取Y的模”表示“X除以Y的余数”例如,100对11取余是1因为100除以11,结果是9余数是1。C中的模数运算符被写成%。因此在C中,表达式(100% % 11)的值为1,表达式(100 / 11)的值为9。

模运算对于确保数字被限制在一定范围内很有用,你经常在哈希表中看到它用于这个目的。解释一下,当X取Y的模,X和Y都是正数,结果的最大值是Y-1最小值是0。如果您有一个包含100个buckets的哈希表,并且你需要将一个哈希映射到其中一个bucket,那么你可以这样做:

1
2
3
4
struct bucket *buckets[100];
...
bucket = buckets[hash % 100];

对于模运算是如何工作的,可以见下面的简单循环:

1
2
3
for (i=0; i<20; i++)
printf("%d ", i % 6);
printf("\n");

表达式(i% 6)本质上限定了i在0到5之间的范围。当程序运行时,它输出如下内容:

1
0 1 2 3 4 5 0 1 2 3 4 5 0 1 2 3 4 5 0 1

可以看到,当i从0上升到19时,i%6也上升了,但是每次它达到最大值5时,它就会返回到0。当你通过这个值向前移动时,你将环绕最大值5。如果向后移动这些值,则将从0”向下”环绕到最大值为5。

—- 以下内容接标题“模运算”之前—-

你可以看到它和加法的结果是一样的,但是没有最高位。这与机器层面的情况相差无几。例如,Intel架构有一个carry flag(CF),它包含最高位。C语言没有允许访问这个标志的机制,但是根据底层架构,可以通过汇编代码来检查它。

下面是由于乘法而发生的数字溢出条件的示例 :

1
2
3
unsigned int a;
a=0xe0000020;
a*= 0x42;

同样,以0x100000000为模进行算术运算。乘法的结果是0xC0000840,它是(0xE0000020 * 0x42)取0x100000000的模。下面是二进制表示:

1
2
3
          1110 0000 0000 0000 0000 0000 0010 0000
* 0000 0000 0000 0000 0000 0000 0100 0010
= 11 1001 1100 0000 0000 0000 0000 1000 0100 0000

实际上得到的结果是0xC0000840,它的二进制表示形式是1100 0000 0000 0000 0000。再一次,你可以看到不适合结果的较高位是如何被有效地截断的。在机器级别,通常可以检测整数乘法的溢出,并恢复乘法的高位。例如,在Intel上,imul指令在进行乘法运算时使用的目标对象大小是源操作数的两倍,如果乘法运算的结果需要大于源操作数的宽度,则imul指令将设置OF(overflow flag)和CF (carry flag)标志。一些代码甚至使用内联汇编来检查数值溢出(在本章后面的边栏“Intel上的乘法溢出”中讨论).

你已经看到了一些例子,它们说明了加法和乘法是如何导致算术溢出的。另一个可能导致溢出的操作符是左移,在本讨论中,它可以被认为是与2的乘法。它的行为与乘法非常相似,因此这里没有提供示例。

现在你可以来看一些与无符号整数算术溢出相关的安全问题了。下面的代码是最近在客户机代码中发现的可利用条件的经过清理、编辑的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
u_char *make_table(unsigned int width, unsigned int height,
u_char *init_row)
{
unsigned int n;
int i;
u_char *buf;
n = width * height;
buf = (char *)malloc(n);
if (!buf)
return (NULL);
for (i=0; i< height; i++)
memcpy(&buf[i*width], init_row, width);
return buf;
}

makr_table()函数的目的是获取宽度(width),高度(height),以及初始行(init_row)在内存中创建一个表格,每行初始化为和init_row相同的值。即假定用户能用width和height控制表格的维度。如果他们使用了一个非常大的维度,例如width设置为1,000,000,宽度设置为3,000,那么malloc()函数就会尝试申请3,000,000,000字节的内存空间。内存分配可能会失败,然后所调用的函数检测到错误以后会优雅地去处理它。然而,用户可以用它在width和height的相乘中造成算术溢出,只要将维度设置的足够大。潜在来说这种溢出已经可以被利用了,因为内存分配在width和height相乘后完成,而表格的初始化是在后面的for循环中进行的。因此如果我将width设置为0x40,height设置为0x1000001,乘法的结果就会是0x400000400,这个值对0x100000000的模是0x00000400,用十进制表示就是1024.所以1024个字节会被分配,但for循环会将init_row严格地进行1600万次复制。一个聪明的攻击者就能够通过进程运行时环境的底层细节来利用这种溢出获得整个应用程序的控制权。

现在再来看一个和上面例子相近的真实漏洞案例,这个例子发现于在OpenSSH 服务器。下面的代码来自OpenSSH 3.1问答认证(challenge-response authentication)代码:auth2-chall.c中的input_userauth_info_response()函数:

1
2
3
4
5
6
7
8
9
	u_int nresp;
...
nresp = packet_get_int();
if (nresp > 0) {
response = xmalloc(nresp * sizeof(char*));
for (i = 0; i < nresp; i++)
response[i] = packet_get_string(NULL);
}
packet_check_eom()

无符号整数nresp是用户能够控制的,它的目的是告诉服务器有多少响应。它被用来分配response[]数组然后将这个网络数据填充进去。在response[]数组通过xmalloc()的调用分配后,resp会乘以sizeof(char*),也就是4个字节。如果用户将nresp设置得足够大,算术溢出就会发生,然后乘法的结果就可能会变成一个较小的数。例如,如果nresp的值是0x40000020,乘法的结果就会是128(0x80),因此,0x80个字节就会被分配,但for循环会尝试将0x40000020个字符从packet取出然后送到response[]里!这就是一个可以远程利用的危险漏洞。

现在将注意力集中到算数下溢上。对于无符号整数,做减法时可能会导致一个值从最小可表示值0环绕。由于取模的过程,最终下溢的结果会是一个非常大的正数。下面是一个简短的例子:

1
2
unsigned int a;
a=0x10;a-=0x30;

我们来看一下二进制下的计算:

1
2
3
  0000 0000 0000 0000 0000 0000 0001 0000
- 0000 0000 0000 0000 0000 0000 0011 0000
= 1111 1111 1111 1111 1111 1111 1110 0000

最终a的结果会是0xffffffe0,在二进制补码的表示下是一个负数-0x20。但在模运算的前提下,如果你将数值移动超过了最大可能的值,那你就会在0处环绕。类似的情况也会在你低于最小值时发生:你将会环绕到最大的数值上。由于a是一个unsigned int型,因此它的值在减法后就会是0xffffffe0而不是-0x20。限免的代码是一个关于无符号整数算数下溢的例子:

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
struct header {
unsigned int length;
unsigned int message_type;
};
char *read_packet(int sockfd)
{
int n;
unsigned int length;
struct header hdr;
static char buffer[1024];
if(full_read(sockfd, (void *)&hdr, sizeof(hdr))<=0){
error("full_read: %m");
return NULL;
}
length = ntohl(hdr.length); //这里length是加粗
if(length > (1024 + sizeof (struct header) - 1)){
error("not enough room in buffer\n");
return NULL;
}
if(full_read(sockfd, buffer,
length sizeof(struct header))<=0) //这里length加粗
{
error("read: %m");
return NULL;
}
buffer[sizeof(buffer)-1] = '\0';
return strdup(buffer);
}

这份代码从网络中读入packet的header然后取出它的32位长度写入length变量中。length变量表示packet所占的总字节数,因此程序会先检查packet数据部分是否长于1024个字节以防止溢出。然后它尝试通过将(length sizeof(struct header))个字节读入缓冲区,从网络中读取packet的其余部分。这是有意义的,因为代码希望读取packet的数据部分,即总长度减去头的长度。

有漏洞的地方在于如果用户将长度设置为小于sizeof(struct header)的值,那么减去(length sizeof(struct header))就会造成整数下溢,最后将一个很大的size参数掺入full_read()。这个错误可能造成缓冲区溢出,因为在那个时候,read()可能会一直将数据复制到缓冲区知道连接关闭时,这样就可能允许攻击者获得进程的控制权。

Intel中的乘法溢出

一般地,处理器会在发生整数溢出时探测到它然后提供处理机制;然而,它们几乎没有用在错误检查上并且一般这种机制C语言也没有访问权限。例如,Intel处理器会在乘法发生溢出时将overflow flag(OF)的值保存在EFLAGS寄存器中,但C成语言如果不使用内部汇编代码的话就无法检查这个flag。有时这样做是出于安全原因,例如在Windows操作系统中处理MSRPC请求的NDR解组例程。下面的代码来自rpcrt4.dll,会在从RPC请求中各种数据类型的解组中调用:

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
sub_77D6B6D4 proc near
var_of = dword ptr -4
arg_count = dword ptr 8
arg_length = dword ptr 0Ch
push ebp
mov ebp, esp
push ecx
and [ebp+var_of], 0
; set overflow flag to 0
push esi
mov esi, [ebp+arg_length]
imul esi, [ebp+arg_count]
; multiply length * count
jno short check_of // 这一行是加粗,第一次检查
mov [ebp+var_of], 1
; if of set, set out flag
check_of:
cmp [ebp+var_of], 0
jnz short raise_ex
; must not overflow
cmp esi, 7FFFFFFFh //第二次检查
jbe short return
; must be a positive int
raise_ex:
push 6C6h
; exception
call RpcRaiseException
return:
mov eax, esi
; return result
pop esi
leave
retn 8

你能看到这个函数会将所提供的元素数量和每个元素的大小相乘,这个过程中做了两次检查。第一次是它使用jno检查overflow flag来保证乘法没有导致溢出。然后它确保了结果值的大小小于或者等于带符号整数能表示的最大值,也就是0x7FFFFFFF,二者只要有其中之一检查失败,函数就会抛出异常。

带符号整数边界

带符号整数略有不同。根据C语言特性,带符号整数的算术溢出或下溢的结果是实现定义的,可能包含机器陷阱或故障(machine trap or fault)。然而在大多数常见架构中,有符号算术溢出的结果定义良好且可预测,不会导致任何类型的异常。这些边界行为是补码算法在硬件上实现的自然结果,在主流机器上应该是一致的。

如果你还记得的话,可以用二进制补码表示的最大带符号整数的正值是,除有效位为0外,所有位都被设为1。这是因为最大的位表示该数字的符号,而该位中的值为1表示该数字为负数。当对有符号整数的操作导致算术溢出或下溢时,结果值“包围符号边界”并通常导致符号更改。例如,在32位整数中,值0x7FFFFFFF是一个大的正数。向其添加1将产生结果0x80000000,这是一个很大的负数。看看另一个简单的例子:

1
2
3
int a;
a=0x7FFFFFF0;
a+=0x100

加法的结果是-0x7fffff10, 或者 -2,147,483,408。来看一下它的二进制加法过程:

1
2
3
  0111 1111 1111 1111 1111 1111 1111 0000
+ 0000 0000 0000 0000 0000 0001 0000 0000
= 1000 0000 0000 0000 0000 0000 1111 0000

a的结果值是0x800000f0,它是正确的结果,但由于整数是用二进制补码来表示的,实际的值就会被表示为-0x7fffff10。在这个情况下,一个很大的正数加上一个很小的正数得到了一个很大的负数。

使用带符号加法,你可以通过使正数绕到0x80000000周围变成负数来溢出符号边界。你还可以通过使一个负数绕到0x80000000以下并变成正数来降低符号边界。负数的减法和加法是一样的,所以你可以把它们分析成本质上是相同的运算。乘法和移位过程中也可能出现溢出,对其结果进行分类就不那么容易了。从本质上说,这些位元可能会下落;如果结果的符号位中有1位,结果就是负的。否则,它不是。乍一看,涉及乘法的算术溢出似乎有点棘手,但攻击者通常可以让它们返回有用的目标值。

注

在本章中,read()函数用于演示与整数相关的各种形式的缺陷。为了清晰起见,这有点过于简化了,因为许多现代系统在系统调用级别验证read()的长度参数。这些系统(包括BSDs和更新的Linux 2.6内核)检查这个参数是否小于或等于相应大小的有符号整数的最大值,从而最小化内存损坏的风险。

很多在计算中未预料到的符号改变可以导致代码中微妙的可利用漏洞。这些变化可能曹正程序错误地计算所要求的空间,导致出现和上面无符号整数越过边界相似的情况。这种性质的错误通常发生在对直接从外部源(如网络数据或文件)获取的整数执行算术运算的应用程序中。下面的代码就是一个简单的例子,它展示了应用程序越过符号边界可能造成的影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char *read_data(int sockfd)
{
char *buf;
int length = network_get_int(sockfd);
if(!(buf = (char *)malloc(MAXCHARS)))
die("malloc: %m");
if(length < 0 || length + 1 >= MAXCHARS){
free(buf);
die("bad length: %d", value);
}
if(read(sockfd, buf, length) <= 0){
free(buf);
die("read: %m");
}
buf[value] = '\0';
return buf;
}

这个例子从网络中读入一个整数然后首先理智地做了一些检查。首先,检查了长度来保证它是否大于等于0,也就是是否为正数。然后检查长度来保证它是否小于MAXCHAS。然而,在代码的第二部分检查中,length变量被加了1.这就给攻击向量开了一扇门:0x7FFFFFFF会通过第一道检查(因为它大于0)然后进入第二道检查后,0x7FFFFFFF + 1是0x80000000,也就是一个负数,read()然后就可能在使用了一个有效的没有限制长度的参数下调用,从而导致潜在的缓冲区溢出情况。

在对待带符号整数时,这样的错误会很容易犯,而且发现它同样具有挑战性。允许用户直接指定整数的协议特别容易出现这种类型的漏洞。为了在实践中检验这一点,我们来看看一个执行不安全计算的真实应用程序。以下漏洞是OpenSSL 0.9.6代码基中与处理抽象语法符号(Abstract Syntax Notation ASN.1)编码数据相关的漏洞。(ASN.1是一种用于描述在计算机之间发送的任意信息的语言,这些信息使用它的基本编码规则BER进行编码。)这种编码是这种性质的漏洞的完美候选,因为该协议显式地处理由不受信任的客户机提供的32位整数。下面代码取自crypto/asn1/a_d2i_fp.c的 ASN1_d2i_fp()函数,该函数负责从IO (BIO)缓冲流中读取ASN.1对象。为简洁起见,对该代码进行了编辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
c.inf=ASN1_get_object(&(c.p),&(c.slen),&(c.tag),&(c.xclass),
len-off);
...
{
/* suck in c.slen bytes of data */
want=(int)c.slen;
if (want > (len-off))
{
want-=(len-off);
if (!BUF_MEM_grow(b,len+want))
{
ASN1err(ASN1_F_ASN1_D2I_BIO,
ERR_R_MALLOC_FAILURE);
goto err;
}
i=want;
// 这里所有want都加粗了

这份代码在一个获取ASN.1对象的循环中被调用。ASN1_get_object()函数读取下一个ASN.1对象指定长度的对象头部。这个长度被放置在c.slen里,是一个带符号整数,然后被传入want中ASN.1对象函数确保了这个值非负,因此c,slen的最大值可以是0x7FFFFFFF。在这种情况下,len就是内存中早已读入的数据数量,off是该数据到被解析对象的偏移量。因此,len-off就是被读入内存但还未被解析的数据量。如果代码发现对象大小大于可用的未解析数据大小,则决定分配更多空间并读入对象的其余部分。

BUF_MEM_grow()函数用来在内存缓冲区b中分配所要求的内存空间。它第二个变量是大小参数。这里的问题就是,len+want表达式用于第二个参数就可能导致溢出。比如假设len是200字节,off是50字节,攻击者经对象大小设置为0x7FFFFFFF,这将会传给want。 0x7FFFFFFF比在内存中的150个字节数据要大得多,因此会进入分配内存的代码。want会被减去150个已经读入的数据大小,然后得到值0x7FFFFF69,然后BUF_MEM_grow()的调用会请求len+want个字节的数据,或者说x7FFFFF69 + 200,也就是0x80000031,这个数是一个非常大的负数。

在内部,BUF_MEM_grow()函数进行比较,检查长度参数与之前分配的空间大小。因为负数小于它已经分配的内存量,所以它假定一切正常。因此,重新分配将被绕过,任意数量的数据可能被复制到分配的堆数据中,这会带来严重的后果。

7.2.5 类型转换

C语言在处理不同数据类型的交互上非常灵活。例如,通过一些强制类型转换,可以轻松地将无符号字符与有符号长整数相乘,将其添加到字符指针中,然后将结果传递给需要指向结构的指针的函数。程序员已经习惯了这种灵活性,因此他们倾向于混合数据类型,而不太关心幕后发生的事情。为了处理这种灵活性,当编译器需要将一种类型的对象转换为另一种类型时,它将执行所谓的类型转换。类型转换有两种形式:显式类型转换,程序员通过强制转换显式指示编译器从一种类型转换为另一种类型;隐式类型转换,编译器对变量进行“隐藏”转换,以使程序按预期运行。

注

你可能会看到在编程语言文献中称为“类型强制转换”(type coercions)的类型转换;这两个术语是同义的。

当你第一次了解一个典型的C程序中在幕后发生了多少隐式转换时,通常会感到惊讶。这些自动类型转换统称为默认类型转换,这会在当程序员执行看似简单的任务(如进行函数调用或比较两个数字)时,几乎不可思议地发生。

类型转换产生的漏洞通常很有趣,因为它们很微妙,很难在源代码中定位,而且它们常常导致这样的情况:关键远程漏洞的补丁就像将char更改为unsigned char一样简单。控制这些转换的规则看起来很微妙,你很容易认为你已经牢牢掌握了它们,而忽略了在分析或编写代码时造成巨大差异的一个重要的细微差别。

我们先部不要直接跳到已知的漏洞类别中,首先看看C编译器是如何在较低的级别上执行类型转换的,然后详细研究C的规则,以了解发生转换的所有情况。本节相当长,因为在有信心分析C的类型转换的基础之前,你必须涵了解很多内容。然而,这方面的语言是非常微妙的,它绝对值得花时间来获得一个坚实的基本规则的理解;你可以利用这种理解来发现大多数程序员甚至在概念级别上都没有意识到的漏洞。

概述

当面对协调两种不同类型的普遍问题时,C尽量避免让程序员感到意外。编译器遵循一组规则,这些规则试图封装关于如何管理混合不同类型的“常识”,通常,这些程序结果是正确的,且简单地执行程序员想要的操作。也就是说,应用这些规则通常会导致令人惊讶的、意想不到的行为。此外,如你所料,这些意外行为往往会带来可怕的安全后果。

在下一节中,我们将从探讨转换规则开始,即C在类型之间转换时使用的一般规则。它们指示机器如何在位级从一种类型转换为另一种类型。在您你很好地掌握了C如何在机器级别上在不同类型之间转换之后,你将研究编译器如何选择在C表达式上下文中应用哪种类型转换,这涉及到三个重要的概念:简单转换(simple conversions)、整数提升(integer promotions)和通常的算术转换(arithmetic conversions)。

注

虽然浮点数和指针等非整型类型有一定的覆盖范围,但本文主要讨论的是C如何操作整数,因为这些转换被广泛误解,并且对安全性分析至关重要。

转换规则

下面的规则描述了C如何从一种类型转换为另一种类型,但它们不描述何时执行转换或为什么执行转换。

注

下面的内容是特定于二进制补码实现的,代表了C规范中规则的精炼和实用版本。

整数类型:保值

在整数类型的转换中,一个重要的术语就是保值转换(value-preserving conversion)。

简单来说,如果新的类型能够表示所有旧类型可能的值,那么这种转换就成为保值的。在这种情况下,转换的结果不会导致值发生任何变化或者丢失。例如,如果一个unsigned char转换为int,这个转换就是保值的,因为整型能够表示无符号字符型的任意值。你可以在后面的表中对它进行验证。假设你考虑的是使用二进制补码的机器,那么一个8位的unsigned char能够表示0-255的任意值。一个32位的int型能够表示 -2147483648和 2147483647之间的任意值。因此没有一个unsigned char能表示的数字是int不能表示的。

相应地,在变值转换(value-changing conversion)中,旧地类型可能包含了新类型无法表示的值。例如,如果你将int转换为unsigned int,你就创造了这样一个棘手的情况。unsigned int在32位机器上的范围是0-4294967295,int的范围是-2147483648-2147483647.unsigned int无法表示任何int能表示的负数。

根据C标准,一些变值转换的结果有实现定义。这仅适用于具有带符号目标类型的值更改转换;对无符号类型的变值转换进行了定义,并且在所有实现中保持一致。(如果你还记得边界条件的讨论,这是因为无符号算术被定义为模运算系统。)二进制补码机遵循相同的基本行为,因此你可以相当有把握地解释它们如何执行对带符号目标类型的值更改转换。

整数类型:扩展

当你将一个较窄类型转换为另一个更宽的类型时,机器会按位将旧的变量复制到新的变量,然后将其他的高位设为0或者1.如果源类型是无符号的,机器就会使用零扩展(zero extension),也就是在宽类型中将剩余高位设为0.如果源类型是带符号的,机器就会使用符号位扩展(sign extension),也就是将宽类型剩余未使用位设为源类型中符号位的值。

警告

扩展过程会出现一些没有预料的实现:如果一个较窄的带符号类型,例如signed char,转换为一个更宽的无符号类型,例如unsigned int,那么符号位扩展仍然会发生。

图6-1展示了一个值为5的unsigned char类型保值转换为signed int型的过程

6-1
6-1

字符被放置到整数中,值被保留。在位模式级别,这只涉及零扩展:清除高位并将最低有效字节(least significant byte,LSB)移动到新对象的最低有效字节。

现在考虑一下signed char转换为int。int能表示所有signed char能表示的值,因此这个转换仍然是保值的,图6-2展示了在位级别的转换过程。

6-2
6-2

这个情况稍微会有些不同,因为值是相同的,但转变过程更复杂了一点。-5在signed char中位级别的表示为1111 1011。在int中,-5的位级表达为1111 1111 1111 1111 1111 1111 1111 1011.为了实现这个转换,编译器会生成汇编代码来执行符号位扩展。在图6-2中你能看到符号位在signed char中为1,因此为了保值,符号位就会被复制到其他int型中剩下的24位里。

前面的例子是保值转换。现在考虑一下变质扩展转换。如果你想要将一个值为-5的signed char转换为unsigned int。由于源类型是带符号的,因此符号位扩展就会执行,见图6-3:

6-3
6-3

正如前面所提到的,这个结果可能会震惊到开发者。你可以在本章后面“符号位扩展”小节中看到与之相关的安全影响。这种转换是变值的,因为一个unsigned int无法表示一个小于0的值。

整数类型:收缩

当将一个宽类型转换为窄类型时,机器只会使用一种机器:截断(truncation)。宽类型中与窄类型不匹配的位会被全部舍去。图6-4和图6-5展示了两个收缩转换。注意,所有的收缩转换都是变值转换,因为转换过程中精度丢失了。

6-4
6-4

图6-5:

6-5
6-5

整数类型:带符号与无符号

最后我们要考虑这样的整数转换:如果转换在相同宽度的带符号和无符号数之间发生,那么在位级别上什么都不会发生。这样的转换是变值的。例如,如果你有一个signed int型的-1,在二进制的表示为:1111 1111 1111 1111 1111 1111 1111 1111。

如果将具有相同位级别的这个数看作unsigned int,那么它的值就是4,294,967,295。这个过程在图6-6中有总结。 从unsigned int到int的转换在技术上可能是实现定义的,但其工作方式相同:保留位模式,值在新类型的上下文中进行解释

6-6
6-6

图6-7:

6-7
6-7

整数类型转换总结:

对于整数类型转换,这里有一些实用的规则:

  • 从窄的带符号类型转换为宽的无符号类型,编译器会生成汇编指令执行符号位扩展,对象的值可能会改变。
  • 从窄的带符号类型转换为宽的带符号类型,编译器会生成汇编指令执行符号位扩展,对象的值不变
  • 从窄的无符号类型转换为宽的类型,编译器会生成汇编指令执行零扩展,对象的值不变
  • 从宽类型转换为窄号类型,编译器会生成汇编指令执行截断,对象的值可能改变
  • 相同宽度无符号类型和带符号类型之间的转换,编译器实际上不会做任何事,在位模式下是相同的,但对象的值可能会改变

下面的表总结了不同整数类型在C语言的二进制补码实现下转换时进行的操作。在接下来的章节中,这个表可以当作类型转换发生时一个有用的参考文献。表中左边是源类型,顶部代表目标类型。

signed char unsigned char short int unsigned short int signed int unsigned int
signed char 相容类型 变值 保值 变值 保值 变值
位模式不变 符号位扩展 符号位扩展 符号位扩展 符号位扩展
unsigned char 变值 相容类型 保值 保值 保值 保值
位模式不变 零扩展 零扩展 零扩展 零扩展
short int 变值 变值 相容类型 变值 变值 变值
截断 截断 位模式不变 符号位扩展 符号位扩展
unsigned short int 变值 变值 变值 相容类型 保值 保值
截断 截断 截断 零扩展 零扩展
signed int 变值 变值 变值 变值 相容类型 变值
截断 截断 截断 截断 位模式不变
unsigned int 变值 变值 变值 变值 变值 相容类型
截断 截断 截断 截断 位模式不变

浮点型和复数型

尽管由浮点数算术导致的漏洞并没有广泛地被挖出来,但造成漏洞这一件事是确实可能的。在财务软件中肯定会出现与浮点类型转换或表示问题相关的微妙错误。本章对浮点类型的讨论相当简短。有关更多信息,请参阅C标准文档和前面提到的C编程参考资料。

对于真正的浮点类型和整数类型之间的转换,C标准的规则为实现定义的行为留下了很大的空间。在从实类型到整数类型的转换中,将丢弃数字的小数部分。如果整数类型不能表示浮点数的整数部分,则结果是未定义的。类似地,从整数类型到实类型的转换也会尽可能地转移值。如果实类型不能表示该整数的值,但可以接近,则编译器将按照实现定义的方式将该整数四舍五入到下一个最大值或最小值。如果整数超出实类型的范围,则结果是未定义的。

不同精度的浮点类型之间的转换使用类似的逻辑处理。精度提升(promotion)不会引起值的变化。在导致值更改的精度下降(demotion)期间,编译器可以自由地使用实现定义的方式对数字进行四舍五入(如果可能的话)。如果由于目标类型的范围而无法四舍五入,则结果是未定义的。

其他类型

除了整数和浮点数之外,C语言中还有无数其他类型,包括指针、布尔型、结构体、联合体、函数、数组、枚举类型等等。在大多数情况下,从安全的角度来看,这些类型之间的转换并不十分关键,因此本章不会详细介绍它们。

指针运算会在本章中的“指针运算”小节中进行详细介绍。指针类型转换很大程度上取决于底层机器的架构,很多的类型转换都是实现定义的。实质上,程序员能够将指针和整型来回转换,将指针从一种类型转换为其他类型。其结果是实现定义的, 程序员需要认识到对齐限制和其他底层细节。

简单转换

现在你对C语言将一个整数类型转换为其他类型已经了解了,现在你可以来看一下这些类型转换发生时的情况了。简单转换(simple conversions)是一种C语言表达式,它直接使用前面提到的转换规则。

强制类型转换(casts)

强制类型转换(typecasts)是C语言让程序员进行显式类型转换的机制,就像下面的例子一样:

1
(unsigned char) bob

不管bob是什么,这个表达式都会将它转换为unsigned char类型。表达式的结果类型是unsigned char。

赋值

简单类型的转换也会在赋值运算符中发生。编译器一定会将右部的类型转换为左部的类型,就像下面例子所显示的:

1
2
3
short int fred;
int bob = -10;
fred = bob;

对于这两个赋值运算,编译器一定会将右部的类型转换为左部的类型。转换规则告诉你,从int类型的bob转换为short int型的fred会导致截断。

函数调用:原型(prototype)

C语言有两种风格的函数声明:旧的K&R风格,也就是参数类型在函数声明中没有具体说明。在ANSI风格中, 函数原型的使用仍然是可选的,但它很常见,在ANSI风格下,你会见到一些像这样的东西:

1
2
3
4
5
6
7
8
int dostuff(int jim, unsigned char bob);
void func(void)
{
char a=42;
unsigned short b=43;
long long int c;
c=dostuff(a, b);
}

函数dostuff()的声明中含有告诉编译器参数个数的原型。 经验法则是,如果函数有原型,则使用前面记录的规则以简单的方式转换类型。 如果函数没有原型, 就会出现所谓的默认参数提升(default argument promotions)(在整数提升中会提到)。

前面的例子中一个字符型(a)被转换为了int型(jim),一个unsigned short型(b)被转换为了unsigned char(bob),一个int型(do_stuff()的返回值)被转换成了long long int(c)。

函数调用:返回

return 将其操作数转换为封闭函数定义中指定的类型。例如,在下面的例子中,int类型a通过return被转换为了char类型:

1
2
3
4
5
char func(void)
{
int a=42;
return a;
}
整数提升(integer promotions)

整数提升详细说明了C语言如何将一个窄的整数类型,例如char或者short转换为int型(或者不常见的情况,转换为unsignd int)。由于下面的两种原因会使用这种向上的转换(up-conversion),或者提升:

  • 很多C语言的运算符要求操作对象为int或者unsigned int类型。对于这些运算符,C会使用整数提升规则将窄类型操作对象转换为int或者unsigned int。
  • 整数提升是C语言中处理算术表达式中很重要的规则,它也被称为常规算术转换规则 (usual arithmetic conversions)。对于涉及整数的算术表达式,整数提升通常应用于操作数两边。
注

你可能会在其他文献中交替见到“整数提升(integer promotion)”和“整型提升(integral promotion)”,它们是相同的术语。

从C语言便准中能得到一个有用的概念:每个整数型数据都有一个叫整数转换等级(integer conversion rank)的东西。这些等级将整数类型数据做了一个等级的排序,通过它们的宽度从低到高。每种类型的带符号和无符号种类级别是相同的。 下面的列表按从高到低的等级转换对整数类型进行排序。对于其他整数类型,C标准也会设置等级,但是这个列表应该足以满足本文的讨论:

  • long long int, unsigned long long int
  • long int, unsigned long int
  • unsigned int, int
  • unsigned short,short
  • char, unsigned char, signed char
  • _Bool

基本来说, 在C中可以使用int或unsigned int的任何地方,也可以使用具有较低整数转换等级的任何整型。这意味着你可以使用更小的类型,比如char和short int,来代替C表达式中的int。你也可以使用类型为_Bool、int、signed int或unsigned int的位字段。位字段不被赋予整数转换等级,但它们被视为比它们相应的基类型更窄的类型。 这是有意义的,因为int的位字段通常比int小,最宽的情况下和int的宽度相同。

如果将整数提升应用于变量,会发生什么?首先,如果变量不是整数类型或位字段,提升过程不会执行任何操作。第二,如果变量是整数类型,但是它的整数转换级别大于或等于int类型,提升过程也不做任何事情。因此,int、unsigned int、long int、指针和浮点数不会因整数提升而改变。

因此,整数提升负责获取更窄的整型类型或位字段,并将其提升为整型或无符号整型。否则,将执行一个到unsigned int的保值转换。

在实际应用中,这意味着几乎所有东西都能被转换为int,因为int可以保存所有较小类型的最小值和最大值。唯一可能提升为无符号整型的类型是具有32位的无符号整型位字段,或者某些特定于实现的扩展整型类型。

关于过去版本的注解

C89标准对C类型转换规则进行了重要的修改。在C语言的K&R时代,整数提升是保符号(unsigned-preserving)的,而不是保值的。因此,在当前的C规则中,如果较窄的无符号整数类型(如unsigned char)被提升为较宽的有符号整数(如int),则值转换规定新类型为有符号整数。在旧规则中,提升将保留无符号性,因此结果类型将是unsigned int,这改变了许多有符号/无符号比较的行为,这些比较涉及到比int窄的类型提升。

整数提升的总结

基本上的规则如下:如果一个整数类型比int要窄,那么证书体系基本上都会将它们转换为int。下面的表总结了几个常见类型的整数提升结果:

源类型 结果类型 转换原理
unsigned char int 提升;源类型等级低于int等级
char int 提升;源类型等级低于int等级
short int 提升;源类型等级低于int等级
unsigned short int 提升;源类型等级低于int等级
unsigned int:24 int 提升;unsigned int的位字段
unsigned int:32 unsigned int 提升;unsigned int的位字段
int int 不提升,源类型等级等于int等级
unsigned int unsigned int 不提升,源类型等级等于int等级
long int long int 不提升,源类型等级大于int等级
float float 不提升,源类型不是整数类型
char* char* 不提升,源类型不是整数类型
整数提升应用

现在你理解整数提升了,下面的小节会探讨它们会在C语言哪些地方被使用。

一元算符 +

一元算符 +会对其操作数执行整数提升。例如,如果变量bob的类型是char,那么(+bob)的结果类型为int,尽管(bob)表达式的结果类型为char。

一元算符 -

一元算符-会对其操作数先执行整数提升再取负数。 无论提升后的操作数是否有符号,都会执行二进制补码的取负,这涉及到位的反转,然后加一。 (一个数取负,只要把数的补码表示再取一次补码就可以了 —by译者)

Leblancian悖论

David Leblanc是一位专业的研究员和审计者,也是世界上C/C++的顶级专家之一。 他记录了在与同事Atin Bansal一起开发SafeInt类时发现的两个补码运算的一个迷人的细微差别(http://msdn.microsoft.com/library/en-us/dncode/html/secure01142004.asp)。将两个补码数相减,会执行各位取反然后加1。假设一个32位的带符号数据类型,那么0x80000000取反是什么?

你将所有位取反后,得到了0x7fffffff,然后加上1,你得到了0x80000000。因此这个数的取负就是它本身!

当开发人员使用负整数表示一组特殊的数字或尝试取整数的绝对值时,这种特性就会发挥作用。下面的代码让一个负索引指定一个辅助哈希表。除非攻击者能够指定索引为0x80000000,否则这种方法可以正常工作。对数字进行取负不会导致值发生变化,并且0x80000000 % 1000是-648,这会导致数组之前的内存被修改。

1
2
3
4
5
6
7
8
9
10
11
int bank1[1000], bank2[1000];
...
void hashbank(int index, int value)
{
int *bank = bank1;
if (index<0) {
bank = bank2;
index = -index;
}
bank[index % 1000] = value;
}

译者注:

补码在数学性质上是单满映射,也就是在能够表示的范围之内,一个数的和它的二进制补码是一一对应的,那么为什么会出现上述的悖论(一个数的补码负数是本身)呢?本质上就是0x80000000是int型能表示的最小值,而int型能表示的最大值是最小值的绝对值减一,也就是说int是无法表示-0x80000000的,它理论值是-INT_MIN-1,即INT_MAX+1,但在int里这个数就越界了,然后就从INT_MAXoverflow回了INT_MIN。

一元算符 ~

一元算符~会在进行整数提升之后将操作数取反码。 对于补码实现,这有效地对有符号和无符号操作数执行相同的操作:将位反转。

移位算符

移位算符>>和<< 改变变量的位模式。整数提升会应用到这两个算符中,结果类型等于算符左边操作数提升后的类型,就像下面例子一样:

1
2
3
4
char a = 1;
char c = 16;
int bob;
bob = a << c;

a会被转成整数,c也会被转成整数。提升后的左边操作数是int型,因此表达式的结果类型是int。a的整数表达就是原来的值往左移16位。

switch语句

整数提升也会用在switch语句中。switch语句的表达式一般会像这样:

1
2
3
4
5
6
7
switch (controlling expression)
{
case (constant integer expression): body;
break;
default: body;
break;
}

整数提升以以下方式使用:首先,它们应用于控制表达式,以便表达式具有提升类型。然后,将所有整型常量转换为控制表达式提升的类型。

函数调用

使用K&R语义的旧C语言程序在其函数声明中没有指定参数的数据类型。当在没有原型的情况下调用函数时,编译器必须执行称为默认参数提升(default argument promotions)的操作。基本上,整数提升应用于每个函数参数,任何浮点类型的参数都转换为double类型的参数。考虑以下示例:

1
2
3
4
5
6
7
8
9
10
int jim(bob)
char bob;
{
printf("bob=%d\n", bob);
}
int main(int argc, char **argv)
{
char a=5;
jim(a);
}

在这个例子中,a的拷贝值被传入了jim()函数。char类型首先会被整数提升整数类型,然后这个整数会被传入jim()函数。 编译器为jim()函数发出的代码需要一个整型参数,它执行将该整型直接转换回bob变量的char格式。

常规算术转换

在许多情况下,C应该接受两个可能具有不同类型的操作数,并执行一些涉及这两个操作数的算术运算。C标准给出了一种通用算法,用于将两种类型协调为兼容类型。 这个方法称为常规算术转换(usual arithmetic conversions)。

规则1:浮点优先

浮点数要优先于整数类型,也就是如果一个变量在算术表达式中是浮点数类型,那么其他类型就会被转换为浮点型。如果一个浮点型比其他的类型精度低,那么这个浮点型会被提升为精度更高的类型。

规则2:应用整数提升

如果两个操作数都不是浮点型,那么回到整数类型的规则。首先,整数提升会应用到两边的操作数。这是拼图中极其重要的一块!如果你回顾前面的章节,这个规则表示任何小于int的整数类型都会被转换为int,与int有相同宽度或者更宽的会被放到一边。下面是一个简单的例子:

1
2
3
unsigned char jim = 255;
unsigned char bob = 255;
if ((jim + bob) > 300) do_something();

在这个表达式中,运算符+会对操作数使用常规算术转换,结果类型会是一个int型,并且记录加法的值(510)。因此,do_something会被调用,尽管这个表达式看起来会导致溢出。 总而言之:只要算术涉及小于整数的类型,就会在幕后将窄类型提升为整数。这里有另一个简单的例子:

1
2
unsigned short a=1;
if ((a-5) < 0) do_something();

从直观上看如果你有一个值为1的unsigned short,减去5以后会在0处下溢到一个很大的值。然而,如果你测试这段代码,你将会看到do_something()被调用了因为减法算符两边的操作数在比较前都被提升到了int型。因此a被从unsigned short转换成了int,然后一个int类型的的数减去了5,结果是-4。这对int是合法值,因此比较表达式的结果为真。请注意如果你做下面的操作,那么do_something()将不会被调用:

1
2
3
unsigned short a=1;
a=a-5;
if (a < 0) do_something();

整数提升发生在(a-5)这里,但结果的整数值-4会被赋值到unsigned short的a里面。正如你所知道的,一个int型被转换为unsigned short会导致截断,最后导致a的值为一个非常大的正数。因此,比较表达式将不返回真。

规则3:整数提升后为相同类型

如果两个操作数在整数提升后是相同类型,那么久不必进行后面的类型转换,因为算术运算会被直接带到机器级别。这可能会在两边操作数都被提升到int型时发生,或者它们本身就是相同的没有被整数提升影响到的类型。

规则4:相同符号,不同类型

如果两个操作数在整数提升后是不同的类型,但是它们是否有符号位是相同的,那么窄的类型会被转换为宽的类型。换句话说,如果两边操作数都是signed或者两边都是unsigned,那么在整数转换层级上较低的类型会被转换成转换层级上较高的类型。

注意这个规则对于short或者符号类型没有用,因为它们早就通过整数提升变成了int。这个规则对于更大大小数的算术运算更管用,例如long long int或者long int。下面是一个例子:

1
2
3
4
int jim =5;
long int bob = 6;
long long int fred;
fred = (jim + bob);

整数提升不会改变任何类型,因为它们都是在宽度上大于或者等于int型的。因此这个规则会在加法开始前让jim被转换为long int,加法结果的类型是long int,然后再被转换为long long int赋值给fred。

在下一节中,你将会考虑操作数为不同类型,并且一个是signed另一个是unsigned。这种情况在安全层面上会有很多有趣的东西。

规则5:带符号类型比带无符号类型更宽

对于这个规则(signed遇见unsigned —by译者),第一种情形就是unsigned操作数在转换等级上大于signed操作数,或者它们的等级是相同的的情况下,你将一个signed操作数转换为unsigned操作数。这个行为可能会震惊到你,然后导致像下面的情况:

1
2
3
int jim = -5;
if (jim < sizeof (int))
do_something();

比较算符<会导致常规类型转换应用到两边的操作数。因此整数提升会应用到jim和sizeof(int)里,但这并不会影响到它们。然后继续进行常规算术转换,它试图照除哪个类型应该被当作比较的通用类型。在这种情况下,jim是带符号整数,sizeof(int)是size_t,也就是无符号整数类型。由于size_t在类型转换等级上更高,因此无符号类型在这里就有了优先级,因此,jim会被转换为无符号整数类型,然后比较表达式结果为假,do_something()不会被调用。在32位系统里,真实的比较是下面这样:

1
2
if (4294967291 < 4)
do_something();
规则6:带符号类型比无符号类型更窄,能保值

如果带符号类型比无符号类型的转换等级更高,并且能够在从无符号类型到带符号类型执行保值转换,那么就会将任何东西转换为带符号整数。就如下面的例子:

1
2
3
long long int a=10;
unsigned int b= 5;
(a+b);

带符号变量为long long int,能够表示任何unsigned int的值,因此编译器会将两边操作数转换为带符号类型:long long int.

规则7:带符号类型比无符号类型更窄,不能保值

还有一项规则:如果带符号类型比无符号类型的转换等级更高,但是不是所有的无符号整数能表示的数带符号类型都能表示,那么就会发生有点奇怪的事。 获取带符号整数的类型,将其转换为对应的无符号整数类型,然后将两个操作数转换为该类型并使用。 下面是一个例子:

1
2
3
unsigned int a = 10;
long int b=20;
(a+b);

这个例子要假设在这个机器上,int类型和long int类型长度相同。假发算符会导致常规算术转换被应用。首先进行整数提升,但不会改变任何类型。带符号类型long int转换层级比无符号类型unsigned int更高。但带符号类型无法表示无符号类型的所有值。因此会启动最后这条规则。首先找到带符号操作数类型(long int),对应相应的无符号类型,unsigned long int,然后将两边操作数都转换为unsigned long int。因此表达式结果类型为unsigned long int,值为30.

算术转换总结

下面是对有用的算术转换做总结。后面的表格也是同样的总结。

  • 如果任意一方是浮点数,双方操作数都会被转换为两边中精度最高的浮点数。这个你掌握了。
  • 对两边都进行整数提升。如果这两个操作数现在是相同类型的。这个你掌握了
  • 如果两个操作数在是否带符号,这种情况一样的会将低转换等级的一方转换为高转换等级的一方。这个你掌握了。
  • 如果无符号操作数等级大于等于带符号操作数,就将带符号操作数转换为无符号数。这个你掌握了。
  • 如果如果带符号操作数等级大于无符号操作数,并且能够进行保值转换,将无符号数转换为带符号数类型。这个你掌握了。
  • 如果如果带符号操作数等级大于无符号操作数,并且不能够进行保值转换,那就将两边都转换为带符号类型对应的无符号类型。
左操作数类型 右操作数类型 结果 公共类型
int float 左操作数转换为float float
double char 右操作数转换为double double
unsigned int int 右操作数转换为unsigned int unsigned int
unsigned short int 左操作数转换为int int
unsigned char unsigned short 左右两边操作数都转换为int int
unsigned int: 32 short 左右操作数都转换为int int
unsigned int long int 左右边操作数转换为unsigned long int unsigned long int
unsigned int long long int 左操作数转换为long long int long long int
unsigned int unsigned long long int 左操作数转换为unsigned long long int unsigned long long int
常规算术转换应用

现在你理解了常规算术转换,那就可以来看看这些转换被用在了哪里:

加法

加法可以在两个算术类型,以及算术类型和指针类型之间发生。指针运算会在小节“指针运算”中介绍。但现在,我们只需要考虑变量为算术类型,编译器会对两边使用常规算术转换。

减法

减法可以在两个算术类型,以及算术类型和指针类型之间发生。在两个算术类型进行减法的情况时,编译器会对两边使用常规算术转换。

乘法算符

算符* /的操作数两边都必须时算术类型,并且%的变量必须时整数类型。常规算术转换会被用到两边的操作数中。

关系和等价算符

当两个算术操作数被比较时,常规算术转换会被用到两边的操作数中。结果类似会是int,值为1或者0,取决于测试的结果。

二进制位级算符

二进制位级算符& ^ !要求整数型操作数。常规算术转换会被使用。

问号标记算符(question mark operator)

从类型转换的视角看,条件运算符是C语言最有趣的算符之一。下面有一个例子可以看出它的应用有多广泛:

1
2
3
4
5
int a=1;
unsigned int b=2;
int choice=-1;
...
result = choice ? a : b ;

在这个例子中,第一个操作数choice如果为真,那么表达式的结果就是第二个操作数,也就是a,否则就是第三个操作数b。

编译器必须在编译时知道条件表达式的结果类型,这在这种情况下可能比较棘手。C语言所做的是确定对第二个和第三个参数运行常规算术转换后的结果是哪种类型,并使该类型成为表达式的结果类型。因此,在前面的示例中,无论choice的值是什么,表达式的结果都是unsigned int。

类型转换总结

下面的表格总结了一些常见类型转换的细节:

操作 操作数类型 类型转换 结果类型
强制类型转换 (type) expression 表达式通过简单转换被转换为type type
赋值= 右边操作数通过简单转换转换为左边操作数类型 左边操作数类型
带有原型的函数调用 使用简单转换转换变量,转换取决于原型 函数的返回类型
不带有原型的函数调用 变量通过常规变量提升,即整数提升 int
一元算符返回+,- +a -a 操作数类型必须为算术类型 操作数通过整数提升 操作数提升后的类型
一元算符~ ~a 操作数类型必须为整数类型 操作数通过整数提升 操作数提升后的类型
位级算符<<和>> 操作数类型必须为整数类型 操作数通过整数提升 左边操作数提升后的类型
switch语句 表达式必须为整数类型 表达式通过整数提升,case会被转换为这个类型
二元算符+ - 操作数类型必须为算术类型或者*pointer(在指针运算中有介绍) 操作数通过常用算术转换 常用算术转换的公共类型
二元算符* \ 操作数类型必须为算术类型 操作数通过常用算术转换 常用算术转换的公共类型
二元算符 % 操作数类型必须为整数类型 操作数通过常用算术转换 常用算术转换的公共类型
二元下标[] a[b] 解释为*((a)+(b))
二元算符! 操作数类型必须为算术类型或者指针 int,值为0或者1
sizeof size_t(无符号整数类型)
二元算符> < <= >= == != 操作数类型必须为算术类型或者*pointer(在指针运算中有介绍) 操作数通过常用算术转换 常用算术转换的公共类型
二元算符& ^ | 操作数类型必须为整数类型 操作数通过常用算术转换 常用算术转换的公共类型
二元算符&& | | 操作数类型必须为算术类型或者指针 int,值为0或1
条件三元算符? 第二或者第三操作数必须为算术类型或者指针 第二第三操作数通过常用算术转换 常用算术转换的公共类型
审计提示:类型转换

即使那些广泛研究过转换的人也会对编译器将某些表达式呈现为汇编的方式感到惊讶。当你看到让你觉得可疑或可能含糊不清的代码时,请毫不犹豫地编写一个简单的测试程序或研究生成的程序集,以验证你的直觉。

如果你生成程序集来验证或研究本章中讨论的转换,请注意C编译器可以优化某些转换,或使用架构技巧,这可能会使程序集看起来不正确或不一致。在概念层面上,编译器的行为与C标准描述的一样,并且它们最终生成遵循规则的代码。但是,由于优化,程序集可能看起来不一致,甚至不正确,因为它可能操作寄存器中不应该使用的部分。

7.2.6 类型转换漏洞

现在你对C语言类型转换有了坚实基础了,现在来探寻一些它们能够创造的异常情况。 隐式类型转换在某些情况下会让程序员猝不及防。本小节会主要集中在带符号和无符号数的简单转换,符号位扩展,截断,以及常见算术转换, 专注于比较。

带符号/无符号转换

大多数和类型转换相关的安全问题都是由带符号和无符号数之间的简单转换造成的。这里我们只探讨赋值,函数调用,强制类型转换的情形。

快速复习一下简单转换规则,当带符号数转换为相同大小的无符号数时,位模式被保留,然后值也会相应变化。当无符号数转换为带符号数时也会发生相同的事。严格来说,无符号到带符号数的转换是实现定义的,但在二进制补码是线下,通常位模式被保留。

这种转换最重要的情况是在函数调用期间,如本例所示 :

1
2
3
4
5
int copy(char *dst, char *src, unsigned int len)
{
while (len--)
*dst++ = *src++;
}

第三个参数是一个unsigned int,用来表示内存中需要复制的长度。如果你将一个signed int传入这个函数当作第三个参数,那么它将会被转换为无符号整数,假如你这么做:

1
2
int f = -1;
copy(mydst, mysrc, f);

copy()函数将会看到一个非常大的len并且极有可能执行复制直到产生分段错误(segmentation fault)。几乎所有的libc例行程序都将大小参数的类型定为size_t,这是一种和指针长度相等的无符号类型。这也是为什么你必须永远不要将一个负长度的参数被传入libc例行程序重,例如snprintf(), strncpy(), memcpy(), read(), 或者strncat() 。

这种情况经常发生,特别是带符号整数被用作长度值并且程序员并没有考虑可能小于0的情况。在这种场合,所有小于0的值在被强制转换为无符号类型时都会被改为很大的正数。不怀好意的用户经常会将特定的负整数传入很多程序接口中然后破坏程序逻辑。这种类型的bug在用户指定的整数上的最大长度检查时经常发生, 但是没有检查该整数是否为负,就像下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int read_user_data(int sockfd)
{
int length, sockfd, n;
char buffer[1024];
length = get_user_length(sockfd);
if(length > 1024){
error("illegal input, not enough room in buffer\n");
return 1;
}
if(read(sockfd, buffer, length) < 0){
error("read: %m");
return 1;
}
return 0;
}

在上面的代码中,假设get_user_length()函数从网络中读取一个32位整数。如果用户提供的长度为负数,则可以避免长度检查,从而危及应用程序。对于read()调用,一个负长度被转换为size_t类型,正如你所知道的,它将转换为一个大的无符号值。代码审查人员应该始终考虑带符号类型中的负值的含义,并查看是否会产生可能导致安全性暴露的意外结果。在这种情况下,由于错误的长度检查,可能触发缓冲区溢出;因此,这个疏忽是相当严重的。

审计技巧:带符号/无符号转换

你希望查找这样的情况:函数采用size_t或无符号整型长度参数,而程序员传递一个可能会受到用户的影响的有符号整数。适合查找的函数包括read()、recvfrom()、memcpy()、memset()、bcopy()、snprintf()、strncat()、strncpy()和malloc()。如果用户可以强制程序传入一个负值,那么函数将其解释为一个大值,这可能导致一个可利用的条件。

另外,查找直接从网络读取的长度参数位置,或者用户通过某种输入机制指定的位置。如果在代码的某些部分中将长度解释为带符号的变量,则应该评估用户提供负值的影响。

在检查应用程序中的函数时,最好在函数审计日志中注意每个函数的参数的数据类型。这样,每次审计对该函数的后续调用时,就可以简单地比较类型并对照本章中的转换表,来准确预测将会发生什么,以及这种转变的含义。 在第7章“程序构建模块”中,你会学到更多关于分析函数以及保持函数原型和行为的日志。

符号位扩展

符号位扩展发生在将带符号的较小整数类型转换为较大类型时,并且机器通过较大类型的未使用位传播较小类型的符号位。符号位扩展的目的是在从较小的有符号类型转换为较大的有符号类型时保值。

如你所知,符号位扩展可以以多种方式出现。首先,如果通过类型转换、赋值或函数调用从小带符号类型到大带符号类型进行简单转换,则会发生符号位扩展。你还知道,如果通过整数提升提升了小于整数的有符号类型,则会发生符号位扩展。符号位扩展还可能是在整型提升之后应用常规算术转换的结果,因为有符号整数类型可以升级为更大的类型,比如long long。

符号位扩展是该语言的一个自然组成部分,它对于整数的值保持提升是必要的。那么,为什么提到它是一个安全问题呢?有两个原因 :

  • 在某些情况下,符号位扩展是会产生意外结果的变值转换。
  • 程序员总是忘记他们使用的char和short类型是有符号的!

要检查第一个原因,如果你还记得转换部分,一个更有趣的发现是,如果将较小的有符号类型转换为较大的无符号类型,则会执行符号位扩展。假设一个程序员做了这样的事情:

1
2
3
char len;
len=get_len_field();
snprintf(dst, len, "%s", src);

这段代码写得一团糟。如果get_len_field()的结果使得len的值小于0,那么这个负值将作为长度参数传递给snprintf()。假设程序员试图修复此错误并执行以下操作:

1
2
3
char len;
len=get_len_field();
snprintf(dst, (unsigned int)len, "%s", src);

这个解决方案有点道理。一个无符号整数不可能是负的,对吧?不幸的是,符号位扩展发生在从char到unsigned int的转换过程中,因此试图删除小于0的字符会适得其反。如果len恰好小于0,(unsigned int)len就会得到一个大的值。

这个示例看起来有些随意,但是它类似于作者最近在客户机代码中发现的一个实际bug。这个故事的寓意是,你应该始终记住,在从较小的有符号类型转换为较大的无符号类型时,将应用符号位扩展。

第二个原因是程序员总是忘记他们使用的char和short类型是有符号的。这句话听起来非常正确,特别是在处理带符号整数长度的网络代码或每次处理一个字符的二进制或文本数据的代码中。看看l0pht的反嗅探(antisniff)工具( http://packetstormsecurity.org/sniffers/antisniff/ ) 的DNS包解析代码中一个真实存在的漏洞。它是演示前面讨论过的一些漏洞的绝佳错误。首先在该软件中发现了涉及不当使用strncat()的缓冲区溢出,在该漏洞被修补后,TESO的研究人员发现,由于符号位扩展问题,该软件仍然很脆弱。由于对符号位扩展问题的修复不正确,他们又发布了另一个漏洞。下面的示例将带你了解此漏洞的时间轴。

下面的代码在raw_watchdn .c文件的watch_dns_ptr()函数中包含了反嗅探研究发布版本1中稍微编辑过的易受攻击的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
char *indx;
int count;
char nameStr[MAX_LEN]; //256
...
memset(nameStr, '\0', sizeof(nameStr));
...
indx = (char *)(pkt + rr_offset);
count = (char)*indx;
while (count){
(char *)indx++;
strncat(nameStr, (char *)indx, count);
indx += count;
count = (char)*indx;
strncat(nameStr, ".",
sizeof(nameStr) strlen(nameStr));
}
nameStr[strlen(nameStr)-1] = '\0';

在理解这段代码之前,需要了解一些背景知识。watch_dns_ptr()函数的目的是从包中提取域名,并将其复制到nameStr字符串中。DNS包中的DNS域名有点像Pascal字符串。域名中的每个标签都有一个包含其长度的字节作为前缀。当你到达一个大小为0的标签时,域名结束。(DNS压缩方案与此漏洞无关。)图6-8显示了DNS域名在包中的样子。有三个标签 test、jim和com,以及一个0长度的标签,用于指定名称的结束。

6-8
6-8

该代码首先从包中读取第一个长度字节,并将其存储为整数count。这个长度字节是存储在整数中的带符号字符,因此你应该能够在count中放入-128到127之间的任何值。记住这一点,后面会用到。

while()循环继续读取标签,并在标签上调用strncat()到nameStr字符串。发布的第一个漏洞是在这个循环中没有长度检查。如果你只是在包中提供一个足够长的域名,那么它可能写过nameStr[]的边界。下面的代码显示了研究版本1.1中如何修复这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
char *indx;
int count;
char nameStr[MAX_LEN]; //256
...
memset(nameStr, '\0', sizeof(nameStr));
...
indx = (char *)(pkt + rr_offset);
count = (char)*indx;
while (count){
if (strlen(nameStr) + count < ( MAX_LEN - 1) ){ //这一行加粗
(char *)indx++;
strncat(nameStr, (char *)indx, count);
indx += count;
count = (char)*indx;
strncat(nameStr, ".",
sizeof(nameStr) strlen(nameStr));
} else { //这一行加粗
fprintf(stderr, "Alert! Someone is attempting "
"to send LONG DNS packets\n");
count = 0;
} // else括号里的都加粗
}
nameStr[strlen(nameStr)-1] = '\0';

代码基本相同,但是增加了长度检查,以防止缓冲区溢出。在循环的顶部,程序在执行字符串连接之前检查以确保缓冲区中有足够的空间用于count字节。现在在考虑符号位扩展漏洞的情况下检查这段代码。计数可以是-128到127之间的任意值,如果计数为负数会怎样呢?看看长度检查部分:

1
if (strlen(nameStr) + count < ( MAX_LEN - 1) ){

你知道,strlen(nameStr)会返回一个size_t,在32位系统中也就是等同于unsigned int,你同样直到,count是一个小于0的数,假如这个循环已经进行了一次,并且strlen(nameStr)是5,并且count是-1,对于加法,count会被转换为无符号整数,也就是(5+4,294,967,295),这将造成算术溢出然后得到一个小的值,例如4,4小于(MAX_LEN-1),也就是256,这看起来还是很好的。接下来,你会看到count(值被你设为-1)被传入strcat()里,strcat()函数取的是size_t,因此这个值会被解释为4,294,967,295。因此,你又取得了漏洞利用的胜利。你可以将足够多的你想要的信息写入nameStr字符串中。

下面的代码显示了这个漏洞在研究发布版本1.1.1中是如何被解决的:

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
char *indx;
int count;
char nameStr[MAX_LEN]; //256
...
memset(nameStr, '\0', sizeof(nameStr));
...
indx = (char *)(pkt + rr_offset);
count = (char)*indx;
while (count){
/* typecast the strlen so we aren't dependent on
the call to be properly setting to unsigned. */
if ((unsigned int)strlen(nameStr) +
(unsigned int)count < ( MAX_LEN - 1) ){
//上面两行加粗
(char *)indx++;
strncat(nameStr, (char *)indx, count);
indx += count;
count = (char)*indx;
strncat(nameStr, ".",
sizeof(nameStr) strlen(nameStr));
} else {
fprintf(stderr, "Alert! Someone is attempting "
"to send LONG DNS packets\n");
count = 0;
}
}
nameStr[strlen(nameStr)-1] = '\0';

这个解决方案基本上就是相同的代码,除了强制类型转换被加入了长度检查中。在下面的代码体现:

1
2
if ((unsigned int)strlen(nameStr) +
(unsigned int)count < ( MAX_LEN - 1) ){

strlen()的结果会被强制转换为unsigned int,显然是多余的举动,因为它已经是size_t了。count会被强制类型转换为unsigned int。这也是多余的,因为它会被加法运算符隐式地转换为无符号整数类型。本质上来说,什么都没有改变。你仍然可以将一个负值标签长度传入然后通过长度检查!下面的代码显示了这个问题是如何在1.1.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
unsigned char *indx;
unsigned int count;
unsigned char nameStr[MAX_LEN]; //256
//上面三行加粗
...
memset(nameStr, '\0', sizeof(nameStr));
...
indx = (char *)(pkt + rr_offset);
count = (char)*indx;
while (count){
if (strlen(nameStr) + count < ( MAX_LEN - 1) ){
indx++;
strncat(nameStr, indx, count);
indx += count;
count = *indx;
strncat(nameStr, ".",
sizeof(nameStr) strlen(nameStr));
} else {
fprintf(stderr, "Alert! Someone is attempting "
"to send LONG DNS packets\n");
count = 0;
}
}
nameStr[strlen(nameStr)-1] = '\0';

开发者将count,nameStr,indx变为了无符号数然后回到了以前版本的长度检查。因此,你现在使用的符号位扩展似乎消失了,因为字符指针indx现在是无符号类型。但是,仔细看看这一行:

1
count = (char)*indx;

这份代码解引用了indx,它是一个unsigned char指针。这会给你一个无符号的字符,它会被显式地转换为signed char。你知道在位模式上不会改变, 这样就回到了-128到127的范围。它被赋值给无符号整型,但是你知道从较小的有符号类型转换为较大的无符号类型会导致符号位扩展。因此,由于类型转换到(char),你仍然可以在循环中获得一个恶意的大count,但仅针对第一个标签。现在看看这个长度检查:

1
if (strlen(nameStr) + count < ( MAX_LEN - 1) ){

不幸地是,strlen(nameStr)在第一次循环时是0,因此任意大值的count不会比(MAX_LEN-1)小, 然后你被抓住,被踢出了循环。接近了,但还不清楚。有趣的是,如果你在第一次进入循环时被踢出,程序将执行以下操作:

1
nameStr[strlen(nameStr)-1] = '\0';

由于strlen(nameStr)是0,意思就是它在缓冲区后面1字节处写入0,在nameStr[-1]。 现在你已经从20-20的后见之明的角度了解了修复的发展,请看下面的代码,这是一个基于short整数数据类型的示例:

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
unsigned short read_length(int sockfd)
{
unsigned short len;
if(full_read(sockfd, (void *)&len, 2) != 2)
die("could not read length!\n");
return ntohs(len);
}
int read_packet(int sockfd)
{
struct header hdr;
short length;
char *buffer;
length = read_length(sockfd);
if(length > 1024){
error("read_packet: length too large: %d\n", length);
return 1;
}
buffer = (char *)malloc(length+1);
if((n = read(sockfd, buffer, length) < 0){
error("read: %m");
free(buffer);
return 1;
}
buffer[n] = '\0';
return 0;
}

许多在本章中探索过的概念都会在这里起作用。首先,read_length()函数的结果是unsigned short int,会被转换为signed short int然后被储存在length中。在接下来的长度检查中,比较的两边都会被提升为整数。如果length是一个负数,只要它大于1024就会通过检查。接下来的一行将length加一然后传入malloc()的第一个参数。length参数再一次符号位扩展因为它会被加法提升为整数。因此,如果length的值设为0xFFFF,符号位扩展后就是0xFFFFFFF。这个值加1会环绕到0,然后malloc(0)返回一个非常小的内存。最终,read()的效用造成第三个变量,length参数被直接从short int转换为size_t。符号位扩展会发生因此这是一个小的带符号类型转换为大的无符号类型的情况。因此对read()的调用允许你从缓冲区中读入非常大数目的字节,造成潜在的缓冲区溢出。

另一个典型的例子是程序员在使用ctype libc函数时忘记小类型是否有符号。考虑toupper()函数,它具有以下原型:

1
int toupper(int c);

toupper()函数在绝大多数libc实现中通过在查找表中搜索正确答案来工作。 一些libc不能正确地处理负数参数,和在内存中对表进行的索引。 下面toupper()的定义不常见:

1
2
3
4
int toupper(int c)
{
return _toupper_tab[c];
}

现在假如你做像下面的事情:

1
2
3
4
5
6
7
8
void upperize(char *str)
{
while (*str)
{
*str = toupper(*str);
str++;
}
}

如果libc实现没有健壮的toupper()函数,则可能会对字符串进行一些奇怪的更改。如果其中一个字符是-1,那么它将被转换为一个值为-1的整数,toupper()函数将在其内存中的表后面进行索引。

看看程序员不考虑符号位扩展的最后一个实际例子。下面是是安全研究员Michael Zalewski发现的一个Sendmail漏洞(www.cert.org/advisories/CA-2003-12.html)。它来自Sendmail版本8.12.3中的prescan()函数,主要负责将电子邮件地址解析为令牌(来自sendmail /parseaddr.c)。为简洁起见,这里对代码进行了编辑。

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
register char *p;
register char *q;
register int c;
...
p = addr;
for (;;)
{
/* store away any old lookahead character */
if (c != NOCHAR && !bslashmode)
{
/* see if there is room */
if (q >= &pvpbuf[pvpbsize - 5])
{
usrerr("553 5.1.1 Address too long");
if (strlen(addr) > MAXNAME)
addr[MAXNAME] = '\0';
returnnull:
if (delimptr != NULL)
*delimptr = p;
CurEnv->e_to = saveto;
return NULL;
}
/* squirrel it away */
*q++ = c;
}
/* read a new input character */
c = *p++;

..

/* chew up special characters */
*q = '\0';
if (bslashmode)
{
bslashmode = false;
/* kludge \! for naive users */
if (cmntcnt > 0)
{
c = NOCHAR;
continue;
}
else if (c != '!' || state == QST)
{
*q++ = '\\';
continue;
}
}
if (c == '\\')
bslashmode = true;
}

NOCHAR常数定义为-1, 用于表示处理字符时的某些错误条件。变量p处理一个用户提供的地址,并在读取完整的符号(token)后退出循环。在循环中有一个长度检查;但是,只有当两个条件为真时才检查它:当c不是NOCHAR(即,c != -1)和bslashmode为假时。问题在这一行:

1
c = *p++;

由于p指向的字符的符号位扩展,用户可以指定字符0xFF并将其扩展到0xFFFFFFFF,也就是NOCHAR。如果用户提供一个重复模式,即0x2F(反斜杠字符)后跟0xFF,则循环可以连续运行,而不需要在顶部执行长度检查。这将导致反斜杠连续写入目标缓冲区,而不检查是否还有足够的空间。因此,由于存储在变量c中的字符被标记扩展,会触发一个意外的代码路径,从而导致缓冲区溢出。

这一漏洞还加强了本章开头所述的另一项原则。编译器执行的隐式操作很微妙,在检查源代码时,你需要检查类型转换的含义,并预期程序将如何处理意外值(在本例中是NOCHAR值,由于符号位扩展,用户可以指定它)。

符号位扩展似乎应该是普遍存在的,而且在C代码中基本无害。但是,程序员在转换较小的数据类型时很少打算使用符号位扩展,符号位扩展的出现通常表明存在错误。符号位扩展在C中很难定位,但是它在汇编代码中很好地表现为movsx指令。尝试通过汇编练习搜索符号位扩展转换,然后将它们与源代码关联起来,这是一种有用的技术。

1
2
3
4
5
6
7
8
//符号位扩展示例
unsigned int l;
char c=5;
l=
//零扩展示例
unsigned int l;
unsigned char c=5;
l=

假设实现调用有符号字符,你知道符号位扩展将出现在上面符号位扩展示例中,而不是零扩展示例。比较生成的汇编代码,如下表所示。

符号位扩展 零扩展
mov [ebp+var_5], 5 mov [ebp+var_5], 5
movsx eax, [ebp+var_5] xor eax, eax
mov al, [ebp+var_5]
mov [ebp+var_4], eax mov [ebp+var_4], eax

可以看到,在符号位扩展示例中,使用了movsx指令。在零扩展示例中,编译器首先使用xor eax、eax清除寄存器,然后将字符字节移动到该寄存器中。

审计提示:符号位扩展

在寻找与符号位扩展相关的漏洞时,你应该关注处理带符号字符值/指针或有符号短整数值/指针的代码。通常,你可以在字符串处理代码和对带有长度元素的数据包进行解码的网络代码中找到它们。通常,你希望查找具有字符或short整数类型的代码,并在将其转换为整数的上下文中使用它。记住,如果看到带符号字符或signed short转换为无符号整数,仍然会出现符号位扩展。 如前所述,查找符号位扩展漏洞的一种有效方法是搜索movsx指令的应用程序二进制代码的汇编代码。在搜索代码中可能存在漏洞的位置时,这种技术通常可以帮助你穿越typedef、宏和类型转换的多个层面的干扰。

截断

截断(truncation)经常在大类型转换为小类型时发生。请注意常规算术转换以及整数提升实际上从未要求将大型类型转换为较小的类型。因此,截断只能在赋值、类型转换或涉及原型的函数调用时发生。这里有一个截断的简单例子:

1
2
3
int g = 0x12345678;
short int h;
h = g;

当g赋值到h时,前16字节的值就会被截断,h的值会变成0x5678。因此如果这种数据丢失的情况如果程序员并没有预期到的话,就肯定会造成安全问题。下面的代码基于历史版本的网络文件系统(Network File System, NFS)的整数截断安全漏洞:

1
2
3
4
5
6
7
8
9
10
11
void assume_privs(unsigned short uid)
{
seteuid(uid);
setuid(uid);
}
int become_user(int uid)
{
if (uid == 0)
die("root isnt allowed");
assume_privs(uid);
}

公平地说,这个漏洞大多是趣闻轶事,它的存在并没有通过源代码验证。NFS禁止用户使用root权限远程装载磁盘。最终,攻击者发现他们可以指定一个UID为65536,它将通过防止root访问的安全检查。但是,这个UID将被分配给一个unsigned short整数类型,并被截断为一个值0。因此,攻击者可以假定root用户的UID为0,从而绕过保护。

在查看真实的截断问题之前,请先查看下面代码中的另一个合成漏洞:

1
2
3
4
5
6
7
8
unsigned short int f;
char mybuf[1024];
char *userstr=getuserstr();

f=strlen(userstr);
if (f > sizeof(mybuf)-5)
die("string too long!");
strcpy(mybuf, userstr);

strlen()函数的返回值是size_t,被转换成了unsigned short。如果一个字符串有66,000个长度的字符,那么截断就会发生,f的值将会是464.因此,对函数strcpy()的长度检查保护就会被突破,缓冲区溢出就会发生。

大多数SSH守护进程中的一个停止显示(show-stopping)的错误是由整数截断引起的。具有讽刺意味的是,易受攻击的代码是在一个旨在解决另一个安全漏洞的函数中,即由CORE-SDI识别的SSH插入攻击。关于这次攻击的详细信息可以在 www1.corest.com/files/files/11/CRC32.pdf. 上找到。

这种攻击的本质是,攻击者可以对块密码使用一种已知的聪明的明文攻击,将他们选择的少量数据插入到SSH流中。通常,这种攻击可以通过消息完整性检查来阻止,但是SSH使用了CRC32, CORE-SDI的研究人员找到了在SSH协议上下文中规避它的方法。

包含截断漏洞的函数的职责是确定插入攻击是否发生。这些插入攻击的一个属性是在包的末尾有一长串类似的字节,目的是操纵CRC32值,使其正确。设计的防御措施是搜索数据包中的重复块,然后进行CRC32计算直到重复点,以确定是否发生了任何操作。这种方法对于小数据包来说很容易,但是对于大数据集可能会产生性能影响。因此,大概是为了解决性能影响,我们使用了一种哈希方案。

你将要看到的函数有两个独立的代码路径。如果数据包小于一定的大小,它就对数据进行直接分析。如果大于这个大小,则使用哈希表来提高分析效率。没有必要了解功能来理解脆弱性。但是,如果你感到好奇,你将看到用于较小数据包的更简单的情况,其算法大致如下面代码所示。

1
2
3
4
5
6
7
8
9
10
11
for c = 包中的每8位块
if c 等于初始化向量块
检查c是否遭受攻击.
如果检查成功, 返回DETECTED.
如果检查失败, 则没有遭受攻击,返回OK.
for d = c之前包中的每8位块
如果d等于c, 检查c是否遭受攻击.
如果检查成功, 返回DETECTED.
如果检查失败, break出d的循环.
next d
next c

该代码遍历包中的每个8字节块,如果它看到包中与当前块相同的块,它就检查是否正在进行攻击.

代码中基于哈希表的路径稍微复杂一些。它们的算法广义上是相同的,但不是比较一堆8字节的块,而是对每个块的32位的哈希值来进行比较。哈希表由8字节块的32位哈希索引,对哈希表的大小取模,而bucket包含最后哈希到该bucket的块的位置。在哈希表的构造和管理中存在截断问题。下面代码包含代码的开头部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Detect a crc32 compensation attack on a packet */
int
detect_attack(unsigned char *buf, u_int32_t len,
unsigned char *IV)
{
static u_int16_t *h = (u_int16_t *) NULL;
static u_int16_t n = HASH_MINSIZE / HASH_ENTRYSIZE;
register u_int32_t i, j;
u_int32_t l;
register unsigned char *c;
unsigned char *d;
if (len > (SSH_MAXBLOCKS * SSH_BLOCKSIZE) ||
len % SSH_BLOCKSIZE != 0) {
fatal("detect_attack: bad length %d", len);
}

首先,这份代码检查了包是否足够长或者是否不是8字节的倍数。SSH_MAXBLOCKS是32,768,BLOCKSIZE是8,因此包的大小能够达到262,144字节。在下面的代码,n从HASH_MINSIZE / HASH_ENTRYSIZE开始,也就是8,192/2=4096,目的是保存哈希表中的入口数:

1
2
for (l = n; l < HASH_FACTOR(len / SSH_BLOCKSIZE); l = l << 2)
;

哈希表初始大小是8,192个元素。这个循环尝试得到一个对于哈希表较好的大小。它先从一个n的猜测值开始,也就是当前大小,然后检查它是否对于包足够大,如果不是,那就通过左移两次将l增大四倍。因为包中有8字节的块,它通过确定对于8位块是否有对应2/3数目的哈希表入口决定哈希表是否足够大。HASH_FACTOR定义为((x)*3/2)。下面的代码就是有趣的部分了:

1
2
3
4
5
6
7
8
9
10
11
if (h == NULL) {
debug("Installing crc compensation "
"attack detector.");
n = l;
h = (u_int16_t *) xmalloc(n * HASH_ENTRYSIZE);
} else {
if (l > n) {
n = l;
h = (u_int16_t *)xrealloc(h, n * HASH_ENTRYSIZE);
}
}

如果h为NULL,就表示这是第一次通过这个函数,你需要为新的哈希表分配空间。如果你记得的话,l是计算后得到的哈希表大小,n包含了哈希表入口数量。如果h不为NULL,就表明哈希表已经分配过了。然而,如果当前哈希表对于新的计算过的l并没有足够大,然后就回到前面重新分配。

你已经看过足够的代码了,现在可以看它的问题了:n是unsigend short int。如果你传入的包大小够大,l,一个unsigned int,就可能得到一个大于65,535的值,当l传入n时,截断就会发生。例如,假如你传入一个大小为262,144字节大小的包。首先它通过了第一个检查,然后在循环中,l会像下面这样改变:

1
2
3
Iteration 1: l = 4096 l < 49152 l<<=4
Iteration 2: l = 16384 l < 49152 l<<=4
Iteration 3: l = 65536 l >= 49152

当l的值为65,536,被传入n时,前面16位就会截断,然后n的值就会是0.在现代操作系统中,malloc(0)的结果是一个指向小对象的合法返回指针,然后函数后面的行为就非常可疑。

在接下来函数的部分中,这些代码进行直接分析,由于他们并没有直接使用哈希表,因此并不那么有趣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if (len <= HASH_MINBLOCKS) {
for (c = buf; c < buf + len; c += SSH_BLOCKSIZE) {
if (IV && (!CMP(c, IV))) {
if ((check_crc(c, buf, len, IV)))
return (DEATTACK_DETECTED);
else
break;
}
for (d = buf; d < c; d += SSH_BLOCKSIZE) {
if (!CMP(c, d)) {
if ((check_crc(c, buf, len, IV)))
return (DEATTACK_DETECTED);
else
break;
}
}
}
return (DEATTACK_OK);
}

接下来是执行基于哈希的检测例程的代码。在下面的代码中,请记住n的值是0,h是一个在堆中很小但合法的对象。在这种情况下,就可以在进程内存中做一些有趣的事:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
memset(h, HASH_UNUSEDCHAR, n * HASH_ENTRYSIZE);
if (IV)
h[HASH(IV) & (n - 1)] = HASH_IV;
for (c = buf, j = 0; c < (buf + len); c += SSH_BLOCKSIZE, j++) {
for (i = HASH(c) & (n - 1); h[i] != HASH_UNUSED;
i = (i + 1) & (n - 1)) {
if (h[i] == HASH_IV) {
if (!CMP(c, IV)) {
if (check_crc(c, buf, len, IV))
return (DEATTACK_DETECTED);
else
break;
}
} else if (!CMP(c, buf + h[i] * SSH_BLOCKSIZE)) {
if (check_crc(c, buf, len, IV))
return (DEATTACK_DETECTED);
else
break;
}
}
h[i] = j;
}
return (DEATTACK_OK);
}

如果你没有立刻看出在这个循环中实施攻击的方法,不用担心。(你很好,代码还缺少一些关键的宏定义。)这个bug非常的微妙,对它的利用很复杂并且要动点脑子。事实上,这个漏洞对于很多角度来说是独一无二的。它强调了安全编程是非常困难的,每个人都会犯错,就算CORE-SDI这个世界上最有技术含量的安全公司。它仍然展现了有时候一个简单的黑盒测试仍然不能发现一些难以在源代码审计中发现的漏洞。这个漏洞的发现者,Micheal Zalewski, 以一种令人震惊的直接方式定位了这个弱点(ssh -l long_user_name).最终, 它强调了一个值得注意的例子,在这个例子中,编写一个漏洞比找到它的根漏洞更加困难。

译者注:

这个漏洞的描述是这样的(节选):

By sending a crafted SSH1 packet to an affected host, an attacker can cause the SSH daemon to create a hash table with a size of zero. When the detection function then attempts to hash values into the null-sized hash table, these values can be used to modify the return address of the function call, thus causing the program to execute arbitrary code with the privileges of the SSH daemon, typically root.

翻译为中文就是:

通过向受影响的主机发送精心设计的SSH1包,攻击者可以导致SSH守护进程创建一个大小为0的哈希表。当检测函数尝试对空哈希表进行哈希索引时,这些值可以用来改变调用函数的返回地址,最终导致程序在使用SSH守护进程的权限(一般为root)下执行任意代码。

带有漏洞的守护进程源代码和漏洞描述见 https://web.archive.org/web/20051013074750/http://www.kb.cert.org/vuls/id/945216

审计提示:截断

当整数值被分配给较小的数据类型(如short整数类型或字符)时,通常会发现与截断相关的漏洞。要查找截断问题,请查找使用这些较短数据类型跟踪长度值或保存计算结果的位置。寻找潜在变量的一个好地方是在结构体定义中,特别是面向网络的代码。

程序员通常使用short或字符数据类型,只是因为变量的预期值范围很好地映射到该数据类型。但是,使用这些数据类型通常会导致未预期的截断。

比较

你已经看到了在长度检查中对负数进行符号比较的示例,以及它们如何暴露安全性问题。 另一个潜在的危险情况是比较具有不同类型的两个整数。如你所知,在进行比较时,编译器首先对操作数执行整数提升,然后对操作数执行常规的算术转换,以便可以对兼容类型进行比较。因为这些提升和转换可能会导致值的更改(因为符号的更改),所以比较可能不会完全按照程序员的意图进行。攻击者可以利用这些转换来规避安全性检查,并经常危及应用程序。

为了看看比较可以怎样地走错路,可以参见下面的代码。这份的代码从网络中读取一个short整数,以确定读入包的长度。长度检查的前半段比较了(length-sizeof(short))和0来确保长度不会小于sizeof(short)。 如果是,那么在read()语句中稍后减去sizeof(short)时,它可以环绕成一个大整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define MAX_SIZE 1024
int read_packet(int sockfd)
{
short length;
char buf[MAX_SIZE];
length = network_get_short(sockfd);
if(length-sizeof(short) <= 0 || length > MAX_SIZE){
error("bad length supplied\n");
return 1;
}
if(read(sockfd, buf, length sizeof(short)) < 0){
error("read: %m\n");
return 1;
}
return 0;
}

第一个检查实际上是不正确的。注意sizeof运算符的返回类型是size_t,一个无符号类型。因此对于减法(length-sizeof(short))来说,length首先会被整数提升为signed int,然后被常规算术转换转换为无符号整数类型。减法运算的最终类型为无符号整数类型。最后,减法的结果永远不可能小于0,因此这个检查实际上什么都没有做。·为length提供一个值1可以避免if语句前半部分中的length检查在read()调用中试图防止并触发整数下溢的情况。

可以提供多个值以规避这两个检查并触发缓冲区溢出。如果length是一个负数,例如0xFFFF,那么第一个检查会通过,因为减法的结果类型是无符号的。第二个检查(length>MAX_SIZE)仍然会通过,因为length在比较时是一个signed int,并且是个负数,那么它就小于MAX_SIZE(1024)。 这个结果表明length变量在一种情况下是无符号的,而在另一种情况下是有符号的,因为在比较中使用了其他操作数。

在处理小于int的数据类型时,整型提升会使窄值变成带符号整数。这是一种保值的整数提升,本身并不是什么大问题。但是,有时候比较可能无意中被提升为有符号类型。下面的代码说明了这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int read_data(int sockfd)
{
char buf[1024];
unsigned short max = sizeof(buf);
short length;
length = get_network_short(sockfd);
if(length > max){
error("bad length: %d\n", length);
return 1;
}
if(read(sockfd, buf, length) < 0){
error("read: %m");
return 1;
}
... process data ...
return 0;
}

这份代码阐释了为什么你必须清楚在比较中你使用的类型结果是什么。max和length变量都是short整数类型,因此,它们会被提升为带符号整数。这意味着任何length提供的负值都会越过和max的长度检查。 由于在比较中执行数据类型转换,不仅可以避免完整性检查,而且会使整个比较变得无用,因为它检查的是不可能的条件。 考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int get_int(char *data)
{
unsigned int n = atoi(data);
if(n < 0 || n > 1024)
return 1;
return n;
}
int main(int argc, char **argv)
{
unsigned long n;
char buf[1024];
if(argc < 2)
exit(0);
n = get_int(argv[1]);
if(n < 0){
fprintf(stderr, "illegal length specified\n");
exit(-1);
}
memset(buf, 'A', n);
return 0;
}

这份代码检查了变量n,以确保它落在0到1024的范围内。由于变量n是无符号的,小于0的检查就是不可能的。因为任何可以表示的值都必须为正数。潜在的漏洞非常微妙;如果攻击者在argv[1]中提供一个非法的整数,get_int()返回-1,然后在赋值给n后被转换成unsigned long。因此,这就会变为一个很大的值然后导致menset()让程序崩溃。

编译器可以检测永远不会为真的条件,并在传递某些标志时发出警告。看看用GCC编译前面的代码时会发生什么 :

1
2
3
4
5
6
7
8
9
[root@doppelganger root]# gcc -Wall -o example example.c
[root@doppelganger root]# gcc -W -o example example.c
example.c: In function 'get_int':
example.c:10: warning: comparison of unsigned expression < 0 is always
false
example.c: In function 'main':
example.c:25: warning: comparison of unsigned expression < 0 is always
false
[root@doppelganger root]#

请注意,-Wall标志并不像大多数开发人员所期望的那样警告这种类型的错误。 为了生成这种类型bug的警告,必须使用-w标志。如果代码if(n<0)变为if(n<=0),那么警告就不会发生因为这个条件已经不再成为可能。现在来看看真实世界的错误。下面的代码来自读入POST数据的PHP Apache模块(4.3.4)。(PHP 是这个世界上最好的语言(滑稽) —by译者)

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
/* {{{ sapi_apache_read_post
*/
static int sapi_apache_read_post(char *buffer,
uint count_bytes TSRMLS_DC)
{
uint total_read_bytes=0, read_bytes;
request_rec *r = (request_rec *) SG(server_context);
void (*handler)(int);
/*
* This handles the situation where the browser sends a
* Expect: 100-continue header and needs to receive
* confirmation from the server on whether or not it
* can send the rest of the request. RFC 2616
*
*/
if (!SG(read_post_bytes) && !ap_should_client_block(r)) {
return total_read_bytes;
}
handler = signal(SIGPIPE, SIG_IGN);
while (total_read_bytes<count_bytes) {
/* start timeout timer */
hard_timeout("Read POST information", r);
read_bytes = get_client_block(r,
buffer + total_read_bytes,
count_bytes - total_read_bytes);
reset_timeout(r);
if (read_bytes<=) {
break;
}
total_read_bytes += read_bytes;
}
signal(SIGPIPE, handler);
return total_read_bytes;
}

从get_client_block()返回的值会被存在read_bytes变量中然后做比较来确定没有返回负值。因为raed_bytes是无符号的,这个检查就不会从get_client_block()中得到任何错误。不过事实证明,这个bug并不能在这个函数中立刻实施漏洞利用,你能看出来为什么吗?循环的控制中也有一个无符号比较,因此如果total_read_bytes减到0以下就会产生下溢,因此,得到一个比count_bytes还要大的值,然后循环结束。

审计技巧

检查比较对于审计C代码是必不可少的。特别注意保护分配(protect allocation)、数组索引和复制操作的比较。检验这些比较的最好方法是逐行仔细研究每个相关的表达式。

通常,你应该跟踪每个变量及其底层数据类型。如果可以将函数的输入追溯到熟悉的源,那么应该对每个输入变量可能具有的值有一个很好的了解。继续进行每个可能有趣的计算或比较,并在函数求值的不同点跟踪变量的可能值。你可以使用类似于前一节中查找整数边界条件问题所概述的过程。

在计算比较时,一定要注意有无符号整数值,以免它们的对等操作数被提升为无符号整型。sizeof和strlen()是导致这种提升的操作数的经典例子。

记住一定要注意无符号变量在比较中的使用,就像下面的这样:

1
2
if (uvar < 0) ...
if (uvar <= 0) ...

第一种形式会让编译器抛出警告,但第二种不会。如果你看到了这样的代码,那么一定会在这一节中的代码找到一些错误。 你应该仔细地逐行分析周围代码的功能。

7.2.7 运算符

运算符可以产生意想不到的结果。如你所见,在简单算术操作中使用未净化的(unsantilized)操作数可能会在应用程序中打开安全漏洞。这些漏洞曝光通常是跨越影响结果意义的边界条件的结果。此外,每个操作符都有关联的类型提升,这些提升隐式地对每个操作数执行,可能会产生一些意外的结果。由于产生意外结果是漏洞发现的本质,所以了解如何产生这些结果以及可能出现什么异常情况是很重要的。下面几节将重点介绍这些异常情况,并解释可能导致潜在漏洞的一些常见操作符误用。

sizeof运算符

第一个值得提及的运算符是sizeof。它经常被用在缓冲区分配,大小比较,以及和长度有关函数的长度变量中。sizeof运算符在某些情况下很容易被误用,这可能会在看起来很坚固的代码中导致一些微妙的漏洞。

sizeof最常见的错误之一就是在指针上不小心的误用。下面的代码展示了这样的类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
char *read_username(int sockfd)
{
char *buffer, *style, userstring[1024];
int i;
buffer = (char *)malloc(1024);
if(!buffer){
error("buffer allocation failed: %m");
return NULL;
}
if(read(sockfd, userstring, sizeof(userstring)-1) <= 0){
free(buffer);
error("read failure: %m");
return NULL;
}
userstring[sizeof(userstring)-1] = '\0';
style = strchr(userstring, ':');
if(style)
*style++ = '\0';
sprintf(buffer, "username=%.32s", userstring);
if(style)
snprintf(buffer, sizeof(buffer)-strlen(buffer)-1,
", style=%s\n", style);
return buffer;
}

在这份代码中,一些用户数据从网络中读入然后被复制到分配后的缓冲区中。然而,sizeof在buffer中误用了。直觉上sizeof(buffer)会返回1024,但是由于它用在了符号指针类型上,因此它只会返回4!这个结果会在style值存在时导致snprintf()的长度参数整数下溢;最后的后果就是任意数量的数据会被写入buffer变量指向的地址位置。这个错误非常容易犯,并且经常在读代码时不被注意到,因此要格外小心被传入sizeof运算符变量的类型。 它们最经常出现在长度参数中,如前面的示例中所示,但是在为分配空间而计算长度时,偶尔也会出现。这种类型的错误很少出现的原因是,错误的分配可能会导致程序崩溃,因此,在许多应用程序的发布之前就会被捕获(除非是在很少被遍历的代码路径中)。

sizeof()也会在带符号和无符号变量的比较漏洞(在前面的小节“比较”中),同时也在结构体填充问题(structure padding issues)(在本章后面“结构体填充”小节介绍)担任重要角色。

审计技巧:sizeof

要注意使用sizeof的情况,即开发人员在打算获取缓冲区的大小时获取指向缓冲区的指针的大小。这通常是由于编辑错误造成的,即缓冲区从函数内部移动到传递到函数中。

同样,在表达式中查找导致操作数转换为无符号值的sizeof。

出人意料的结果

你已经探索了算术运算符的两个主要特性:与整数类型存储相关的边界条件,以及在表达式中使用算术运算符时发生的转换所引起的问题。C的其他一些细微差别可能导致未预料到的行为,特别是与底层机器代码(underlying machine primitives)意识到符号相关的细微差别。如果预期结果在特定范围内,攻击者有时会违背这些预期。

有趣的是,在二进制补码机器中,在C中只有少数运算符的符号性(sign-ness)可以影响操作的结果。这些运算符中最重要的运算符是比较。除了比较之外,只有其他三个C操作符的结果对操作数是否有符号敏感:右移(>>)、除法(/)和取模(%)。当这些操作符与带符号操作数一起使用时,可能会产生意外的负结果,因为它们的底层机器级操作是可以识别符号的。作为代码审查人员,你应该注意这些操作符的滥用,因为它们可能产生超出预期值范围的结果,并使开发人员措手不及。

右移运算符(>>)常用于应用程序中代替除法运算符(在除以2的乘方时)。当使用带符号整数作为左操作数时,可能会出现问题。当右移一个负值时,执行符号位扩展算术移位(arithmetic shift)的底层机器保留该值的符号。这种保留符号的右移如下面代码所示:

1
2
3
4
signed char c = 0x80;
c >>= 4;
1000 0000 value before right shift
1111 1000 value after right shift

下面的代码显示了上面这段代码如何产生导致漏洞的意外结果。它接近于最近在客户端代码中发现的一个实际漏洞。

1
2
3
4
5
6
int print_high_word(int number)
{
char buf[sizeof("65535")];
sprintf(buf, "%u", number >> 16);
return 0;
}

这个函数是设计用来打印一个16位的带符号整数(number变量的高16位)。由于number是带符号的,因此number如果是负数就会发生符号位扩展右移。 所以,sprintf()的%u说明符可以打印比为目标缓冲区分配的空间大小sizeof("65535")大得多的数字,结果就是缓冲区溢出。

易受攻击的右移是一些bug的好例子,这些bug在源代码中很难定位,但在汇编代码中却很容易看到。在Intel汇编代码中,sar助记符执行一种有符号的或算术的右移。shr助记符执行逻辑或无符号右移。因此,分析汇编代码可以帮助你确定右移是否可能容易受到签名扩展的影响。下表显示了汇编代码中的有符号和无符号右移操作。

带符号右移运算符 无符号右移运算符
mov eax, [ebp+8] mov eax, [ebp+8]
sar eax, 16 shr eax, 16
push eax push eax
push offset string push offset string
lea eax, [ebp+var_8] lea eax, [ebp+var_8]
push eax push eax
call sprintf call sprintf

除法(/)是另一个可以造成出人意料结果的运算符,同样是出于符号的原因。只要有一边操作数是负值, 得到的商也是负的。通常,在对整数进行除法时,应用程序通常不会考虑负结果的可能性。下面的代码展示了在除法中使用负值操作数的漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int read_data(int sockfd)
{
int bitlength;
char *buffer;
bitlength = network_get_int(length);
buffer = (char *)malloc(bitlength / 8 + 1);
if (buffer == NULL)
die("no memory");
if(read(sockfd, buffer, bitlength / 8) < 0){
error("read error: %m");
return -1;
}
return 0;
}

这份代码中从网络中取出bitlength参数然后基于次分配内存。bitlength除以8以得到接下来从套接字中读取的数据需要的字节数。并且结果加了1,用来储存除以8余下的那一部分位数。如果除法能够用来返回-1,那么加上1之后就是0,最后导致malloc返回一个分配了的非常小的内存。然后read()的第三个变量将会是-1,然后被转换为size_t后解释为一个非常大的正数值。

相似地,取模运算符(%)也会在应对负操作数时产生负值。代码审计人员应该注意没有正确地清理(santilize)操作数的取模操作,因为它们可能产生负面结果,从而导致安全性暴露。模运算符通常用于处理固定大小的数组(比如哈希表),因此负的结果可以立即在数组开始前面索引,如下面代码所示。

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
#define SESSION_SIZE 1024
struct session {
struct session *next;
int session_id;
}
struct header {
int session_id;
...
};
struct session *sessions[SESSION_SIZE];
struct session *session_new(int session_id)
{
struct session *new1, *tmp;
new1 = malloc(sizeof(struct session));
if(!new1)
die("malloc: %m");
new1->session_id = session_id;
new1->next = NULL;
if(!sessions[session_id%(SESSION_SIZE-1)])
{
sessions[session_id%(SESSION_SIZE-1] = new1;
return new1;
}
for(tmp = sessions[session_id%(SESSION_SIZE-1)]; tmp->next; tmp = tmp->next);
tmp->next = new1;
return new1;
}
int read_packet(int sockfd)
{
struct session *session;
struct header hdr;
if(full_read(sockfd, (void *)&hdr, sizeof(hdr)) !=
sizeof(hdr))
{
error("read: %m");
return 1;
}
if((session = session_find(hdr.session_id)) == NULL)
{
session = session_new(hdr.sessionid);
return 0;
}
... validate packet with session ...
return 0;
}

正如你所见,首先从网络中读取头, 会话(session)的信息根据头的会话标识符字段(identifier field)从哈希表检索。 所有会话会存储在sessions哈希表中以便稍后由程序检索。如果session标识符是负数,那么取模运算的值也是负数,然后sessions的越界元素会被检索,也可能会被写入,然后造成可以利用的条件。

与右移操作符一样,无符号和有符号的除法和模数操作在Intel汇编代码中可以很容易地加以区分。无符号除法指令的助记符是div,有符号的对应指令是idiv。下表显示了有符号除法和无符号除法操作之间的区别。注意,当除数为常数时,编译器通常使用右移操作而不是除法。

带符号除法操作 无符号除法操作
mov eax, [ebp+8] mov eax, [ebp+8]
mov ecx, [ebp+c] mov ecx, [ebp+c]
cdq cdq
idiv ecx div ecx
ret ret

审计技巧:出人意料的结果

每当遇到右移位时,一定要检查左操作数是否有符号。如果是这样,则可能存在轻微的潜在漏洞。类似地,寻找具有符号操作数的取模和除法操作。如果用户可以指定负值,则可能会导致意外的结果。

译者注

在“出人意料的结果”这一小节,作者只是在编译的角度去解释对一些语法的误用导致的意外结果。但在链接过程中也会产生意外结果,本质上是全局变量区域(ELF中是.data,.bss节)符号的重复定义导致的,比如下面的这种例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.c
#include <stdio.h>
int d = 100;
int x = 200;
void p1(void);
int main(){
p1();
printf("d=%d,x=%d\n",d,x);
return 0;
}
// p1.c
double d;

void p1(){
d = 1.0;
}

//output: d=0,x=1072693248 (Ubuntu 16.04,gcc 5.4.0)

在这里,p1.c中的d是弱符号(即给出声明,没有定义值),main.c里的d是强符号(即定义了值),按照链接规则,多次定义的符号取强符号为准,然后在汇编级别就导致了一个double的赋值绕过了编译阶段的常规算术转换直接在位模式上赋到了int里,向main.c中的d和x位置上写入了8个字节。

不过这种级别的错误编译器会抛出警告,加了extern甚至编译器就不抛出警告了。

7.2.8 指针运算

指针通常是初学C编程的人遇到的第一个主要障碍,因为它们很难理解。涉及指针运算、解引用和间接指向(indirection, i.e. 指针指向指针 —by译者)、按值传递语义、指针操作符优先级和数组的伪等价(pseudo-equivalence)的规则可能很难学习。下面几节将重点讨论指针运算的几个方面,这些方面可能会让开发人员措手不及,并导致可能的安全暴露。

指针概述

你知道一个指针本质上是一个在内存地址的位置,所以它是一个数据类型,并且必须是实现依赖的。在不同的体系结构上,指针的表示可能会有显著的不同,即使在32位的Intel体系结构上,指针也可以以不同的方式实现。例如,你可以使用基于16位的代码,甚至可以使用透明地支持包含段的自定义虚拟内存方案的编译器。因此,以下讨论假设使用GCC或vc++编译器的通用架构来编写英特尔机器上的用户代码。

你知道,指针可能必须是无符号整数,因为有效虚拟内存地址的范围从0x0到0xffffffff。也就是说,当你减去两个指针时,它看起来有点奇怪。难道指针不需要以某种方式表示负值吗?所以减法的结果根本不是一个指针;相反,它是一个有符号整数类型,称为ptrdiff_t。

通过强制类型转换,指针可以自由地转换为整数和其他类型的指针。但是,编译器不保证结果指针或整数正确对齐或指向有效对象。因此,指针是C语言中更依赖于实现的部分之一。

指针运算概述

当你你在做指针运算时会发生什么?下面时一个简单的给指针加1的例子:

1
2
short *j;j=(short *)0x1234;
j = j + 1;

这份代码中有一个名为j的指针。它初始化为任意的不变地址,0x1234.这是一份很差劲的C代码,但也值得用来讨论我们指向的内容。就像前面所提到的,你可以将指针在强制类型转换后当作整数使用,但结果取决于实现。你可能假设当你将j加1后会得到0x1235。然而,这不会发生,j的值应该时0x1236才对。

当C执行涉及指针的算术运算时,它会相对于指针目标的大小执行操作。所以当你给一个对象的指针加1时,结果是一个指向内存中下一个相同大小的对象的指针。在本例中,对象是一个short整数,占用2个字节(在32位Intel架构中),因此内存中紧跟在0x1234后面的short整数位于0x1236位置。如果减去1,结果是在0x1234之前的short的地址,即0x1232。如果加5,就会得到地址0x123e,它是距离0x1234的第5个short的地址。

另一种考虑方法是,将一个指向对象的指针视为由该对象的一个元素组成的数组。所以j,一个指向short的指针,被当作数组short j[1],它包含一个short。因此,j + 2就等于&j[2]。下表显示了这个概念。

指针表达式 数组表达式 地址
j-2 &j[-2] 0x1230
j-1 &j[-1] 0x1232
j j 或者 &j[0] 0x1234
j+1 &j[1] 0x1236
j+2 &j[2] 0x1238
j+3 &j[3] 0x123a
j+4 &j[4] 0x123c
j+5 &j[5] 0x123e

现在看看重要的指针算术运算符的详细信息,这将在以下部分中介绍。

加法

指针加法的规则比你预期的要严格一些。可以将整数类型添加到指针类型或将指针类型添加到整数类型,但不能将指针类型添加到指针类型。考虑指针加法实际上是做什么是有意义的;编译器不会知道哪个指针用作基类型,哪个指针用作索引。例如,看看下面的操作:

1
2
3
unsigned short *j;
unsigned long *k;
x = j+k;

这个操作是无效的,因为编译器不知道如何将j或k转换为指针运算的索引。你当然可以将j或k转换为整数,但结果可能出乎意料,而且不太可能有人有意这样做 。

C语言的一个有趣规则是下标操作符属于指针加法的范畴。C标准声明下标运算符等价于以下方式涉及加法的表达式:

1
E1[E2] 等价于 (*((E1)+(E2)))

记住这一点,看看下面的例子 :

1
char b[10];b[4]='a';

表达式b[4]表示符号数组b的第5个元素。根据上面的规则,对于这个写入操作有以下等价的方法:

1
(*((b)+(4)))='a';

从前面的分析中你已经知道了b+4代表什么,由于b是指向char的指针,因此它就等于在说&b[4];因此,这个表达式也就是在说(*(&b[4]))或者b[4]。

最后,请注意整数和指针之间的加法的结果类型就是指针的类型。

减法

减法的规则与加法相似,但只允许从一个指针减去另一个指针。当你从相同类型的指针中减去一个指针时,你要求的是两个元素下标的差。因此,减法结果类型不是指针,而是ptrdiff_t,它是有符号整数类型。C标准指出应该在stddef.h头文件中定义它。

比较

指针之间的比较就像你所期望的那样。他们考虑两个指针在虚拟地址空间中的相对位置。结果类型与其他比较相同:包含1或0的整数类型

条件运算符

条件运算符(?)可以使用指针作为它的最后两个操作数,并且必须协调它们的类型,就像使用算术操作数一样。它通过将指针类型的所有限定符应用到结果类型来实现这一点。

漏洞

涉及指针运算的漏洞很少被广泛报道,至少在写这本书时是这样的。许多涉及字符指针操作的漏洞本质上都归结为对缓冲区大小的错误计算,尽管它们在技术上被定义为指针运算错误,但它们并不像指针漏洞那样微妙。更有害的问题形式是,开发人员错误地对指针执行算术运算,而没有意识到它们的整数操作数是按指针目标的大小缩放的。考虑以下代码:

1
2
3
4
5
6
int buf[1024];
int *b=buf;
while (havedata() && b < buf + sizeof(buf))
{
*b++=parseint(getdata());
}

b < buf + sizeof(buf)是为了阻止b向前推进时超过buf[1023]。然而,它实际上却阻止b超过buf[4092]。因此,这段代码可能容易出现相当简单的缓冲区溢出。

下面的代码分配了一个缓冲区然后将参数字符串中的第一个路径组件复制到缓冲区中。 一个长度检查用来保护wscat函数不会溢出分配的缓冲区,然而它的构造不正确。因为字符串是宽字符类型,指针减法(sep-string)检查输入大小返回两个指针的宽字符之差。也就是两个指针数值之差除以2.因此,只要sep-string小于(MAXCHARS*2)那么检查就会成功,这是分配的缓冲区两倍空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
wchar_t *copy_data(wchar_t *string)
{
wchar *sep, *new;
int size = MAXCHARS * sizeof(wchar);
new = (wchar *)xmalloc(size);
*new = '\0';
if(*string != '/'){
wcscpy(new, "/");
size -= sizeof(wchar_t);
}
sep = wstrchr(string, '/');
if(!sep)
sep = string + wcslen(string);
if(sep - string >= (size-sizeof(wchar_t))
{
free(new);
die("too much data");
}
*sep = '\0';
wcscat(new, string);
return new;
}
审计技巧

指针运算错误可能很难发现。每当执行涉及指针的算术运算时,查找那些指针的类型,然后检查操作是否与所发生的隐式运算一致。在上面的代码中,sizeof()是否被错误地用于指向非单字节类型的指针?是否发生过类似的操作,开发人员假设指针类型不会影响操作的执行?

7.2.9 其他C的细微差别

下面几节将讨论C语言的特性和可能会出现与安全性相关错误的黑暗角落。这些漏洞的实际例子并不多,但是你仍然应该意识到潜在的风险。有些示例可能看起来是人为设计的,但是尝试将它们想象为隐藏在宏层和相互依赖的函数之下,这样看起来可能更现实。

运算顺序

对于大多数操作符,C不能保证操作数求值的顺序或表达式“副作用”赋值的顺序。例如,考虑以下代码:

1
printf("%d\n", i++, i++);

我们不能保证这两个增量的执行顺序,你会发现输出会根据编译器和编译程序的体系结构而变化。保证计算顺序的唯一运算符是&&,||,?:,和,。注意逗号不是指函数的参数;它们的运算顺序是由实现定义的。因此,在如下简单代码中,不能保证在b()之前调用a():

1
x = a() + b();

模棱两可的副作用与模棱两可的运算顺序略有不同,但是它们有相似的结果。副作用是导致修改变量赋值或增量运算符(如++)的表达式。副作用的求值顺序没有在同一个表达式中定义,因此如下内容是已定义的实现,并可能会导致问题:

1
a[i] = i++;

这些问题会导致什么样的安全后果?在下面的代码中, 开发人员使用getstr()调用来获取用户字符串并从外部源传递字符串。但是,如果重新编译了系统,并且getstr()函数的运算顺序发生了变化,那么代码最终可能会记录密码而不是用户名。诚然,这是一个在测试过程中就能发现的低风险问题。

1
2
3
4
5
6
7
8
9
10
11
12
int check_password(char *user, char *pass)
{
if (strcmp(getpass(user), pass))
{
logprintf("bad password for user %s\n", user);
return -1;
}
return 0;
}
...
if (check_password(getstr(), getstr())
exit(1);

下面的代码有一个copy_packet()函数用来从网络中读取包。它使用GET32()宏将一个整数送入包中然后将指针向前移动。协议中有一个可选填充的规定,填充大小字段的存在是由数据包部头中的一个标志指示的。因此,如果设置了FLAG_PADDING,那么用于计算数据的GET32()宏的求值顺序可能会颠倒。如果填充选项是协议中相当未使用的部分,那么这种性质的错误在生产使用中可能不会被检测到。

1
2
3
4
5
6
7
8
9
10
11
12
13
#define GET32(x) (*((unsigned int *)(x))++)
u_char *copy_packet(u_char *packet)
{
int *w = (int *)packet;
unsigned int hdrvar, datasize;
/* packet format is hdr var, data size, padding size */
hdrvar = GET32(w);
if (hdrvar & FLAG_PADDING)
datasize = GET32(w) - GET32(w);
else
datasize = GET32(w);
...
}

结构体填充

C结构体的一个有点模糊的特性是,结构成员不必在内存中连续地布局。成员的顺序保证遵循程序员指定的顺序,但是可以在成员之间使用结构填充来促进对齐和性能需求。这里有一个简单结构体的例子:

1
2
3
4
5
6
struct bob
{
int a;
unsigned short b;
unsigned char c;
};

你觉得sizeof(bob)会是什么?从道理上说应该是7,也就是sizeof(a)+sizeof(b)+sizeof(c),也就是4+2+1。但大多数编译器会返回8因为它们插入了结构体填充。 这种行为现在还不太清楚,但是随着更多64位代码的引入,它肯定会成为一个众所周知的现象,因为它可能会对这段代码产生更严重的影响。 这会造成怎样的安全问题?考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct netdata
{
unsigned int query_id;
unsigned short header_flags;
unsigned int sequence_number;
};
int packet_check_replay(unsigned char *buf, size_t len)
{
struct netdata *n = (struct netdata *)buf;
if ((ntohl(n->sequence_number) <= g_last_sequence number)
return PARSE_REPLAYATTACK;
// packet is safe - process
return PARSE_SAFE;
}

在32位大端系统中,netdata结构体会像图6-9那样布局。一个unsigned int,一个unsigned short,两个字节的填充,一个unsigned int,因此结构体总的大小是12字节。图6-10显示了在网络中的布局。如果开发者没有预料到结构体填充的插入,那么他们就可能会将网络协议的解释写错。 此错误可能导致服务器接受重放攻击(replay attack)。

figure 6-9:

6-9
6-9
6-10
6-10

在64位体系架构中,犯这种错误的可能性会增加。如果一个结构包含一个指针或long值,结构在内存中的布局很可能会改变。任何64位的值,例如指针或长整型,都将占用32位系统的两倍空间,并且必须放置在64位对齐边界上。

填充位的内容取决于分配结构时内存中的内容。这些位可能不同,这可能导致涉及内存比较的逻辑错误,如下面代码所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct sh
{
void *base;
unsigned char code;
void *descptr;
};
void free_sechdrs(struct sh *a, struct sh *b)
{
if (!memcmp(a, b, sizeof(a)))
{
/* they are equivalent */
free(a->descptr);
free(a->base);
free(a);
return;
}
free(a->descptr);
free(a->base);
free(a);
free(b->descptr);
free(b->base);
free(b);
return;
}

如果两个结构体的填充是不同的,那么久可能造成双重释放错误(double-free error)的发生。看看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct hdr
{
int flags;
short len;
};
struct hdropt
{
char opt1;
char optlen;
char descl;
};
struct msghdr
{
struct hdr h;
struct hdropt o;
};
struct msghdr *form_hdr(struct hdr *h, struct hdropt *o)
{
struct msghdr *m=xmalloc(sizeof *h + sizeof *o);
memset(m, 0, sizeof(struct msghdr));
..

hdropt的大小应该是3,因为出于对齐原因不会有填充。hdr的大小应该是8,由于对其的要求msghdr的大小应该是12。因此,menset就会超过分配的数据写入1个字节的\0。

优先级

当你查看由有经验的开发人员编写的代码时,你经常会看到复杂的表达式似乎没有括号。一个有趣的漏洞可能是这样一种情况:犯了优先级错误,但发生的方式不会完全中断程序。

第一个潜在问题是位级别操作符&和|的优先级,特别是当你将它们与比较和相等操作符混合使用时,如本例所示:

1
2
3
4
if ( len & 0x80000000 != 0)
die("bad len!");
if (len < 1024)
memcpy(dst, src, len);

程序员试图通过检查最高位来判断它是否为负值。他的意图就像这样:

1
2
if ( (len & 0x80000000) != 0)
die("bad len!");

然而代码实际做的是这样:

1
2
if ( len & (0x80000000 != 0))
die("bad len!");

这份代码最终会评估len & 1。如果len的最小有效位没有设置。那么测试就会通过,用户将能够把一个负值传入mencpy()。

还有一些涉及赋值的潜在优先级问题,但是由于编译器的警告,这些问题不太可能出现在生产代码中。例如,看看下面的代码;

1
2
if (len = getlen() > 30)
snprintf(dst, len - 30, "%s", src)

代码的作者想做这样的事情:

1
2
if ((len = getlen()) > 30)
snprintf(dst, len - 30, "%s", src)

然而代码却会这样做:

1
2
if (len = (getlen() > 30))
snprintf(dst, len - 30, "%s", src)

len会在if语句后变为1或者0.如果为1,那么第二个snprintf()的变量就会说-29,这实际上是一个无限的字符串。

下面是另一个更潜在的优先级错误:

1
int a = b + c >> 3;

代码的作者想做这样的事情:

1
int a = b + (c >> 3);

正如你所想,代码会实际这样做:

1
int a = (b + c) >> 3;

宏/预处理器

C的预处理器也可能成为安全问题的源头。许多人都熟悉像这样的宏:

1
#define SQUARE(x) x*x

如果像这样使用:

1
y = SQUARE(z + t);

那么就会做这样的事情:

1
y = z + t*z + t;

结果显然是错误的。推荐的修复的方法是在宏里加上括号,就像下面这样:

1
#define SQUARE(x) ((x)*(x))

在考虑求值顺序和副作用问题时,用这种方式构造的宏仍然会遇到麻烦。例如,如果你使用以下方法:

1
y = SQUARE(j++);

宏会展开为:

1
y = ((j++)*(j++));

于是结果就成为了实现定义的了。相似地,如果你这样做:

1
y = SQUARE(getint());

宏会展开为:

1
y = ((getint())*(getint()));

这个结果可能不是作者想要的。如果宏在主流使用方法之外使用,则肯定会引入安全问题,因此在审计大量使用宏的代码时要多加注意。如果有疑问,可以手动展开它们,或者查看预处理器通过的输出。

拼写错误

程序员可能会犯许多简单的拼写错误,这些错误可能不会影响程序编译或中断程序的运行时进程,但是这些拼写错误可能会导致与安全性相关的问题。这些错误在生产代码中很少出现,但偶尔也会出现。尝试发现代码中的拼写错误可能很有趣。可能出现的拼写错误已经作为一系列的挑战提出。在阅读分析之前,试着找出错误。

1
2
3
4
5
6
7
8
// 挑战1
while (*src && left)
{
*dst++=*src++;
if (left = 0)
die("badlen");
left--;
}

if (left = 0)应该写成if (left == 0)才对。

在正确的代码版本中,如果left为0,则循环检测缓冲区溢出尝试并终止。在不正确的版本中,if语句将0赋值给left,赋值的结果是0。if(0)不为真,那么接下来发生的是left--。因为left是0,left--就变成了- 1或者一个大的正数,这取决于left的类型。不管怎样,left都不是0,因此while循环继续进行,并且检查不能防止缓冲区溢出。

1
2
3
4
5
6
7
8
//挑战2
int f;
f=get_security_flags(username);
if (f = FLAG_AUTHENTICATED)
{
return LOGIN_OK;
}
return LOGIN_FAILED;

语句if (f = FLAG_AUTHENTICATED)应该这样写:

1
if (f == FLAG_AUTHENTICATED)

在代码的正确版本中,如果用户的安全标志表明他们已经过身份验证,则函数返回LOGIN_OK。否则,返回LOGIN_FAILED。在不正确的版本中,if语句将FLAG_AUTHENTICATED赋值给f。if语句总是成功,因为FLAG_AUTHENTICATED是某个非零值。因此,该函数为每个用户返回LOGIN_OK。

1
2
3
4
5
// 挑战3
for (i==5; src[i] && i<10; i++)
{
dst[i-5]=src[i];
}

语句for (i==5; src[i] && i<10; i++)应该这样写:

1
for (i=5; src[i] && i<10; i++)

在代码的正确版本中,for循环复制4个字节,从src[5]开始读取并开始写入dst[0]。在错误的版本中,表达式i=的值为真或假,但并不影响i的内容。因此,如果i小于10,它可能会导致for循环在dst和src缓冲区的边界之外读写。

1
2
3
4
// 挑战4
if (get_string(src) &&
check_for_overflow(src) & copy_string(dst,src))
printf("string safely copied\n");

if声明应该像这样:

1
if (get_string(src) &&	check_for_overflow(src) && copy_string(dst,src))

在代码的正确版本中,程序将一个字符串放入src缓冲区并检查src缓冲区是否有溢出。如果没有溢出,它将字符串复制到dst缓冲区并打印“string safely copied”。

在错误版本中,&运算符与&&运算符没有相同的特征。在这种情况下,即使不存在逻辑与位操作的差异造成的问题,也存在短路评估(short-circuit evaluation)和按顺序执行(graranteed order of execution)的关键问题。因为它是按位AND运算,所以两个操作数表达式都要求值,而它们求值的顺序不一定是已知的。因此,即使check_for_overflow()失败,也会调用copy_string(),并且可能会在调用check_for_overflow()之前调用它。

1
2
3
// 挑战5
if (len > 0 && len <= sizeof(dst));
memcpy(dst, src, len);

if声明应该像这样:

1
if (len > 0 && len <= sizeof(dst))

在正确的代码版本中,程序仅在长度在一定范围内时执行memcpy(),从而防止缓冲区溢出攻击。在不正确的版本中,if语句末尾的额外分号表示一个空语句,这意味着memcpy()始终运行,而不管长度检查的结果如何。

1
2
3
// 挑战6
char buf[040];
snprintf(buf, 40, "%s", userinput);

语句char buf[040];应该写为char buf[40];。

在代码的正确版本中,程序为用于复制用户输入的缓冲区留出40个字节。在不正确的版本中,程序设置了32个字节。在C语言中,当整数常量前面有0时,它指示编译器该常量是八进制的。因此,缓冲区长度被解释为八进制040或十进制32,并且snprintf()可以写入超过堆栈缓冲区的末尾。

1
2
3
4
5
// 挑战7
if (len < 0 || len > sizeof(dst)) /* check the length
die("bad length!");
/* length ok */
memcpy(dst, src, len);

if语句应该这样写:

1
if (len < 0 || len > sizeof(dst)) /* check the length */

在代码的正确版本中,程序在执行memcpy()之前检查长度,如果长度超出了适当的范围,则调用abort()。

在不正确的版本中,注释没有结束符号意味着memcpy()成为if语句的目标语句。所以memcpy()只在长度检查失败时发生。

1
2
3
4
5
6
// 挑战8
if (len > 0 && len <= sizeof(dst))
copiedflag = 1;
memcpy(dst, src, len);
if (!copiedflag)
die("didn't copy");

第一个if语句应该这样写:

1
2
3
4
5
if (len > 0 && len <= sizeof(dst))
{
copiedflag = 1;
memcpy(dst, src, len);
}

在正确的版本中,程序在执行memcpy()之前检查长度。如果长度超出了适当的范围,程序将设置导致中止的标志。

在不正确的版本中,if语句后面缺少复合语句意味着始终执行memcpy()。缩进的目的是欺骗读者的眼睛。

1
2
3
4
5
6
7
// 挑战9
if (!strncmp(src, "magicword", 9))
// report_magic(1);
if (len < 0 || len > sizeof(dst))
assert("bad length!");
/* length ok */
memcpy(dst, src, len);

report_magic(1)语句应该这样写:

1
2
// report_magic(1);
;

在正确的版本中,程序在执行memcpy()之前检查长度。如果长度超出了适当的范围,程序将设置导致中止的标志。

在错误的版本中,在magicword if语句后面缺少复合语句意味着长度检查只在magicword比较为真时执行。因此,很可能总是执行memcpy()。

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
// 挑战10
l = msg_hdr.msg_len;
frag_off = msg_hdr.frag_off;
frag_len = msg_hdr.frag_len;
...
if ( frag_len > (unsigned long)max)
{
al=SSL_AD_ILLEGAL_PARAMETER;
SSLerr(SSL_F_DTLS1_GET_MESSAGE_FRAGMENT,
SSL_R_EXCESSIVE_MESSAGE_SIZE);
goto f_err;
}
if ( frag_len + s->init_num >
(INT_MAX - DTLS1_HM_HEADER_LENGTH))
{
al=SSL_AD_ILLEGAL_PARAMETER;
SSLerr(SSL_F_DTLS1_GET_MESSAGE_FRAGMENT,
SSL_R_EXCESSIVE_MESSAGE_SIZE);
goto f_err;
}
if ( frag_len &
!BUF_MEM_grow_clean(s->init_buf, (int)frag_len +
DTLS1_HM_HEADER_LENGTH + s->init_num))
{
SSLerr(SSL_F_DTLS1_GET_MESSAGE_FRAGMENT,
ERR_R_BUF_LIB);
goto err;
}
if ( s->d1->r_msg_hdr.frag_off == 0)
{
s->s3->tmp.message_type = msg_hdr.type;
s->d1->r_msg_hdr.type = msg_hdr.type;
s->d1->r_msg_hdr.msg_len = l;
/* s->d1->r_msg_hdr.seq = seq_num; */
}
/* XDTLS: ressurect this when restart is in place */
s->state=stn;
/* next state (stn) */
p = (unsigned char *)s->init_buf->data;
if ( frag_len > 0)
{
i=s->method->ssl_read_bytes(s,SSL3_RT_HANDSHAKE,
&p[s->init_num],
frag_len,0);
/* XDTLS: fix thismessage fragments cannot
span multiple packets */
if (i <= 0)
{
s->rwstate=SSL_READING;
*ok = 0;
return i;
}
}
else
i = 0;

找到bug了吗?

在一次长度检查中,开发人员使用了按位的AND运算符(&),而不是逻辑的AND运算符(&&)。具体来说,声明应该是这样的:

1
2
3
if ( frag_len &&
!BUF_MEM_grow_clean(s->init_buf, (int)frag_len +
DTLS1_HM_HEADER_LENGTH + s->init_num))

如果BUF_MEM_grow_clean()函数失败,这个简单的错误可能会导致内存损坏。如果失败,该函数将返回0,逻辑not运算将其设置为1。然后,将使用frag_len执行按位的AND操作。因此,在失败的情况下,畸形语句实际上执行以下操作:

1
2
3
4
if(frag_len & 1)
{
SSLerr(...);
}

7.2.10 总结

本章讨论了C编程语言的细微差别,这些差别可能导致微妙和复杂的漏洞。这种背景应该使你能够识别操作符处理、类型转换、算术操作和常见的C输入错误可能出现的问题。然而,这一主题的复杂性并不足以让人一蹴而就地完全理解。因此,在进行应用程序审计时,请参阅本材料。毕竟,即使是最好的代码审计员也很容易忽略可能导致严重漏洞的细微错误。

bison parser深入分析

Posted on 2020-06-05 | In Computer Science
Words count in article: 3.2k | Reading time ≈ 16

GNU Bison Parser 深入分析

一些约定

bison对我们定义好的文法进行了增广(augmentation),添加了$accept和$end符号表示接收和终止,并且增加了一条规则用于判断是否完成语法分析:

1
$accept : <start-symbol> $end

除此之外还增加了$undefined来表示未出现在文法中的symbol。增加了error用来生成错误。

一个简单的例子

就像flex一样,bison也是表驱动的,为了理解bison如何parse,我们用这样一个文法去生成一个parser:

1
2
3
4
5
6
7
8
(1) L → L;E
(2) L → E
(3) E → E,P
(4) E → P
(5) P → a
(6) P → (M)
(7) M → ε
(8) M → L

对应到bison里,就是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
%%
L : L ';' E
| E
;
E : E ',' P
| P

;P : 'a'
| (M)

;M : /* nothing */
| L
;
%%

下面是这个文法的LALR(1)算法生成的自动机表(增广后的):

action GOTO
state ; , a ( ) $end L E P M
0 s1 s2 3 4 5
1 r5 r5 r5 r5 r5 r5
2 r7 r7 s1 s2 r7 r7 6 4 5 7
3 s9 s8
4 r2 s10 r2 r2 r2 r2
5 r4 r4 r4 r4 r4 r4
6 s9 r8 r8 r8 r8 r8
7 s11
8* acc acc acc acc acc acc
9 s1 s2 12 5
10 s1 s2 s11 13
11 r6 r6 r6 r6 r6 r6
12 r1 s10 r1 r1 r1 r1
13 r3 r3 r3 r3 r3 r3

这个例子的符号表如下:

Symbol Number
$end 0
error 1
$undefined 2
';' 3
',' 4
'a' 5
'(' 6
')' 7
$accept 8
L 9
E 10
P 11
M 12

注意,$end,error, $undefined对应的number永远是0,1,2,然后是terminal symbol,再然后是$accept,再然后是non-terminal。

如此简单的语法都要生成一个庞大的表,更何况一门完备的语言,并且注意到了表中有很多地方是空的(对应error),因此bison对这个表进行了压缩。

数据压缩

default action

首先是对action的部分进行压缩,注意到state 1,2,4,5,6,11,12,13仅仅只有1种reduction操作(有的还有shift,但它不是reduction),因此就可以先单独考量这样的行,对它们定义一个default action(reduction),也就是只要碰到这些state,在排除shift操作后一律使用reduction操作。对于空白的error部分即使进行了reduction,也仅仅是推迟了错误的发生。

形式上这个就应该是这个样子:

1
default_reductions[] = {none,r5,r7,none,r2,r4,r8,none,none,none,none,r6,r1,r3}

在bison生成的文件种就对应了yydefact[],这个表的index表示的是state number,里面的值表示reduction的rule number,0表示error:

1
static const yytype_uint8 yydefact[] ={0,6,8,0,3,5,9,0,1,0,0,7,2,4};

注意第一条规则是bison的默认增广规则,因此所有规则号都加了1。

default GOTO

接下来要压缩GOTO的部分,bison的压缩策略是这样的:将每一个non-terminal对应的GOTO列中最多的那一个单独拿出来(L:3,E:4,P:5,M:7)构成一个default GOTO:

1
default_gotos[] = { 3, 4, 5, 7 }

在生成文件中就对应了yydefgoto[]:

1
static const yytype_int8 yydefgoto[] ={ -1,3,4,5,7 };

它的index是用non-terminal的symbol number减去terminal个数得到的(0处对应的-1好像没有什么用)。

压缩non-default action

在选出default reduction表之后,去除它们后action剩下的部分是:

action
state 3 4 5 6 7 0
0 1 2
2 1 2
3 9 8
4 10
6 9
7 11
9 1 2
10 1 2 11
12 10

可以看出有很多空白,接下来的操作就是要对这些空白进行压缩。

bison会将这个表变成两个一维的表,一个用来存储这些非空白的信息,一个用来存储它们的dimension。一般表达式就是:

1
action number = T[D[state number] + symbol number]

例如,我们想要得到state 4在遇到symbol号为4时的操作,那就像这样进行索引:

1
T[D[4] + 4] = 10

根据这个压缩规则,bison分别在yytable[]和yypact[]存储这个‘T’和‘D’:

1
static const yytype_uint8 yytable[] ={8,1,2,9,11,10,9,6,12,0,0,0,13};static const yytype_int8 yypact[] ={-4,-5,-4,0,1,-5,3,-3,-5,-4,-4,-5,1,-5};

我们可以验证一下,state 4遇到规则4时对应的是s10:

1
yytable[yypact[4]+4] = yytable[5] = 10

注意,尽管去除了default reduction,这里面的操作也是可能有reduction的,为此,bison使用正负号来区别它们,yytable[]里正号代表shift,负号代表reduction(当然这个例子里没有),如果是0就执行default action。

压缩non-default GOTO

和non-default action一样,采用相同的方法去压缩non-default GOTO。只是在indexing的时候,公式变为了:

1
action number = T[ND[symbol number - num_terminal] + state number]

在这个例子中,non-default GOTO的表是这样的:

GOTO
state 9 10 11
2 6
9 12
10 13

压缩后的index在bison生成的表中对应了yypgoto[],value仍然用yytable[]:

1
static const yytype_int8 yypgoto[] ={      -5,     5,    -1,     2,    -5};

例如,我们要symbol 9在state 2中的GOTO:

1
yytable[yypgoto[9-8]+2] = yytable[7] = 6

算法的实现

一些static数据

yytranslate

yytranslate[]定义了文法中符号到symbol number的映射过程,假如这些符号是non-terminal,那index就对应non-terminal %token声明时的值,如果这些符号是ASCII码中的terminal,那么index就对应了ASCII值。

1
static const yytype_uint8 yytranslate[] ={       0,     2,     2,     2,     2, ...};

比如说这个例子中出现的’a’,对应的ASCII值是97,根据yytranslate[]的索引,可以得到符号a对应的symbol number是5:

1
yytranslate[97] = 5;

在这里面有很多的2(undefined),因为很多ASCII表中的符号并没有出现在文法中。

yyr1

yyr1[]是每条文法规则的LHS符号的symbol number:

1
static const yytype_uint8 yyr1[] ={       0,     8,     9,     9,    10,    10,    11,    11,    12,    12};

0并没有用作文法的number,所以yyr1[0]是0.

yyr2

yyr2[]是每条文法规则的RHS符号的个数:

1
static const yytype_uint8 yyr2[] ={       0,     2,     3,     1,     3,     1,     1,     3,     0,     1};

同上,yyr2[0]`是0.

yycheck

yycheck[]是用来判断使用default还是non-default的,在生成文件的代码中表现为:

1
2
3
4
5
6
7
8
9
10
11
12
/* If the proper action on seeing token YYTOKEN is to reduce or to   detect an error, take that action.  */
yyn += yytoken;
if (yyn < 0 || YYLAST < yyn || yycheck[yyn] != yytoken)
goto yydefault;

/* Now 'shift' the result of the reduction. Determine what state that goes to, based on the state we popped back to and the rule number reduced by. */
yyn = yyr1[yyn];
yystate = yypgoto[yyn - YYNTOKENS] + *yyssp;
if (0 <= yystate && yystate <= YYLAST && yycheck[yystate] == *yyssp)
yystate = yytable[yystate];
else yystate = yydefgoto[yyn - YYNTOKENS];
goto yynewstate;

在这个例子中,yycheck[]的值如下:

1
static const yytype_int8 yycheck[] ={       0,     5,     6,     3,     7,     4,     3,     2,     9,    -1,      -1,    -1,    10};

其他不重要的表:

1
2
3
4
5
yyrhs: yyrhs[n] = 第n条规则的RHS第一个symbol
yyprhs: yyprhs[n] = yyrhs[n]symbol的index
yyrline: yyrline[n] = .y文法源文件中第n条规则定义时对应的行
yytname: yytname[n] = symbol number n对应的symbol string
yytoknum: yytoknum[n] = token n的token number

yyparse()

整个算法的主程序是yyparse(),具体的框架如下:

首先我们要定义一个state stack:

1
2
3
4
/* The state stack.  */
yytype_int16 yyssa[YYINITDEPTH];
yytype_int16 *yyss;
yytype_int16 *yyssp;

然后定义符号的stack:

1
2
3
4
/* The semantic value stack.  */
YYSTYPE yyvsa[YYINITDEPTH];
YYSTYPE *yyvs;
YYSTYPE *yyvsp;

这里的YYSTYPE是文法的non-terminal所允许的type,可以自己定义,在这个例子里默认为了int。

然后定义用来输出错误的一个buffer:

1
2
3
/* Buffer for error messages, and its allocated size.  */
char yymsgbuf[128];
char *yymsg = yymsgbuf;

定义将new state push到栈里的操作:

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
yynewstate:
/* In all cases, when you get here, the value and location stacks
have just been pushed. So pushing a state here evens the stacks. */
yyssp++;

yysetstate:
*yyssp = yystate;

if (yyss + yystacksize - 1 <= yyssp)
{
/* Get the current used size of the three stacks, in elements. */
YYSIZE_T yysize = yyssp - yyss + 1;

#ifdef yyoverflow
{
/* Give user a chance to reallocate the stack. Use copies of
these so that the &'s don't force the real ones into
memory. */
YYSTYPE *yyvs1 = yyvs;
yytype_int16 *yyss1 = yyss;

/* Each stack pointer address is followed by the size of the
data in use in that stack, in bytes. This used to be a
conditional around just the two extra args, but that might
be undefined if yyoverflow is a macro. */
yyoverflow (YY_("memory exhausted"),
&yyss1, yysize * sizeof (*yyssp),
&yyvs1, yysize * sizeof (*yyvsp),
&yystacksize);

yyss = yyss1;
yyvs = yyvs1;
}
#else /* no yyoverflow */
# ifndef YYSTACK_RELOCATE
goto yyexhaustedlab;
# else
/* Extend the stack our own way. */
if (YYMAXDEPTH <= yystacksize)
goto yyexhaustedlab;
yystacksize *= 2;
if (YYMAXDEPTH < yystacksize)
yystacksize = YYMAXDEPTH;

{
yytype_int16 *yyss1 = yyss;
union yyalloc *yyptr =
(union yyalloc *) YYSTACK_ALLOC (YYSTACK_BYTES (yystacksize));
if (! yyptr)
goto yyexhaustedlab;
YYSTACK_RELOCATE (yyss_alloc, yyss);
YYSTACK_RELOCATE (yyvs_alloc, yyvs);
# undef YYSTACK_RELOCATE
if (yyss1 != yyssa)
YYSTACK_FREE (yyss1);
}
# endif
#endif /* no yyoverflow */

yyssp = yyss + yysize - 1;
yyvsp = yyvs + yysize - 1;

YYDPRINTF ((stderr, "Stack size increased to %lu\n",
(unsigned long int) yystacksize));

if (yyss + yystacksize - 1 <= yyssp)
YYABORT;
}

YYDPRINTF ((stderr, "Entering state %d\n", yystate));

if (yystate == YYFINAL)
YYACCEPT;

goto yybackup;

核心部分只是前面和后面的几行代码,中间都是在处理overflow的,用一些宏来决定是将stack size 扩张两倍还是直接扔出memory exhausted错误。

然后定义读取lookahead token的操作yybackup,这里就要决定是使用default还是non-default,假如是non-default,是shift还是reduce还是报错了:

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
yybackup:

/* Do appropriate processing given the current state. Read a
lookahead token if we need one and don't already have one. */

/* First try to decide what to do without reference to lookahead token. */
yyn = yypact[yystate];
if (yypact_value_is_default (yyn))
goto yydefault;

/* Not known => get a lookahead token if don't already have one. */

/* YYCHAR is either YYEMPTY or YYEOF or a valid lookahead symbol. */
if (yychar == YYEMPTY)
{
YYDPRINTF ((stderr, "Reading a token: "));
yychar = yylex ();
}

if (yychar <= YYEOF)
{
yychar = yytoken = YYEOF;
YYDPRINTF ((stderr, "Now at end of input.\n"));
}
else
{
yytoken = YYTRANSLATE (yychar);
YY_SYMBOL_PRINT ("Next token is", yytoken, &yylval, &yylloc);
}

/* If the proper action on seeing token YYTOKEN is to reduce or to
detect an error, take that action. */
yyn += yytoken;
if (yyn < 0 || YYLAST < yyn || yycheck[yyn] != yytoken)
goto yydefault;
yyn = yytable[yyn];
if (yyn <= 0)
{
if (yytable_value_is_error (yyn))
goto yyerrlab;
yyn = -yyn;
goto yyreduce;
}

/* Count tokens shifted since error; after three, turn off error
status. */
if (yyerrstatus)
yyerrstatus--;

/* Shift the lookahead token. */
YY_SYMBOL_PRINT ("Shifting", yytoken, &yylval, &yylloc);

/* Discard the shifted token. */
yychar = YYEMPTY;

yystate = yyn;
YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN
*++yyvsp = yylval;
YY_IGNORE_MAYBE_UNINITIALIZED_END

goto yynewstate;

然后定义default操作,很简单,读取yydefault[]表,如果是0就报错,不是0就进入reduce:

1
2
3
4
5
yydefault:
yyn = yydefact[yystate];
if (yyn == 0)
goto yyerrlab;
goto yyreduce;

接下来是reduce操作:根据yyr2[]找到对应规则的reduce的RHS符号个数以用来决定pop多少,然后根据yyr1[]找到对应规则的LHS值push进去:

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
yyreduce:
/* yyn is the number of a rule to reduce with. */
yylen = yyr2[yyn];

/* If YYLEN is nonzero, implement the default value of the action:
'$$ = $1'.

Otherwise, the following line sets YYVAL to garbage.
This behavior is undocumented and Bison
users should not rely upon it. Assigning to YYVAL
unconditionally makes the parser a bit smaller, and it avoids a
GCC warning that YYVAL may be used uninitialized. */
yyval = yyvsp[1-yylen];


YY_REDUCE_PRINT (yyn);
switch (yyn)
{

#line 1179 "eg.tab.c" /* yacc.c:1646 */
default: break;
}
/* User semantic actions sometimes alter yychar, and that requires
that yytoken be updated with the new translation. We take the
approach of translating immediately before every use of yytoken.
One alternative is translating here after every semantic action,
but that translation would be missed if the semantic action invokes
YYABORT, YYACCEPT, or YYERROR immediately after altering yychar or
if it invokes YYBACKUP. In the case of YYABORT or YYACCEPT, an
incorrect destructor might then be invoked immediately. In the
case of YYERROR or YYBACKUP, subsequent parser actions might lead
to an incorrect destructor call or verbose syntax error message
before the lookahead is translated. */
YY_SYMBOL_PRINT ("-> $$ =", yyr1[yyn], &yyval, &yyloc);

YYPOPSTACK (yylen);
yylen = 0;
YY_STACK_PRINT (yyss, yyssp);

*++yyvsp = yyval;

/* Now 'shift' the result of the reduction. Determine what state
that goes to, based on the state we popped back to and the rule
number reduced by. */

yyn = yyr1[yyn];

yystate = yypgoto[yyn - YYNTOKENS] + *yyssp;
if (0 <= yystate && yystate <= YYLAST && yycheck[yystate] == *yyssp)
yystate = yytable[yystate];
else
yystate = yydefgoto[yyn - YYNTOKENS];

goto yynewstate;

然后是三个用来检测error的操作:

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
yyerrlab:
/* Make sure we have latest lookahead translation. See comments at
user semantic actions for why this is necessary. */
yytoken = yychar == YYEMPTY ? YYEMPTY : YYTRANSLATE (yychar);

/* If not already recovering from an error, report this error. */
if (!yyerrstatus)
{
++yynerrs;
#if ! YYERROR_VERBOSE
yyerror (YY_("syntax error"));
#else
# define YYSYNTAX_ERROR yysyntax_error (&yymsg_alloc, &yymsg, \
yyssp, yytoken)
{
char const *yymsgp = YY_("syntax error");
int yysyntax_error_status;
yysyntax_error_status = YYSYNTAX_ERROR;
if (yysyntax_error_status == 0)
yymsgp = yymsg;
else if (yysyntax_error_status == 1)
{
if (yymsg != yymsgbuf)
YYSTACK_FREE (yymsg);
yymsg = (char *) YYSTACK_ALLOC (yymsg_alloc);
if (!yymsg)
{
yymsg = yymsgbuf;
yymsg_alloc = sizeof yymsgbuf;
yysyntax_error_status = 2;
}
else
{
yysyntax_error_status = YYSYNTAX_ERROR;
yymsgp = yymsg;
}
}
yyerror (yymsgp);
if (yysyntax_error_status == 2)
goto yyexhaustedlab;
}
# undef YYSYNTAX_ERROR
#endif
}



if (yyerrstatus == 3)
{
/* If just tried and failed to reuse lookahead token after an
error, discard it. */

if (yychar <= YYEOF)
{
/* Return failure if at end of input. */
if (yychar == YYEOF)
YYABORT;
}
else
{
yydestruct ("Error: discarding",
yytoken, &yylval);
yychar = YYEMPTY;
}
}

/* Else will try to reuse lookahead token after shifting the error
token. */
goto yyerrlab1;


/*---------------------------------------------------.
| yyerrorlab -- error raised explicitly by YYERROR. |
`---------------------------------------------------*/
yyerrorlab:

/* Pacify compilers like GCC when the user code never invokes
YYERROR and the label yyerrorlab therefore never appears in user
code. */
if (/*CONSTCOND*/ 0)
goto yyerrorlab;

/* Do not reclaim the symbols of the rule whose action triggered
this YYERROR. */
YYPOPSTACK (yylen);
yylen = 0;
YY_STACK_PRINT (yyss, yyssp);
yystate = *yyssp;
goto yyerrlab1;


/*-------------------------------------------------------------.
| yyerrlab1 -- common code for both syntax error and YYERROR. |
`-------------------------------------------------------------*/
yyerrlab1:
yyerrstatus = 3; /* Each real token shifted decrements this. */

for (;;)
{
yyn = yypact[yystate];
if (!yypact_value_is_default (yyn))
{
yyn += YYTERROR;
if (0 <= yyn && yyn <= YYLAST && yycheck[yyn] == YYTERROR)
{
yyn = yytable[yyn];
if (0 < yyn)
break;
}
}

/* Pop the current state because it cannot handle the error token. */
if (yyssp == yyss)
YYABORT;


yydestruct ("Error: popping",
yystos[yystate], yyvsp);
YYPOPSTACK (1);
yystate = *yyssp;
YY_STACK_PRINT (yyss, yyssp);
}

YY_IGNORE_MAYBE_UNINITIALIZED_BEGIN
*++yyvsp = yylval;
YY_IGNORE_MAYBE_UNINITIALIZED_END


/* Shift the error token. */
YY_SYMBOL_PRINT ("Shifting", yystos[yyn], yyvsp, yylsp);

yystate = yyn;
goto yynewstate;

然后定义accept,abort,overflow时yyparse()分别返回什么值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
yyacceptlab:
yyresult = 0;
goto yyreturn;

/*-----------------------------------.
| yyabortlab -- YYABORT comes here. |
`-----------------------------------*/
yyabortlab:
yyresult = 1;
goto yyreturn;

#if !defined yyoverflow || YYERROR_VERBOSE
/*-------------------------------------------------.
| yyexhaustedlab -- memory exhaustion comes here. |
`-------------------------------------------------*/
yyexhaustedlab:
yyerror (YY_("memory exhausted"));
yyresult = 2;
/* Fall through. */
#endif

最后一步:yyreturn,返回时清空stack,error message的buf:

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
yyreturn:
if (yychar != YYEMPTY)
{
/* Make sure we have latest lookahead translation. See comments at
user semantic actions for why this is necessary. */
yytoken = YYTRANSLATE (yychar);
yydestruct ("Cleanup: discarding lookahead",
yytoken, &yylval);
}
/* Do not reclaim the symbols of the rule whose action triggered
this YYABORT or YYACCEPT. */
YYPOPSTACK (yylen);
YY_STACK_PRINT (yyss, yyssp);
while (yyssp != yyss)
{
yydestruct ("Cleanup: popping",
yystos[*yyssp], yyvsp);
YYPOPSTACK (1);
}
#ifndef yyoverflow
if (yyss != yyssa)
YYSTACK_FREE (yyss);
#endif
#if YYERROR_VERBOSE
if (yymsg != yymsgbuf)
YYSTACK_FREE (yymsg);
#endif
return yyresult;
}

flex lexer分析

Posted on 2020-06-05 | In Computer Science
Words count in article: 1.9k | Reading time ≈ 8

flex lexer分析

flex会根据你定义的正则表达式匹配到相应字段,然后根据你定义的函数进行操作,返回相应的token。

为了了解flex如何work的,我们新建一个空的flex规则文件null.flex:

1
%%

然后运行命令flex null.flex生成lex.yy.c文件。

接下来逐行分析它的code:

首先是一些flex的版本相关的宏:

1
2
3
4
5
6
7
#define FLEX_SCANNER
#define YY_FLEX_MAJOR_VERSION 2
#define YY_FLEX_MINOR_VERSION 6
#define YY_FLEX_SUBMINOR_VERSION 0
#if YY_FLEX_SUBMINOR_VERSION > 0
#define FLEX_BETA
#endif

然后将一些type define成自己的格式:

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
#ifndef __STDC_LIMIT_MACROS
#define __STDC_LIMIT_MACROS 1
#endif
#include <inttypes.h>
typedef int8_t flex_int8_t;
typedef uint8_t flex_uint8_t;
typedef int16_t flex_int16_t;
typedef uint16_t flex_uint16_t;
typedef int32_t flex_int32_t;
typedef uint32_t flex_uint32_t;
#else
typedef signed char flex_int8_t;
typedef short int flex_int16_t;
typedef int flex_int32_t;
typedef unsigned char flex_uint8_t;
typedef unsigned short int flex_uint16_t;
typedef unsigned int flex_uint32_t;
#ifndef INT8_MIN
#define INT8_MIN (-128)
#endif#ifndef INT16_MIN
#define INT16_MIN (-32767-1)
#endif
#ifndef INT32_MIN
#define INT32_MIN (-2147483647-1)
#endif
#ifndef INT8_MAX
#define INT8_MAX (127)
#endif
#ifndef INT16_MAX
#define INT16_MAX (32767)
#endif
#ifndef INT32_MAX
#define INT32_MAX (2147483647)
#endif#ifndef UINT8_MAX
#define UINT8_MAX (255U)
#endif
#ifndef UINT16_MAX
#define UINT16_MAX (65535U)
#endif
#ifndef UINT32_MAX
#define UINT32_MAX (4294967295U)
#endif

然后定义EOF的宏:

1
#define YY_NULL 0

将signed char安全转换为unsigned int的宏YY_SC_TO_UI:

1
#define YY_SC_TO_UI(c) ((unsigned int) (unsigned char) c)

start condition中用的BEGIN():

1
#define BEGIN (yy_start) = 1 + 2 *

在后面的代码可以看出,这些start condition定义的关键词都被定义成了宏,比如我们有两个start condition COMMENT和STRING:

1
#define INITIAL 0#define COMMENT 1#define STRING 2

这时BEGIN(COMMENT)就等价于:

1
(yy_start) = 1 + 2 * COMMENT

之后就可以得到相应的YY_STATE:

1
#define YY_START (((yy_start) - 1) / 2)#define YYSTATE YY_START

略去其他不重要的宏,接下来看两个FILE指针yyin和yyout,可以看出yyin指向了stdin,yyout指向了stdout,分别对应了标准输入输出流:

1
2
3
4
5
6
7
8
9
extern FILE *yyin, *yyout;
/*a lot of code*/
#ifdef YY_STDINIT
yyin = stdin;
yyout = stdout;
#else
yyin = (FILE *) 0;
yyout = (FILE *) 0;
#endif

接下来的代码就可以看出,flex的lexer是使用自顶向下的表驱动预测分析法来实现匹配的,关于这里面的预测分析,LL(1)文法等定义见编译原理的相关教材。

首先要实现表驱动预测分析,就要定义读入text的方法,理论上是一个个character读入,但为了效率起见,flex将它们批量读取存入了buffer中:

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
#ifndef YY_INPUT
#define YY_INPUT(buf,result,max_size) \
if ( YY_CURRENT_BUFFER_LVALUE->yy_is_interactive ) \
{ \
int c = '*'; \
size_t n; \
for ( n = 0; n < max_size && \
(c = getc( yyin )) != EOF && c != '\n'; ++n ) \
buf[n] = (char) c; \
if ( c == '\n' ) \
buf[n++] = (char) c; \
if ( c == EOF && ferror( yyin ) ) \
YY_FATAL_ERROR( "input in flex scanner failed" ); \
result = n; \
} \
else \
{ \
errno=0; \
while ( (result = fread(buf, 1, max_size, yyin))==0 && ferror(yyin)) \
{ \
if( errno != EINTR) \
{ \
YY_FATAL_ERROR( "input in flex scanner failed" ); \
break; \
} \
errno=0; \
clearerr(yyin); \
} \
}\
\
#endif

这里就要吐槽一下它的缩进了,简直惨不忍睹。如果你觉得这种读取方法很复杂的话,可以rewrite自己的YY_INPUT:

1
2
3
4
#undef YY_INPUT
#define YY_INPUT(buf,result,max_size) \
if ( (result = fread( (char*)buf, sizeof(char), max_size, fin)) < 0) \
YY_FATAL_ERROR( "read() in flex scanner failed");

这段代码引自参考文献[1]。

为什么说它用的是表驱动法呢?从它在静态区cache了一些预测分析表的常量:

1
2
3
4
5
6
7
static yyconst flex_int16_t yy_accept[6] = {...};
static yyconst YY_CHAR yy_ec[256] = {...};
static yyconst YY_CHAR yy_meta[2] = {...};
static yyconst flex_uint16_t yy_base[7] = {...};
static yyconst flex_int16_t yy_def[7] = {...};
static yyconst flex_uint16_t yy_nxt[5] = {...};
static yyconst flex_int16_t yy_chk[5] = {...};

以及定义了将缓冲区变量压栈和出栈的一系列操作:

1
2
3
4
5
6
void yypush_buffer_state (YY_BUFFER_STATE new_buffer )
{/*a lof of code*/}
void yypop_buffer_state (void)
{/*a lof of code*/}
static void yyensure_buffer_stack (void)
{/*a lof of code*/}

再对比下自顶向下表驱动法的算法结构:

top-down-push-pop
top-down-push-pop

就一目了然了。

接下来就来看这个算法flex具体是如何实现的,它过程就在于yylex()这个函数,大致的框架如下:

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
#define YY_DECL int yylex (void)
/*a lot of code*/
YY_DECL
{ // initialize
// crate stack
// load buffer
while(1)
{
yy_match:
/* 在这里不断进行状态转移,直至无法继续转移 */
/* 用到了前面提的YY_SC_TO_UI*/
do{
YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)] ;
if ( yy_accept[yy_current_state] )
{
(yy_last_accepting_state) = yy_current_state;
(yy_last_accepting_cpos) = yy_cp;
}
while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state )
{
yy_current_state = (int) yy_def[yy_current_state];
if ( yy_current_state >= 6 )
yy_c = yy_meta[(unsigned int) yy_c];
}
yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];
++yy_cp;
}
while ( yy_base[yy_current_state] != 3 );
yy_find_action:
/*根据yy_current_state返回对应的yy_act*/
/*检查是否停止在接受状态(yy_act==0的情况)*/
/*yy_act == 1 表示accept*/
/*具体见do_action*/
yy_act = yy_accept[yy_current_state];
if ( yy_act == 0 )
{
/* have to back up */
yy_cp = (yy_last_accepting_cpos);
yy_current_state = (yy_last_accepting_state);
yy_act = yy_accept[yy_current_state];
}
YY_DO_BEFORE_ACTION;
do_action:
/*处理各种yy_act*/
switch(yy_act){
case 0: /*读到EOF*/
case 1: /*accept*/
}
}
}

这里的do_action中,yy_act只有0和1两种情况,即读到非EOF的直接accept,读到EOF转case 0。因为这是一个空的规则文件,假如我们用带有规则的xxx.flex生成code,这些规则中的正则表达式匹配方法会表现到静态区中cache的预测分析表中,匹配到的action会体现在这个switch-case语句中。

为了验证以上的说明,我们定义一个用来匹配能够解释为十进制数字符串的规则,放到文件test.flex:

1
2
3
4
5
WHITE   " "|\t|\f|\r|\v
%%
{WHITE}*[+-]?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))([Ee][+-]?[0-9]+)?{WHITE}* {return true;}
. {return false;}
%%

对应的DFA图如下:

DFA
DFA

这个例子来自参考文献[1].

flex为了减少内存开销,对原来的状态表进行了压缩,因此在空规则生成的文件中,我们能看到很多个静态区常量(见上面),为了看到原本的状态表,我们使用 -Cf进行编译:flex -Cf test.flex ,然后可以看到:

1
2
static yyconst flex_int16_t yy_nxt[][128] = {...};
static yyconst flex_int16_t yy_accept[9] = {...};

其中flex_int16_t yy_nxt[][128]就是原来的状态表。在yylex()中,匹配的过程是这么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
yy_match:		
while ( (yy_current_state = yy_nxt[yy_current_state][ YY_SC_TO_UI(*yy_cp) ]) > 0 )
{
if ( yy_accept[yy_current_state] )
{
(yy_last_accepting_state) = yy_current_state;
(yy_last_accepting_cpos) = yy_cp;
}
++yy_cp;
}
yy_current_state = -yy_current_state;
yy_find_action:
yy_act = yy_accept[yy_current_state];
YY_DO_BEFORE_ACTION;

现在我们只要照着它稍微修改一下,就能得到一个判断字符串是否能作为数字的程序了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define YY_SC_TO_UI(c) ((unsigned int) (unsigned char) c)
#define yyconst const
typedef int flex_int16_t;
typedef int flex_int32_t;
typedef unsigned char YY_CHAR;
static yyconst flex_int16_t yy_nxt[][128] = {...};
static yyconst flex_int16_t yy_accept[21] = {...};
bool isNumber(string s)
{
int yy_current_state = 1;
for ( int i = 0; i < s.length(); ++i ) {
yy_current_state = yy_nxt[yy_current_state][YY_SC_TO_UI(s[i])];
if ( yy_current_state < 0 )
return false;
}
return yy_accept[yy_current_state] == 1;
}

这里只需要判断新的current state是否大于0.没用必要查询是否accept(因为match时小于0直接跳出循环,说明匹配失败)。

对于压缩版本的,也可以有如下的对应。

yylex():

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
yy_match:
do
{
YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)] ;
if ( yy_accept[yy_current_state] )
{
(yy_last_accepting_state) = yy_current_state;
(yy_last_accepting_cpos) = yy_cp;
}
while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state )
{
yy_current_state = (int) yy_def[yy_current_state];
if ( yy_current_state >= 22 )
yy_c = yy_meta[(unsigned int) yy_c];
}
yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];
++yy_cp;
}
while ( yy_base[yy_current_state] != 43 );
yy_find_action:
yy_act = yy_accept[yy_current_state];
if ( yy_act == 0 )
{
/* have to back up */
yy_cp = (yy_last_accepting_cpos);
yy_current_state = (yy_last_accepting_state);
yy_act = yy_accept[yy_current_state];
}
YY_DO_BEFORE_ACTION;

valid_number程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define YY_SC_TO_UI(c) ((unsigned int) (unsigned char) c)
#define yyconst consttypedef int flex_int16_t;
typedef int flex_int32_t;
typedef unsigned char YY_CHAR;
static yyconst flex_int16_t yy_accept[6] = {...};static yyconst YY_CHAR yy_ec[256] = {...};
static yyconst YY_CHAR yy_meta[2] = {...};static yyconst flex_uint16_t yy_base[7] = {...};
static yyconst flex_int16_t yy_def[7] = {...};
static yyconst flex_uint16_t yy_nxt[5] = {...};
static yyconst flex_int16_t yy_chk[5] = {...};
bool isNumber(string s){
int yy_current_state = 1;
for(int i = 0; i < s.length(); ++i){
YY_CHAR yy_c = yy_ec[YY_SC_TO_UI(*yy_cp)] ;
while ( yy_chk[yy_base[yy_current_state] + yy_c] != yy_current_state )
{
yy_current_state = (int) yy_def[yy_current_state];
if ( yy_current_state >= 22 )
yy_c = yy_meta[(unsigned int) yy_c];
}
yy_current_state = yy_nxt[yy_base[yy_current_state] + (unsigned int) yy_c];
return(yy_accept[yy_current_state]);
}

这里需要判断current state是否能被accept(即返回1),当然根据while循环条件也可以。

References:

[1].Flex技巧总结 && [LeetCode]Valid Number题解 https://blog.finaltheory.me/research/Flex-Tricks.html

the art of software security assessment Chap1. translate

Posted on 2020-06-05 | In Computer Science
Words count in article: 12.9k | Reading time ≈ 43

软件安全审计的艺术——识别以及预防软件的漏洞

Translator: 山海(Chenyu ZHU)

注:翻译从原书 6.软件安全审计概述(Introduction to Software Security Assessment)开始,前面的并不是正文内容。 ——By 译者

第一部分:软件安全审计概述

6.1 第一章:关于软件漏洞的基础知识

“任何足够高级的技术都与魔法难以区分”

—— 亚瑟 C. 克拉克

6.1.1 概论

对软件技术缺乏理解的普通人倾向于将软件简单地看作一种像魔法一样的技术。一些软件在复杂度上决不输于任何硬件,但绝大多数人不会看到软件转动的轮子,听到它引擎的轰鸣声,或者拆开螺母和螺栓看看它是如何工作的。计算机软件早已经集成到我们的社会中,影响着几乎所有人日常生活的每一方面。人们对于软件的依赖性使得我们不得不面对它的安全问题。你无法忍住去了解什么样的软件能够保证你在使用它时是是安全的。你能怎样确认安全问题?软件安全失效又代表什么?

在这本书涵盖的课程中,你将会学习到如何去理解和评估软件安全性。你将会看到怎样结合理论和代码实践来进行安全审计。这个过程包括了怎样剖析应用程序,发现安全漏洞,评估每个漏洞表现出来的危险性。你还会学到怎样最大化利用你的时间,专注于一个程序中最与安全问题相关的部分然后优先找出最关键的漏洞。这些都是你理解一个程序安全审计所需要掌握的基础知识。

本章内容介绍软件漏洞的原理,并阐述违反软件系统安全原则的含义。你还能学到软件审计的基础,包括动机(即为什么这么做 ——By 译者),审计的种类,以及审计如何配合软件开发进程。最后,一些能帮助你分类安全漏洞以及发现这些安全问题一般情况下是由什么原因造成的知识也会列出。

6.1.2 漏洞

现代软件就像魔法一样。一群专业团队花费超过十年的时间开发的复杂的软件可以立刻被黑客抬走。从第一眼看下来,这好像是不太可能的,就像魔法一样。然而, 当你透过帘子看它是如何运作的时候,这种奇妙的感觉就会消失。 总之,软件漏洞能让攻击者能轻松利用这些弱点以打成他们目的。在软件安全的语境下,漏洞的定义是一些软件的缺点与疏忽,这些缺点与疏忽能被攻击者恶意利用来干坏事,或者获取敏感信息,破坏以及摧毁一个系统,以及控制计算机系统以及程序。

这个定义和软件 bug 有点类似,bug 是造成程序产生非预期结果的错误,过失与疏忽。很多程序员都吃过软件 bug 的亏。广义地来说,软件漏洞也是 bug 地一种。软件漏洞是能给你隐藏惊喜的 bug(surprise mother fucker? ——by 译者):恶意用户能够利用它们对软件与系统实施攻击,或者控制你的系统与程序。绝大多数软件漏洞都是软件 bug,但仅仅有一部分 bug 能被算作安全漏洞。一个 bug 在被称为安全漏洞时,它必须造成与安全有关的影响。也就是说,它会允许攻击者做一些用户通常不会做的事。(这个主体会在接下来的章节继续提到,人们经常犯这种错误,将疏忽造成的安全问题当作 bug)。

人们常说,安全是可靠性的一个子集。这可能不是一个宇宙级真理,但我们可以拿这句话做一个类比。一个可靠的程序相对来说 bug 会少很多:它很少在用户使用下崩溃,它能很好地处理异常条件。它通过“防御式”的方法开发出来所以能处理不确定的执行环境和不正确的输入。(关于 ”防御式“ ,参见代码大全的防御式编程——by 译者)安全程序具有鲁棒性(robust):它可以击退入侵者的集中攻击,这些入侵者试图操纵它的环境和输入,以便利用它达到某种邪恶的目的。软件安全性和可靠性也有相似的目标,因为它们都需要在开发策略上关注于消除软件缺陷。

注

尽管将安全漏洞与软件 bug 进行比较是有用的,但有些漏洞并不那么清晰。例如,允许你编辑你不应该访问的关键系统文件的程序可能会根据其规范和设计完全正确地运行。所以它可能不属于大多数人对软件 bug 的定义,但它确实是一个安全漏洞。

攻击程序中的漏洞的过程称为利用(exploiting)。攻击者可能会利用漏洞,以一种聪明的方式运行程序,在程序运行时改变或监视程序的环境,或者如果程序本身就不安全,那就就简单地用这个程序达到预期目的。当攻击者使用外部程序或脚本执行攻击时,这个攻击程序通常被称为攻击脚本。

注

如前所述,攻击者可以利用漏洞来破坏系统的安全性。将“系统的安全性”概念化的一个有用方法是将系统的安全性看作是由安全策略定义的。从这个角度来看,当系统的安全策略被违反时,软件系统的安全性就会被破坏。

注

加州大学戴维斯分校的计算机科学教授 Matt Bishop 在计算机漏洞领域已经有了很多年的研究。 他从正式的学术角度和技术角度对计算机安全进行了大量的思考。如果你对这些话题感兴趣,可以看看他的书《计算机安全:艺术与科学》 ( Bishop, M. Computer Security: Art & Science. Addison-Wesley, 2003 ), 他的主页也奉上: http://nob.cs.ucdavis.edu/~bishop/

对于由软件组成的系统,用户对应有安全政策,它简要指出了哪些能做哪些不能做。例如,这个政策可能会这样说:”未经身份验证的用户禁止使用日历服务。”如果未经身份验证的用户拥有访问日历服务的权限,那么这毫无疑问违反了安全政策。

每个软件系统都应该考虑自己的安全政策。它的形式可能是编写的文档,也可能是一个非正式的松散的期望集合,用户对在这个系统中怎么做是合理的有这样的期望。对于绝大多数软件系统,人们通常能懂得什么样的行为违反了安全规则,即使它没有在安全政策中被提及。因此,“安全政策”通常表示用户和社区对于什么行为是允许的,什么行为是禁止的所达成的共识。安全政策可以有如下所示的几种形式:

  • 对于特别敏感和具有严格限定作用域的系统,安全政策可以是通过数学学证明然后通过程序代码验证的规范约束。这种方法通常很昂贵,并且只适用受到严格控制的软件环境。比如信号灯,电梯,飞机等性命攸关的设备中所嵌入的系统能够通过这种验证。然而这种方法对于很多这样的应用程序也是非常昂贵且笨拙的。
  • 安全政策可以是一个正式的书面条款。就像附录 Q 中阐述的“C.2 信用卡信息应当永远不泄露给第三方或者未经充分加密后通过媒介传输。”这个条款可能来自于在开发过程中创建的关于软件的政策,也可能来自于与软件使用的资源相关的政策,例如网站的安全政策,操作系统政策或者数据库安全政策。
  • 安全政策可以仅仅由非正式的,模糊的人们对合理程序安全行为所期望的集合构成。例如“让犯罪组织访问我们的信用卡数据库可能是不好的”。
注

Java 虚拟机(Java Virtual Machine, JVM) 以及.Net 公用语言运行库(Common Language Runtime, CLR)有不同程度的代码访问安全性(Code Access Security, CAS)。代码访问安全性提供了加载与运行时广泛的验证方法。这些验证包括字节码的完整性,软件的始发者以及代码访问限制的应用程序。这些技术最明显的应用包括 Java applet 和.NET 管理的浏览器控件的沙箱环境。

尽管代码访问安全性可以用作严格的,形式化的安全模型的平台,但有些与之相关的注意事项必须要题。第一个问题就是大多数开发人员不完全了解它的应用和功能,因此商业软件中很少使用。第二个问题是代码访问安全性完全依赖于底层组件的安全性,JVM 和.NET CLR 都是漏洞的受害者,这些漏洞允许应用程序走出虚拟机沙箱并运行任意代码。

在实践中,软件系统的安全政策可能大部分都是由人们非正式的期望组成。然而,它经常来自开发过程和参考站点资源的正式文档的安全政策。系统安全政策这个定义有助于澄清“系统安全”这个概念。总之安全就是终端用户的需求和期望。

安全期望

考虑人们对软件安全可能有的期望有助于确定他们认为哪些行为是违反安全的。安全通常用三个部分来描述:保密性(avalability),完整性(integrity)以及实用性(avalability).

保密性

保密性要求信息私有(private). 在任何情况下,软件都应当做到隐藏信息或者隐藏信息的存在。软件系统经常要处理机密数据,比如国家级机密,公司的商业机密,甚至敏感的个人机密信息。

商业以及其他组织在软件中存储了很多机密信息。比如财务信息通常就是保密的。关于商业计划与业绩的信息更是战略级别的,这些信息对于非法竞争或者内幕交易等犯罪活动都可能有很大的用处。因此商业数据通常是要求保密性的。这些保密的商业数据包括商业关系,联系人,法律诉讼,或者其他需要保密的敏感信息。

一个软件系统处理信息时,出于隐私的考量,人们对保密的要求通常是很高的。组织以及个人用户都希望小心地把控哪些人能够访问这些信息。如果这些信息还包括财务数据或者医疗记录,不当的数据纰漏就可能涉及到责任问题。软件通常都想要对个人以及用户的信息保密,例如个人文件,邮箱,活动记录,账号和密码等。

在不同类型的软件中,机密是由软件代码构造出来的。比如一段用来评估市场上的潜在交易或者新的 3D 图形引擎的代码,尽管它不是交易机密,但它仍然是敏感的.比如评估贷款申请人信用风险的代码,或者在线视频游戏战斗系统背后的算法.

软件通常会将信息做一个划分,确保只有通过验证的用户允许访问授权过的信息.这个要求意味着软件通常需要使用访问权限控制技术来对用户在访问数据时进行验证.加密方法在数据传输与存储时也通常用于数据保密.

完整性

完整性亦即数据的可信度与正确性.人们对软件有这样的要求,即拥有防止自己的数据被更改的能力.完整性不仅仅时对于数据的内容来讲,而且要包括数据的来源.有的软件可以通过记录一段数据的更改或者数据来源的更改来检测数据完整性的变化.

数据完整系通常包括了信息的划分,也就是软件使用访问权限控制技术验证用户是否拥有权限对数据进行更改.验证过程也是软件的一个重要组成部分,它能保护数据来源的完整性,因为它严格地告知了软件这个用户是谁.(例如你通过了 QQ 密码验证, 就等于告诉了系统你是这个 QQ 的拥有者,你可以随便更改你的 QQ 信息 — By 译者)

特别地,相比于保密性,用户对完整系也有类似的需求.任何允许攻击者修改他们本不允许修改的数据都的问题应该被视为一个安全漏洞.任何允许用户为章程其他用户并操纵数据的问题也应当被认定为对数据完整性的破坏.

软件在完整性方面的漏洞可以是毁灭性的.攻击者能够利用数据更改权限访问软件系统,并且得到它的控制权.

实用性

实用性也就是使用信息以及资源的能力.一般来说, 它指的是用户对系统的可用性及其抵御拒绝服务攻击(denial-of-service DoS)的能力的期望。

允许用户摧毁一个软件可以被认定为违反了实用性原则的漏洞.攻击者可以利用这个漏洞,使用一些特殊的输入或者环境破坏来通过消耗软件系统资源,例如 CPU,硬盘存储,网络带宽等方式使一个程序无法运行.

6.1.3 审计的必要性

绝大多数人都希望供应商能够给他们的软件提供一定程度的对于完整性的保证.然而供应商在真实情况下几乎不给自己的软件提供质量保证.如果你对此表示怀疑,只要阅读下每个商业软件附带的几乎所有商业软件都适用的最终用户许可协议即可(end user license agreement ,EULA).当然,为了讨好用户,绝大多数供应商都会说自己对软件质量有自己的保障评估指标.这些评估指标往往出于市场考量,例如功能,实用性,一般的安全性.从历史上看,这使得安全性被随意地应用或者偶尔被完全忽略.

注

一些产业确实出台了他们自己的安全要求和标准,但他们仅仅在特定的环境下进行使用.但这种做法正在改变.因为备受瞩目的时间正促使监管机构和行业标准机构转向为更加积极主动地对安全提出要求.

好消息是人们对于安全的态度近年来已经产生了改变,许多供应商已经在业务流程中开始使用严格地安全性测试.许多方法已经变得常见,比如自动代码分析,安全单元测试,手动代码审计等.从这标题你可以看出,这本书主要讨论手动代码审计.

审计(auditing) 是一个分析代码的过程(无论这段代码是源代码还是二进制形式),这个过程用来发现一些可能被攻击者利用的漏洞.通过这个过程,你可以确认以及消除那些让敏感数据和商业资源陷入不必要风险的安全漏洞.

除了公司开发内部软件的明显情况外,代码审计在其他一些情况下也有意义。 表 1-1 总结了最常见的审计方法:

审计形式 描述 优势
内部软件审计(发布前) 软件公司在自己的新产品发布之前做的代码审计 漏洞能在产品进入市场前发现并填补,能节省开发和部署更新的费用,同时让公司免于潜在的骚扰
内部软件审计(发布后) 软件公司在产品发布之后进行审计 在恶意团队发现漏洞之前将其修复.这个过程可以花较长时间进行测设与检查而不是在漏洞暴露时匆忙发布
第三方产品范围比较 第三方拿相互竞争产品的某个方面进行审计 客观的第三方可以为消费者提供有价值的信息,并且帮助他们选择最安全的产品
第三方评估 第三方为客户对一个单独的软件进行审计 客户可以了解它正在考虑部署的应用程序的相对安全性。这可以为选择某个产品而不是其他提供相关参数的证明
第三方初步评估 第三方对一个还没有进入市场的产品进行评估 风险投资家可以了解未来技术在投资项目上的可行性.供应商也可以进行这种类型的评估,以确保它们所想要销售的产品的质量
独立研究 安全公司或咨询公司独立地执行软件审计 安全产品供应商可以识别扫描仪和其他安全设备的漏洞并实施保护措施。独立研究也起到行业监督的作用,为研究人员和安全公司提供了建立专业信誉的途径。

正如你所看到的,代码审计在很多情况下都适用. 尽管具备这些技能的人员的需求大,但是,很少有专业人员具有高水平执行这些审计的培训和经验。我们希望这本书能帮助填补这一空白。

审计与黑盒测试

黑盒测试(black box testing)是一种只通过操作软件所给的接口来评估软件的方法.特别地,这个过程更倾向于给一些精心设计的输入,这些输入能够让应用程序产生一些期望之外的结果,比如宕机或者暴露敏感数据.例如对一个 HTTP 服务器进行黑盒测试时可以向它发出一个字段大小异常大请求,这种请求可能造成内存崩溃的 bug(在第五章”内存崩溃”我们会详细讨论).这个测试的请求可以是合法的,例如下面的内容:

1
GET AAAAAAAAAAAAAAAAAAA...AAAAAAAAAAAAAAAAAAA HTTP/1.0

或者它也可以是非法请求,就像这样:

1
GET / AAAAAAAAAAAAAAAAAAA...AAAAAAAAAAAAAAAAAAAA/1.0

任何这种请求导致的宕机都说明这个应用程序存在严重的 bug.当考虑可以使用自动化测试应用程序的工具时,黑盒测试更有吸引力.这种自动化的黑盒测试被称为模糊测试(fuzz-testing). 而模糊测试工具包括通用的“dumb”和协议感知的“智能”fuzzer.因此你不需要手动地去尝试所有你可能想象的到的情况,你只要根据自己的设计运行这个这个工具然后收集结果就可以了.

黑盒测试的好处就是快,能迅速得到测试结果.然而,黑盒测试有几个非常关键的缺点.本质上说,黑盒测试就是丢一堆数据给应用程序然后看看他是不是做了一些设计者不想让他做的事情.你不知道这个过程中程序是如何处理数据的,所以有大量的不接触这些数据的代码你并没有进行探究.例如,回到这个 web 服务器的例子,假设请求的查询字符串中存在特定的关键词时它会具有特定的内部功能,就像下面的代码一样:请注意加粗的代码行(Markdown 代码块好像无法加粗,注意最后一个 if 语句即可 — by 译者):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct keyval {
char *key;
char *value;
};
int handle_query_string(char *query_string){
struct keyval *qstring_values, *ent;
char buf[1024];
if(!query_string)
return 0;
qstring_values = split_keyvalue_pairs(query_string);
if((ent = find_entry(qstring_values, "mode")) != NULL)
{
sprintf(buf, "MODE=%s", ent->value);
putenv(buf);
}
... more stuff here ...
}

这个 web 服务器有一个特殊的不规范的行为:如果查询字符串中含有 mode=,那么环境变量MODE就会被修改为xxx.这个特别的行为在实现上有一个缺陷,如果粗心地使用函数sprintf()就会造成缓冲区溢出.如果你不清楚为什么这段代码很危险,不必担心,缓冲区溢出漏洞会在第五章详细讨论.

从这个例子可以看出段存在 bug 的代码在黑盒测试中完全可能正常运行,简单的漏洞也无法探测出来。因此,你需要对程序代码本身进行评估而不是只靠跑几个测试例子然后记录结果就行。这也是代码审计很重要的原因。你需要能够分析代码然后发现自动测试工具无法探测到的漏洞。

幸运的是,代码审计配合黑盒测试能够利用最少的时间得到最好的修补漏洞的结果。这本书将会教会你这些知识和技术来彻底地分析一个应用程序方方面面的漏洞以及怎样应用你对这些知识的理解和创造力来发现一个应用程序的缺陷。

代码审计与开发生命周期

当你考虑应用程序会暴露给潜在的恶意用户时,应用程序安全审计的重要性不言而喻。然而你需要明确知道什么时候进行审计。一般地,你可以在系统开发生命周期(Systems Developement Life Cycle, SDLC)的任何阶段进行审计。让么人,在什么时候进行审计所花费的代价是不一样的。因此在开始之前,我们先回顾一下系统开发生命周期的每个阶段:

  1. 可行性研究。这个阶段会考虑确认完成这个项目的需求并决定什么样的开发解决方案在技术上和预算上是合适的。
  2. 需求定义。这个阶段将对于完成项目的需求进行更深入的研究,项目的目标也将确立。
  3. 设计。设计解决方案来使得目标系统能够技术性地实现并且满足需求。
  4. 实现。根据前面地设计阶段完成应用程序代码。
  5. 集成与测试。解决方案要经过一定程度的质量保证,以确保它按预期工作,并捕获软件中的任何 bug。
  6. 使用与维护。解决方案发布并投入使用,然后根据用户地反馈进行修正,更新,更正。

每个软件地开发过程都在某种程度上根据这套规则。经典的瀑布模型(waterfall models)要求系统周期严格地按照这个过程迭代一次。相反的是,新的方法论例如敏捷式软件开发(agile developement)更倾向于通过不断地迭代系统开发生命周期来改善程序。所以系统开发生命周期这个模型地应用是多样的,但基础的概念与阶段已经足够我们用于接下来地讨论。你可以用这些特点来帮助区分漏洞,在接下来的章节,你将会学到进行不同类别的审计的最佳阶段。

6.1.4 漏洞的分类

一个漏洞的类别(vulnerability class)是一系列拥有统一常见特征或者在不同软件缺陷中具有类似概念的漏洞的集合。当然,这个定义可能有点晦涩,但归根结底漏洞类别就是用来概念化软件缺陷的精神装置(mental device)。它们在我们理解问题以及将这种理解扩散到其他部分时大有帮助。但将漏洞分组到精确地不重叠的类中是不可能的。一个简单的漏洞很有可能根据代码审计者的术语,分类系统以及个人观点被分到许多类中。

这本书里不会使用一个对漏洞严格的形式化的分类,相反,这些问题会以一个一致的,实用的,适合于材料的方法进行分类。一些软件漏洞最好通过一些特殊的方法来处理。例如, 某些缺陷最好通过从高级软件组件的交互角度来看程序来解决 ; 另一种类型的缺陷最好通过将程序概念化为一系列系统调用来解决。无论采用哪种方法,本书都将解释你将在安全文献中遇到的术语和概念,以便你可以读懂安全社区在某种上下文中使用的一系列术语和分类方法。

在定义一般漏洞类时, 你可以从 SDLC 阶段的讨论中得出一些一般性的区别。有两个漏洞的类别叫设计漏洞(SDLC 阶段 1,2,3)和实现漏洞(SDLC 阶段 4,5) 。并且,这本书还会提到第三个类别:操作漏洞(operation vulnerability)(SDLC 阶段 6)。安全社区通常将设计漏洞作为软件系统体系结构和规范中的缺陷,实现漏洞时软件系统实际构建过程中的技术缺陷。操作漏洞的类别则针对在特定环境中部署和配置软件时出现的缺陷。

设计漏洞

设计漏洞来源于在软件设计时的根本的错误或者疏忽。由于设计缺陷的存在,软件本身就不会安全,因为软件做它被设计出来做得事,即错误的事情。这种缺陷时常出现, 因为对程序将在其中运行的环境所做的假设,或者程序组件在实际生产环境中所面临的暴露风险。 设计缺陷应当认定为高级别的漏洞,体系结构的缺陷,或者 程序需求或约束的问题。

在 SDLC 的简要介绍中我们知道,软件系统的设计是依托于软件需求的定义的,这些需求是一系列软件系统所必须达到的目标。特别地, 工程师获取需求集并构造设计规范, 这些设计规范关注于如何创造满足这些需求的软件。 需求通常是软件系统必须完成的任务 。例如:“允许用户从服务器中检索事务文件”。需求也可以说明软件必须要用的功能,例如:“它必须支持每小时同时下载 100 个文件。”

设计规范就是怎样构建程序来实现预期需求的计划。特别地,它包括了一个软件系统不同组成部分地描述,这些部分如何实现的信息,以及各个组成部分如何交互的信息。设计规范可以包含结构图,逻辑关系图,进程流图,接口和协议说明书,类的继承层次关系,以及其他的技术性的说明书。

当人们说起设计缺陷,它们通常不会发现软件需求方面与软件规范方面缺陷的不同。这种不同一般难以发现因为很多高层次的问题可能是需求中的疏忽造成,也可能是规范中的错误造成。

举个例子, TELNET 协议是被设计用来允许用户在远程机器中连接并像本地终端一样访问该机器。从设计的角度看,可以说 TELNET 具有一个依赖于非加密交流的漏洞。在一些环境中,如果底层网络是可信的,那么这种缺陷是可以接受的。然而,在企业网络和因特网中,非加密的交流就是一个大的缺陷,因为位于路由路径上的攻击者可以监视和劫持 TELNET 上的会话。如果管理者通过 TELNET 连接上了一个路由器然后输入了账号和密码,监视者就可以获得它们并将其记录。相反,像 SSH 这样的协议功能与 TELNET 几乎一样,但它能免于监视威胁因为所有交流信息都是经过加密的。

实现漏洞

在实现漏洞中,一份代码通常做到了他应该做的事情,但在运行操作的方式上它有安全隐患。顾名思义,这种问题通常在 SDLC 的实现阶段产生,但它经常被带入集成与测试阶段。如果在解决技术差异的实现过程偏离了原有设计,这种问题就有可能发生。然而,大多数情况是由软件构件时的技术工具,平台的细微差别以及语言环境造成的。实现漏洞通常也被称作低级别缺陷或者技术缺陷。

本书会给出很多关于实现漏洞的例子,因为发现这种技术缺陷是代码评审的基础操作之一。实现漏洞又包括了几种你可能听说过的广为人知的漏洞类,例如缓冲区溢出和资料隐码攻击(SQL injection)。

回到 TELNET 的例子,你能在 TELNET 的一些特定版本软件中找到实现漏洞。一些 TELNET 先前的版本不能正确地清理用户的环境变量,允许入侵者利用 UNIX 机器的动态连接特性提高他们在这台机器上的权限。还有一些缺陷允许入侵者制造缓冲区溢出,格式化字符串攻击各种版本的 TELNET 守护进程,通常这些操作都完全未经授权。这些缺陷导致攻击者能够在远处输入任何命令,就像是特权用户一样。基本上,攻击者可以对一个 TELNET 守护程序运行一个小小的攻击程序,然后立即在服务器上得到 root 权限。

操作漏洞

操作漏洞是在特定环境中操作程序和软件的一般使用过程中出现的问题。一种区分出这种漏洞的方法就是这种漏洞并不是由于软件源代码源代码造成的,而是根植于软件如何与其运行环境。特别地,它们可以包括软件在其环境中的配置问题,支持软件和计算机的配置问题,由围绕系统的自动化和手动过程引起的问题。操作漏洞可以在用户系统中引起很多种类的攻击,例如社会工程和盗窃(social engineering and theft).这些问题一般发生在 SDLC 的使用与维护阶段,尽管它与继承和测试阶段也有一些重叠部分。

回到 TELNET 的例子,由于缺乏加密手段,它具有设计漏洞。假如你在寻找一个可以安全地实现自动化交易的软件系统。假设它需要每晚都要设定一系列权重参数来石英第二天的交易策略。更新这些数据的过程就是管理者在每个交易日的结尾通过 TELNET 登陆计算机,然后通过一个简单的应用程序输入新的变量集合。取决于环境,这个过程可能有很大的操作漏洞,因为在使用 TELNET 时有有非常多的风险,例如监视与登陆劫持。总之,维护软件的操作过程是有缺陷的,因为它使系统暴露于潜在的欺诈与攻击之下.

灰色地带

从 SDLC 的视角看,设计漏洞和实现漏洞的区别非常简单.但事实不是总如此.许多实现漏洞同样可以解释为设计漏洞,因为在设计流程中并没有正确地预料到会有问题发生.另一方面,你也可以说软件系统低层次的部分也是以某种方式设计的.一个程序员可以在实现设计规范时设计软件的很多部分.这些部分可能包括一个类,一个函数,一个网络歇息,一个虚拟机,或者一系列巧妙的循环与分支.在缺乏严格的区分下,本书对设计漏洞是这样定义的:

通常情况下,设计漏洞是程序结构中高层次的问题,比如需求,基础接口,以及核心算法.

扩展一下设计漏洞的定义,本书使用如下定义来表示实现漏洞:

在低层次设计中的安全问题,例如一些独立的函数和类的问题就会被当作实现漏洞.实现漏洞当然也包括一些更复杂的但不在设计规范中提及的逻辑单元.(这些问题通常被称为逻辑漏洞).

同样的,操作漏洞和设计漏洞与实现漏洞之间也没有清晰的区别.比如一个程序在某个不安全的环境中下载了以后,你当然可以认为它是在设计与实现上有缺陷.你可以认为一个应用程序应当在开发出来后其安全性不依赖于所运行的环境.由于缺乏严格的区分,本书关于操作漏洞是这么定义的:

通常情况下,操作漏洞用于表示软件不安全的发布以及配置问题,不健全的管理以及围绕软件的管理实践,以及支持组件,比如比如应用程序和 Web 服务器的问题,以及对软件用户的直接攻击等问题.

你能看到对于设计,实现以及操作漏洞这三个概念有非常多的解释,所以不要认为这些定义是一个标记软件缺陷的可靠的正式的系统.它们只是用来学习软件漏洞的简单且有用的方法而已.

常见的线索

在学习了一些关于审计过程,安全模型,异界三个常见的漏洞分类这些的背景知识后, 当你深入了解具体技术问题的细节时,本书将用剩下的篇幅继续讨论它们.现在,我们回过头来先看看隐藏在软件安全漏洞背后的一些常见线索,主要先关注一下漏洞最优可能在软件中出现的位置以及原因.

输入与数据流

大量的软件漏洞都来自于一个程序对有害数据处理时的预期之外的行为.所以第一格问题就是解决恶意数据时怎样被系统所接受并且造成了严重影响的.解释它的最好办法就是先从一个简单的缓冲区溢出漏洞的例子说起.

考虑一个 UNIX 程序,它包含了一个非常长的能造成缓冲区溢出的命令行变量.在这种情况下,恶意数据就是直接来自于攻击者通过命令行接口的输入.这个数据通过整个程序直到一些函数通过不安全的方式使用它,最后导致了受攻击的局面.

对于绝大多数漏洞,你都能发现一些攻击者往系统中注入一些恶意数据以触发攻击.然而,这些恶意数据可能通过比用户直接输入更迂回的方式发挥作用.这些数据可以来自不同的源头并且经过不同的接口.它也可以通过系统的多个部分,并在到达最终触发可以用条件的位置之前进行大量的修改. 因此,在检查软件系统时,需要考虑的最有用的属性之一是贯穿系统各个组件的数据流 .

例如,有一个应用可以处理大组织的会议日程表.在每个月结尾,这个应用都会生成一份本周起协调的所有会议的报告,包括一份对每个会议的简短总结.仔细检查代码就会发现,当应用程序创造这个总结时,大于 1000 个字符的会议描述会导致 可以遭受攻击的缓冲区溢出的情况.

对这个漏洞进行攻击,你可以创建一个新的会议,他的描述性文字超过 1000 个字符,然后使用这个程序安排时间表.然后你就可以等待每个月的报告创建以后看看这个攻击时如何运转的.你的恶意数据可能通过几个系统的部分然后被存入数据库,同时避免被其他系统的用户发现.相应的,作为一个安全审查人员,你必须评估这个攻击向量的可行性.这个观点涉及分析会议描述的流程,从最初的创建,到多个应用程序的组件, 最后到在易受攻击的报告生成代码中使用。 这个跟踪数据流的过程是对软件设计和实现的审查的核心。用户可塑数据对系统构成严重威胁,跟踪端到端数据流是评估这种威胁的主要方法。通常,你必须确定用户可塑数据通过外部世界的接口(例如命令行或 Web 请求)进入系统的位置。然后,你要研究用户可塑数据在系统中传输的不同方式,同时查找任何可能利用这些数据的代码。数据很可能会通过软件系统的多个组件,并在其生命周期的几个点进行验证和操作。这个观点涉及分析会议描述的流程,从最初的创建,到多个应用程序组件,最后到在易受攻击的报告生成代码中使用。

这个过程并不总是那么简单. 通常你会发现一段代码是很容易受到攻击的,但它最终是安全的,因为恶意输入的数据流在早以前就能被捕获或者过滤.通常情况下,这种攻击时通过偶然事件来组织的.例如开发认为处于完全与安全性无关的原因引入了一些代码,但这样的副作用就是在数据流的后期保护了易受攻击的组件.此外,跟踪真实应用程序中的数据流可能非常困难,复杂系统通常时有机地开发的,导致数据流高度碎片化.在处理单个用户的请求过程中,实际数据可能会遍历几十个组件,并出入于第三方代码框架中.

信任关系

软件系统中不同组件对彼此具有不同程度的信任,在分析给定软件系统的安全性时理解这些信任关系非常重要.信任关系对于数据流是不可或缺的,因为 i 组件 组件之间的信任级别通常决定了对它们之间交换的数据进行验证的数量。

设计人员和开发人员通常认为两个组件之间的接口是可信的,或者将对等组件或者支持软件的组件指定为可信的.这意味着它们通常相信受信任的组件不会受到恶意干扰,并且它们认为对组建的数据和行为进行假设是安全的. 当然,如果这种信任是错位的,并且攻击者可以访问或操作受信任的实体,系统安全性就会像多米诺骨牌一样下降(即一个部分不安全,其他地方由于传递效应也变得不安全 —by 译者)。

说到多米诺骨牌,在评估系统中的信任关系时,理解信任的传递性非常重要。例如,如果你的软件系统信任某个特定的外部组件,而该组件又信任某个网络,那么你的系统就间接地信任了该网络。如果组件对网络的信任不佳,它可能会成为攻击的受害者,最终使你的软件处于危险之中。

假设与错位信任

看待软件缺陷的另一种有用的方法是,从程序员和设计人员在构建软件时做出毫无依据的假设的角度来考虑它们.开发人员可以在一个软件的许多方面做出错误的假设.包括输入数据的有效性和格式,支撑程序的安全性,环境中潜在的敌意,攻击者和用户的能力,甚至特定应用程序接口(API)的调用以及语言特性上的行为和细微差别.

不适当的假设与错位信任这两个概念密切相关,因为你可以说对组件进行不适当的信任与对组件进行毫无根据的假设非常相似,下面几节将讨论开发人员可能会以几种方式犯下与安全性相关的错误,这些错误就包括做出毫无根据的假设以及扩展不值得信任的内容.

输入

如前所述,大多数软件漏洞都是攻击者向软件系统注入恶意数据后出发的.这些数据之所以能造成这样的麻烦,原因之一就是软件往往过于信任与它通信的人,并且对数据的潜在来源和内容做了假设.(即假设我不会输入恶意数据 —by 译者)

具体说,当开发人员编写处理数据的代码时,它们经常对提供数据的用户或者软件组件做出假设.在处理用户输入时,开发人员通常认为用户不大可能做出这样的事情,比如输入一个包含 5000 字符的街道地址,期中还包含了无法打印出来的符号(emoji 里的抽象文字? — by 译者). 类似地,如果开发人员正在为两个软件组件之间编写接口代码,它们通常会假设输入格式是好的.例如,它们可能没有预料到程序在文件中防止一个负长度地二进制记录,或者发送一个 40 亿字节长的网络请求.

相反,攻击者在查看输入处理代码时,就试图考虑每一个可能导致不一致或者意外程序状态的输入.攻击者会试图探索软件的每一个可访问接口,并专门寻找开发人员所作的假设.对于攻击者来说,任何提供意外输入的机会都是宝贵的,因为这种输入通常会对开发人员没有预料到的后续处理产生微妙的影响.通常,如果你可以对软件运行时的属性进行以外的更改,那么你通常就可以找到一种方法来利用它对程序产生更大的(负面的)影响.

接口

接口就是软件各组件相互之间以及软件与外部世界进行通信的机制.许多漏洞时由于开发人员并没有充分认识到这些接口的安全属性,从而假设只有受信任的对等放可以使用它们而造成的.如果一个程序的组件可以通过网络或者本地机器上的各种机制访问,那么攻击者可能能直接列街道该组件并且输入恶意数据.如果编写该组件时假设其对等组件是可信的,则应用程序可能会以一种能够被利用攻击的方式错误地处理输入.

使得这个漏洞更加严重的是,开发人员通常错误地估计了攻击者到达这个接口地难度,因此他们信任这个接口,而这个接口是没有保证地.例如,开发人员可能认为自己的系统有很高的安全性,因为他们使用了带有自定义加密和专有的且复杂的网络协议.他们可能错误地认为攻击者不大可能构建自己爹客户端和加密层然后以意想不到的方式操纵协议. 不幸地是,这种假设非常不合理,因为许多攻击者在专有协议地逆向工程中找到了一种独特的乐趣.

总结一下,开发者可能由于以下原因对一个接口产生错位信任:

  • 他们选择了一种暴露接口的方法,这种方法不能提供足够的保护来抵御外来攻击者
  • 他们选择了一种可靠的方法来公开接口,通常是操作系统的服务,但他们使用了错误的配置.攻击者还可能利用基础平台的漏洞获得对该接口的控制.
  • 他们假设一个接口对于攻击者来说太难访问了,这是一个危险性极高的赌博.
环境的攻击

软件系统不是在真空中运行的,他们在一个大的计算环境中作为程序来运行,这个计算环境包括操作系统,硬件架构,网络,文件系统,数据库以及用户.

尽管许多软件漏洞来自于恶意数据的处理过程,但有的软件漏洞是在攻击者更改软件所依赖的环境中发生的.这种缺陷可以当作一种对软件的底层环境所作的假设造成的漏洞.软件系统所依赖的每种支撑技术可能都有许多最佳实践和细微差别,如果应用程序开发人员没有完全了解每种技术的潜在安全问题,那么犯下一个导致安全暴露的错误就太容易了.

关于这个问题的一个经典的例子就是 UNIX 软件种常见的一个文件夹叫/tmp.当一个程序需要使用临时文件时,它会在系统公共目录中创建这个文件,一般它位于/tmp或者var/tmp.如果这个程序没有细心地编写,攻击者就可以预测程序地移动并在公共目录中设置陷阱.如果攻击者在正确的位置和正确的事件创建了一个符号连接,程序就会被欺骗,在系统其他地方以不同名称创建它的临时文件,如果易受攻击的程序以 root 权限运行,这通常就导致系统遭受攻击.

在这种情况下,漏洞不是通过攻击者提供给程序的数据触发的.相反,这是对程序运行时环境的攻击,导致程序与操作系统以一种预期之外以及不期望发生的方式进行交互.

异常条件

与处理异常情况相关的漏洞与数据和环境漏洞交织在一起。基本上,当攻击者可以通过外部手段(external measure)在程序的正常控制流中引起意外更改时,就会发生异常情况。这种行为可能导致程序的异步中断,例如信号的传递。它还可能涉及消耗全局系统资源来故意在程序的特定位置诱发故障条件。

例如,如果一个进程试图写入一个关闭的网络连接或者通道,UNIX 系统将发送一个 SIGPIPE 信号,接收到此信号时的默认行为就是终止进程.攻击者可能会是一个易受攻击的程序在适当的时候对通道进行写入操作,然后应用程序能够成功执行写操作之前关闭通道,这将导致 SIGPIPE 信号,然后导致应用程序终止,并使得整个系统处于不稳定状态.对于更具体的示例,某些 Linux 发行版的网络文件系统(Network File System,NFS)状态守护进程很容易由于在正确的事件关闭连接而崩溃.利用这个漏洞能够破坏 NFS 功能,这种情况会持续到管理员可以干预并重置守护进程为止.

6.1.6 总结

在短短的这一章中已经涵盖了很多内容,当然可能还会留下一些问题.但无需担心,后续章节将会讨论更多细节,并在你的学习过程正给出答案.闲杂,重要的时要对计算机软件中可能出现的问题有很好的了解,并理解在讨论这些问题中所使用的术语.你还应该了解应用程序安全审计的必要性,并熟悉流程的不同方面.在后面的章节中,你将在此基础上学习如何使用这个审计过程来识别你所审查的应用程序中的漏洞.

the art of software security assessment Chap2. translate

Posted on 2020-06-05 | In Computer Science
Words count in article: 25.2k | Reading time ≈ 85

第二章 设计审查

“确实如此,我们每个人背上都背着一个未经许可的核动力加速器,没什么大问题。”

—— Bill Murray在捉鬼敢死队(1984)中饰演Peter Venkman时所说

6.2.1 导言

在设计审查(design review)方面,计算机安全人员往往分为两大阵营。具有正式开发背景的人通常能够接受设计审查过程。这是很自然的,因为它与大多数正式的软件开发方法密切相关。设计审查过程似乎也比手工审查大型应用程序代码库更省事。

另一派是代码审计员,他们乐于发现最模糊、最复杂的漏洞。这群人倾向于将设计审查看作是一个象牙塔结构,只是妨碍了真正的工作。设计评审的形式化过程和对文档的关注成为深入研究代码的障碍。

事实是,设计审查(的价值)介于这两个阵营的观点之间,它对两者都有价值。设计审查是一个有用的工具,用于识别应用程序体系结构中的漏洞,并为实现审查(implementation review)确定组件的优先级。然而,它并没有取代实现审查;这只是整个审查过程的一部分。它使识别设计缺陷变得容易得多,并提供了对软件设计安全性的更全面的分析。在这种情况下,它可以使整个审查过程更有效,并确保你在投入的时间中获得最佳回报。

本章介绍了一些软件设计和设计漏洞的背景知识,并介绍了一个审查过程,以帮助你确定软件设计中的安全问题。

6.2.2 软件设计基础

在进入设计审查这个课题之前,我们先来回顾一下一些关于软件设计的基础。许多关于软件设计的概念在这章中与安全息息相关,特别是在威胁建模中(threat modeling)。以下几个小节介绍了学习设计安全所必要的关于软件设计的概念:

算法

软件工程归根结底可以认为是开发和实现算法的过程。从设计的角度来看,这个过程着重于开发关键的程序算法和数据结构,以及指定问题域逻辑。要了解系统设计的安全需求和漏洞潜力,你必须首先了解组成系统的核心算法。

问题域逻辑

问题域逻辑(Problem Domain Logic),或者业务逻辑(Business Logic) 提供了程序在处理数据时遵循的规则。 软件系统的设计必须包括软件执行的主要任务的规则和过程。软件设计的一个主要组成部分是与系统用户和资源相关的安全预期。例如,考虑具有以下规则的银行软件:

  • 一个人可以从他/她的主要账户到任何其它合法账户中转账
  • 一个人可以从他/她的市场账户(market account)到任何合法账户中转账。
  • 一个人每个月只能从他/她的市场账户转一次账。
  • 如果一个人在他/她的主要账户上的余额低于零,钱就会自动从他/她的市场账户转出,以抵消低于零的部分(如果这些钱足够的话)。

这个例子很简单,但是你可以看到,银行客户可能能够绕过市场帐户每月一次的转账限制。他们可以故意将自己的主要账户的资金取到余额低于0。因此,该系统的设计存在一个漏洞,银行客户可能会利用这个漏洞。

核心算法

通常,程序的性能要求决定了用于管理关键数据段的算法和数据结构的选择。有时可以从设计的角度评估这些算法的选择,并预测可能影响系统的安全漏洞。

例如,假如你知道一个程序将传入的一系列记录存储在支持基本顺序搜索的已排序链表中。基于这一知识可以预见的是,一个特别精心制作的庞大记录列表可能会导致程序花费大量时间在链表中进行搜索。对这样的关键算法的重复集中攻击很容易导致服务器功能的暂时甚至永久中断。

抽象与分解

软件设计的每个地方都不可避免地包含两个基本概念:抽象和分解。你可能已经熟悉了这些概念,但如果还不熟悉,下面的段落将提供一个简要的概述。

抽象(abstraction)是一种降低系统复杂性、使其更易于管理的方法。要做到抽象,只需要隔离最重要的元素并删除不必要的细节。抽象是人们感知周围世界的重要部分。他们解释了为什么你可以看到一个符号😊,就把它与一个微笑的脸联系起来。抽象允许你概括一个概念,例如面孔,以及其他相关的概念,例如微笑的面孔和皱眉的面孔。

在软件设计中,抽象是对应用程序将执行的流程进行建模的方式。它们使你能够建立相关系统、概念和流程的层次结构,从而隔离问题域逻辑和关键算法。实际上,设计过程只是构建一组抽象的方法,你可以通过开发过程实现它们。当一个软件必须解决一系列用户所关心的问题,或者它的实现必须分布在一个开发团队中时,这个过程变得特别重要。

分解(decopmesiton),或因式分解(factoring)是定义组成抽象的泛化(generalization)和分类的过程。分解可以在两个不同的方向运行。自顶向下的分解,即所谓的专门化,是将一个较大的系统分解成更小、更易于管理的部分的过程。自底向上的分解,称为泛化,涉及到识别许多组件中的相似性,并开发一个应用于所有组件的更高层次的抽象。

结构化软件分解的基本元素可能因语言的不同而不同。标准的自顶向下进展是应用程序、模块、类和函数(或方法)。有些语言可能不支持列表中的所有区别(例如,C语言不支持类);其他语言添加了更多的区别或使用略有不同的术语。 对于我们作设计审查的目的而言,这些差异并不重要,但是为了简单起见,本文主要讨论模块和函数。

信任关系

在第一章软件漏洞基础中,我们已经介绍了关于信任以及它是如何影响系统的全的。 本章对这一概念进行了扩展,指出多方之间的每一次通信都必须具有一定程度的信任。有一个术语叫信任关系(trust relationship)可以表述它。对于简单的通讯,两个群体都可以假设对另一方完全信任,也就是每个通讯群体都允许其他群体在参与通信时对暴露的功能拥有完全访问权限。然而,你更关心的时通信双方应该限制彼此信任的情况,这意味着各方只能访问到彼此功能的有限子集。通信的每一方施加的限制定义了他们之间的信任边界(trust boundary)。信任边界区分了信任共享的区域,称为信任域(trust domains)。如果你对这些概念有点迷惑,无需担心,下一节将提供一些示例。

软件设计需要考虑系统的信任域、边界和关系;信任模型(trust model)是表示这些概念的抽象,是应用程序安全政策的一个组件。此模型的影响在系统如何分解上很明显,因为信任边界也往往是模块边界。模型通常要求信任不是绝对的;相反,它支持被称为特权的不同程度的信任。一个典型的例子是标准的UNIX文件权限,用户可以为系统上的其他用户提供有限的文件访问权限。具体来说,用户可以指定是否允许其他用户读取、写入或执行文件(或这些权限的任何组合),从而将有限的信任扩展到系统的其他用户。

简单的信任边界

举一个简单的信任边界例子。考虑一个简单的单用户操作系统,比如Windows 98.为了使这和例子简单化,我们不考虑关于网络的部分。Win 98有简单的内存保护机制以及一些关于用户的概念,但它没有提供访问控制或者执行的措施。也就是说,如果用户可以登陆Win 98的系统,他就可以任意更改文件或者系统设定。因此,对于能够登陆上Win 98的交互性用户而言就不存在什么安全性。

你可以认为对于交互性的用户之间,(没有联网的)Win 98系统没有提供信任边界。然而你可以作这样的假设,即什么人可以物理访问这个系统。所以你可以说信任边界就在这种情况下定义了,即在能够拥有物理访问权限的用户和没有权限的用户。(这段有点拗口,用大白话说就是能摸到这台电脑的人和摸不到这台电脑的人之间有信任边界 —by 译者) 这样就只剩下一个由受信任用户组成的域和一个表示所有不受信任用户的隐式域。

让这个例子变得复杂一点,现在我们升级到多用户操作系统,比如Win XP专业版。现在我们就要考虑更多了。你可以想象两个拥有一般权限的用户不可以更改彼此的数据或者进程,当然,这个假设建立在你不是管理员用户(administrative user)的情况下。所以现在两个用户在系统中拥有了保密性以及完整性,这种保密性与完整系就构成了彼此的信任边界。当然由于管理员用户的存在我们也要假如其他的边界:非管理员用户无法影响到系统的完整性以及设置。这种边界是自然的,强加给用户之间的界限是必须的,毕竟如果任何用户都能影响到系统的状态,那就是单用户操作系统没什么不同了。下面是一个多用户操作系统的信任关系的图例:

2-1
2-1

现在退一步考虑信任的本质。也就是说,每个系统最终都必须有一些绝对可信的权威。没有办法,因为必须有人对系统的状态负责。这就是为什么UNIX有一个root帐户,而Windows有一个管理员帐户。当然,你可以对这个级别的权限给与一系列的控件。例如,UNIX和Windows都有向不同用户授予不同程度的管理特权的方法。然而,一个简单的事实仍然是,在每个信任边界中,至少有一个可以承担责任的绝对权威(也就是至少有一个边界时由root 与非 root来划分的 ——by译者)。

复杂的信任关系

到目前为止,了解稍后需要处理的问题领域,你已经了解了相当简单的信任关系。然而,一些更详细的细节被忽略了。为了使讨论更实际一些,我们考虑连接到网络的同一个系统。

将系统连接到网络后,必须开始添加一系列区分。你可能需要为系统的本地用户和远程用户考虑单独的域,并且你可能需要 能够通过网络访问系统但不是“常规”用户的人提供域。 防火墙和网关进一步使这些区别复杂化,并引入了更多的区分。

很明显,定义和应用信任模型对任何软件设计都有巨大的影响。真正的工作在设计过程开始之前就开始了。可行性研究和需求收集阶段必须充分确定和定义用户的安全期望和目标环境的相关因素。生成的模型必须有足够的鲁棒性,以满足这些需求,但又不能复杂到难以实现和应用。这样,安全性就必须小心地平衡清楚性和准确性的需要。在本章后面的部分中,当研究威胁建模时,你将通过评估不同系统组件之间的边界和系统上不同实体的权限来考虑信任模型。

信任链

第一章介绍过了信任传递的观点,本质上说,信任传递就是如果组件A信任组件B,那么A就必须信任组件B所信任的所有组件。这个概念也可以称为信任链(chain of trust)关系。

信任链是一个完全可行的安全构造,也是许多系统的核心。考虑证书在到Web服务器的典型安全套接层协议(Secure Sockets Layer, SSL)连接中分发和验证的方式。你有一个本地签名数据库,用于标识你信任的提供者。然后,这些提供者可以向证书颁发机构(certificate authority CA)颁发证书,然后CA可以扩展到其他颁发机构。最后,托管站点的证书由这些机构之一签署。在建立SSL连接时,必须遵循从一个CA到另一个CA的信任链。 只有当到达可信数据库中的某个权限时,遍历才会成功。

现在,假设你想要模拟一个Web站点,以达到某种邪恶的目的。目前,不考虑域名系统(Domain Name System, DNS),因为它通常是一个容易的目标。相反,你所要做的就是找到一种方法来操作信任链中任何位置的证书数据库。这包括操作访客的客户端证书数据库、直接危害目标站点、或操作链中的任何CA数据库(包括根CA)。

为了确保重点明确,重复最后一部分更好一些。每个CA共享的信任的传递性意味着任何CA安全性弱的部分都允许攻击者利用然后成功地模拟任何站点。颁发实际证书的CA是否受到威胁并不重要,因为由有效的CA颁发的任何证书就足够了。这意味着任何SSL事务的完整性只取决于最弱的CA。不幸的是,此方法是用于建立主机标识的最佳方法。 有些系统只能通过使用可传递的信任链来实现。但是,作为一名审计人员,你需要仔细研究选择这种信任模型的影响,并确定信任链是否合适。你还需要遵循所有包含组件的信任关系,并确定任何组件的实际公开程度。你经常会发现,使用信任链的结果是造成复杂而微妙的信任关系,攻击者可以利用这些关系。

纵深防御

纵深防御(defense in depth)就是分层保护,这样系统如果一个地方有弱点那这个弱点就能被其他控制手段减轻。纵深防御的简单示例包括使用低权限用户运行服务和守护进程,以及将不同的功能隔离到不同的硬件上,更复杂的例子包括网络非军事区(network demilitarized zones, DMZs)、 chroot jails以及栈(stack)和堆(heap)保护。

当你为审查的组件划分优先级时,应当考虑分层防御。你可以能会将较低优先级分配给在低权限用户上运行的面向内部网的组件,该组件位于chroot jail中,并使用缓冲区保护进行编译。相反,你可能会为必须以root身份运行的面向因特网的组件分配更高级的优先级。这并不是说第一格组件时安全的,第二个不是。优先化威胁(prioritizing threat)将会在本章后面的“威胁建模”中详细讨论。

软件设计的原则

软件开发方法的数量似乎与软件开发人员的数量成正比。不同的方法适合不同的需求,项目的选择也因各种因素而异。幸运的是,每种方法都有一些公认的原则。准确性(accuracy)、清晰性(clarity)、松散耦合(loose coupling)和强内聚性(strong coherence)这四个核心原则适用于每个软件设计,是讨论设计如何影响安全性的良好起点。

准确性

准确性也就是一个设计抽象是怎样高效地来符合需求的。准确性包括了一个抽象对需求如何准确地建模,还包含了它们怎样被合理地实现。 当然,我们的目标是用最直接的实现方法提供最精确的模型。

在实践中,软件设计可能不会准确地转换为实现。在需求收集阶段的疏忽可能导致设计遗漏了重要的功能或强调了错误的关注点。设计过程中的失败可能会导致实现必然与设计产生巨大的差异,以满足实际的需求。即使流程中没有失败,预期和需求也会在实现阶段发生变化。所有这些问题都可能导致实现偏离预期的(和文档化的)设计。

软件设计及其实现之间的差异导致了设计抽象中弱点的出现。这些弱点是滋生各种漏洞的温床,包括安全漏洞。它们迫使开发人员在预期的设计之外做出假设,而未能传达这些假设通常会造成易受攻击的情况。注意设计没有充分定义的地方,或者对程序员有不合理的期望。

清晰性

软件设计可以为极其复杂且常常令人困惑的过程建模。为了达到清晰的目的,一个好的设计应该以一种合理的方式分解问题,并提供清晰、不证自明的抽象。结构的文档也应该很容易获得,并且参与实现过程的所有开发人员都应该很好地理解它。

不必要的复杂或文档记录不良的设计可能导致类似于不准确设计的漏洞。在这种情况下,抽象中的弱点会出现,因为对于精确的实现来说,对设计的理解太不到位了。你的审查应该识别出没有充分文档化或异常复杂的设计组件。你可以在整本书中看到这个问题的例子,特别是在第7章,“程序构建块”。

弱耦合性

耦合是指模块之间的通信级别以及模块之间相互公开内部接口的程度。松散耦合的模块通过定义良好的公共接口交换数据,这通常会导致更具适应性和可维护性的设计。相反,强耦合模块具有复杂的相互依赖关系,并公开其内部接口的重要元素。

强耦合模块通常彼此高度信任,很少为它们的通信执行数据验证。在这些通信中缺少定义良好的接口也使数据验证变得困难和容易出错。当其中一个组件可被攻击者控制时,这往往会导致安全缺陷。从安全的角度来看,您需要寻找任何跨信任边界的强模块间耦合。

强内聚性

内聚是指模块的内部一致性。这种一致性主要是模块的接口处理一组相关活动的程度。强内聚性鼓励模块只处理紧密相关的活动。保持强内聚的一个副作用是,它倾向于鼓励强内部耦合(单个模块的不同组件之间的耦合程度)。

当设计无法沿着信任边界分解模块时,可能会出现内聚相关的安全漏洞。由此产生的漏洞类似于强耦合问题,只不过它们发生在同一个模块中。这通常是系统在设计的早期阶段没有考虑安全性的结果。要特别注意在单个模块中处理多个信任域的设计。

基础的设计缺陷

现在你已经有了基本的理解,可以考虑一些基本设计概念如何影响安全性的示例。特别是,你需要了解误用这些概念会如何造成安全漏洞。在阅读下面的例子时,你会很快注意到它们往往是由一系列问题导致的。通常,一个错误是可解释的,并且很大程度上取决于审查者的观点。不幸的是,这是设计缺陷的一部分。它们通常在概念级别上影响系统,并且很难进行分类。相反,你需要关注问题的安全影响,而不是陷入分类中。

强耦合利用

本节将探索一个基本的设计缺陷,该缺陷是由于未能沿着信任边界正确分解应用程序而导致的。这些被称为粉碎漏洞(Shatter class of vulnerabilities),最初报告的独立研究的一部分由克里斯佩吉特进行。特定的攻击方式利用了Windows GUI应用程序编程接口(API)的某些属性。下面的讨论避免了许多细节,以突出设计的具体性质的粉碎漏洞。第十二章,“Windows II:进程间通信”提供了与这类漏洞相关的技术细节的更深入的讨论。

Windows程序使用消息系统(messaging system)来处理所有和GUI相关的事件,每个桌面都有一个消息队列,用于与之关联的所有应用程序。所以任意两个进程在同一个桌面上运行时都可以向彼此发送消息, 不管流程的用户上下文是什么。 这就会造成一个问题,即高权限的进程,例如服务进程,在普通用户的桌面上运行。

Windows API提供了 SetTimer()函数来为发送WM_TIMER消息安排时间。这个消息可以内含一个指向函数的指针,这个函数在当默认消息句柄(message handler)收到WM_TIMER调用。这就会造成一种情况,一个进程可以控制同一个桌面上运行的任何其他进程的函数的调用。 攻击者惟一关心的是如何为目标进程的执行提供代码。

Windows API包含了很多的消息用来更改窗口元素,通常,它们是用来设定文本框内容以及标签,更改剪贴板的内容。然而,攻击者可以利用这些消息从目标进程的地址空间出插入数据。将这些数据和WM_TIMER结合起来之后,攻击者可以在相同桌面的任何进程部署和运行任何代码。这就是一个权限升级漏洞,可以用于攻击在交互式桌面上运行的服务。

当这个漏洞发表后,微软更改了WM_TIMER消息的处理方法。核心问题就是,跨桌面的通信必须被视为潜在的攻击载体。 当你考虑到最初的消息传递设计受到单用户操作系统很大的影响时,这就更有意义了。在这种情况下,这样的设计就是准确的,清晰的,有强内聚性的。(即最初的消息传递机制就是为单用户操作系统设计的,这不影响到软件设计的三大原则,但扩展到多用户操作系统这就是一个漏洞了——by 译者)

此漏洞说明了为何难以将安全性添加到现有设计中。最初的Windows消息传递设计对于它的环境来说是合理的,但是引入了一个多用户操作系统改变了这种情况。消息传递队列现在在同一个桌面上拥有了强耦合以及不同的信任域。其结果是出现了新的漏洞类型,可以利用桌面作为公共接口。

信用传递的利用

一个很吸引人的Solaris安全问题显示了攻击者能够怎样在两个组件之间操纵它们的信任关系。一些版本的Solaris包括了使用root权限运行的RPC程序,automonted。这个程序允许root用户指定一个命令作为挂载操作的一部分运行,通常用于代表内核处理挂载和卸载。automonted程序不监听IP网络,只能通过三个受保护的环回传输访问,这意味着程序只接受来自root用户的命令,看起来还是很安全的。

另外一个程序,rpc.stated,在root权限下允许并且监听传输控制协议( Transmission Control Protocol, TCP)和用户数据报协议(User Datagram Protocol, UDP)。它被用作网络文件系统(NFS)协议支持的一部分,它的目的是监视NFS服务再它们关停时发送通知。通常的,NFS锁定守护进程询问rpc.stated来监视服务器。然而,注册rpc.stated要求客服端告诉它要联系哪个主机,以及要在该主机上调用哪个RPC程序号。

因此攻击者就可以联系一台机器的rpc.stated然后注册audomonted程序以接收崩溃通知。然后攻击者告诉rpc.stated被监视的NFS服务器崩溃了。于是roc.stated在本地机器上联系automonted守护进程(通过一些特殊的回送接口(look back interface))然后给它一个RPC消息。这个消息并不是automonted所期望的,但在一些修改之后,你就能对一个合法的audomonted请求进行解码。这个请求通过回送接口发自root,于是automonted就认为这个请求来自于内核部分,而结果是它执行了攻击者选择的命令。

在这个例子中,攻击rpc.stated的公共接口只在与automonted建立信任通信时有用。这个情况会发现因为所有在相同账户下运行的进程都是相互信任的,利用这种信任可以让远程攻击者向automonted进程发送命令。最终关于通信源的假设导致开发人员对automonted所接受的格式比较宽容。这个问题与模块之间的相互信任结合导致了远程root级别的漏洞。

故障处理

在软件设计中,正确的故障处理是清晰准确的可用性的重要组成部分。你当然只是希望应用程序能够正确地处理不正常的情况,并为用户提供解决问题的帮助。但是,故障条件可能会导致可用性和安全性出现冲突。有时,必须对应用程序的功能进行妥协,以实现安全性。

考虑一个网络程序,它检测客户端系统收到的数据中的故障或者故障条件。准确而清晰的可用性要求应用程序尝试恢复并继续运行。当无法恢复时,应用程序应该通过提供有关错误的详细信息来帮助用户诊断问题。

然而,面向安全的程序通常采用完全不同的方法,这可能涉及终止客户端会话和提供最低限度的必要反馈。之所以采用这种方法,是因为围绕安全理想而设计的程序假定故障条件是攻击者操纵程序输入或环境的结果。从这个角度来看,绕过问题并继续处理的尝试通常会正中攻击者的下怀。务实的防御反应是放弃正在发生的事情,在日志中发出血腥的尖叫,并终止处理。尽管这种反应似乎违反了一些设计原则,但这只是安全性需求的准确性取代了可用性需求的准确性和明确性的一种情况。

6.2.3 执行安全政策

第一章讨论了安全期望以及它时怎样影响一个系统的。现在你可以用这些概念来理解安全期望是怎样来执行安全政策的。 开发人员主要通过识别和加强信任边界来实现安全政策。作为一名审计人员,你需要分析这些边界的设计以及实现它们的实施的代码。为了更容易地处理安全策略的元素,执行被分成六个主要类型,在下面的部分中讨论。

身份验证

身份验证(authentication) 是程序确定用户声明的身份,然后检查该声明的有效性的过程。软件组件在发起通信时使用身份验证来建立对等方(客户端或服务器)的标识。一个典型的例子是要求网站的用户输入用户名和密码。正如你在前面关于SSL证书的讨论中所看到的,身份验证也不仅适用于人类。在此示例中,系统彼此进行身份验证,以在不可靠的接口上安全地运行。

常见的身份验证漏洞

一个值得注意的设计疏忽是在需要身份验证的情况下不进行身份验证。例如,一个Web应用程序提供了一个可能对内幕交易有用的敏感公司会计信息的摘要。将这些信息暴露给任意的互联网用户,而不要求进行某种身份验证,这将是一个设计缺陷。请注意,“缺乏身份验证”问题并不总是很明显,尤其是在大型应用程序中处理对等模块时。通常很难确定攻击者是否可以访问两个组件之间的内部接口。

通常,最佳实践是在设计中集中身份验证,特别是在Web应用程序中。有些Web应用程序要求通过主页访问的用户进行身份验证,但在后续页面中不强制进行身份验证。这种身份验证的缺乏意味着你不需要输入用户名或密码就可以与应用程序进行交互。相反,集中式身份验证通过验证受保护域中的每个Web请求来缓解这个问题

不可信的凭证

另一个常见的错误发生在向软件提供一些身份验证信息,但这些信息不值得信任。当在客户端执行身份验证时,这个问题经常发生,攻击者通过它可以完全控制连接的客户端。例如,SunRPC框架包括了AUTH_UNIX身份验证方案,这个方案是给予完全信任客户端系统的。客户端只是传递一条记录,该记录高屋服务器用户以及组的id是什么,而服务器只将他们作为事实接受。

UNIX系统以前包括一个RPC保护进程叫rexed(远程执行守护进程, remote execute daemon )。这个程序的目的是让远程用户像本地用户一样运行程序。如果你连接上了一个rexed系统然后告诉rexed程序运行/bin/sh命令,程序就会将shell像bin一样运行然后让你和它进行交互。这就是它的全部功能,除了不能作为root用户运行程序之外。特别地,在将shell作为bin运行之后,只需要几分钟就可以绕过这个限制。最近,一个远程root缺陷,在Solaris上默认安装的sadmind暴露了, 它将AUTH_UNIX身份验证作为代表客户机运行命令的充分验证。

注

关于sadmind的bug文档见 www.securityfocus.com/bid/2354/info

许多网络守护进程使用网络连接或包的源IP地址来建立对等点的身份确认。对于它自己来说,这个信息不足够可信并且容易收到篡改。UDP可以被很简单的方法欺骗,TCP连接也可以在很多情况下被欺骗或者拦截。UNIX提供了多个守护进程,它们遵循基于源地址的可信主机的概念。这些守护进程是rshd和rlogind,甚至sshd也可以通过配置来遵守这些信任关系。攻击者可以利用两个机器的信任关系,从这些受信任的机器中的高权限的端口通过初始化,欺骗攻击或者劫持一个TCP连接。

你可能在两个系统的程序化的身份认证中看到这种设计缺陷。如果程序使用这种身份认证机制,例如证书,设计层面的问题就会出现。首先,很多分布式的客户端/服务器应用只从一个方面认证身份:只通过客户端或者只通过服务器。攻击者经常可以利用这种身份验证的结构伪装成未经身份验证的用户,并对系统进行微妙的攻击。

使用加密方法自制的身份验证也是你可能常会遇到的问题。从一个概念性的角度来看,验证自己的身份看起来很简单。(此处省略一段难翻译的废话 —by译者)。但是,在从头创建身份验证协议时,有很大的错误空间。Thomas Lopatic在Firewall-1和FWN/1协议中发现了一个有趣的漏洞。每个对等点发送一个随机数R1和该随机数的哈希值以及共享密钥,Hash(R1+K).接收端库查看发送的随机数,计算哈希值,并将其与传输的值进行比较。问题是,你可以简单地将R1和Hash(R1+K)的值在服务器中重现,因为它们时使用相同的共享对称密钥生成的。

授权

授权是一个决定系统中的用户拥有什么样的权限在信任域中去做一些特定事务的过程。它作为权限控制(access control)策略的一部分与身份验证是一致的:身份验证告诉了这个用户是谁,授权决定了这个验证过的身份拥有做什么的权限。 有许多访问控制系统的正式设计,包括自由访问控制、强制访问控制和基于角色的访问控制。 此外,有几种技术可用于将访问控制集中到各种框架、操作系统和库中。由于不同访问控制方案的复杂性,最好从一般的角度来看待授权。

一般的授权漏洞

Web应用经常没有或者缺少足够的授权。你经常能发现只有一小部分网站做到了授权的检查。带有授权逻辑的页面通常是主菜单页面和主要子页面,但是实际的处理程序页面省略了授权检查。通常情况下,找到一种方法以相对低权限的用户登陆,然后能执行一些访问不属于你的账户信息以及做一些不属于你账户的行为这些为高权限用户所准备的操作。

不安全的授权

缺少授权显然是一个问题。你还可能遇到授权检查的逻辑不一致或者留下滥用空间的情况。例如,假如你有一个简单的消费跟踪系统,每个在公司的用户都有自己的账号。这个系统使用了公司的等级架构进行编写,所以它直到哪些员工是管理者,哪些是被管理者。它的主逻辑是像下面这样的数据驱动:

1
Enter New Expensefor each employee you manage	View/Approve expenses

这个系统非常简单。假设初始化的公司员工等级架构是正确的,管理者可以审查与批准他们下属的费用。一般员工只能见到Enter New Expense的界面因为他们并不是管理者。

现在假设你在这样一种情况下运行这个程序,即一些员工都被一个人管理,但实际上他们要向另一个管理者报告日常事务。为了解决这个问题,你对这个程序做了这样的更改,即允许每个用户去选择其他用户作为自己的“虚拟的”管理员。一个用户的虚拟管理员(virtual manager)拥有查看与批准这个用户费用的权力,就像这个用户真正的管理员一样。这个解决方案第一眼看起来似乎还行,但它是有缺陷的。它可以允许用户将自己亲密的同事设置为虚拟管理员,包括他自己,这将导致费用的批准不受到任何限制。

这个简单的系统有一个明显的问题,可能看起来是人为设计的,但它是从实际应用中遇到的问题派生出来的。随着应用程序中的用户和组数量的增加以及系统复杂性的增加,设计人员很容易忽略授权逻辑中潜在的滥用可能性。

可追责性

可追责性(Accountability) 就是一个系统能够确认以及记录用户在系统中的所作所为。不可抵赖性是一个相关术语,实际上是可追责性的一个子集。 它指的是系统对某些用户操作进行日志记录的保证,这样用户就不能否认曾经执行过这些操作。 可追责性,身份验证和授权共同建立了完整的权限控制策略。不像身份验证和授权,可追责性并不形成一个信任边界或者防止漏洞的发生。但是,可追责性提供的数据对于减轻成功的入侵和进行法医式的分析(forensic analysis)是必不可少的。不幸的是,可追责行是应用程序设计安全中最容易忽视的部分之一。

常见的可追责性漏洞

最常见的可追责性漏洞就是一个系统对于登陆操作与敏感数据记录的失效。事实上,很多应用并不提供日志功能。当然,许多应用也不提供处理敏感数据时的日志记录。然而,管理者或者最终用户开发者需要决定什么样的日志记录是需要的。

另外一个主要的可追责性漏洞就是系统并没有正确地保护它的日志数据。当然,这个问题也可以划分为授权,保密性或者完整性漏洞。不管如何,任何系统在维护日志时需要保证它的安全。例如,下面展示了一个简单的文本日志,每行记录了时间戳以及登陆日志:

1
2
3
20051018133106 Logon Failure: Bob
20051018133720 Logon Success: Jim
20051018135041 Logout: Jim

如果我在用户名上做手脚又会发生什么呢?比如说,一个叫"Bob\n20051018133106 Logon Success: Greg"的用户名看起来人畜无害,但是它确实可以用来做坏事。攻击者可以使用假的登录信息去掩盖有害的登陆,或者破坏日志使其变得不可读或者不可写。 这种破坏可能会造成拒绝服务的情况,或者打开通向其他漏洞的通道。它甚至可以在日志系统本身中提供可利用的路径。

除了这种日志的维护以外还有其他问题。如果攻击者能够读取日志呢?至少他们能够直到什么时候哪个用户会登入或者登出。从这个数据中,他们可能推断出一些登陆规律或者监视哪些用户会有忘记密码的习惯。这种信息看起来人畜无害,但它能为更大的攻击埋下伏笔。因此,未经授权的用户不能够向系统日志读取内容进行或者写入操作。

保密性

第一章给出了保密性的定义,即只有经过授权的一方才能够查看数据。这要求通过权限控制机制来实现,这种机制囊括了身份验证和授权。然而当通信在不安全的地段发生时,我们必须对安全引入新的考量(additional measure)。在这种情况下,加密技术通常用来满足保密性的需求。

加密(eccryption) 就是一个对信息编码的过程,如果第三方没有相关的知识(即解码方法 —by译者),那他就无法获得信息的确切内容。加密过程通常对一些数据有核心意义。核心数据只有通过授权之后才能访问其信息。

关于加密算法以及过程的主体并没有包含在在本书中,因为这其中的数学非常复杂,包含了一整个研究领域,要对它们有更深的了解,参考这本书:实用密码学 by Bruce Schneier and Niels Ferguson .(此处省略一些废话 —by译者)

加密算法

加密方法有很悠久的历史。然而我们关心的是可以用来有效保护交流数据的现代加密协议,本章我们关注两种加密的类别:对称型和反对称型。

对称加密(symmetric encryption) 或者共享密钥加密是一类所有授权方共享一份相同密钥的加密算法。 对称算法通常是最简单和最有效的加密算法。它们的主要缺点是,它们要求多方能够访问相同的共享机密。另一种方法是为每个通信关系生成和交换一个惟一的密钥,但是这种解决方案很快会导致无法维持的密钥管理情况。此外,非对称加密无法在任何共享密钥用户组中验证消息的发送方。

非对称加密(asymmetric encryption)(或公钥加密)是指每一方都有一组不同的密钥来访问相同加密数据的算法。这是通过为每一方使用一个公钥和私钥对来完成的。任何希望通信的各方都必须提前交换它们的公钥。然后,通过组合接收方的公钥和发送方的私钥对消息进行加密。生成的加密消息只能通过使用接收方的私钥解密。此外,非对称加密无法在任何共享密钥用户组中验证消息的发送方。

也就是说,非对称加密简化了密钥管理,并不要求暴露私有密钥,并且隐式地验证发送者的信息。然而, 非对称算法通常用于交换对称密钥,然后在通信会话期间使用该密钥。

分组密码

分组密码(block cipher)是一种对称加密算法, 它在固定大小的数据块上工作,并以多种模式运行。但是,在使用它们时,你应该知道一些注意事项。一个需要考虑的问题是,分组密码是独立加密每个组,还是使用前一个组的输出加密当前组。独立加密组的密码更容易受到密码分析攻击(cryptanalytic attacks ),应该尽可能避免。因此,分组密码链(cipher block chaining, CBC)模式密码是常规使用的唯一合适的固定组密码。它使用前面的数据组执行XOR操作,导致的性能开销可以忽略不计,并且比独立处理组的模式具有更高的安全性。

流密码

分组密码的最不便之处就在于它必须处理固定大小的数据组。任何一组数据如果比size大都必须分段,小于size的组必须进行填充。这个要求会在处理一些数据比如标准的TCP套接字(TCP socket)时为代码添加复杂度和难度。

幸运的是,分组密码可以允许在数据块大小任意时运行。在这种情况下,分组密码作为流密码(stream cipher)运行。 计数器(counter, CTR)模式密码是流密码的最佳选择。它的性能特征与CBC模式相当,但不需要填充或分段。

初始化向量

初始化向量(initialization vector)时用来启动分组密码的初始段。一个初始化向量需要进行加密时给出唯一的输出流,不管输入是否相同。初始化向量不需要保证私有(keep private),尽管它对于相同的密钥在每次新的加密过程中都必须不同。在有限的情况下,重复使用用初始化向量会导致使用CBC密码的信息泄漏;然而,它严重降低了其他块密码的安全性。一般来说,初始化向量的重复使用应该被认为是一个安全漏洞。

密钥交换算法

密钥交换协议的形式可以非常复杂,所以这一小节仅仅提供一些简单的知识点。首先,它的实现上应该使用标准密钥,比如RSA, Diffie-Hellman, o 或者El Gamal. 这些算法都已经被广泛验证,并且提供了最高等级的保障。

下一个问题是密钥交换是以一种安全的方式执行的,这意味着通信双方必须提供一些识别方法来防止中间人攻击。前面提到的所有密钥交换算法都提供了相关的签名算法,可用于验证连接的两端。这些算法要求双方已经交换了公钥,或者可以通过可信的源(如公钥基础设施( Public Key Infrastructure ,PKI)服务器)获得公钥。

加密过程的常见漏洞

现在你已经有了一些关于正确使用加密的背景知识,了解什么地方可能出错是很重要的。自定义加密是与机密性相关的漏洞的主要原因之一。加密是非常复杂的,需要广泛的知识和测试来正确设计和实现。因此,大多数开发人员应该将自己限制在已知的算法、协议和实现上,这些算法、协议和实现都经过了广泛的审查和测试。

不必要地存储敏感数据

通常情况下,没有任何实际原因就去设计维护敏感据,通常是因为对系统需求的误解。例如,验证密码不需要将密码存储在可检索的表单中。你可以安全地存储密码的哈希值并使用它进行比较。如果操作正确,此方法可以防止真正的密码被公开。(如果你不熟悉哈希值,请不要担心;它们将在本章后面的“哈希函数”中介绍。)

明文密码是不必要地存储数据的最典型的情况之一,但它远不是这个问题的唯一例子。有些应用程序设计不能正确地对敏感信息进行分类,或者只是莫名其妙地将其存储起来。真正的问题是,任何设计都需要正确地对其数据的敏感性进行分类,并且只在绝对需要时才存储敏感数据。

缺少必要的加密

通常,如果系统的设计目的是在可公开访问的存储、网络或不受保护的共享内存段之间传输明文信息,那么它就不能提供足够的保密性。例如,使用TELNET交换敏感信息几乎肯定是与机密性相关的设计漏洞,因为TELNET不加密其通信通道。

一般来说,任何有可能包含敏感信息的通信,在经过可能受到危害的公共网络时,都应该进行加密。在适当的情况下,应该在敏感信息存储在数据库或磁盘时对其进行加密。加密需要某种密钥管理解决方案,它通常可以绑定到用户提供的秘密,例如密码。在某些情况下,特别是在存储密码时,可以将敏感数据的散列值存储在实际敏感数据的位置。

不足的或者过时的加密

当然,使用设计不够强大的加密技术来提供所需的数据安全性也是有可能的。例如,56位的单一数字加密标准( Digital Encryption Standard,DES)加密可能是当前廉价的千兆赫计算机时代的一个糟糕的选择。请记住,攻击者可以记录加密的数据,如果这些数据足够有价值,他们可以在计算能力提高的后再等待解密成果。最终,他们将能够在Radio Shack公司获得一台128 q位的量子计算机,你的数据将属于他们(假设科学家在2030年前解决了老龄化问题,每个人都能长生不死)。

撇开玩笑不谈,重要的是要记住加密实现是会随着时间而变老的。计算机变得更快了,数学家发现算法中出现了新的漏洞,就像代码审核员在软件中发现的漏洞一样。一定要注意算法和密钥大小,它们不适合所保护的数据。当然,这是一个不断变化的目标,所以你所能做的最好的事情就是了解当前推荐的标准。国家标准和技术研究所(NIST;(www.nist.gov)在发布算法和密钥大小的普遍接受标准方面做得很好。

数据混淆与数据加密

一些应用程序甚至整个行业的安全标准似乎无法区分数据混淆(Obfuscation)和数据加密。简单地说,当攻击者能够访问恢复编码的敏感数据所需的所有信息时,数据就会变得混淆。这种情况通常发生在编码数据的方法没有包含唯一密钥,或者密钥与数据存储在同一个信任域中的情况下。不包含唯一密钥的编码方法的两个常见示例是ROT13文本编码和简单的XOR机制。

将密钥存储在与数据相同的上下文中的问题更令人困惑,但并不一定就不那么常见。例如,许多支付处理应用程序在其数据库中存储加密的敏感帐户持有人信息,但所有处理应用程序都需要密钥。这一要求意味着,窃取备份媒介可能不会向攻击者提供帐户数据,但破坏任何支付服务器都可以让他们获得密钥和加密的数据。当然,你可以添加另一个密钥来保护第一个密钥,但是所有处理应用程序仍然需要访问。你可以按自己的意愿分层存储任意多的密钥,但最终,这只是一种混淆技术,因为每个处理应用程序都需要解密敏感数据。

注

支付卡行业( Payment Card Industry, PCI)1.0数据安全要求是整个行业标准的一部分,以帮助确保安全处理支付卡数据和交易。这些需求是业界的一个前瞻性举措,其中许多与最佳安全实践保持一致。然而,该标准包含的要求恰恰产生了本章所述的机密性问题。特别是,要求允许将加密的数据和密钥存储在相同的上下文中,只要密钥是由驻留在相同上下文中的另一个密钥加密的。

最后一点是,在过去的几年中,通过模糊(或混淆)实现的安全性已经赢得了不好的名声。就其本身而言,它还不足以保护数据不受攻击者的攻击;它只是没有提供足够强的保密性。然而,在实践中,混淆可能是任何安全策略的一个有价值的组成部分,因为它可以阻止偶然的窥探者,并且通常可以放缓专业的攻击者的脚步。

完整性

第一章将完整系定义为只有经过授权的用户能够修改数据的期望。这个需求像保密性一样也是通过权限管理机制来实现的。 但是,当通过不安全的通道进行通信时,必须采取其他措施。在这些情况下,将使用下面讨论的某些加密方法来确保数据完整性。

哈希函数

加密数据的完整性时通过各种方法来实现的,尽管哈希函数是大多数方法的基础。哈希函数,或者消息摘要函数( message digest function)接受可变长度的输入并生成固定大小的输出。哈希函数的有效性主要通过三个要求来衡量。首先它必须是不可逆的,也就是知道输出,不能确定输入。这个要求称为无预映像(no pre-image)要求。第二个要求是函数没有预映像,也就是给定输入和输出,不能生成具有相同输出的输入。最后也是最严格的,哈希函数必须相对无中途,也就是不可以由不同的输入生成相同的输出。

哈希函数提供了大多数编程完整性保护的基础。它们可用于将任意一组数据与惟一的、固定大小的值相关联。这种关联可以用来避免保留敏感数据,并大大减少验证数据所需的存储空间。哈希函数最简单的形式是循环冗余校验(cyclic redundancy check,CRC)例程。它们速度快、效率高,并提供了一定程度的保护,防止无意的数据修改。然而,CRC函数对有意修改无效,这使得它们不能用于安全目的。一些常用的CRC函数包括CRC-16、CRC-32和Adler-32。

CRC函数的下一步是加密哈希函数。它们的计算量要大得多,但它们对有意和无意的修改提供了高度的保护。常用的哈希函数包括SHA-1、SHA-256和MD5。(有关MD5的问题将在本章后面的“诱饵-开关攻击”中详细讨论。)

盐值

盐值(salt values)(这个真不知道怎么翻译,那就借用大逼乎的盐值吧 —by 译者)和初始化向量几乎非常相近。’salt‘表示信息中加入了一些随机数以至于两条信息不会生成相同的哈希值。相比于初始化向量,盐值必须不能从信息之间复制。除了哈希值之外,还必须存储一个盐值一遍能够正确地重构柴窑以进行比较。 然而和初始化向量不同的是, 盐值在大多数情况下应该得到保护。

盐值最常用来防止基于预计算的对消息摘要的攻击。大多数密码存储方法都用一个固定的哈希值来防止这个问题。再预计算攻击中,攻击者构建一个包含所有可能摘要值得字典,以便能够确定原始数据值。这种方法只适用于输入值很小的范围,比如密码,然而它在这种范围内可以非常有效。

考虑一个应用于任意密码的32位随机值。盐值将密码预计算字典的大小增加了40亿倍(232)。由此产生的预计算字典对于密码的一小部分来说可能太大了。由Philippe Oechslin开发的Rainbow表是一个真实的例子,它说明了缺少盐值会使密码哈希容易受到预计算攻击。Rainbow表可用于在几秒钟内破解大多数密码哈希,但这种技术只有在散列不包含盐值的情况下才有效。你可以在Project RainbowCrack网站上找到更多关于彩虹表的信息:http://www.antsight.com/zsl/rainbowcrack/。

发起者验证

哈希函数提供了验证消息内容的方法,但是它们不能验证消息源。验证消息的来源需要在哈希操作中加入某种形式的私钥;这种类型的函数称为基于哈希的消息验证码( hash-based message authentication code HMAC)函数。消息验证码是一个函数,返回从密钥和可变长度消息计算得到的固定长度值。

基于哈希的消息验证码是一种使用共享秘密验证消息内容和发送方的相对快速的方法。不幸的是,基于哈希的消息验证码与任何共享密钥系统都有相同的弱点:攻击者可以通过仅泄漏一方的密钥来模拟对话中的任何一方。

加密签名

加密签名(Cryptographic Signatures)是一种将消息摘要与特定公钥相关联的方法,它使用发送方的公钥和私钥对消息摘要进行加密。 任何收接收者可以使用发送者的公钥对消息摘要进行解码然后讲结果值与计算后得到的消息摘要做比较。这种比较为消息来源者必须对密钥具有访问权提供了保证。

完整性的常见漏洞

完整性漏洞和保密性漏洞很相似,大多数完整性漏洞事实上可以通过对保密性的严格要求来预防。接下来几小节讨论在一些特定的情况下与完整性有关的设计漏洞。

诱骗攻击(Bait-and-Switch Attacks)

(注:Bait-and-Switch为美国俚语,bait即引诱别人的鱼饵鱼饵,switch即在别人上套之前用别的东西替代,这里翻译为诱骗 —By译者)

常用的哈希函数必须接受大量的公共审查。然而,随着时间的推移,出现的弱点往往可以被攻击者利用。诱骗攻击就是对几种首先被发现的老以前哈希函数的弱点之一实行打击的。这种攻击利用了哈希函数在一些特定范围的输入中倾向于产生冲突的弱点。攻击者可以利用这种弱点用两个不同的输入产生相同的值。

例如,假设你有一个处理钱款转账请求的银行应用程序。这个程序收取请求,如果请求是合法的,那么就进行转账步骤。如果哈希函数是有缺陷的,攻击者就可以生成两笔拥有相同信息摘要的转账。然后攻击者可以用最低余额开设帐户,并获得较小的转账批准。然后,他们会向下一个系统提交更大的请求,并在其他人知道之前结清账户。

诱骗攻击最近是一个流行的主题因为SHA-1和 MD5已经开始被发现出了一些弱点。MD5的冲突漏洞最早在1996年就被发现,但直到2004年四个人(名字不翻译了 —by译者)发表了一篇成功导致MD5冲突漏洞算法的文章。2005年三月,三个研究者又紧随其后, 他们成功地生成了一对具有不同公钥的X.509证书,这是SSL中使用的证书格式。 最近,Vlastimil Klima在2006年3月发布了一种算法,能够在极短的时间内找到MD5碰撞。

SHA算法家族也受到密切关注。若干对SHA-0的潜在攻击已经确定了;但在SHA-0很快就被SHA-1所取代,并且没有出现任何明显的漏洞。SHA-0攻击研究为识别SHA-1算法的漏洞提供了基础,尽管在撰写本文时,还没有任何一方成功地生成SHA-1冲突。然而,这些问题已经导致了几个主要的标准机构(如美国)开始逐步淘汰SHA-1,支持SHA-256(也称为SHA-2)。

当然,寻找随机冲突比寻找可以实施诱骗攻击的碰撞要困难得多。然而,就其本质而言,选择加密算法时应考虑到其安全性将远远超出适用系统的生命周期。这种观点解释了近年来哈希算法的转变,哈希算法之前被认为是相对安全的。这种变化的影响甚至可以在密码哈希应用程序中看到,这些应用程序不直接受到基于冲突的攻击,但也被升级为更强大的哈希函数。

实用性

第1章实用性定义为在需要时使用资源的能力。这种实用性预期通常与可靠性有关,而与安全性无关。但是,在许多情况下,系统的可用性应该被视为安全需求。

常见的实用性漏洞

与实用性设计失败相关的一般漏洞只有一种类型——拒绝服务(denial-of-service, DoS)漏洞。当攻击者可以通过执行一些未预期的操作使系统不可用时,就会出现DoS漏洞。

DoS攻击的影响很大程度上取决于它发生的环境。一个关键的系统可能包含对持续实用性的期望,而进程中断往往是不可接受的业务风险。核心业务系统(如集中式身份验证系统或旗舰网站)通常都是这种情况。在这两种情况下,成功的DoS攻击可能直接导致收入的重大损失,因为企业在没有系统的情况下无法正常运作。实用性的缺乏还会带来安全风险,因为停机会迫使以不太安全的方式处理需求。例如,考虑一个销售点(point-of-sale,PoS)系统,该系统通过一个中央调节服务器处理所有信用卡交易。当调节服务器不可用时,PoS系统必须在本地暂时存储所有事务,并在稍后的时间执行它们。攻击者在PoS系统和协调服务器之间诱导DoS的原因可能有很多。DoS条件可能允许攻击者用偷来的或无效的信用卡购物,也可能在不太安全的PoS系统上暴露持卡人信息。

6.2.4 威胁建模

现在,你应该对设计如何影响软件系统的安全性有了很好的了解了。系统定义了向用户提供的功能,但受安全策略和信任模型的约束。下一步是将你的注意力转移到开发一个将这些知识应用到您要审查的应用程序的过程上。理想情况下,你需要能够识别系统设计中的缺陷,并根据最安全关键的模块对实现审查进行优先级排序。幸运的是,一种称为威胁建模(threat modeling)的形式化方法正是为此目的而存在的。

在这一节,你将会使用具体有5个步骤阶段的威胁建模方式:

  • 信息收集
  • 应用架构建模
  • 威胁识别
  • 对寻找的结果进行文档记录
  • 确定实施审查的优先次序

此过程是在开发的设计(或重构)阶段最有效的应用,并在稍后的开发阶段进行修改时更新。但是,它可以在SDLC的稍后阶段完全集成。它还可以在开发后应用,以评估应用程序的潜在风险。你选择的阶段取决于自己的需求,但是请记住,设计审查只是完整应用程序评审的一个组成部分。因此,请确保考虑到执行最终系统的实现和操作审查的需求。

这种威胁建模方法有助于建立一个框架,将你已经学过的许多概念联系起来。这个过程也可以作为本书其余部分中应用许多概念的路线图。但是,你应该学会调整,根据需要更改这些技术,以适应不同的情况。请记住,过程和方法可以成为好的仆人,但不是好的主人。

信息收集

威胁建模的第一步就是集中应用程序中的所有信息。在这个阶段你应该不要把太多精力花在只和安全相关的信息因为在这个阶段你还不知道什么信息是和安全相关的。相反,你应该为最终的实现阶段审查提供为了理解整个程序尽可能多的信息。下面是在完成这个步骤后你需要确定的信息:

  • 资产。资产包括了在这个系统中所有对于攻击者有价值的东西。它们可能是在应用中包含的数据或者附加的数据库,例如包含用户账号和尼玛的数据表。资产也可以认定为应用程序的某些部分,例如在目标系统中运行任意代码的能力。
  • 进入点。进入点包括任何攻击者可以连接上系统的路径。它们包括主动暴露的结构,比如监听接口,远程过程调录(Remote Procedure Call ,RPC)终点,上传的文件或者任何客户端发起的活动。
  • 外部信任等级。外部新人等级也就是一个外部实体所拥有的权限,就像在本章前面“信任关系”所讨论的那样。一个复杂的系统可能会对不同的外部实体有好几个外部信任等级,但一个简单的应用程序应该只会考虑局部和远程访问权限的问题。
  • 主要的组件。主要的组件顶一个这个应用设计的结构。组件可以是在程序内部的,或者它们可以是外部的模块依赖。威胁建模过程包括了对这些组件进行分解然后确定只和安全相关部分的过程。
  • 使用场景。使用场景涵盖了系统的所有潜在应用程序。它们包括了授权后的和未授权的使用场景。
开发者访谈

在很多情况下,你可以节省很多时间,直接和开发人员交流。所以,如果你有机会接触到开发人员,一定要利用这种机会。当然,这个选项可能不可用。例如,独立的漏洞研究人员很少能够访问到应用程序的开发人员。

当你接触系统开发人员时,您应该记住以下几点。首先,你可以批评他们投入了大量时间和精力的工作。要明确表示,你的目标是帮助提高应用程序的安全性,并避免在你的方法中出现任何主观判断或屈尊俯就的情况。在进行了适当的对话之后,你仍然需要验证针对应用程序实现获得的任何信息。毕竟,开发人员可能有自己的误解,这可能是导致某些漏洞的一个因素。

开发者文档

一个文档良好的应用程序可以使审查过程更快更彻底;然而,这种便利有一个主要问题。对于现有实现的任何设计文档,都应该始终保持谨慎。这种谨慎的原因通常不是欺骗或不称职的开发人员;只是在实现过程中发生了太多的变化,以至于结果无法完全符合规范。

许多因素导致了规范和实现之间的不一致性。由于开发人员的更替和随着时间的推移而产生的轻微疏忽,非常大的应用程序常常会与它们的规范产生巨大的偏差。实现也可以不同,因为两个人很少对规范有完全相同的解释。底线是,你应该期望根据实际实现验证你从设计中确定的所有内容。

请记住这一点,你仍然需要知道如何从你获得的文档中提取所有内容。一般来说,你希望得到你可以得到的任何东西,包括设计(图、协议规范、API文档等等)、部署(安装指南、发布说明、补充配置信息等等)和最终用户文档。在二进制(和一些源代码)评审中,你只能获得最终用户文档,但不要低估它的价值。此文档是“面向客户”的文献,因此它一般是相当准确的,并且可以提供以流程为中心的视图,从而使系统更易于理解。

标准文档

了解这些协议和文件格式是如何构造的对于了解应用程序应该如何工作以及可能存在的缺陷是必要的。因此,获取由研究人员和作者创建的任何已发布的标准和相关文档是一个好主意。通常,与因特网相关的标准文档可以作为评论请求(Request for Comments . RFCs)(可在www.ietf.org/rfc/上获得)。相同标准的开源实现在澄清你在研究目标应用程序使用的技术时可能遇到的歧义方面特别有用。

源码概要分析

当你试图收集关于应用程序的信息时,看看源代码的非常有用。在这个阶段,你不需要做得太深入,但是拥有源代码可以加快许多初始建模过程。源代码可用于初始验证文档,你可以从代码中的类和模块层次结构确定应用程序的一般结构。当源看起来不是分层布局的时候,你可以查看应用程序启动,以确定初始化时主要组件是如何区分的。你还可以通过浏览代码来识别入口点,以查找常见的函数和对象,如listen()或ADODB。

系统概要分析

系统概要分析需要访问应用程序的功能,这使你有机会验证文档审查并识别文档遗漏的元素。从文档中严格执行的威胁模型需要跳过此步骤,并在实现检查期间完全验证模型。

你可以使用各种方法来分析应用程序。以下是一些常用的技巧:

  • 文件系统布局。查看程序的文件系统不觉然后记录任何重要信息。这些信息包括了确定权限结构,监听所有可运行模块,异界确定任何相关数据文件。
  • 代码再利用。查看所有可能来自其它库或者包的应用程序组件,例如嵌入的Web服务器或者加密库。这些组件可能自己带有可以攻击的点,所以需要额外的审查。
  • 输入和输出。列出所有模块函数的输入和输出。 仔细查看用于建立或管理外部连接或RPC接口的任何库
  • 沙盒测试。在沙盒中运行程序,然后识别它所触及的每个对象和它所执行的每个活动。使用探测器和应用程序代理来记录任何网络流量并隔离通信。在Windows环境,Filemon、Regmon、WinObj和来自(www.sysinternals.com)Process Explorer中使用程序有助于这个步骤进行。
  • 扫描。 在任何监听端口、RPC接口或类似的外部接口上探测应用程序。尝试抓取横幅以验证正在使用的协议并确定任何身份验证需求。对于HTTP应用程序,尝试搜索链接并标识尽可能多的唯一入口点。
应用程序架构建模

了解了一些背景信息之后,你需要开始研究应用程序体系结构。这个阶段包括熟悉软件的结构,以及哪些组件会影响软件的整体安全性。这些步骤有助于确定设计关注点,并让你知道在实现审查期间应该将精力集中在哪里。你可以通过查看应用程序模型的现有文档并根据需要开发新模型来构建这些知识。在软件开发过程中,对软件的各个部分进行一定程度的建模;唯一的区别是这些模型是否被正式记录过。因此,你需要了解常用的建模类型以及如何开发自己的模型。

统一标记语言

统一标记语言(Unified Markup Language, UML) 是由对象管理小组(OMG;(www.omg.org/uml/)来描述应用程序在相当高的层次上如何运行的许多不同方面。它包括描述信息流、组件之间的交互、应用程序可能处于的不同状态等的图表。在这个阶段中特别有趣的是类图、组件图和用例。下面的列表简要地描述了这些类型的图,以便你对它们试图表达的内容有一个大致的了解。如果你不熟悉UML,那么强烈建议你从关于这个主题的无数书籍中挑选一本。由于UML的复杂性,深入地解释它远远超出了本章的范围。

注

UML已经经过了几个版本迭代,现在大家都用UML2.0.

  • 类图。类图是用于建模面向对象(OO)解决方案的UML图。每个对象类都由一个包含类中的方法和属性的矩形表示。然后,对象之间的关系由类之间的线表示。有箭头的线表示了继承关系,两端有数字没有箭头的线表示基数关系。

    当你试图理解复杂模块中的关系时,类图会很有帮助。它们基本上阐明了应用程序如何建模以及类之间如何交互。然而,实际上,你不会经常遇到这些问题,除非您正在执行内部代码检查。通过分析OO解决方案,可以大致构造类图。尽管这样做似乎有点浪费时间,但是当你稍后需要回来审查相同的软件时,或者当你执行初始的高级审查,然后将各种代码审计任务交给团队的其他成员时,它们会很有用。

  • 组件图。组件图将解决方案划分为其组成的组件,连接符号用来它们之间的交互方式。组件被定义为一个不透明的子系统,它为解决方案提供一个独立的功能。组件的示例包括数据库、某种描述的解析器、排序系统等等。与类图相比,组件图提供的系统视图不太复杂,因为组件通常表示一个完整的自包含子系统,通常由许多类和模块实现。

    组件图公开接口用突出的圆圈表示,使用其他组件的接口用空半圆表示。组件通过这些接口公开或通过关联线的方式绑定在一起,这表明两个组件是内在相关的,不依赖于公开的接口。组件图还允许通过实现将两个组件连接在一起。实现仅仅意味着一个组件所需要的功能是另一个组件的接口所公开的功能的子集。实现用虚线表示。

    在审计过程中,组件图对于定义系统的高级视图及其组件间关系很有价值。当你试图开发威胁模型的初始上下文时,它尤其有用,因为它消除了系统的许多复杂性,并允许你关注全局。

  • 用例。用例可能是UML标准中最模糊的组件。对于用例应该是什么样子或者包括什么,没有严格的要求。它可以用文本或图形表示,开发人员可以选择自己喜欢的方式。从根本上说,用例旨在描述应用程序应该如何使用,因此一组好的用例可以派上用场。毕竟,当你知道应用程序应该做什么时,解决它不应该做什么就比较容易了。在评审用例时,要注意开发人员对系统行为的任何假设。

数据流图

许多绘图工具可以帮助理解系统,但是数据流图(data flow diagram, DFD)是最有效的安全工具之一。这些图用于映射数据如何在系统中移动,并标识任何受影响的元素。如果处理得当,DFD建模过程不仅要考虑直接向外部源公开的应用程序功能,还要考虑间接公开的功能。这个建模过程还考虑了系统设计中的缓和因素,例如加强信任边界的附加安全措施。图2-2显示了DFD的5个主要元素,总结如下:

2-2
2-2
  • 流程。流程是不透明的逻辑组件,具有定义良好的输入和输出需求。它们用一个圆圈表示,相关的进程组用一个带有双边框的圆圈表示。可以在每个流程的附加dfd中进一步分解多个流程组。尽管流程不是典型的资产,但它们在某些上下文中可能是资产。
  • 数据存储。数据存储是系统使用的数据资源。例如文件和数据库。它们用由开放的矩形框表示。 通常任何在系统中的数据存储都属于资产。
  • 外部实体。前面“信息收集”中描述的这些元素是“参与者”和远程系统,它们通过系统的入口点与系统通信。它们用封闭的矩形表示。识别外部实体可以帮助您快速隔离系统入口点,并确定哪些资产可以从外部访问。外部实体也可能表示需要保护的资产,例如远程服务器。
  • 数据流。数据流用箭头表示。它表示数据是从哪来到哪去。这些元素在发现哪些用户支撑的数据能够到达哪些特定组件很有帮助,因此这样你就可以在实现审查阶段对它们进行定位。
  • 信任边界。信任边界就是在系统或者在两个系统之间的边界。它们用两个组件之间的虚线表示。

图2-3演示了如何使用DFD元素建模系统。它表示基本Web应用程序的简化模型,允许用户登录并访问存储在数据库中的资源。当然,DFD在应用程序的不同级别上看起来是不同的。封装大型系统的简单、高级DFD称为上下文关系图。Web站点示例是一个上下文图,因为它表示封装了一个复杂系统的高级抽象。

2-3
2-3

然而,你的分析通常需要您进一步分解系统。每个连续的分解级别都用数字标记,从零开始。0级图标识了主要的应用程序子系统。这个Web应用程序中的主要子系统由用户的身份验证状态来区分。这种区别在图2-4的0级图中表示出来。

2-4
2-4

根据系统的复杂性,你可能需要继续分解。图2-5是Web应用程序登录过程的一级图。通常,在建模复杂的子系统时,你只用在0级图之上进行。然而,这个1级图也为使用dfd隔离设计漏洞提供了一个有用的起点。

2-5
2-5

在准备实现审查时,你可以使用这些图来建模应用程序行为并隔离组件。例如,图2-6显示登录过程略有更改。你能看到漏洞在哪吗?登录过程处理无效登录的方式已经改变,因此它现在直接将每个阶段的结果返回给客户端。这个修改后的进程很容易受到攻击,因为攻击者可以在不成功登录的情况下识别有效的用户名,这在尝试对身份验证系统进行蛮力攻击时非常有用。

2-6
2-6

通过绘制这个系统的图表,你可以更容易地识别它的安全组件。在本例中,它帮助你以系统身份验证的方式隔离漏洞。当然,登录示例仍然相当简单;更复杂的系统可能具有多个复杂层,必须封装在多个dfd中。你可能不希望对所有这些层进行建模,但是你应该分解不同的组件,直到达到与安全相关的考虑事项隔离的程度。幸运的是,有一些工具可以帮助这个过程。绘制图表的应用程序,如Microsoft Visio是有用的,而Microsoft威胁建模工具在此过程中尤其有用。

威胁识别

威胁识别是基于你对系统的了解来确定应用程序的安全暴露的过程。此阶段构建于您在前一阶段所做的工作之上,通过应用您的模型和对系统的理解来确定系统对外部实体的脆弱性。在这个阶段,你将使用一个称为攻击树(或威胁树)的新建模工具,它提供了一种标准化的方法来识别和记录系统中潜在的攻击向量。

画威胁树

攻击树的结构非常简单。它由一个根节点(描述攻击者的目标)和一系列子节点(指示实现目标的方法)组成。树的每一层都将这些步骤分解为更详细的内容,直到你对攻击者如何利用系统有了一个实际的了解。使用上一节中的简单Web应用程序示例,假设它用于存储个人信息。图2-7显示了此应用程序的高级攻击树。

2-7
2-7

如你所见,根节点位于顶部,下面有几个子节点。每个子节点都声明了一种攻击方法,可用于实现根节点中指定的目标。这个过程根据需要进一步分解为最终定义攻击的子节点。查看这个图,你应该开始注意攻击树和dfd之间的相似性。毕竟,攻击树不是在真空中开发的。最好的创建方法是遍历DFD并使用攻击树来记录特定的关注点。作为一个例子,请注意导致子节点1.2.1的分支如何遵循前面在分析有缺陷的登录过程的DFD时使用的相同推理模式。

与dfd一样,你希望只沿着与安全相关的路径分解攻击树。你需要使用你的判断并确定哪些路径构成合理的攻击向量,哪些向量不太可能。但是,在进入这个主题之前,请继续下一节以获得对攻击树结构的更详细的描述。

节点类型

你可能已经注意到在连接每个节点及其子节点的行中有一些奇怪的标记(例如节点1.2.1.1和1.2.1.2)。这些节点连接器之间的弧表示子节点是与(AND)节点,这意味着必须满足子节点的两个条件才能继续计算向量。没有弧的节点只是一个或(OR)节点,这意味着任何一个分支都可以在没有任何附加条件的情况下被遍历。参考图2-7,查看节点1.2.1中的蛮力登录。要遍历此节点,必须满足两个子节点中的以下条件:

  • 验证用户名
  • 验证密码

任何一个步骤都不能掠过。因此,节点1.2.1是一个AND节点。

相反的是,OR节点描述了一个目标可以通过任何一个子节点达到的情况。所以只有满足单个节点的条件才能继续计算子节点。 回到图2-7,看一下节点1.2的“作为目标用户登录”。这个目标可以通过两个方法之一实现:

  • 强制登陆
  • 偷取用户证书

作为用户登陆,只需要实现其中之一就可以。因此它们是OR节点。

文本表示

你可以用文本和图形表示攻击树。文本版本传递的信息与图形版本相同,但有时不太容易可视化(尽管它们更紧凑)。下面的例子展示了如何用文本格式表示图2-7中的攻击树:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. 对方获得访问用户个人信息的权限
OR 1.1 获得直接访问数据库权限
1.1.1 利用系统应用或者内核的漏洞
1.2 作为目标用户登录
OR 1.2.1 蛮力登陆
AND 1.2.1.1 验证用户名
1.2.1.2 验证用户密码
1.2.2 窃取用户证书
1.3 劫持用户会话
窃取用户绘画cookie
1.4 被动截取个人资料
AND 1.4.1 验证用户连接初始化
1.4.2 嗅探网络流量中的个人数据

如你所见,所有相同的信息都存在。首先,根节点目标被声明为攻击树的标题,它的直接后代在标题下面进行编号和缩进。每个新层都再次缩进,并以相同的方式在父节点下面编号。AND和或关键字用于指示节点是AND还是节点。

威胁减轻

攻击树的部分价值在于它允许你跟踪潜在的威胁。但是,如果无法确定如何减轻威胁,跟踪威胁就不是特别有用。幸运的是,攻击树包括一种特殊类型的节点来解决这个问题:循环节点。图2-8显示了一个带有适当缓解因素的示例攻击树。

2-8
2-8

在此攻击树中添加了三个缓解节点,以帮助你认识到这些向量与未缓解的分支相比,不太可能成为攻击的途径。在一个缓解节点中使用的虚线是一种将分支标识为不太可能的攻击向量的简写方法。它不会移除分支,但会鼓励你将注意力转移到其他地方。

关于威胁减轻的最后一点注意事项:你不希望过早地去寻找它。识别缓解因素是有用的,因为它可以防止您追求一个不太可能的攻击向量。但是,你不希望陷入一种错误的安全感,从而错过一个可能的分支。因此,请仔细考虑缓解措施,并确保在将其添加到攻击树之前执行一些验证。

对寻找的结果进行文档记录

现在调查工作已经完成,你需要记录所发现的内容。在文档阶段,你将回顾在前一阶段发现的威胁,并以正式的方式呈现它们。对于你发现的每个威胁,你需要提供一个简短的总结,以及消除威胁的任何建议。要了解这个过程是如何工作的,请使用示例攻击树中的“蛮力登录”威胁(节点1.2.1)。这种威胁允许攻击者使用另一个用户的凭证登录。你的威胁总结文档将类似于表2-1。

威胁 蛮力登陆
受影响的组件 Web应用的登录组件
描述 客户端可以使用登录,通过反复地连接和尝试登陆攻击用户名和密码。这个威胁会因为应用会对不合法的用户名和密码返回不同地错误消息,使得用户名更容易被确认而增加。
造成结果 不可信的客户端可以得到用户的用户名,然后读取或者修改他们的敏感信息。
减轻危害的策略 使错误消息模糊,以至于攻击者不知道什么样的用户名和密码是不合法的。在用户账户多次登陆失败后锁定。(3-5次尝试比较适合)

关于蛮力登录威胁的所有信息都整齐地总结在一个表中。在本阶段的下一部分,您将扩展此表,以包括关于威胁风险的一些附加信息。

风险评级

与本章中的示例相比,实际应用程序在设计和实现方面通常要大得多,也更复杂。增加的大小和复杂性在各种用户类中创建了广泛的攻击向量。因此,你通常可以列出一长串的潜在威胁和可能的建议,以帮助减轻这些威胁。在一个完美的世界中,设计师可以系统地着手解决每一个威胁和潜在问题,必要时关闭每一个攻击向量。然而,某些业务现实可能不允许减少每个已确定的向量,而且几乎肯定不可能同时减少所有向量。显然,在担心那些不那么重要的风险之前,我们需要对一些更严重的风险进行优先排序。通过分配威胁严重程度评级,你可以根据每个未发现的威胁对应用程序和相关系统的安全性造成的风险对其进行排序。然后可以将此评级用作开发人员的指导方针,以帮助确定优先考虑哪些问题 。

你可以选择以多种不同的方式对威胁进行评级。最重要的是,你要考虑到威胁的暴露程度(利用的难易程度和载体的可用性)和在成功利用过程中所造成的伤害。除此之外,你可能希望添加与你的环境和业务流程更相关的组件。为了本章的威胁建模目的,使用了微软开发的恐惧评级系统。没有一个模型是完美的,但是这个模型在普遍接受的威胁特性之间提供了一个相当好的平衡。这些特点简述如下:

  • 潜在损伤。如果威胁被成功利用,会有什么后果?
  • 再现性。再现问题中的攻击有多容易?
  • 可利用程度。实施攻击的难度是多少?
  • 受影响的用户。如果攻击已经成功实施,有哪些用户会被影响,以及这些用户有多重要?
  • 发现的难度。发现这个漏洞难度有多大?

每个类别都可以从1-10打分,1最低,10最高。 类别得分之和除以5作为整体威胁等级。3级或以下可视为低优先级威胁,4至7级为中等优先级威胁,8级或以上为高优先级威胁。

注

风险评级模型在给实现和操作漏洞上打分也非常有用。事实上,你可以使用风险评级模型作为你在整个过程中的审查通用评级系统。

风险评级系统的好处之一是,它提供了一系列的细节,你可以在向业务决策者展示结果时使用。你可以给他们一个简明的威胁评估,只包括总的威胁等级和它所属的类别。你还可以提供更详细的信息,比如五个威胁类别的个人得分。你甚至可以给他们一份完整的报告,包括模型文档和你如何得到每个类别的分数的解释。不管你的选择是什么,在向客户或高级管理人员做演示时,最好在每个细节级别都有可用的信息。

表2-2是一个对蛮力登录威胁的一个风险评级:

威胁 蛮力登录
受影响的组件 Web应用的登录组件
描述 客户端可以使用蛮力登录,通过反复地连接和尝试登陆攻击用户名和密码。这个威胁会因为应用会对不合法的用户名和密码返回不同地错误消息,使得用户名更容易被确认而增加。
造成结果 不可信的客户端可以得到用户的用户名,然后读取或者修改他们的敏感信息。
减轻危害的策略 使错误消息模糊,以至于攻击者不知道什么样的用户名和密码是不合法的。在用户账户多次登陆失败后锁定。(3-5次尝试比较适合)
风险 潜在损伤: 6,再现性: 8
可利用性: 4, 受影响的用户: 5
可发现性:8
总:6.2
自动威胁模型文档

正如你所看到的,在威胁建模过程(包括文本和图表)中涉及到相当多的文档。幸运的是,Frank Swiderski(前面提到的威胁建模的合著者)开发了一个工具来帮助创建各种威胁建模文档。它可以在http://msdn.microsoft.com/security/securecode/threatmodeling/上免费下载。该工具可以轻松地创建dfd、用例、威胁摘要、资源摘要、实现假设和您将需要的许多其他文档。此外,文档被组织成易于导航和维护的树结构。该工具可以使用可扩展样式表语言转换(Extensible Stylesheet Language transformation, XSLT)处理将所有文档输出为HTML或您选择的其他输出形式。强烈建议你熟悉这个用于威胁建模文档的工具。

确定实施审查的优先次序

现在你已经完成了威胁总结并对其进行了评分,你终于可以将注意力转移到构建实现审查上了。在开发威胁模型时,你应该根据各种因素(包括模块、对象和功能)分解应用程序。这些划分应该反映在每个单独威胁摘要的受影响组件条目中。下一步是在适当的分解级别创建组件列表;确切地说,什么级别是由应用程序的大小、审阅人员的数量、可用的审阅时间以及类似的因素决定的。但是,通常最好从抽象的高层开始,因此只需要考虑几个组件。除了组件名称之外,你还需要在列表中另一列列出与每个组件相关联的风险得分。

有了这个组件列表之后,你只需确定威胁摘要属于哪个组件,并将该摘要的风险得分添加到相关组件。在合计了汇总列表之后,你将获得与每个组件相关的风险评分。通常,你希望以最高分的部分开始你的评估,并继续从最高分到最低分的过程。由于时间、预算或其他限制,你可能还需要消除一些组件。所以最好从得分最低的部分开始剔除。你可以将此评分过程应用于下一级别的分解,在你拥有此组件列表之后,你只需确定威胁摘要属于哪个组件,并将该摘要的风险评分添加到相关组件即可。在合计了汇总列表之后,你将获得与每个组件相关的风险评分。通常,你希望以最高分的部分开始您的评估,并继续从最高分到最低分的过程。由于时间、预算或其他限制,你可能还需要消除一些组件。所以最好从得分最低的部分开始剔除。你可以将这个评分过程应用到大型应用程序的下一层分解;尽管这已经开始进入实施审查过程,这在第4章 “申请审查程序” 中会详述。

使用记分表可以让你更容易地对评审进行优先排序,特别是对于初学者来说。然而,这并不一定是完成工作的最佳方式。一个有经验的审核员通常能够根据他们对类似应用程序的理解对审查进行优先排序。理想情况下,这应该与威胁汇总得分一致,但有时情况并非如此。因此,将威胁总结考虑进去是很重要的,但当你有理由遵循更好的计划时,不要坚持使用它们。

6.2.5 总结

本章探讨了应用程序设计审查的基本要素。你已经了解了安全性需要成为应用程序设计中的基本考虑因素,并了解了设计过程中的决策如何极大地影响应用程序的安全性。你同样也了解了一些用于理解应用程序设计的安全性和潜在漏洞的工具。

重要的是,不要将设计审查过程视为一个孤立的组件。设计评审的结果应该自然地进展到在第4章讨论的实现审查过程中。

the art of software security assessment Chap5. translate

Posted on 2020-06-05 | In Computer Science
Words count in article: 17.1k | Reading time ≈ 60

7.第二部分 软件漏洞

7.1 第五章 内存损坏 (memory corruption)

“ 几乎所有的人都能忍受逆境,但如果你想测试一个人的人格,那就给他力量。” ——亚伯拉罕 林肯

7.1.1 概述

在本书中,你应该接受这样的假设,即任何内存损坏漏洞都是可以被利用来实施攻击的,除非你能证明它不成立。这个假设看起来很极端,但这对代码审计者来说非常有用。攻击者经常可以利用对越界的内存进行写操作来任意地改变一个进程的运行状态, 从而违反应用程序应该执行的任何安全策略。 然而,除非你对如利用内存损坏有一定了解,否则你很难接受内存损坏漏洞的严重性,甚至无法理解它们。

漏洞利用和软件审计是两种高度互补的技巧。一个审计者拥有对漏洞利用的理解能提高效率,并且发现那些无伤大雅的bug与真实漏洞的区别。关于内存损坏漏洞的知识有很多,本章会简要介绍在Intel x86架构下机器的一些基本方法(当然这些概念同样适用于所有架构)。顺着漏洞利用的方法,你可以学到更多关于反漏洞利用技术以及发现可利用之处的方法。本教程并不打算作为开发内存损坏漏洞的权威指南,但它确实提供了你需要了解和了解贯穿本书的许多漏洞的背景知识。

注

读者如果有兴趣了解更多关于利用内存损坏漏洞的知识,可以参考The Shellcoder’s Handbook (Wiley, 2004))或 Exploiting Software(Addison-Wesley, 2004))。你还可以找到许多关于开发技术的在线资源,如 phrack magazine(www.phrack.org)和Uninformed magazine(www.uninformed.org)。

7.1.2 缓冲区溢出

你可能很熟悉“缓冲区溢出”(buffer overflow)这个术语,如果不是的话,下面是它的定义:缓冲区溢出是一种软件bug,它是当数据被复制到内存的某个位置但这段内存不足以容纳这些数据时产生的。当缓冲区溢出发生时,溢出的数据会破坏与目标缓冲区相邻的信息,并通常造成灾难性的后果。

缓冲区溢出是最常见的一种内存损坏漏洞。如果不你熟悉如何利用这种bug,它们似乎违背逻辑,以某种方式允许攻击者完全访问脆弱的系统。但的机制是如何的呢?为真么它是对系统一致性(system integrity)的一种威胁呢? 为什么操作系统会保护一段内存不被损坏呢?为了回答这些问题,你需要对程序内部以及CPU和操作系统怎样管理进程有所熟悉。

注

一些在本书种提到的漏洞严格来说并不是缓冲区溢出而是更加复杂的内存损坏漏洞,但它们都有很多共同特征。关于可利用性的讨论主要适用于这类问题,特别是在本章后面的小节“评估内存损坏影响”中。

进程的内存布局

一个进程可以被操作系统选择任意的方式布局在内存中,但几乎所有当下的系统都遵循了一些常见的约定。通常的,一个进程被组织在以下几个主要区域:

  • 程序代码区。这个区域包含了能被处理器解释并运行的可执行程序指令。程序代码包括了编译后的运行程序代码以及额外的被程序所使用的位于共享库的代码。共享库通常不和主程序的代码放在一起。
  • 程序数据区。这个区域储存了非函数本地的变量。包括了全局和静态变量。数据区通常包含了一个动态内存区域称为“程序堆”(program heap)用来储存动态分配内存的变量。
  • 程序栈区。栈(stack)区用来存储当前正在执行的函数,并且它在函数执行时跟踪函数的调用链。

尽管这是个关于进程内存如何组织的高级视角,但它也显示了缓冲区溢出漏洞的影响如何根据缓冲区的位置而变化。下面几节讨论与每个位置相关的常见和惟一的攻击模式。

栈溢出

栈溢出(stack overflow)是一种目标缓冲区位于运行中程序栈的缓冲区漏洞。它们是最容易理解,并且在历史上是最直接的可以利用的缓冲区溢出。本节涵盖了关于运行中程序栈的基础知识,然后显示攻击者是怎样利用基于栈的缓冲区溢出的。

抽象数据类型:栈

从一般的计算机科学视角来看,栈是一种抽象数据类型(abstract data type,ADT),它用来有序存储和检索一系列数据元素。 栈数据结构通常会给用户提供两种用来控制的操作:

  • push()push操作添加一个元素到栈的顶端
  • pop() pop操作将栈顶元素移除,并将其作为返回值;

栈是一个后进先出(last-in, first-out LIFO)的数据结构,你可以将它看成一个物理的盘子的堆栈。你可以将一个盘子放在栈的顶端,相当于push()操作,你也可以将顶端的盘子拿走,对应pop()操作。你不能在移出上面的盘子之前直接从栈的中间拿走一个盘子。

运行时栈

每个进程都有运行时栈(runtime stack),也叫做程序栈(program stack),调用栈(call stack),或者直接称它为“栈”。运行时栈为每种结构化编程语言中使用的函数提供了必要的基础。 函数可以用任意的顺序调用,它们可以是递归的,也可以是相互递归的。运行中的栈通过激活记录(activation record)支持这个功能,激活记录也就是记录了从函数到函数的调用链以便在函数返回时跟踪它们。激活记录当然也包含了每次函数调用时需要分配的数据,例如局部变量,存储的机器状态以及函数参数。

因为运行时栈是程序运行不可分割的一部分 ,因此它们在CPU的帮助下实现而不是通过纯净的软件抽象。处理器通常有专门用来指向栈顶的寄存器,这些寄存器用机器指令push()和pop()来对栈进行修改。在因特尔x86 CPU中,这个指针叫ESP(ESP表示扩展堆栈指针,extended stack pointer)。

在几乎所有现代CPU中,栈都是向下生长的,也就是说栈通常在虚拟内存的一个高地址开始,然后顺着低地址生长。一个push操作让栈指针减去固定值,然后栈指针就移动到了更低的进程内存。相应的,pop操作让栈指针加一个固定值将它移动到更高位置的内存。

每当函数被调用时,程序都会创建一个新的栈区, 它只是一个保留的连续内存块,用于存储本地变量和内部状态信息。 函数在它返回之前就使用这一段内存,在返回之后,它会被移出栈。为了了解这个过程,考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int function_B(int a, int b){
int x,y;
x = a * a;
y = b * b;
return (x+y);
}

int function_A(int p, int q){
int c;
c = p * q * function_B(p, p);
return c;
}

int main(int argc, char **argv, char **envp){
int ret;
ret = function_A(1, 2);
return ret;
}

当function_A()调用时,一个新的栈会被分配出来然后放置在堆栈的顶部,如图5-1所示。

这张图只是程序栈的一个简化版本,但你可以看到main()函数栈的布局中调用了function_A().

5-1
5-1
注

图5-1和图5-2第一眼看起来可能会让你感到困惑,因为栈似乎是从上往下生长的,然而这并不错。如果你想象一个从地址0到0xFFFFFFFF的内存地址空间,低地址接近于0,因此在图中就是比较高的地方。

WeChat Screenshot_20200512175525
WeChat Screenshot_20200512175525

图5-2显示了当function_A()调用function_B()时程序栈会变成的样子。

当function_B()运行完毕后会返回到function_A(),于是function_B()对应的这一栈段就会出栈,然后程序栈会重新像图5-1那样,简单来说就是寄存器ESP储存的值会被重置回当function_B()调用时。

注

5-1和5-2的栈图是简化版的。事实上,main()函数不是调用栈的第一个函数。通常,函数会在main()函数设置进程环境时就被调用。例如,gibic Linux系统通常以名为_start()的函数开始,它会调用_libc_start_main(),然后这个函数最后再调用main()。

每个函数都管理着属于自己的栈段,这些栈段取决于有多少局部变量在函数中以及这些局部变量的大小。局部变量需要当函数需要时被直接访问,如果只使用push和pop指令则效率会不高。因此,很多程序会利用其他的寄存器来完成这些事,它们叫帧指针(frame pointer)或者基址指针(base pointer)。在Intel x86 CPU中,这个寄存器名字叫EBP(extended base pointer)。这个寄存器指向函数栈段的起始点。每个在给定栈段中的变量都可以通过引用基址指针开始的固定偏移量的内存位置被访问。对基址指针的使用并不是必要的,有时候甚至是多余的,当然为了讨论它,我们可以假设它是存在的。

前面忽略的一个重要细节是记录在每个栈段中的内部状态信息。状态信息的存储随着处理器架构的不同而不同。但它通常包含了先前函数段的指针以及返回地址。这个返回地址的值被保存过了,所以当当前运行函数运行完毕返回时,CPU就会知道当前运行程序应该从哪里继续。当然,帧指针的值必须恢复,以便在函数调用分配其自身栈段的子函数之后,局部变量的访问能保持一致。

函数调用约定

调用约定(calling convention)描述了函数参数时怎样传入以及调用和被调用的函数必须执行哪些栈的操作。 在本章前面一节“运行时栈”中说明了最流行的一种调用约定,然而调用约定会随着处理器架构,操作系统,编译器的不同而不同。

编译器可以通过优化来改变调用约定。例如,一种流行的 x86 调用约定叫快速调用(fast call)。如果可能,快速调用会将函数参数传入寄存器,这样可以提高变量访问的速度以及减少栈上的操作。每个编译器都有不同版本的快速调用。

语言特性也可以造成不同的调用约定。一个典型的例子就是要求访问类实例的this指针的C++类成员函数。在windows x86系统中,this指针会被传入ECX寄存器中传递给具有固定数量参数的函数。相反,在GCC C++编译器中会将this指针当作函数最后一个变量将它push到栈中。

栈指针也必须被恢复到它以前的状态,但这个过程不是隐式的,被调用的函数必须在返回前重置栈指针到正确的位置。这个过程时必须的,因为储存的帧指针以及返回的地址会从栈顶被恢复。通过使用隐式使用栈指针的pop指令,帧指针能够被恢复。用于从函数返回的ret指令也隐式地使用ESP寄存器获取返回地址。

每个函数会分配自己的栈段,因此,它们需储存自己的段指针。下面这段代码显示了在Intel机器中函数在开头是怎样储存自己的栈段指针的。

1
2
3
text:5B891A50		mov edi, edi
text:5B891A52 push ebp
text:5B891A53 mov ebp, esp

这段函数开头的代码并不要求调用者具体地将返回地址push到栈中,这个过程会被call指令完成。因此当function_B()被调用时栈地布局会如图5-3所示。

5-3
5-3
注

你可能注意到了在上面那段代码中一个看起来没有用的指令(mov edi, edi)。这个指令只是一个占位符,添加它时为了简化系统监视和debug。

利用栈溢出

如你所见,局部变量非常接近彼此。事实上,它们被安排在了连续的内存中。因此如果一个程序有这样一个漏洞,它允许数据将数据写入到本地栈缓冲区末尾之后的地方,那么与数据相邻的变量就会被重写。这些相邻的变量可以包括其他的局部变量,程序状态信息,甚至函数参数。取决于有多少个字节能够被写入,攻击者能够在前面的栈段中损坏变量以及状态信息。

注

编译器有时会在一个变量和下一个变量之间添加填充,这取决于优化级别异界变量大小等多个因素。出于讨论目的,可以将变量视为连续的。

我们来考虑一个简单的写入覆盖局部变量的情况。写入一个局部变量的危险性就是你可以任意更改一个应用不想让你更改的变量的值。状态的变化通常也可以造成你不想要的后果。考虑以下代码:

1
2
3
4
5
6
7
8
9
int authenticate(char *username, char *password){
int authenticated;char buffer[1024];
authenticated = verify_password(username, password);
if(authenticated == 0){
sprintf(buffer,"password is incorrect for user %s\n",username);
log("%s", buffer);
}
return authenticated;
}

假设用来验证的变量位于栈段的顶部,将其放置在比buffer变量更高的内存位置。函数栈就像图5-4那样

5-4
5-4
注

图5-4展现了代码可能的布局。然而你不能从源代码中推断出变量在栈段中是怎样分布的。为了优化性能,编译器可以(通常也会这样做)重新布局变量。

authenticate()函数有缓冲区溢出的漏洞。具体来说,printf()函数没有规定它写入输出缓冲区数据的多少。因此,如果一个username字符串大约在1024字节大小的话,数据将写入缓冲区变量的末尾之后,并写入变量authenticate。(注意authenticated()在栈的顶部)。图5-5显示了当溢出触发时会发生什么

5-5
5-5

authenticated是一个简单的状态变量,用来表示用户是否能够成功登陆。0值表示验证失败,非0值表示成功。通过溢出缓冲区的变量,攻击者可以重写authenticated变量,然后让它变成非0值,于是调用函数不正确地将攻击者当作了验证成功。

重写相邻地局部变量是一个很有用的技巧,但一般来说它很难应用。这种技巧取决于什么样的变量能够被重写,编译器如何在内存中安排变量,以及程序在溢出发生后会做什么。一个更一般性的技巧是定位到每个栈帧保存的状态信息,即保存的帧指针以及返回地址。对于这两个变量来说,返回地址对于攻击者来说是最重要的。如果缓冲区溢出能够重写保存的返回地址,应用程序就可以在当前运行函数返回时重定位到任意的位置,这个过程见图5-6:

5-6
5-6

本质上说,攻击者会在程序中保存一些对他们有用的代码的地址,并用这个新地址覆盖返回地址。确切的位置取决于攻击者,但有两种比较基本的选择:

  • 运行程序可以被重定位到应用程序运行的代码段或者一些在可以利用的分享库中的代码。例如,在UNIX libc里的system()函数,这个函数通过shell来运行命令。
  • 运行程序可以被重定位到一个包括了攻击者控制数据的内存区域,例如全局变量,一个栈的位置,或者一个静态的缓冲区。在这种情况下, 攻击者用一小段与位置无关的代码填充目标返回位置,以完成一些有用的工作,例如连接回攻击者并在连接的套接字上生成shell。 这些小段的代码通常被称为shellcode.
SEH 攻击

Windows系统很容易受到与传统堆栈溢出攻击稍有不同的攻击,这种变体称为 “销毁结构化异常处理程序”。 Windows提供了结构化异常处理( structured exception handling, SEH),因此成语可以注册一个处理程序,以一致的方式处理错误。当一个线程导致了一个异常抛出,这个线程就有机会捕获异常并进行恢复。每当一个函数注册异常处理程序时, 它都被放在当前注册的异常处理程序链的顶部。 当异常抛出时,这个程序链就会从顶部遍历,直到为抛出的异常找到正确的处理程序类型为止。如果没有找到合适的异常处理程序,异常就会被送到一个“未处理的异常过滤器”(unhandled exception filter),这个过滤器一般会终止该进程。

注

异常处理时许多程序拥有的特性并且是由C++推广而来的。尽管C++的异常处理比基础的Windows SEH机制更复杂,但C++异常在Windows中是通过SEH来实现的。如果想了解更多关于C++异常处理的内容,请查看该链接: www.openrce.org/articles/full_view/21.

SEH提供了一个便利的方法来利用Windows系统的栈溢出实施攻击,因为异常处理程序注册结构就位于栈中。每个结构都由例行注册程序(handler routine)的地址以及一个指针指向它的父注册程序。这种结构见图5-7(其实就是一个链表 — By 译者):

5-7
5-7

当异常发生时,这些记录将从最近注册的处理程序遍历到第一个处理程序。在每一层中,处理程序都运行决定当前抛出的异常是否合适。(这个说明有点过于简化了,但有一篇很好的文章描述了这一过程,见: www.microsoft.com/msj/0197/exception/exception.aspx.)因此,如果攻击者能够通过任何方法触发溢出,然后触发任何类型的异常,检查这些异常注册结构时,异常处理程序会定位到每个调用的结构直到找到其中合适的哪一个。因为它们是被攻击者损坏过的栈,程序就会跳转到攻击者选择的地址。当能够造成大量数据溢出缓冲区时,攻击者就可以复制整个栈区,在栈基指针被修改时导致异常抛出。然后应用程序在栈上使用被损坏的SEH信息然后跳跃到任意地址,这个过程见图5-8:

5-8
5-8
单字节溢出(Off-by-One Errors)

内存损坏经常会由算错了数组的长度造成。其中最常见的错误就是单字节溢出一错误,即算错了数组1个单位的长度。这种错误最典型的原因就是没用考虑终止符号或者错误地理解了数组索引的工作方式。考虑下面的例子:

1
2
3
4
5
6
...
void process_string(char *src){
char dest[32];
for (i = 0; src[i] && (i <= sizeof(dest)); i++)
dest[i] = src[i];
...

process_string()函数一开始从变量src读入一定数量的字符然后将它们存入栈中的缓冲区dest。这份代码想要防止在src大于32个字符时产生缓冲区溢出。然而这里面有一个简单的问题:这个过程可以从dest写入超过边界1个单位的元素。数组索引从0开始,到sizeof(array)-1结束,所以一个长度为32的数组应该从0到31进行索引。上面代码的过程会越过dest的末尾,因为控制循环的语句是(i<= sizeof(dest)),但正确的情况应该是(i< sizeof(dest))。在这份有漏洞的代码中如果i增加到32时,就会越过长度的检查,程序就会将dest[32]设置为src[32]。

这种类型的问题重复地出现在C语言的字符串中。C字符串在要求为每个字符提供存储空间时还会额外加上一个空字节用来存储终止符。这个空字节经常没有被正确地考虑进来,因此就造成了难以被察觉的单字节溢出一错误,就像下面这份代码一样:

1
2
3
4
5
6
int get_user(char *user){
char buf[1024];
if(strlen(user) > sizeof(buf))
die("error: user string too long\n");strcpy(buf, user);
...
}

这份代码使用strlen()函数来检查是否有足够的空间来将用户名复制到缓冲区中。strlen()函数返回C字符串的长度,但没有考虑终止符所占的空间。所以一个strlen()返回长度1024的字符串实际上占用了1025个字节的内存。在get_user()函数中,如果user字符串正好是1024个字符,strlen()返回1024,sizeof()返回1024,长度检查通过,于是strcpy()函数写入1024个字节的数据再加上一个字节的空字符,导致buf中被多写入了一个字节。

你可能觉得单字节溢出一错误很罕见,就算发生,也很难被利用。然而,再Intel x86架构机器上运行的操作系统上,这种错误往往是可以被利用的,因为你至少可以向存储在栈上的帧指针写入至少一个字节的数据。在程序执行的课程上你就知道,每个函数都会分配一个栈帧指针用于局部变量的存储。这个被叫做帧指针或者基址指针的地址由寄存器 EBP 来存储。在函数开始运行前,程序会将旧的基址指针存入返回地址相邻的栈中。如果在一个正好位于基址指针存储位置之下的缓冲区引发了单字节溢出一错误,空字节被写入了缓冲区末尾之后,就会导致至少一个字节写入了保存后的基址指针。这就意味着当函数返回时基址指针至少会出现255个字节的跳转错误,就像图5-9显示的这样。

5-9
5-9

如果新的基址指针指向了一些被用户控制的数据(比如一段字符缓冲区),用户就可以指定来自前一个栈帧的局部变量值以及保存的基址指针的返回地址。因此,当调用函数返回时,返回地址就会变成指定的位置,以夺取对程序的完全控制。

单字节溢出一错误还可以在一个元素越界写入另一个被当前函数使用的变量时被利用。单字节溢出一错误的安全影响取决于相邻的变量在溢出之后是如何使用的。如果这个变量是一个记录大小的整数,在被截短后程序就无法在这个值的基础上做出正确的计算。相邻变量也有可能直接影响到安全模型。比如,如果它是一个用户ID,就可能允许用户得到它没有被授予的权限。尽管这些类型的漏洞利用都是特定实现的,但它们的严重性不亚于广义的攻击(generalized attacks)。

堆溢出

堆溢出(heap overflow)的漏洞利用技巧要更高级一些。尽管在当前已经非常普遍了。常见的堆溢出攻击技术直到2000年7月才浮出水面。这些技术一开始被一个成就非凡的被称为Solar Designer的安全研究员发布,见 www.openwall.com/advisories/OW-002-netscape-jpeg/ 。为了理解堆溢出攻击是如何工作的,你需要熟悉堆是如何维护的。下面的小节将介绍堆管理的基础,然后阐述堆溢出攻击如何实现。

堆管理

堆的实现各不相同,但在几乎所有的算法中都有一些共通性。本质上,当调用malloc()或者相似的分配函数时,一些内存就会从堆中取出返回给用户。当这些内存被free()函数取消分配时,系统就必须将这些内存标记为未被使用以便在以后被用户继续使用。结果就是, 必须为返回给调用者的内存区域保留状态,以便能够有效地分配和回收内存。 在很多情况下,这些状态信息被存在了里面。特别地,大多数堆实现中会在向用户返回的一块内存前面留一个头,描述这个内存块的基本特征,以及相邻地址内存块的一些附加信息。在这块头部中的信息通常包括:

  • 当前内存块的大小
  • 前面内存块的大小
  • 这个内存块是否正在被使用
  • 一些附加的标记
注

BSD系统和其他操作系统对堆的内存管理不一样,它们会将内存块的信息存在内存块之外。

未被使用的内存块通常使用一些标准数据结构链接在一起,比如单向或者双向链表。大多数堆的实现中会定义一个最小大小,这个最小大小通常是大到能够存下指向前后元素的链表指针,在这个内存块未被使用时存储这些变量。图5-10时一个简单的由glbic malloc()实现的内存块结构:

5-10
5-10
堆溢出漏洞利用

就像你所猜测的那样,能够任意地对头部数据和链表指针进行写入的操作(通常是在堆溢出发生时)给了攻击者破坏堆块管理的机会。这些破坏可以用来修改块头部信息,通过堆管理算法来执行任意代码,特别是未被使用的链表块的管理算法。这个过程第一次被Solar Designer发现, 在Phrack 57中有深入的描述 (www.phrack.org/phrack/57/p57-0x09). 下面总结一下标准的流程:

  • 内存块被标记为未被使用时会有链表指针指向下一个和前一个在链表中的内存块
  • 当一个内存块被标记为未被使用时,通常会和相邻未被标记的内存块合并
  • 因为两个内存块被合并为一个,堆算法会移除在链表中的下一个块,调整当前块的大小,将新的大内存块加入链表中
  • 溢出的缓冲区能够修改位于损坏地区的链表指针,将它们指向对于攻击者有利的位置
  • 当unlink操作执行后,一个由攻击者提供的,一定长度的值就被攻击者写入他们决定的内存位置上

为了理解为什么将两个块unlink能够导致任意地址被改写,考虑下面的在双向链表中用来unlink一个元素的代码:

1
2
3
4
5
6
7
int unlink(ListElement *element){
ListElement *next = element->next;
ListElement *prev = element->prev;
next->prev = prev;
prev->next = next;
return 0;
}

这个代码通过更新相邻两个链表元素的指针,移出对当前元素的引用来移出元素。如果你可以修改 element->next 和 element->prev的值,你就能看到这份代码不经意地将任意地址的值修改为了你可以控制的值。这个过程在图5-11 unlink前和图5-12 unlink后可以显示:

5-11_12
5-11_12

能够向任意内存位置写入可控制的值通常就是攻击者能够得到进程控制权所有需要的前提。许多有用的值都可以让攻击者利用。一些比较常见的目标在下面列出:

  • 全局偏移表(Global offset table,GOT)/过程连接表( process linkage table /PLT),UNIX ELF二进制文件使用几种加载结构来将库中调用的函数解析为地址。 这些结构使共享库能够位于内存中的任何位置,以便应用程序在编译时不需要API函数的静态地址。 通过针对这些结构,攻击者可以在调用某个API函数时将执行重定向到任意位置(例如,free())。
  • 退出处理程序(exit handlers)。退出处理程序是一个函数指针表,当进程在UNIX操作系统中退出时调用。通过重写这些值中的一个,就可以在main()函数返回时调用exit()函数时让任意代码执行。
  • 锁指针(lock pointers)。Windows使用一系列在进程环境空间( process environment block ,PEB)的函数指针用来防止竞争线程对进程信息的非同步修改。这些锁指针可以被改写然后由特定类型的异常条件触发 。
  • 异常处理程序例程 (exception handler routine)。Windows PEB 为未处理的异常过滤器例程维护一个地址。 这个例程在当异常未被成功被其他异常处理程序成功处理时被调用。一个常见的方法就是当更新链表的一部分(比如前一个元素)时利用链表维护代码覆盖未处理的异常例程然后导致当更新其他部分的链表(下一个元素)时违反内存访问权限。这个技术保证了异常处理程序例程在假设了其他异常处理程序没有成功 捕获产生的访问冲突异常时立即被调用。
  • 函数指针。应用程序会由于很多原因使用函数指针,比如调用函数来自动态加载的库中,用于C++虚成员函数, 或者用于在不透明结构中抽象低级工作函数。 覆盖应用程序特地给的函数指针能够提供针对应用程序的可靠利用。
全局和静态数据溢出

全局和静态变量用于存储在不同函数调用之间持久存在的数据,因此他们通常存储在不同于栈和堆的内存段中。通常,这些位置不含有广义的程序运行时的数据结构,例如栈激活记录或者堆块数据,因此在这个段中的漏洞利用要求和单字节溢出一错误差不多,应用于特定程序。可利用程度取决于什么样的变量能在缓冲区溢出发生时够被损坏以及这些变量如何被使用。例如,如果一个指针变量能够被损坏,可利用的几率就增加了,因为这个被损坏的变量能够有概率向任意位置进行写入。

7.1.3 Shellcode

缓冲区溢出通常是通过将执行定向到内存中存储攻击者控制数据的已知位置来利用的。为了成功利用,该位置必须包含允许攻击者执行恶意活动的可执行机器代码。这是通过构造用于启动shell、连接回原始用户或执行攻击者选择的任何操作的一小段机器码来实现的。在撰写本文时,shellcode构造中最常见的趋势是使用能够根据需要在连接的套接字上加载额外组件的存根,这是另一端的攻击者所需要的。

写code

在最基本的层次上,shellcode是一小块位置无关的代码,它使用系统api来实现你您的目标。要了解这是如何做到的,请考虑在UNIX中生成shell的简单情况。在本例中,要运行的代码大致如下 :

1
2
char *args[] = { "/bin/sh", NULL };
execve("/bin/sh", args, NULL);

这个简单的代码在运行时生成一个command shell。如果此代码在网络服务中运行,则需要在stdin、stdout和可选的stderr上复制用户连接的套接字描述符。

要构造生成shell所需的机器码,你需要在较低的层次上理解这些代码是如何工作的。execve()函数由标准C库导出,因此普通程序首先在加载器的帮助下找到libc execve()实现,然后调用它。由于这种功能很难在适当大小的shellcode中复制,所以通常需要寻找一种更简单的解决方案。事实证明,execve()也是UNIX系统上的系统调用,libc函数所做的只是执行系统调用。

在基于intel的操作系统上调用system call通常涉及构建参数列表(在寄存器或堆栈中,这取决于操作系统),然后请求内核代表进程执行系统调用。这可以通过多种方法来实现。对于Intel系统,系统调用功能可以依赖于一个软件中断,由int指令发起;呼叫门用lcall调用;或特殊用途的机器支持,如sysenter。对于Linux和许多BSD变体,int 128中断是为系统调用保留的。当这个中断生成时,内核会处理它,确定进程需要执行一些系统功能,并执行请求的任务。Linux系统的程序如下:

  1. 将system call参数放在从EBX开始的通用寄存器中。如果一个系统调用需要五个以上的参数,则会在堆栈上放置其他参数。
  2. 将所需的system call number放入EAX。
  3. 使用int 128指令执行system call

汇编代码一开始会像这样:

1
2
3
4
5
6
xorl %eax, %eax ; zero out EAX
movl %eax, %edx ; EDX = envp = NULL
movl $address_of_shell_string, %ebx; EBX = path parameter
movl $address_of_argv, %ecx; ECX = argv
movb $0x0b ; syscall number for execve()
int $0x80 ; invoke the system call

当你创建shellcode时,你需要的几乎所有功能都由一系列system call组成,并遵循这里给出的基本原则。在Windows中,system call number在操作系统版本中是不一致的,因此大多数Windows shellcode加载系统库并调用这些库中的函数。一个名为“谵语的最后阶段”(Last Stage of Delirium,LSD)的黑客组织在www.lsd-pl.net/projects/winasm.zip上记录了编写大多数现代Windows shellcode的基础。

在内存中找到你的代码

所构造的机器码段必须是位置独立的,也就是说,无论它们在内存中的位置如何,它们都必须能够成功运行。要理解为什么这很重要,请考虑上一节中的示例;你需要提供参数数组向量的地址和pathname参数的字符串"/bin/sh"的地址。通过使用绝对地址,你在很大程度上限制了shell代码的可靠性,并且需要针对你编写的每个漏洞修改它。因此,你应该有一种动态确定这些地址的方法,而与代码运行的进程环境无关。

通常,在Intel x86 cpu上,shellcode所需要的字符串或数据是与代码一起提供的,它们的地址是独立计算的。要理解这是如何工作的,请考虑调用指令的语义。这个函数隐式地在堆栈上保存一个返回地址;它是调用指令后的第一个字节的地址。因此,通常使用以下格式构造shellcode:

1
2
3
4
5
6
jmp end
code:
... shellcode ...
end:
call code
.string "/bin/sh"

这个示例跳到代码的末尾,然后使用call运行直接位于jmp指令之后的代码。这种间接的意义是什么?基本上,你将字符串"/bin/sh"的相对地址定位在堆栈上,因为调用指令隐式地将返回地址推入堆栈。因此,无论shellcode位于目标应用程序中的何处,都可以自动计算“/bin/sh”的地址。结合上面的信息,execve() shellcode看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
jmp end
code:
popl %ebx ; EBX = pathname argument
xorl %eax, %eax ; zero out EAX
movl %eax, %edx ; EDX = envp
pushl %eax ; put NULL in argv array
pushl %ebx ; put "/bin/sh" in argv array
movl %esp, %ecx ; ECX = argv
movb $0x0b, %al ; 0x0b = execve() system call
int $0x80 ; system call
call code
.string "/bin/sh"

如你所见,启动shell的代码相当简单;你只需分别用pathname、argv和envp填充EBX、ECX和EDX,然后调用一个system call。这个示例是一个简单的shellcode片段,但是更复杂的shellcode基于相同的原则。

7.1.4 保护机制

到目前为止所涵盖的基础知识代表了一些当代系统的可行的开发技术,但是安全前景正在迅速变化。现代操作系统通常包括预防技术,使其难以利用缓冲区溢出。这些技术通常会减少攻击者利用漏洞的机会,或者至少减少构建程序可靠地利用目标主机上的漏洞的机会。

第三章“操作审查”,从高级操作的角度讨论了这些技术中。本节以第三章的内容,侧重于常见的反内存损坏保护的技术细节,并解决这些机制中潜在的和真正的弱点。本讨论并不是对保护机制的全面研究,但确实涉及了最常用的保护机制 。(第三章我没有翻译 — by译者)

Stack Cookies

Stack Cookies(通常也叫金丝雀值 (canary values))是一种目的在于检测和预防应用于栈的缓冲区溢出攻击的方法。Stack Cookies是Windows XP SP2及以后版本附带的大多数默认应用程序和库中提供的编译时解决方案。还有一些堆栈信息记录程序保护的UNIX实现,最著名的是ProPolice和Stackguard。

Stack Cookies通过插入随机的32位值,通常在运行时栈上当在局部变量之前的返回地址和保存的栈帧指针之后立即生成,就像图5-13显示那样。

5-13
5-13
不足

这个技术很有用但并不完善。尽管它防止了保存的栈帧指针和返回地址被修改,但它并没有保护相邻局部变量被修改,图5-5显示了覆盖局部变量会对系统安全造成什么样的影响,特别是当你损坏了函数用来修改数据的指针的值。通过修改这些指针的值通常导致了攻击者通过覆盖指针为有用的值来获得应用程序的控制权。不过, 许多栈保护系统对局部变量重新排序,这可以最小化相邻变量覆盖的风险。

另一种攻击是写入Stack Cookies并覆盖当前函数的参数。攻击者通过覆盖函数参数来破坏Stack Cookies,但攻击的目标是不让函数返回。在某些情况下,覆盖函数参数允许攻击者在函数返回之前获得对应用程序的控制,从而使Stack Cookies保护失效。

尽管这种技术似乎对攻击者很有用,但优化有时会无意中消除漏洞被利用的机会。当经常使用一个变量值时,编译器通常会生成代码,将它从栈中读取一次,然后在函数运行期间或函数中重复使用该值的部分中将其保存在寄存器中。因此,即使在触发溢出后可能会频繁访问参数或局部变量,攻击者也可能无法使用该参数来执行任意重写。

Windows上另一个类似的技术是不用担心保存的返回地址,而是尝试SEH覆盖。这样,攻击者可以破坏SEH记录并在当前运行的函数返回之前触发访问冲突;因此,攻击者控制的代码会运行,而不会检测到溢出。

最后,请注意,Stack Cookies是一种编译时解决方案,如果开发人员不能重新编译整个应用程序,那么它可能不是一个现实的选择。开发人员可能无法访问所有源代码,比如商业库中的代码。对大型应用程序的构建环境进行更改也可能会出现问题,特别是手工优化的组件。

堆实现强化(Heap Implementation Hardening )

堆溢出经常通过系统的内存分配和取消分配的unlinking操作实施攻击。内存管理中的链表操作能够被利用来向任意内存中的位置进行写入操作以得到整个应用程序的控制权。为了处理这种威胁,很多系统都将它们的堆实现进行了强化,让它们变得更难进行漏洞利用。

Windows XP SP2以及后续的版本实现了很多种类的保护措施以确保堆操作不会在不经意见允许攻击者使用有害的方法去操控进程。这些机制具体如下;

  • 一个8位大小的Cookie被保存在了堆头部结构中,然后一个XOR操作将这个Cookie和全局堆Cookie连接起来, 并将堆块的地址除以8。 如果结果值不是0,则表示发生了堆损坏。因为在这个操作中使用了堆块的地址,cookie不应该容易受到蛮力攻击。

  • 每当发生unlink操作时,都会进行检查,以确保上一个和下一个元素确实是有效的。具体来说,下一个和上一个元素都必须指向即将断开链接的当前元素。如果没有,则假定堆已损坏,操作将中止。

    UNIX glibc堆实现也经过了强化,以防止堆被进行漏洞利用。glibc开发人员在他们的堆管理代码中添加了unlink检查,类似于Windows XP SP2防御措施。

不足

堆保护技术并不完美。大多数都有弱点,仍然允许攻击者利用堆数据结构进行可靠(或相对可靠)的利用。以下给出一些已发表的关于破解Windows堆保护措施的工作:

  • 破解微软Windows XP SP2堆保护和DEP绕过(Defeating Microsoft Windows XP SP2 Heap Protection and DEP Bypass),作者为 Alexander Anisimov
  • (www.maxpatrol.com/defeating-xpsp2-heap-protection.htm) 一种绕过Windows堆保护措施的新方法( A New Way to Bypass Windows Heap Protections )作者为 Nicolas Falliere
  • (www.cybertech.net/~sh0ksh0k/heap/XPSP2%20Heap%20Exploitation.ppt) Windows堆漏洞利用( Windows Heap Exploitation )作者为 Oded Horovitz和 Matt Connover

UNIX glibc的实现上也有类似的安全问题,一个有用的资源链接如下:The Malloc Maleficarum (www.securityfocus.com/archive/1/413007/30/0/threaded) 作者为 Phantasmal Phantasmagoria 。

这些堆保护措施机制中最重要的不足之处就是它们只保护了内部的堆管理结构。他们没有对在堆中对应用程序数据的改写进行保护。如果你能够更改其他有用的数据,那么这个漏洞利用只会是时间和精力问题。然而更改程序变量比较困难,因为这需要一些特定的变量布局。 攻击者可以在许多应用程序中创建这些布局,但它并不总是一种可靠的利用形式,特别是在多线程应用程序中。

另外一个需要记住的点是在系统分配历程的顶端实现应用程序自己的内存分配策略通常是很常见的。在这种情况下, 有问题的应用程序通常一次向系统请求一个或一系列页面,然后用自己的算法在内部管理它们。 这对于攻击者来说就很开心,因为自己设计的内存管理算法经常是没有受到保护的,使用经典的堆溢出攻击方法通常都能奏效。

不可执行堆栈保护(Nonexecutable Stack and Heap Protection)

许多CPU在内存方面提供了细粒度的保护措施,允许CPU将内存块标记为可读,可写,或者可执行。如果应用程序将代码和数据完全分割,那么就可能通过将数据页面设置为不可执行以防止shellcode的运行。通过强制执行不可执行保护,CPU防止了大多数常见漏洞利用方法,这些方法通过将控制流转移到攻击者预先设定好数据的内存位置中。

注

Intel CPU直到最近(2004)年才推行了不可执行页面。一些有趣的变通方法也被开发出来克服这一限制,最明显的是由PaX开发团队(现在是GR-Security团队的一部分)开发的。 文档见 http://pax.grsecurity.net/.

不足

因为不可执行内存是由CPU来强制执行的, 一般来说,直接绕过这种保护是不可行的 。 攻击者完全不能定向到栈或堆上的某个位置去执行代码。 然而,这并不能阻止攻击者返回可执行代码部分中的有用代码。 不管它是在被利用的应用程序中还是在共享库中。 规避这些保护的一种流行技术是在栈上构造一系列返回地址,以便攻击者可以对有用的API函数进行多次调用。通常,攻击者可以通过API函数来不保护他们控制的数据区域内存。这将目标页标记为可执行文件并禁用保护,从而允许漏洞运行自己的shellcode。 一般来说,这种保护机制使得利用受保护的系统更加困难,但是老练的攻击者通常可以找到绕过它的方法。只需一点创造性,就可以对现有代码进行拼接、切割和强迫,以满足攻击者的目的。

地址空间布局随机化

地址空间布局随机化(Address space layout randomization, ASLR)是一种通过将应用程序在运行时映射数据和代码时进行随机化来试图缓和缓冲区溢出威胁的方法。本质上,就是数据和代码段在加载时映射到随机的内存位置。因为缓冲区溢出攻击的关键部分就是将关键数据结构覆盖或者返回到特定的内存位置。在理论上,地址空间布局随机化能够防止这种漏洞利用因为攻击者将不再能够依赖静态的地址。尽管地址空间布局随机化是一种晦涩的安全形式,但它对于防止漏洞利用是一种有效的方法,特别是和前面讨论过的技术一起使用时。

不足

击败地址空间布局随机化本质上依赖于找到地址空间布局随机化实现中的弱点。攻击者通常尝试采用以下方法之一:

  • 在内存中找到一些尽管有空间布局随机化但仍然处于静态位置的东西。不管静态元素是什么,它都可能以某种方式有用。静态定位元素的示例可能包括不包含重定位信息的基本可执行文件(因此加载程序可能无法对其进行重定位)、出现在所有映射进程中的专用数据结构(例如Windows PEB和Linux vsyscall页面)、加载程序本身以及不可重定位的共享库。如果空间布局随机化未能随机化过程中的任何特定部分,就可以依赖并潜在地破坏空间布局随机化的保护
  • 尽可能使用蛮力攻击。在很多情况下,数据元素会在内存中移动,但移动的幅度并不大。例如,当前的Linux exec-shield ASLR将堆栈映射到一个随机位置;但是,仔细检查代码就会发现这些映射只包含256个可能的位置。这一小组可能的位置不能提供大的随机因素,而且大多数ASLR实现不会随机化子进程的内存布局。当脆弱的服务为服务请求创建子进程时,这种随机性的缺乏可能导致蛮力攻击。攻击者可以针对每个可能的偏移量发送请求,并最终在找到正确的偏移量时成功利用。
SafeSEH

现代Windows系统(XP SP2+, Windows 2003, Vista)为栈中的SEH结构实现了保护机制。当异常发生时,异常处理程序会追踪到异常发生前的地址以确保每个异常处理程序例程都是合法的。 以下过程确定异常处理程序的有效性:

  1. 获取异常处理程序地址,确定哪个模块(DLL或者可执行二进制文件)是异常处理程序指向的。
  2. 检查模块是否有注册异常表。异常表是一个记录了可以合理进入 EXCEPTION_REGISTRATION结构的的异常处理程序表。这个表是可选的,模块可能会忽略它。在这种情况下,就假定处理程序合法并且可以调用。
  3. 如果异常表存在并且处理程序在EXCEPTION_REGISTRATION结构中没有匹配到合法的处理程序入口,那么这个结构就会被认为已经损坏,处理程序就不会被调用
不足

SafeSEH保护是对最近Windows版本中使用的stack cookie的一个很好的补充,因为它可以防止攻击者使用SEH覆盖作为绕过stack cookie保护的方法。但是,同其他保护机制一样,它在过去也有弱点。 Next Generation Security Software (NGSSoftware)的大卫·利奇菲尔德(David Litchfield)写了一篇论文,详细描述了SafeSEH早期实现中出现的一些问题,这些问题已经得到了解决(可以在www.ngssoftware.com/papers/defeating-w2k3-stack-protection.pdf找到)。绕过SafeSEH的主要方法包括返回到内存中不属于任何模块的位置(比如PEB),返回到没有注册异常表的模块,或者滥用可能允许间接运行任意代码的已定义异常处理程序。

函数指针混淆(Function Pointer Obfuscation)

长期存在的函数指针通常是内存损坏利用的目标,因为它们提供了一种获取程序执行控制的直接方法。防止这种攻击的一种方法是混淆存储在全局可见数据结构中的任何敏感指针。这种保护机制不能防止内存损坏,但它确实降低了成功利用任何攻击(denial of service除外)的概率。例如,你在前面看到,攻击者可能能够利用正在运行的Windows进程的PEB中的函数指针。为了帮助减轻这种攻击,微软现在使用EncodePointer()、DecodePointer()、EncodeSystemPointer()和DecodeSystemPointer()函数来混淆这些值。这些函数通过使用异或操作将指针的指针值与秘密cookie值组合在一起来混淆指针。Windows的最新版本也在堆实现的某些部分中使用了这种反漏洞利用技术。

不足

这项技术无疑提高了开发人员利用的门槛,特别是与其他技术(如ASLR和非可执行内存页)结合使用时。然而,它本身并不是一个完整的解决方案,只有有限的用途。攻击者仍然可以覆盖特定于应用程序的函数指针,因为编译器目前没有对应用程序使用的函数指针进行编码。攻击者还可能覆盖普通的未编码变量,这些变量最终通过不那么直接的向量提供执行控制。最后,攻击者可能会识别以有限但有用的方式重定向执行控制的环境。 例如,当用户控制的数据接近函数指针时,只要破坏已编码函数指针的低字节就可能给攻击者提供运行任意代码的合理机会,特别是当他们可以重复利用尝试,直到成功识别出一个值时。

7.1.5 审计内存损坏漏洞的影响

既然你已经熟悉了内存损坏,那么你就需要知道如何正确地评估这些漏洞的代表的风险。很多参数都影响着一个漏洞是如何被利用的。通过认识到这些参数,代码审计者能够预测一个漏洞会有多么严重以及它能被利用到什么程度。它能只被用来摧毁整个应用程序?或者能够让任意代码被运行?唯一去具体知道它的途径就算写出漏洞存在证明(proof-of-concept exploit),但这个过程会消耗很多时间,即使是一个合适大小的应用程序审计。但是, 你可以通过回答一些关于结果内存损坏的问题来合理地估计可利用性。这种方法不像poc那样明确,但它花费的时间少得多,因此适合大多数审计。

修复漏洞的实际成本

你可能会惊讶地发现,在向供应商披露漏洞时可能遇到的阻力,即使是专门雇佣你来执行审计的供应商。供应商经常说,潜在的内存损坏bug是不可利用的,或者出于某种原因不是问题。但是,内存损坏会在最基本的层次上影响应用程序,因此需要认真考虑所有实例。事实上,历史已经表明,攻击者和安全研究人员都已经想出了巧妙的方法来利用看起来不可利用的东西。我想到一句老话“有志者事竟成”,当涉及到违背电脑系统原则的时候,肯定会有很多的人愿意去做漏洞利用。

因此,大多数审计员认为软件供应商应该将所有问题视为高优先级;毕竟,为什么供应商不希望他们的代码尽可能安全,而不尽快修复问题呢?事实上,修复软件缺陷总是要付出代价的,包括开发人员时间、补丁部署成本以及可能的产品召回或重新发布。例如,考虑一下向广泛部署的嵌入式系统(如智能卡或手机)发布漏洞更新的成本。更新这些嵌入式系统通常需要硬件修改或由合格技术人员进行其他干预。如果一个公司没有合理地预期漏洞会被利用,那么它就会不负责任地承担与更新相关的成本。

缓冲区位于内存的哪个地方?

缓冲区在内存的位置非常重要,它影响了攻击者做出什么样的选择去获得进程的控制权。变量通常存储在三个内存的位置:栈,堆和不变数据(persistent data),包括静态和全局变量。然而,不同的操作系统常常将这些地区再进行分段或者添加新的地区。初始化的和未初始化的全局数据会有所不同(未初始化的全局变量在数据区中只有一个placeholder,初始化的全局变量会占一定内存空间—by译者),或者操作系统会将线程本地存储(thread local storage, TLS)放置在一个特定的位置。并且,共享库还会有它专门的初始化和未初始化的数据在程序代码运行后迅速映射到进程内存中。当在确定可利用性时,你需要跟踪发生内存损坏的位置以及应用特定的注意事项。这项任务可能包括进行一些额外的研究,以了解特定操作系统的进程内存布局

哪些其他的数据会被覆盖重写?

内存损坏可能不能仅与攻击者所针对的变量有关。它还可以覆盖可能使开发过程复杂化的其他变量。当试图利用进程栈上的损坏时,通常会发生这种情况。你已经知道,通过覆盖保存的程序计数器,栈段中的漏洞经常被利用。然而,它并不总是那么直接;攻击者通常会在重写保存的程序计数器之前先重写本地变量,这可能会使利用程序变得复杂,例如下面的代码:

1
2
3
4
5
6
7
8
9
int dostuff(char *login){
char *ptr = (char *)malloc(1024);
char buf[1024];
...
strcpy(buf, login);
...
free(ptr);
return 0;
}

这个例子有一个很小的问题:尽管攻击者可以覆盖保存的程序计数器,但它们也会覆盖ptr变量,让它在函数返回前就被释放。这意味着攻击者必须将ptr覆盖成一个内存中合法位置的值并且不会在free()调用前导致程序崩溃。尽管这个方法让攻击者可能利用对free()的调用,但这就让漏洞利用的方法比简单的计数器覆盖要更加复杂,特别是当在内存静态区域中没有用户控制的数据时。

在评估缓冲区溢出漏洞的风险时,要特别注意溢出路径中可以减少利用尝试的任何变量。另外,请记住编译器可能会在编译期间重新排序变量的布局,因此你可能需要检查二进制文件以确认可利用性。

注

有时需要多个函数返回才能利用错误。例如,由于Sun SPARC cpu注册窗口的工作方式,运行在Sun SPARC cpu上的操作系统经常需要两个函数返回。

有多少字节能够被覆盖?

你需要考虑缓冲区溢出了多少字节,以及用户能控制多少大小的溢出。字节过少或过多的溢出会使利用变得更加困难。显然,攻击者的理想情况是选择任意长度的数据进行溢出。

有时,攻击者只可以溢出一个固定数量的缓冲区,这提供的选项较少,但仍然有可能对缓冲区漏洞成功利用。如果只有少量的字节可以溢出,可利用性就取决于什么数据被破坏了。如果攻击者只能破坏内存中再也不会使用的相邻变量,那么这个错误可能是不可利用的。显然,攻击者破坏的内存越少,漏洞被利用的可能性就越小。

相反,如果攻击者可以溢出一个固定的数量,而这个数量恰好是非常大的,那么这个错误总是会导致程序内存的很大一部分被破坏,并且几乎肯定会使进程崩溃。在某些情况下,当信号处理程序或异常处理程序可能被破坏时,攻击者可以利用这种情况,并在异常发生后获得对流程的控制。最常见的例子是Windows中基于栈的大量溢出,因为攻击者可以覆盖包含在发生异常时被访问的函数指针的SEH结构。

此外,一些错误可能导致对内存中任意位置的多次写操作。尽管通常只能执行一次覆盖,但如果可以执行多次覆盖,攻击者在选择如何利用脆弱的程序时就有更大的优势。例如,对于格式化字符串漏洞,攻击者通常可以任意写入任意位置,这增加了成功利用的可能性。

注

有时,1到2字节的覆盖比4字节的覆盖更容易被利用。例如,假设你覆盖了一个指向一个对象的指针,该对象由几个指针和一个带有你控制的数据的缓冲区组成。在这种情况下,可以覆盖指针值的最低有效字节,以便指向对象中的数据缓冲区而不是对象本身。你可以任意更改任何对象属性的状态,并可能相当可靠地利用该缺陷。

什么样的数据能够用来损坏内存?

一些内存损坏漏洞不允许直接控制用于覆盖内存的数据。可能会根据使用方式对数据进行限制,如字符限制、单字节覆盖或对攻击者能延展的(attaker-malleable)memset()的调用。下面是一个漏洞示例,其中内存被攻击者无法控制的数据覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int process_string(char *string){
char **tokens, *ptr;
int tokencount;
tokens = (char **)calloc(64, sizeof(char *));
if(!tokens)
return -1;
for(ptr = string; *ptr;){
int c;
for(end = ptr; *end && !isspace(end); end++);
c = *end;
*end = '\0';
tokens[tokencount++] = ptr; //这一行是粗体 --by译者
ptr = (c == 0 ? end : end + 1);
}
...

这份代码在操作tokens数组的粗体行中有缓冲区溢出。用于覆盖内存的数据不能被攻击者直接控制,但被覆盖的内存中包含指向攻击者可控制数据的指针。这使得漏洞利用比使用标准技术更容易。例如,如果函数指针被覆盖,攻击者不需要内存布局信息,因为可以用指向攻击者控制的数据的指针替换函数指针。但是,如果覆盖了堆的头部或其他复杂结构,漏洞利用可能会更加复杂。

单字节溢出漏洞是最常见的漏洞之一,涉及到攻击者无法控制的数据覆盖。下面是一个单字节溢出的漏洞示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct session {
int sequence;
int mac[MAX_MAC];
char *key;
};

int delete_session(struct session *session){
memset(session->key, 0, KEY_SIZE);
free(session->key);
free(session);

}

int get_mac(int fd, struct session *session){
unsigned int i, n;
n = read_network_integer(fd);
if(n > MAX_MAC) // 这一行是粗体 --by译者
return 1;
for(i = 0; i <= n; i++)
session->mac[i] = read_network_integer(fd);
return 0;
}

如果攻击者将mac的长度设定为正好等于MAX_MAC,get_mac()函数就会在分配的空间中读取一个多余元素(在粗体的一行中显示)。在这种情况下,最后一个整数被读入后会覆盖变量key。变量key在被删除前会被传入menset,这样就允许攻击者能够向任意内存的位置进行写入操作。此外,攻击者提供的内存位置随后被释放,这意味着攻击很可能指向内存管理例程。成功地执行这种攻击可能非常困难,特别是在多线程应用程序中。

这两段代码显示了当覆盖的数据无法控制时,攻击者可能难以利用漏洞。在检查类似问题时,你需要确定覆盖的数据中包含什么,以及攻击者是否可以控制它。通常,攻击者对写入的数据有相当直接的控制,或者可以损坏结果来访问攻击者控制的数据。

内存块是否是共享的?

偶尔,应用程序中会出现一些bug,其中内存管理器错误地多次分配相同的内存块,即使它仍在使用中。当这种情况发生时,应用程序的两个或多个独立部分使用内存块,期望它们能够独占地访问它,而实际上它们并没有。这些漏洞通常是由以下两个错误之一引起的 :

  • 内存管理代码中的bug
  • 内存管理的API没有被正确使用

这些类型的漏洞还可能导致远程执行;然而,确定内存块共享漏洞是否可以利用通常是复杂的,并且是特定于应用程序的。一个原因是攻击者可能无法准确地预测应用程序的其他部分获得相同的内存块,并且不知道提供什么数据来执行攻击。此外,在他们试图连接时应用程序可能存在时间问题,特别是服务于大量客户机的多线程软件。在接受这些困难的情况下,有一些程序可以利用这些漏洞,所以它们不应该被认为是没有理由的低优先级。

类似的内存崩溃也可能发生,即一个内存块只分配一次(正确的行为),但随后该内存块被分配给两个并发运行的线程,并假定其访问是互斥的。 这种类型的漏洞经常由于同步问题导致,这会再13章“同步与状态”中详细提及。

什么样的保护机制在运行?

在了解了潜在可利用的内存损坏漏洞的细节之后,你需要考虑可能阻止利用的任何缓解因素。例如,如果一个软件只在Windows XP SP2+上运行,你知道存在stack cookie和SafeSEH,因此典型的栈溢出可能无法利用。当然,你不能仅仅因为有了保护措施就对内存损坏视而不见。攻击者很有可能通过使用不安全的加载模块或覆盖函数参数来破坏stack cookie,从而破坏SafeSEH。但是,您需要考虑这些保护措施,并尝试评估攻击者绕过它们并可靠地利用系统的可能性。

7.1.6 总结

本章解释了内存损坏是如何发生的,以及它如何影响应用程序的状态。特别是,你已经看到了攻击者如何利用内存损坏bug来控制应用程序并执行恶意活动。在评估应用程序安全漏洞时,这些知识非常重要,因为它允许你准确地确定攻击者利用特定内存损坏问题的可能性。然而,内存损坏利用本身就是一个完整的研究领域,而且技术的发展状况也在不断变化,以找到新的方法来利用以前无法利用的漏洞。作为审计者,你应该将所有内存损坏问题视为潜在的严重漏洞,直到你能够证明并非如此。

About ME

Posted on 2020-01-01
Words count in article: 20 | Reading time ≈ 1

关于我

Ph.D Stuident in Physics, INTP.

Interests: Electromagnetism, Condensed Matter Physics, Low Latency Trading, High-Performance Computing

Sponsorship

Posted on 2020-01-01
Words count in article: 5 | Reading time ≈ 1

请我喝咖啡

<i class="fa fa-angle-left"></i>12

20 posts
6 categories
17 tags
GitHub 知乎 QQ 哔哩哔哩 Steam 微信 公众号 学术主页
© 2024 Chenyu Zhu
Powered by Hexo
|
Theme — NexT.Muse v5.1.4