MIT 6.858 计算机系统安全讲义 2014 秋季(一)(1)https://developer.aliyun.com/article/1484144
缓解方法 1:金丝雀(例如,StackGuard,gcc 的 SSP)
- 理念:覆盖代码指针是可以接受的,只要我们在调用之前捕捉到它。
- 较早的一个系统:StackGuard
- 在进入时在堆栈上放置一个金丝雀,在返回前检查金丝雀值。
- 通常需要源代码;编译器插入金丝雀检查。
- Q: 堆栈图中的金丝雀在哪里? A: 金丝雀必须放在堆栈上返回地址的“前面”,这样任何溢出重写返回地址也将重写金丝雀。
堆栈布局:
| | +------------------+ entry %esp ----> | return address | ^ +------------------+ | new %ebp ------> | saved %ebp | | +------------------+ | | CANARY | | Overflow goes +------------------+ | this way. | buf[127] | | | ... | | | buf[0] | | +------------------+ | |
- Q: 假设编译器总是将金丝雀设为 4 个字节的
'a'
字符。这有什么问题吗? - A: 对手可以在缓冲区溢出中包含适当的金丝雀值!
- 因此,金丝雀必须要么难以猜测,要么可以容易猜测但仍然能够抵御缓冲区溢出。以下是这些方法的示例。
- “终结符金丝雀”:四个字节(0,CR,LF,-1)
- 理念:许多 C 函数将这些字符视为终结符(例如,
gets()
,sprintf()
)。因此,如果金丝雀与这些终结符之一匹配,那么进一步的写入将不会发生。 - 在程序初始化时生成随机金丝雀:今天更常见(但是,你需要良好的随机性!)。
堆栈金丝雀不会捕捉到哪些类型的漏洞?
- 在金丝雀之前覆盖函数指针变量。
- 攻击者可以覆盖数据指针,然后利用它进行任意内存写入。
数据指针示例:
int *ptr = ...; char buf[128]; gets(buf); // Buffer is overflowed, and overwrites ptr. *ptr = 5; // Writes to an attacker-controlled address! // Canaries can't stop this kind of thing.
- 堆对象溢出(函数指针,C++ vtables)。
malloc
/free
溢出
malloc 示例:
int main(int argc, char **argv) { char *p, *q; p = malloc(1024); q = malloc(1024); if(argc >= 2) strcpy(p, argv[1]); free(q); free(p); return 0; }
- 假设属于
p
和q
的两个内存块在内存中是相邻/附近的。 - 假设
malloc
和free
表示内存块如下:
malloc 内存块:
+----------------+ | | | App data | | | Allocated memory block +----------------+ | size | +----------------+ +----------------+ | size | +----------------+ | ...empty... | +----------------+ | bkwd ptr | +----------------+ | fwd ptr | Free memory block +----------------+ | size | +----------------+
- 因此,在
p
的缓冲区溢出将覆盖q
内存块中的大小值!为什么这是一个问题?
- 当
free()
合并两个相邻的空闲块时,需要操作bkwd
和fwd
指针… - …并且指针计算使用大小来确定空闲内存块结构的位置!
free()
内部:
p = get_free_block_struct(size); bck = p->bk; fwd = p->fd; fwd->bk = bck; // Writes memory! bck->fd = fwd; // Writes memory!
- 空闲内存块表示为 C
struct
;通过破坏大小值,攻击者可以强制free()
在位于攻击者控制的内存中的伪造struct
上操作,并具有攻击者控制的值用于前向和后向指针。 - 如果攻击者知道
free()
如何更新指针,他可以使用该更新代码将任意值写入任意位置。例如,攻击者可以覆盖返回地址。
- 实际细节更加复杂;如果你对血腥细节感兴趣,请访问这里
- 高层次的观点是,栈金丝雀不会阻止这种攻击,因为攻击者正在“越过”金丝雀并直接写入返回地址!
缓解方法 2: 边界检查
- 总体目标: 通过检查指针是否在范围内来防止指针误用。
- 挑战: 在 C 语言中,很难区分有效指针和无效指针。例如,假设一个程序分配了一个字符数组…
char x[1024];
- … 以及该数组中的某个位置的指针,例如,
char *y = &x[107];
- 增加
y
以访问后续元素是否可以?
- 如果
x
代表一个字符串缓冲区,也许是。 - 如果
x
代表一个网络消息,也许不。 - 如果程序使用联合体,情况会变得更加复杂。
联合体示例:
union u{ int i; struct s{ int j; int k; }; }; int *ptr = &(u.s.k); // Does this point to valid data?
- 问题 是,在 C 语言中,指针不会编码关于该指针的预期使用语义的信息。
- 因此,很多工具不会尝试猜测这些语义。相反,这些工具的目标并不像“完全正确”的指针语义那样高远:这些工具只是强制执行堆对象和栈对象的内存边界。在高层次上,这是目标:
- 对于从
p
派生出的指针p'
,p'
只能被解引用以访问属于p
的有效内存区域。
- 强制执行内存边界是一个比强制“完全正确”指针语义更弱的目标。程序仍然可以通过恶意方式践踏其内存而自掘坟墓(例如,在联合体示例中,应用程序可能会写入指针,尽管未定义)。
- 然而,边界检查仍然很有用,因为它可以防止任意内存覆写。程序只能践踏其内存,如果该内存实际上已分配!这在 C 语言世界中被认为是进步。
- 边界检查的一个缺点是通常需要对编译器进行更改,并且必须使用新编译器重新编译程序。如果只能访问二进制文件,则这是一个问题。
有哪些实现边界检查的方法?
边界检查方法 #1: 电子围栏
- 这是一个旧方法,其优点在于简单。
- 思路: 将每个堆对象与一个守卫页对齐,并使用页表确保对守卫页的访问导致故障。
电子围栏:
+---------+ | Guard | | | ^ +---------+ | Overflows cause a page exception | Heap | | | obj | | +---------+
- 这是一种方便的调试技术,因为堆溢出会立即导致崩溃,而不是悄无声息地破坏堆并在未来某个不确定的时间导致失败。
- 重要优势:无需源代码即可运行:无需更改编译器或重新编译程序!
- 你确实需要重新链接它们,以便它们使用实现电子围栏的新版本的
malloc
。
- 主要缺点:巨大的开销!每页只有一个对象,并且您有一个未用于“真实”数据的虚拟页面的开销。
- 摘要:电子围栏可以作为调试技术很有用,并且可以防止堆对象的一些缓冲区溢出。然而,电子围栏无法保护堆栈,并且内存开销太高,无法在生产系统中使用。
**边界检查方法#2:**胖指针
- **想法:**修改指针表示以包含边界信息。现在,指针包括关于生存在该内存区域中的对象的边界信息。
示例:
Regular 32-bit pointer +-----------------+ | 4-byte address | +-----------------+ Fat pointer (96 bits) +-----------------+----------------+---------------------+ | 4-byte obj_base | 4-byte obj_end | 4-byte curr_address | +-----------------+----------------+---------------------+
- 您需要修改编译器并重新编译程序以使用胖指针。编译器生成的代码会在它解引用地址超出自己的
base ... end
范围的指针时中止程序。
示例:
int *ptr = malloc(sizeof(int) * 2); while(1){ *ptr = 42; <----------| ptr++; | } | ______________________________| | This line checks the current address of the pointer and ensures that it's in-bounds. Thus, this line will fail during the third iteration of the loop.
- **问题#1:**检查所有指针解引用可能很昂贵。C 社区讨厌昂贵的东西,因为 C 就是关于速度速度速度。
- **问题#2:**胖指针与许多现有软件不兼容。
- 你不能将胖指针传递给未修改的库。
- 你不能在固定大小的数据结构中使用胖指针。例如,
sizeof(that_struct)
将会改变! - 对胖指针的更新不是原子的,因为它们跨越多个字。一些程序假设指针写入是原子的。
边界检查方法#3:影子数据结构
**想法:**使用影子数据结构来跟踪边界信息(Jones and Kelly,Baggy bounds)。
- 对于每个分配的对象,存储对象的大小。例如:
- 记录传递给 malloc 的值:
char *p = malloc(mem_size);
- 对于静态变量,值由编译器确定:
char p[256];
- 对于每个指针,我们需要对两个操作进行干预:
- 指针算术:
char *q = p + 256;
- 指针解引用:
char ch = *q;
- Q: 为什么我们需要对解引用进行干预?不能只进行算术吗?
- A:无效指针并不总是一个错误!例如,数组最后一个元素之外的一个元素的指针可能被用作循环中的停止测试。应用程序还可以执行一些愚蠢的操作,如:
- 模拟从 1 开始的数组
- 计算 p+(a-b)为(p+a)-b
- 生成稍后检查有效性的 OOB 指针
- 因此,仅仅创建无效指针不应该导致程序失败。
- Q: 为什么我们需要对算术进行干预?不能只进行解引用吗?
- A: 干预算术是允许我们跟踪指针的来源并设置 OOB 位的原因。没有 OOB,我们将无法确定派生指针何时超出其基本对象的边界。
- 挑战 1:我们如何找到常规指针(即在边界内的指针)的边界信息?
- 天真:使用哈希表或区间树将地址映射到边界。
- 好: 空间高效(仅存储正在使用的指针的信息,而不是所有可能的地址)。
- 不好: 查找慢(每次查找多次内存访问)。
- 天真:使用数组存储每个内存地址的边界信息。
- 好: 快速!
- 不好: 内存开销很高。
- 挑战 2:我们如何强制越界指针解引用失败?
- 天真:对每个指针解引用进行检测。
- 好: 哦,它有效。
- 不好: 昂贵。我们必须为每次解引用执行额外的代码!
Baggy bounds 方法:5 个技巧
- Trick 1: 将每个分配向上舍入为 2 的幂,并将分配的起始对齐到该 2 的幂。
- Trick 2:将每个范围限制表示为
log_2(alloc_size)
。
- 对于 32 位指针,只需要 5 位来表示可能的范围:我们只能分配 32 种不同大小的对象:
2¹ = 2 字节,2² = 4 字节...,2³¹ 字节或 2³² 字节
,并且我们存储分配大小的以 2 为底的对数,这是一个介于 1 和 32 之间的数字,因此我们只需要 5 位来表示它。
- Trick 3: 在线性数组中存储限制信息:每个条目一个字节的快速查找。此外,我们可以使用虚拟内存按需分配数组!
- Trick 4:以插槽粒度(例如,16 字节)分配内存:更少的数组条目。
- 这意味着最小分配大小为 16 字节
- …而且,由于指针将对齐到其分配大小边界,这意味着指针的最后 4 位都是零
示例:
slot_size = 16 p = malloc(16); --> table[p/slot_size] = 4; p = malloc(32); --> table[p/slot_size] = 5; \-> table[(p/slot_size) + 1] = 5;
- Trick 4 (续):
- 现在,给定一个已知的好指针
p
,和一个派生指针p'
,我们可以通过检查这两个指针的地址位中是否有相同的前缀,并且它们只在它们的e
个最低有效位上有所不同,其中e
等于分配大小的对数,来测试p'
是否有效。
示例:
C code ------ p' = p + i; Bounds check ------------ size = 1 << table[p >> log_of_slot_size]; base = p & ~(size - 1); (p' >= base) && ((p' - base) < size) Optimized bounds check ---------------------- (p^p') >> table[p >> log_of_slot_size] == 0
- Trick 5: 使用虚拟内存系统来防止越界解引用:在 OOB 指针中设置最高有效位,然后将地址空间上半部分的页面标记为不可访问。这样,我们就不必对指针解引用进行检测以防止错误的内存访问!
示例代码(假设slot_size=16
)
char *p = malloc(44); //Note that the nearest power of 2 (i.e., //64 bytes) are allocated. So, there are //64/(slot_size) = 4 bounds table entries //that are set to log_2(64) = 6. char *q = p + 60; //This access is ok: It's past p's object //size of 44, but still within the baggy //bounds of 64. char *r = q + 16; //r is now at an offset of 60+16=76 from //p. This means that r is (76-64)=12 bytes //beyond the end of p. This is more than //half a slot away, so baggy bounds will //raise an error. char *s = q + 8; //s is now at an offset of 60+8=68 from p. //So, s is only 4 bytes beyond the baggy //bounds, which is les than half a slot //away. No error is raised, but the OOB //high-order bit is set in s, so that s //cannot be dereferenced. char *t = s - 32; //t is now back inside the bounds, so //the OOB bit is cleared.
对于 OOB 指针,高位被设置(如果 OOB 在半个插槽内)。- 通常,操作系统内核位于上半部分,通过分页硬件保护自身。- 问: 为什么越界是半个插槽?
那么作业问题的答案是什么?
char *p = malloc(256); char *q = p + 256; char ch = *q; // Does this raise an exception? // Hint: How big is the baggy bound for p?
Baggy bounds 论文勘误
Baggy bounds 论文中的一些错误:
- 图 3,显式边界检查应该生成如下大小:
size = 1 << table[p >> log_of_slot_size]
- 图 3,优化的边界检查应该是:
(p^p') >> table[p >> log_of_slot_size] == 0
- 图 5 和 18,指针算术代码应该是以下之一:
char *p = &buf[i];
char *p = buf + i;
Baggy bounds(续)
注意: 这些讲座笔记是从 2014 年 6.858 课程网站上发布的笔记中稍作修改而来。
示例代码:(假设slot_size = 16
)
char *p = malloc(44); // Note that the nearest power of 2 (i.e., // 64 bytes) are allocated. So, there are // 64/(slot_size) = 4 bounds table entries // that are set to log_2(64) = 6. char *q = p + 60; // This access is ok: It's past p's object // size of 44, but still within the baggy // bounds of 64. char *r = q + 16; // ERROR: r is now at an offset of 60+16=76 // from p. This means that r is (76-64)=12 // beyond the end of p. This is more than // half a slot away, so baggy bounds will // raise an error. char *s = q + 8; // s is now at an offset of 60+8=68 from p. // So, s is only 4 bytes beyond the baggy // bounds, which is less than half a slot // away. No error is raised, but the OOB // high-order bit is set in s, so that s // cannot be derefernced. char *t = s - 32; // t is now back inside the bounds, so // the OOB bit is cleared.
对于越界指针,高位被设置(如果在半个插槽内越界)。
- 通常,操作系统内核位于上半部分,通过分页硬件保护自身。
- Q: 为什么要为越界分配半个插槽?
那么作业问题的答案是什么?
char *p = malloc(255); char *q = p + 256; char ch = *q; // Does this raise an exception? // Hint: How big is the baggy bound for p?
宽松边界检查是否必须检测每个内存地址计算和访问?
- 不,静态分析可以证明某些地址始终是安全的。但是,某些地址计算是“不安全”的,因为无法静态确定其值的边界。这些不安全的变量需要检查。
处理函数调用参数有点棘手,因为 x86 调用约定是固定的,即硬件期望栈上的某些内容放在特定位置。
- 但是,我们可以将不安全的参数复制到一个单独的区域,并确保复制的参数对齐和受保护。
- Q: 我们是否必须在函数返回时用复制的值覆盖原始参数?
- A: 不,因为在 C 语言中一切都是按值传递的!
宽松边界检查如何确保与现有库的二进制兼容性?
特别是,宽松边界代码如何与由未经检测的代码分配的内存指针交互?
- 解决方案:边界表中的每个条目都初始化为值 31,这意味着相应的指针具有 2³¹ 的内存边界(这是所有可寻址内存)。
- 在经过检测的代码中进行内存分配时,边界条目如前所述设置,并在释放内存时重置为 31。
- 分配给未经检测的代码的内存永远不会改变边界表条目的默认值 31;因此,当经过检测的代码与这些指针交互时,边界错误永远不会发生
例子:
Contiguous range of memory used for the heap +-------------------+ | | | | | Heap allocated by | | uninstrumented |---+ | code | \ Bounds table | | \ +-------------------+ \ +-----------+ | | +->| | | | | Always 31 | | Heap allocated by | | | | instrumented code | +-----------+ | | | Set using | | |--------->| baggy bnds| +-------------------+ +-----------+
- 这一切意味着什么?
- 无法检测在未经检测的代码中生成的越界指针。
- 无法检测传递给库的越界指针何时再次进入边界内。
- Q: 为什么?
- A: 因为未经检测的代码中没有指针检查可以清除高位越界位!
- Q: 为什么要检测
strcpy()
和memcpy()
? - A: 否则,这些函数就是未经检测的代码,并且会遇到我们刚刚讨论过的相同问题。例如,现成的
strcpy()
不能确保目标有足够的空间来存储源!
宽松位如何利用 64 位地址空间?
可以摆脱存储边界信息的表,并将其放入指针中。
Regular pointer +---------------+-------+------------------------+ | zero | size | supported addr space | +---------------+-------+------------------------+ 21 5 38 OOB pointer +--------+------+-------+------------------------+ | offset | size | zero | supported addr space | +--------+------+-------+------------------------+ 13 5 8 38
MIT 6.858 计算机系统安全讲义 2014 秋季(一)(3)https://developer.aliyun.com/article/1484146