CS161 Computer Security

本文最后更新于:2022年12月14日 晚上

CS161: Computer Security

Security Principles

1. Security Principles

1.1 Know your threat model

A threat model :攻击者是谁?它们拥有什么资源?它们为什么要攻击?

一些对攻击者的设想:

  1. 攻击者可以在不被察觉的条件下干涉系统
  2. 攻击者知道系统的总体情况,比如OS、潜在的软件缺陷
  3. 攻击者具有坚持和幸运的特质
  4. 攻击者有能力和资源执行攻击
  5. 攻击者可以跨系统执行大规模攻击
  6. 每个系统都是潜在的被攻击对象

1.2 Consider Human Factors

安全系统必须对普通用户而言是简单易用的,否则用户很可能为了”方便”去破坏系统。

1.3 Security is economics

没有系统是100%安全的,只要保证系统能抵御一定等级的攻击即可。因为更高的安全性意味者更高的成本,且安全性收益与成本增加是不成比例的。所以安全性通常是一种成本效益分析,应该集中保护最薄弱的环节。安全就像一条链,系统的安全程度取决于最薄弱的一环。

conservative design 保守设计原则:在对攻击者有利的假设下,应该根据可能出现的最坏的安全故障对系统进行评估。如果存在可能导致系统不安全的情况,那么谨慎的做法是寻找一个更安全的系统。

1.4 Detect if you can’t prevent

prevent 预防:阻止攻击发生

detect 检测:了解攻击已经发生

response 响应:针对攻击采取措施

如果你不能阻止攻击的发生,你至少应该知道攻击已经发生了。一旦你知道攻击发生了,你应该找到一种响应的方法,因为没有响应的检测是没有意义的。

在响应时,应该为最坏的结果做好准备,应该始终以一种能回到某种工作状态的方式来规划安全性。

1.5 Defense in depth

多种类型的防御应该分层在一起,使攻击者必须突破所有防御才能成功攻击系统。

1.6 Least privilege

尽量减少给每个程序和系统组件的权限。

它不会降低故障的概率,但可以降低故障的预期成本。一个程序拥有的特权越少,它在出错或被破坏时所造成的伤害就越小。

1.7 Separation of responsibility

没有一个人或程序拥有所有权限。在授予访问权限之前,需要多个方面的批准。

1.8 Ensure complete mediation

在执行访问控制策略时检查对每个对象的每个访问,以确保所有访问都受到监视和保护。实现这一种方法是通过引用监视器,它是一个单点,所有访问都必须通过它进行。

1.9 Shannon’s Maxim

攻击者了解它们攻击的系统。

依赖于其设计、算法或源代码的保密性系统非常脆弱,因为通常很难对动机充分的攻击者保密系统设计。

Kerckhoff’s Principle:即使攻击者知道系统的所有内部细节,密码系统也应该保持安全。秘密密钥应该是唯一必须保密的东西,如果秘密被泄露,更改密钥通常比替换正在运行的软件的每个实例要容易得多。

1.10 Use fail-safe defaults

选择“故障安全”的默认设置,在系统崩溃时平衡安全性和可用性。比如防火墙的default-deny策略。

1.11 Design security in from the start

在现有的应用程序已经进行了规范、设计和实现之后,尝试对其进行安全性改进通常是非常困难的事情。向后兼容通常是非常痛苦的,因为不得不支持所有以前版本的软件中最不安全的部分。

1.12 The Trusted Computing Base (TCB)

TCB是系统中为确保安全性必须正确执行的部分,更小、更简单的TCB更容易编写和审计。

TCB设计原则:

  • Unbypassable / completeness : 没有办法通过绕过TCB来破坏系统安全
  • Tamper-resistant / security : TCB应该受到保护,不被任何人篡改
  • Verifiable / correctness : 可以验证TCB的正确性

与TCB相关的安全原则:

  • Know what is in the TCB
  • make TCB unbypassable, tamper-resistant, verifiable
  • Keep It Simple, Stupid (KISS)
  • Decompose for security, make the TCB simple and clear

1.13 TOCTTOU Vulnerabilities

Time-Of-Check To Time-Of-Use 在检查和使用被检查的状态之间,状态以某种方式发生了变化

Memory Safety

2. x86 Assembly and Call Stack

2.1 Number representation

  • 1 nibble = 4 bits
  • 1 byte = 8 bits
  • 1 word = 32 bits (on 32-bit architectures)
Binary Hexadecimal Binary Hexadecimal
0000 0 1000 8
0001 1 1001 9
0010 2 1010 A
0011 3 1011 B
0100 4 1100 C
0101 5 1101 D
0110 6 1110 E
0111 7 1111 F

2.2 Compiler, Assembler, Linker, Loader (CALL)

