距离上次更新已经 1589 天了,文章内容可能已经过时。

课程主页:http://www.cs.cmu.edu/afs/cs/academic/class/15213-f15/www/schedule.html

课程资料:https://github.com/EugeneLiu/translationCSAPP

课程视频:https://www.bilibili.com/video/av31289365/

这一讲介绍了机器级编程5:高级主题。

内存布局

x86-64 Linux内存布局

  • 堆栈
    • 运行时的堆栈(限制为8MB)
    • 例如,局部变量
    • 根据需要动态分配
    • 调用malloc(), calloc(), new()时使用
  • 数据
    • 静态分配的数据
    • 例如,全局变量,静态变量,字符串常量
  • 文本/共享库
    • 可执行机器指令
    • 只读

缓存区溢出

回忆:内存引用错误示例

c
typedef struct {
  int a[2];
  double d;
} struct_t;

double fun(int i) {
  volatile struct_t s;
  s.d = 3.14;
  s.a[i] = 1073741824; /* Possibly out of bounds */
  return s.d;
}

产生如下结果:

Code
fun(0) ➙ 3.14
fun(1) ➙ 3.14
fun(2) ➙ 3.1399998664856
fun(3) ➙ 2.00000061035156
fun(4) ➙ 3.14
fun(6) ➙ Segmentation fault

内存中结构的布局如下:

如果输入大于1,那么会触碰到存储浮点数的内存部分,所以会产生上述错误。

上述现象会产生大问题

  • 通常称为“缓存区溢出”
    • 当超过分配给数组的内存大小时
  • 为什么会产生大问题?
    • 这是导致安全漏洞的第一大技术原因
      • 总体原因是社会工程/用户无知
  • 最常见的形式
    • 字符串输入中未经检查的长度
    • 特别是对于堆栈上的有界字符数组
      • 有时称为stack smashing

考虑如下例子:

c
/* Get string from stdin */
char *gets(char *dest)
{    int c = getchar();
    char *p = dest;
    while (c != EOF && c != '\n') {
        *p++ = c;
        c = getchar();
    }
    *p = '\0';
    return dest;
}

上述代码无法限制读取字符的数量,同样会产生缓存区溢出的问题。

例子

c
/* Echo Line */
void echo()
{
    char buf[4]; /* Way too small! */
    gets(buf);
    puts(buf);
}

void call_echo() {
	echo();
}
shell
unix>./bufdemo-nsp
Type a string:012345678901234567890123
012345678901234567890123
shell
unix>./bufdemo-nsp
Type a string:0123456789012345678901234
Segmentation Fault

反汇编的结果为如下:

echo:

assembly
00000000004006cf <echo>:
    4006cf: 48 83 ec 18 		sub $0x18,%rsp
    4006d3: 48 89 e7 			mov %rsp,%rdi
    4006d6: e8 a5 ff ff ff 		callq 400680 <gets>
    4006db: 48 89 e7 			mov %rsp,%rdi
    4006de: e8 3d fe ff ff 		callq 400520 <puts@plt>
    4006e3: 48 83 c4 18 		add $0x18,%rsp
    4006e7: c3 				    retq

call_echo:

assembly
4006e8: 48 83 ec 08 			sub $0x8,%rsp
    4006ec: b8 00 00 00 00 		 mov $0x0,%eax
    4006f1: e8 d9 ff ff ff 		 callq 4006cf <echo>
    4006f6: 48 83 c4 08 		 add $0x8,%rsp
    4006fa: c3 				    retq

在调用gets函数之前,栈的布局如下:

如果运行如下命令:

shell
unix>./bufdemo-nsp
Type a string:012345678901234567890123
012345678901234567890123

那么布局如下:

如果运行如下命令:

shell
unix>./bufdemo-nsp
Type a string:0123456789012345678901234
Segmentation Fault

那么布局如下:

如果运行如下命令:

shell
unix>./bufdemo-nsp
Type a string:012345678901234567890123
012345678901234567890123

那么布局如下:

此时会返回到其他的位置,例如:

shell
. . .
400600:	mov    %rsp,%rbp
400603:	mov    %rax,%rdx
400606:	shr    $0x3f,%rdx
40060a:	add    %rdx,%rax
40060d:	sar    %rax
400610:	jne    400614
400612:	pop    %rbp
400613:	retq 

这样就会产生安全问题,例如代码注入攻击,缓冲区溢出错误可使远程计算机在受害计算机上执行任意代码。

蠕虫和病毒

  • 蠕虫:是一个程序
    • 可以自己运行
    • 可以将自身的完全正常版本传播到其他计算机
  • 病毒:是一段代码
    • 将自身添加到其他程序
    • 不独立运行
  • 两者(通常)在计算机之间传播并造成破坏

如何处理缓存区溢出攻击

  • 避免溢出漏洞
  • 采用系统级保护
  • 让编译器使用“堆栈金丝雀”
避免溢出漏洞
c
/* Echo Line */
void echo()
{
    char buf[4]; /* Way too small! */
    fgets(buf, 4, stdin);
    puts(buf);
}

例如在之前代码中限制输入字符的数量。

系统级保护
  • 堆栈随机偏移

    • 在程序开始时,在堆栈上分配随机数量的空间

    • 移位整个程序的堆栈地址

    • 使黑客很难预测插入代码的开始

    • 例如5次执行内存分配代码的结果

      Code
      local 0x7ffe4d3be87c 0x7fff75a4f9fc 0x7ffeadb7c80c 0x7ffeaea2fdac 0x7ffcd452017c
    • 程序每次执行时都会重新定位堆栈

  • 不可执行的代码段
    • 在传统的x86中,可以将内存区域标记为“只读”或“可写”
      • 可以执行任何可读的
    • X86-64添加了明确的“执行”权限
    • 堆栈标记为不可执行
堆栈“金丝雀”(canary)
  • 思想
    • 将特殊值(“ canary”)放在在缓存区之外的堆栈上
    • 退出函数前检查是否损坏
  • GCC实现
    • -fstack-protector
    • 现在是默认值(之前已禁用)

还是echo那段代码,在调用gets之前堆栈的布局如下:

假设输入为

Code
0123456

那么堆栈的布局为

面向返回的编程攻击

  • 挑战(针对黑客)
    • 堆栈随机化使得难以预测缓冲区位置
    • 将堆栈标记为不可执行,使其很难插入二进制代码
  • 替代策略
    • 使用现有代码
      • 例如,来自stdlib的库代码
    • 将片段串在一起以实现总体预期结果
    • 无法克服堆栈金丝雀
  • 从小工具(Gadget)构造程序
    • 以ret结尾的指令序列
      • 由单字节0xc3编码
    • 每次运行固定的代码位置
    • 代码是可执行的
Gadget例子

上述代码的一部分可以对应如下操作

ROP执行

联合

  • 根据最大元素分配
  • 一次只能使用一个字段

例如

c
union U1 { 
	char c;
	int i[2]; 
	double v;
} *up;

内存布局如下:

对比结构:

c
struct S1 { 
	char c;
	int i[2]; 
	double v;
} *sp;

内存布局如下: