1. 指针
  2. 缓冲区溢出
  3. 缓冲区保护
  4. 变长栈帧

指针

这里借着底层知识实际上就把指针又复习了一遍, 指针有如下特性:

  1. 指针有类型, 是指向的是哪一类对象, 指针的类型对于机器级程序表示没有任何用处, 只是为了给编译器看和计算出地址
  2. 指针的值是一个无符号整数, 表示内存地址, 如果为0表示NULL, 即没有指向任何地方的指针
  3. 指针使用&运算符创建, 可以应用到任何左值之上
  4. *操作符用于间接引用指针, 其结果是指针的值当成内存地址后取出来的值
  5. 数组与指针联系密切, 数组的名字可以像指针变量一样引用, 但不能修改. 数组的名字可以和普通指针一样进行运算
  6. 指针可以进行运算, 不管是+i还是-i, 都要按照类型的大小对实际地址进行缩放
  7. 强制转换指针的类型, 只是告诉编译器要按照什么方式计算和排布地址, 并不改变指针的值
  8. 指针也可以指向函数, 一定要将(*name)括起来, 其右边是函数的参数元组, 左边是函数的返回值, 然后有一个

缓冲区溢出

所谓缓冲区, 指的是栈上的缓冲区, 在之前的过程中可以知道, 返回值和保存的寄存器值都存储在过程使用的栈上. 如果在分配局部变量的时候出现问题, 让程序读写到超过局部变量的区域, 从而修改了返回地址甚至其他数据, 程序就有可能崩溃.

如果使用精心写入的数据, 让程序跳转到执行被插入的代码, 就构成了缓冲区溢出攻击.

练习题 3.46 缓冲区错误的实际情况分析p

从题目里可以看出, 在执行gets函数之前, get_line函数在栈上保存了%rbx的值, 这个先占掉了8字节, 然后向下分配了16个字节的栈空间. 很显然是用来存储输入的字符串.之后将%rsp也就是*result
复制到%rdi中, 准备调用gets

此时的栈是这样的:

1 00 00 00 00 00 40 00 76     返回地址
2 01 23 45 67 89 AB CD EF     %rbx的值
3 00 00 00 00 00 00 00 00
4 00 00 00 00 00 00 00 00     栈指针-0x10, 表明空出来16个字节的空间

调用gets的瞬间, 会再push到栈里一个8字节长的返回地址, 之后gets要开始工作.gets的地址分配在书里已经介绍了.

1 00 00 00 00 00 40 00 76     返回地址
2 01 23 45 67 89 AB CD EF     %rbx的值
3 00 00 00 00 00 00 00 00
4 00 00 00 00 00 00 00 00     栈指针-0x10, 表明空出来16个字节的空间
5 00 00 00 00 00 40 06 a0     callq get , 放入返回之后的地址

GET进行的瞬间, 会先保存%rbx的值,再分配8个空间

1 00 00 00 00 00 40 00 76     返回地址
2 01 23 45 67 89 AB CD EF     %rbx的值
3 00 00 00 00 00 00 00 00
4 00 00 00 00 00 00 00 00     栈指针-0x10, 表明空出来16个字节的空间
5 00 00 00 00 00 40 06 a0     callq get , 放入返回之后的地址
6 01 23 45 67 89 AB CD EF     也保存%rbx的值
7 00 00 00 00 00 00 00 00     分配8字节空白用于输入字符, 这是根据收书里的描述,一共分配24字节
8 00 00 00 00 00 00 00 00     最下边一行供读取字符

之后读取了0123456789012345678901234这串25个字符, 实际占用为26个字符, 其中最后两个字符为 0x34 0x00.

1 00 00 00 00 00 40 00 76     返回地址
2 01 23 45 67 89 AB CD EF     %rbx的值
3 00 00 00 00 00 00 00 00
4 00 00 00 00 00 00 00 00     栈指针-0x10, 表明空出来16个字节的空间
5 00 00 00 00 00 40 00 34     callq get , 放入返回之后的地址
6 33 32 31 30 39 38 37 36     也保存%rbx的值
7 35 34 33 32 31 30 39 38     分配8字节空白用于输入字符, 这是根据收书里的描述,一共分配24字节
8 37 36 35 34 33 32 31 30     最下边一行供读取字符

可以看到, 执行完毕之后, get_line的返回值被修改成了 400034地址.

除此之外, 可以看到gets函数保存的寄存器%rbx的值变成了33 32 31 30 39 38 37 36. 这个值会被错误的返回给get_line.

练习 3.47

所谓雪橇, 假设系统分配栈的空间在10000个空间里, 每次运行程序的时候随机选择起点. 假如一次可以插入100行雪橇代码, 那么很显然概率就是10000/100分之一, 换成暴力破解, 只需要从0, 100, 200这样不断插入下去即可.

所以用总空间范围除以雪橇的长度, 就得到了尝试次数.

地址的范围从 0xffffb754 到 0xffffd754 , 这个范围互相减一下, 是0x00002000, 这个地址大概是2的13次方.

如果有128字节 = 2的7次方, 那么需要2的13次方除以2的7次方, 等于2的6次方, 也就是大概64次.

缓冲区保护