运行一个C程序分为4个主要步骤:

  1. 编译器将你的C代码翻译成汇编指令
  2. 汇编程序将编译器中的汇编指令翻译成机器代码(原始位)
  3. 链接器解决了对外部库的依赖。在链接器链接完外部库之后,它输出一个程序的二进制可执行文件,你可以运行它
  4. 当用户运行可执行文件时,加载器在内存中设置一个地址空间,并运行可执行文件中的机器代码指令

2.3 C memory layout

1-dimensional address space

2-dimensional address space

当一个程序正在运行时,地址空间被分成四个部分。从最低地址到最高地址依次为:

  • 代码部分包含程序的可执行指令(即代码本身)
  • 静态部分包含常量和静态变量,它们在程序执行过程中永远不会改变,通常在程序启动时分配
  • 堆存储动态分配的数据。当在C中调用malloc时,将在堆上分配内存并使用,直到调用free为止。堆从较低的地址开始,随着分配的内存越多,“增长”到较高的地址
  • 栈存储局部变量和与函数调用相关的其他信息。堆栈从更高的地址开始,随着调用更多的函数而“向下增长”

Memory Sections

2.4 Little-endian words

X86是小端存储系统。在内存中存储字时,最低有效位字节存储在最低地址,最高有效位字节存储在最高地址。例如,将0x44332211存储在内存中:

Little-endian word format

2.5 Registers

直接在CPU上存储内存。每个寄存器可以存储一个字(4个字节)。与内存不同,寄存器没有地址,而是使用名称来引用寄存器。有三种特殊的x86寄存器:

  • eip:指令指针,存储当前正在执行的机器指令的地址。在RISC-V中,这个寄存器称为PC(程序计数器)
  • ebp:基指针,存储当前栈帧顶部的地址。在RISC-V系统中,这个寄存器称为FP(帧指针)
  • esp:栈指针,存储当前栈帧底部的地址。在RISC-V中,这个寄存器称为SP(栈指针)

2.6 Stack: Pushing and popping

x86 push指令执行两个步骤向堆栈中添加一个值:

  • 通过减小esp来在栈上分配额外的空间
  • 将值存储在新分配的空间中

Before and after of pushing an item onto the stack

x86 pop指令增加esp以删除栈上的下一个值。

Before and after of popping an item off the stack

2.7 x86 calling convention

使用AT&T x86语法(因为这是GDB使用的语法)

  • 目标寄存器排在最后

  • 对寄存器的引用前有%

  • 立即数前有$
  • 内存引用使用(),可以有直接偏移量。如果使用括号而没有直接偏移量,那么偏移量可以被认为是隐式0

    e.g. xorl 4(%esi), %eax is EAX = EAX ^ *(ESI + 4)

2.8 x86 function calls

程序执行从调用方开始,在函数调用后移动到被调用方,然后在函数调用完成后返回到调用方。

e.g. main调用foo函数

Initial stack diagram, with a stack frame for main at the top

  1. Push arguments onto the stack

    Next stack diagram, with argument 2 pushed below the stack frame for main and argument 1 pushed below argument 2

  2. Push the old eip (rip) on the stack

    Next stack diagram, with the old eip pushed below argument 1

  3. Move eip

    Next stack diagram, with the eip moved to the code for foo

  4. Push the old ebp (sfp) on the stack

    Next stack diagram, with the old ebp pushed below the old eip

  5. Move ebp down

    Next stack diagram, with the ebp moved to the esp

  6. Move esp down

    Next stack diagram, with the esp down by 8 bytes

  7. Execute the function

    Next stack diagram, with the 8 bytes previously allocated now having been used for local variables

  8. Move esp up

    Next stack diagram, with the esp moved back up by 8 bytes

  9. Restore the old ebp (sfp)

    Next stack diagram, with the old ebp popped off the stack and the ebp moved to its location

  10. Restore the old eip (rip)

    Next stack diagram, with the old eip popped off the stack and the eip moved to its location

  11. Remove arguments from the stack

    Next stack diagram, with the esp moved up by 8 bytes to now be above the arguments

2.9 x86 function call in assembly

e.g. the following C code:

1
2
3
4
5
6
7
int main(void) {
foo(1, 2);
}

void foo(int a, int b) {
int bar[4];
}

The compiler would turn the foo function call into the following assembly instructions:

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
main:
# Step 1. Push arguments on the stack in reverse order
push $2
push $1

# Steps 2-3. Save old eip (rip) on the stack and change eip
call foo

# Execution changes to foo now. After returning from foo:

# Step 11: Remove arguments from stack
add $8, %esp

foo:
# Step 4. Push old ebp (sfp) on the stack
push %ebp

# Step 5. Move ebp down to esp
mov %esp, %ebp

# Step 6. Move esp down
sub $16, %esp

