2.2 区分数据与代码

上一小节,我们总结了内存劫持攻击的几点原因

  1. 首先,因为数据代码返回的地址都是等同地放在内存中的。因此攻击者写入的数据一样能够被当成代码,或者返回地址来使用。这是冯诺依曼结构决定的。
  2. 其次,用户的输入变量没有被检查是否分配的空间(草稿本上的行数)是足够的。
  3. 再次,用户的输入变量超出范围后,能够直接覆盖到返回地址,而返回地址可以指向任意的页数和行号。

这一节,我们从第一点入手,看一看能不能从这一点入手阻止控制流劫持攻击。

思考

思考题 2.2:

有种说法是认为,内存安全问题的根本原因在于冯诺依曼结构没有区分数据和代码(出处)。一些英文出处1 出处2。甚至保密局官网也有类似的表述。表面上看我们当然可以归结于数据与代码的混用,但是这是否是根本问题呢?

数据与代码不可兼得

可以看到,我们最后攻击的时候栈的空间被我们写成了:

第5页

行数 指令/数据
…… ……
96 临时数据c
97 调用查询今天饮料单价返回的结果 b (这里省略了调用这个函数的过程)
98 预留给用户写饮料名称和数量的空间 我叫……
99 第5页第100行 <-- 篡改了的返回地址
100 把银行卡密码都写在第5页第20行
101 向用户展示第5页第20行
…… ……

在第100行和101行,其实我们写入了代码进去,并且通过第99行的地址的改写让CPU跳到此处执行。但是其实我们可以发现,在没有攻击发生时,似乎第五页原本全是数据。那么如果我们不允许CPU在栈上读取指令是不是就可以了呢?

实际上,这就是防御控制流劫持攻击的一种非常成熟的技术:Executable space protection,在不同的场合它可能被叫做 NX bit (no-execute bit) 或者 Data Execution Prevention (DEP)。基本可以试做等同的技术。这种技术比较好的实现方式是通过页表权限的配置,然后在CPU执行代码时自动检查当前页是否有执行权限。当然也不排除一些其他方式的实现,有时候也会被归类为同一个技术。目前的高性能CPU中,无论是服务器还是终端设备,都已经被广泛使用了。

进一步,我们不仅可以对栈上做此要求。其实对于内存中的所有数据,我们一般都不期望他们被当成指令(代码)来执行。记得冯诺依曼结构的定义么?其实我们最初就是把指令和代码区分来对待的。因此一个比较通用的规则是:

Rule

Rule 2.2:

所有的数据,都不应该作为指令被CPU执行;

所有的指令,都不应该被动态改写。

第一点我们已经解释过了。第二点,作为程序员一般情况下逻辑都是运行前固定的,因此一般自然符合;而从安全的角度考虑,攻击者需要的是一个可以写入并且可以执行的内存段,因此如果指令部分允许写入,那么和数据可以执行基本是等价的。因此也要做出限制。因此,这个技术有时候也叫做 W^X ("write xor execute", pronounced W xor X),表示对一个内存页,要么可写,要么可执行,两者不可兼得。

那么如果我们正常使用程序的时候就需要同时即可写,又可执行,这种场景是否存在呢?答案是存在的,这就是JIT (Just-in-time compilation)技术。不幸的是,这种技术对于非常多的场景的性能提升至关重要,因此这里安全与性能的冲突就十分麻烦。前些年CFI技术在学术界还比较火的时候,很多比较棘手的问题都是JIT场景下出现的,这里我们先不离题太远了。

魔高一丈

的确,有了这种代码与数据的区分后,上一小节我们提到的攻击似乎无法奏效了。因为我们的第100行和后面的恶意代码不能够被执行了。如果仍然这样攻击,CPU其实还是会跳转过去,只是刚要开始执行,突然幡然醒悟这可不是代码页:“这指令可不兴执行啊”。

但是,其实CPU还是被你误导过去了,只是没办法被误导到数据页而已。那么这个问题就转化为了能不能靠误导CPU跳转到已有代码页完成攻击呢?

聪明的读者心里已经猜到了:答案是肯定的。这个问题可以通过以下方面来考虑:

  • 首先,一个程序的代码数量是庞大的,其中有一部分可以构成攻击是大概率事件。
  • 如果读者了解动态链接库的背景就会知道,即使是一个非常简单的程序(如果是动态链接的),那么他会动态载入一些公用的依赖库,这些依赖库中有大量的并非这个程序本身所需要的功能,这大大加重了这个问题。
  • 按照前文所述的攻击方式,似乎我们只有一次跳转的机会。但是我们是否有机会多次误导CPU呢?(别忘了误导CPU跳转的代码是我们控制的哦)

这就引出了内存安全的下一个阶段:代码复用攻击,下回见。😄