针对缓冲区溢出攻击, 有很多防护手段, 最常见的三种保护机制有: 栈地址随机化, 栈破坏检测, 限制可执行代码区域等操作. 这些保护由操作系统和硬件来一起完成.

练习题 3.48 防御缓冲区攻击 – 金丝雀值

金丝雀值就是在当前过程的栈底的位置放置一个金丝雀值, 在过程结束之前, 检测该值是否改变 ,如果改变就说明很可能遭受了缓冲区攻击, 不属于该过程的栈也被写入了内容, 程序就出错退出.

GCC在看到有局部的char类型缓冲区的时候, 就会自动加上金丝雀值验证, 除非使用-fno-stack-protector来关闭该特性

看第一个不带保护的编译, 一开始分配了40字节的空间, 然后将x 保存在%rsp+24的位置, 之后准备第一个参数%rdi为栈指针, 很显然栈顶用来存放字符串; 第二个参数为x的地址.

再看第二个带保护的编译, 一开始分配了56字节的空间, 然后通过段寻址, 写入了%rsp+40的位置,显然这是一个金丝雀值. 之后把%eax清零, 又要准备函数, 可以看到把%rsp+16放入第一个参数位置,说明buf从此处开始, %rsp+8放入第二个参数位置, 表示V的地址.

  1. buf的位置在栈顶也就是%rsp处, v在 %rsp+24的位置
  2. buf的位置在%rsp+16的位置, v在%rsp+8的位置, 金丝雀值在%rsp+40的位置

在有保护的代码中, 可以把金丝雀值放在当前过程栈的最深处, 这样可以保证程序还都在当前过程的内部进行操作. 而单个的局部变量比起缓冲区更接近栈顶, 这样缓冲区即使溢出, 也不会破坏局部变量的值.

变长栈帧

在学到这里之前所有的函数编译的时候, 可以看到移动栈指针的大小都是一个常量, 这是因为都可以事先确定好所要使用的栈空间.

在实际中也有很多过程所需要的空间是变长的, 可能运行的时候才会知道, 就不能简单的用常数写死在汇编语言中.

在这种情况下, 除了原本的%rsp寄存器用来保存栈顶的指针之外, 还使用寄存器%rbp来存储帧指针=基指针.

也就是说, 由于保存被调用者保存寄存器和在程序的末尾弹出这些值的操作是相对的固定的, 而不固定的部分就是从局部变量开始的部分. 因此除了时刻知道栈顶在哪里之外, 还额外保存一个过程在刚刚保存完寄存器值的时候的栈指针, 就一直指向那个位置.

这样在栈顶和%rbp之间的内容, 去掉相对固定的参数构造区,就是局部变量的区域了. 而局部变量也不能瞎存, 就规定好对齐到8字节. 这样只需要根据偏移量和%rbp的位置就可以找到局部变量了.

再用最简单的一句话来说, %rbp就指向局部变量区域的栈底部, 固定规则是8字节一个变量, 然后按照便宜量用 %rbp-8i 找想要的局部变量就行了.

在运行结束的时候返回也非常方便, 由于%rbp在整个过程中没变化, %rbp地址前边只有一个保存的%rbp的地址, 因此直接将%rsp设置成%rbp, 栈就回到%rbp的位置, 再一弹出, %rsp回到调用开始处, 而%rbp恢复到调用开始时候的值, 全部栈都被释放掉了.

可以用一条指令leave(无操作数), 来替代上边的这个恢复%rsp和%rbp的位置的两条语句: movq %rbp, %rsp | popq %rbp.

练习题 3.49 分析例子中如何排布数组元素

long vframe(long n ,long idx, long *q){
    long i;
    long *p[n];
    p[0] = &i;
    for (i = 1; i < n; i++) {
        p[i] = q;
    }

    return *p[idx];
}
long vframe(long n ,long idx, long *q)
n in %rdi, idx in %rsi, q in %rdx

vframe:
    pushq   %rbp                    保存%rbp的值, 因为是被调用者保存
    movq    %rsp, %rbp              把当前栈顶指针存到%rbp中, 当前栈顶就是只完成了保存寄存器之后的栈, 所以这个就是基指针
    subq    %16, %rsp               栈顶分配16字节空间
    leaq    22(,%rdi,8), %rax       开始计算偏移量, 用 22+8n算出了一个偏移量
    andq    $-16, %rax              再计算偏移量 = (22+8n) 和 -16 做与运算 .这里发现很有意思, 当 n=0和1的时候,结果是16, n=2和3的时候, 结果是32, 发现当元素每超过2的倍数的时候, 一次性分配16个字节.
    subq    %rax, %rsp              将栈往下移动偏移量, 即按照n的长度分配了空间.
    leaq    7(%rsp), %rax           将%rax更新为此时%rsp的地址+7
    shrq    $3, %rax                舍入到最近的8的倍数
    leaq    0(,%rax,8), %r8         将此时的地址8*%rax偏移量放入%r8vs, 这就是栈顶也就是p[0]的地址.

好吧,这题目看的有点糊涂, 其实主要就是编译器在分配地址的时候使用了对齐的8的倍数的方法.

目前已经把CSAPP看掉200页了, 虽然家庭作业还没有做, 不过至少书都能看懂, 看来还是可以的啊. 后边是浮点数了, 努力一把攻过第三章.