# Step 7. Execute the function (omitted here)

# Step 8. Move esp
mov %ebp, %esp

# Step 9. Restore old ebp (sfp)
pop %ebp

# Step 10. Restore old eip (rip)
pop %eip

3. Memory Safety Vulnerabilities

3.1 Buffer overflow vulnerabilities

缓冲区溢出是 C / C++ / Objective-C 程序运行过程中最常出现的问题之一

缺少数组或指针访问的自动边界检查,攻击者可以使用越界内存访问来破坏程序的预期行为。

malicious code injection 恶意代码注入

3.2 Stack smashing

栈溢出 借栈中局部变量的布局方式乘虚而入

(虽然栈是向下增长的,但是输入的写是从低地址向高地址的)

假设恶意代码不存在于内存中,我们必须在stack smashing期间自己注入恶意代码。有时我们称这种恶意代码为shellcode,因为编写这种恶意代码通常是为了生成一个交互式shell,允许攻击者执行任意操作。

3.3 Format string vulnerabilities

printf的第一个参数中的格式字符串修饰符的数量与附加参数的数量不匹配时,编译器无法发现该种错误,而函数本身则会根据存在的格式修饰符的数量从堆栈中获取参数。在不匹配的情况下,它将从堆栈中获取不属于函数调用的一些数据。

3.4 Integer conversion vulnerabilities

C 编译器不会警告signed intunsigned int之间的类型不匹配,一个负数转换为unsigned int时会变成大正数

整数溢出问题,0xFFFFFFFF + 5 = 4

3.5 Off-by-one vulnerabilities

差一错误经常发生:<= / <, i=0 / i=1

事实证明,即使是off-by-one错误也会导致危险的内存安全漏洞

3.6 Other memory safety vulnerabilities

其他违反内存安全的例子包括:

  • dangling pointer 悬浮指针(指向已被释放且不再有效的内存区域的指针)

  • double-free 重释放错误(动态分配的对象被显式释放多次)

  • “Use after free” bug (内存中的对象或结构被释放但仍在使用)

    通常涉及到攻击者触发两个独立对象的创建,由于第一个对象的free-after-use,这两个对象实际上共享相同的内存。攻击者可以使用第二个对象来操纵第一个对象的解释

  • heap overflow c++虚表指针是典型

4. Mitigating Memory-Safety Vulnerabilities

4.1 Use a memory-safe language

Java, Python, Go, Rust, Swift 等编程语言包括编译时和运行时检查,可以防止发生内存错误。

使用内存安全语言是100%阻止内存安全漏洞的唯一方法

4.2 Writing memory-safe code

仔细考虑代码中的内存访问,为编写的每个函数定义前置条件和后置条件,并使用不变量证明满足这些条件。

通过防御性编程和使用安全库编写内存安全的代码。

  • 防御性编程。类似于为每个编写的函数定义前置和后置条件,即在代码中添加检查,以防出现错误。但很繁琐
  • 安全库。fgets instead of gets, strncpy or strlcpy instead of strcpy, and snprintf instead of sprintf

4.3 Building secure software

使用工具去分析和批处理不安全的代码

利用执行自动边界检查的运行时检查;雇佣人检查代码;测试代码:模糊测试、使用随机输入、边缘用例、使用Valgrind之类的工具、代码覆盖工具

4.4 Exploit mitigations

编译和运行带code hardening defenses的代码,使常见的攻击更加困难。

4.5 Mitigation: Non-executable pages

使内存的某些部分不可执行。这意味着计算机不应该将这些区域中的任何数据解释为CPU指令,即不允许eip包含内存中不可执行部分的地址。

现代计算机中的分页功能为了内存安全,每一页的属性被设置为writableexecutable。所以当stack smashing attack将恶意代码写入内存时,所在页是不会被当作机器指令被执行的,那么该攻击失效。

这种防御的名称:W\\^X(Write XOR Execute) DEP(Data Execution Prevention) NX bit(no-execute bit)

4.6 Subverting non-executable pages: Return into libc

但是页不可执行不能组织攻击者执行内存中现有的代码

毕竟一个C程序导入的库中有成千上万的指令,而且其中一些函数的参数可以是外部可执行文件名。在x86中,参数是在栈上传递的,这意味着攻击者可以将库函数所需的实参放在栈上正确的位置,这样当库函数开始执行时,它就会在栈上查找实参,并找到攻击者放置在那里的恶意实参。该参数没有作为代码运行,因此页不可执行无法阻止这种攻击。

4.7 Subverting non-executable pages: Return-oriented programming

返回到已经加载的代码并进一步扩展它,那么就可以实现执行任意代码。

return-oriented programming覆盖从RIP开始的返回地址链,以执行一系列等同于恶意代码的“ROP gadgets”,通过执行不同代码的不同部分来执行自定代码。

如果代码导入了足够多的库,那么内存中通常会有足够多的gadget能够运行任何攻击者想要的shell代码。ROP编译器存在于互联网上,它会根据目标二进制和所需的恶意代码自动生成ROP链!

4.8 Mitigation: Stack canaries

当调用一个函数时,编译器会在栈上放置一个已知的虚拟值(stack canary),它应该在函数运行的过程中保持不变。当函数返回时,编译器检查该值,如果变化了,则意味着一些攻击已经发生,那么需要自毁程序及时止损。

该策略的设计思路为,许多常见的stack smashing攻击都通过溢出一个局部变量来覆盖上面直接保存的寄存器(sfp和rip),且这些攻击经常写入内存中连续增加的地址,没有任何空白。

A stack canary located between the sfp and the local variables of a given stack frame

stack canary长1字,通常需要确保包含一个null字节(通常是第一个字节),以抵御string-based memory safety exploits

如果每次运行程序时canary都是相同的值,那么攻击者可以运行程序一次,记下canary的值,然后再次运行程序,用正确的值覆盖canary。

4.9 Subverting stack canaries

stack canary 的不足处:

  • 不能防御栈内存外的攻击
  • 不能阻止攻击者覆盖局部变量的值
  • 一些漏洞可以写入内存的非连续部分

在32位机器上,stack canary通常有24位的值+1个null字节,那么攻击者有$1/2^{24}$的可能性将stack canary修改回正确的原始值;在64位机器上,可能性为$1/2^{56}$,大大提高了安全性。

有时候程序会允许攻击者读内存,然后利用该漏洞记下stack canary的值,在攻击后复原,以此实现值不变的表象。

4.10 Mitigation: Pointer authentication

在64位机器上,尽管理论上地址空间有$2^{64}$B,但硬件上没有这么大的CPU。现代CPU使用42位作为内存地址,剩下的22位未被使用,可以存放安全信息。

当我们需要在栈中存地址时,CPU先将该地址的22位替换为秘密值(pointer authentication code / PAC),然后才将其压入栈。当CPU从栈读地址时,需要检查PAC是否被更改:如果PAC不变,那么将那22位回归到未使用状态然后正常使用;如果PAC改变,那么说明攻击者覆写该值,CPU销毁程序。

可以进一步加强这种防御。由于添加和检查PAC是CPU的工作,我们可以要求CPU为存储在堆栈上的每个指针使用不同的PAC。但是,我们并不希望将所有PAC都存储在CPU上,因此我们将使用一些特殊的数学方法来动态生成安全的PAC。$f(KEY,ADDRESS)$ CPU使用KEY经f运算可以为不同的地址生成唯一PAC。

当攻击者修改地址后,f的运算结果会与PAC不一致;攻击者也不能为注入的恶意代码生成正确的PAC,因为他们不知道KEY。

4.11 Mitigation: Address Space Layout Randomization (ASLR)

stack smashing attack 需要知道恶意代码在内存中的起始位置,而ASLR试图使内存中的地址预测更加困难。

通过ASLR,每次程序运行时,内存的每个部分的开头都是随机选择的。另外,如果程序导入库,还可以随机分配每个库源代码的起始地址。

所以,攻击者不能再用固定地址覆盖内存的某些部分(如rip);通过随机栈起始地址,攻击者不能在不知道栈地址的情况下将shellcode放在栈上;通过随机堆起始地址,攻击者不能在不知道堆地址的情况下将shellcode放在堆上;通过随机代码段起始地址,攻击者不能在不知道代码地址的情况下进行return-to-libc或ROP攻击。

但是,因为段通常从页边界开始的,所以起始地址是页大小的倍数。

4.12 Subverting ASLR

Guess the address. 在32位机器上,通常只有16位用于地址随机化,所以猜地址有$1/2^{16}$的成功率。

Leak the address. 有时程序存在漏洞允许攻击者读取部分内存。栈通常存储绝对地址,如指针和保存的寄存器(sfp和rip)。如果泄露一个绝对地址,那么攻击者可能确定内存中其他部分的相对于该泄漏的绝对地址的绝对地址。

需要注意的是,ASLR通过改变内存段的起始地址来随机绝对地址,但它不改变变量的相对地址。

4.13 Combining Mitigations

synergistic protection: 使用多种防御手段相互增强安全性

ASLR + non-executable pages = 攻击者无法编写自己的shell代码。因为页面不可执行,并且由于不知道代码段地址所以不能使用内存中的现有代码。因此,攻击者需要找到两个漏洞:首先,他们需要找到一种泄漏内存和揭示地址位置的方法;接下来,他们需要找到一种写内存和写ROP链的方法。


本文作者: 31
本文链接: http://uuunni.github.io/2022/10/14/CS161/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!