2.3 代码复用攻击

借刀杀人

上一回我们说道,攻击者可以通过误导CPU跳转到已有代码页上完成攻击。我们先展示一下这个过程:(Markdown开始不够用了,开始上图)

这里跟上一小节的区别就是,这里的攻击代码(第6页第100行和第6页第101行)是某个代码页的内容。而这似乎意味着,被攻击的程序中本来就需要有恶意代码存在?实际上,并不需要被攻击的程序中有完全是恶意的代码,这里有两种思路:

  1. 通过配置恶意参数使一个非恶意的程序做出恶意行为。记得吗,配置参数也可以通过栈空间,所以攻击者一样可以控制。
  2. 通过串联不同的代码来“组装”成恶意的程序。

首先说第一种:例如,“向用户展示xxx”很明显是一个常用功能,完全可以封装成一个函数,把展示内容作为参数传进去。

Note

细心的话你会发现其实返回地址的位置下移了,其实这会导致攻击失败,正确的做法是99行和100行都上移一行。不过这无伤大雅。

这种方式就是比较初期的ret-to-lib攻击,即return to library。不过这种方式其实能力比较有限,因为它只能通过配置参数+选择函数来攻击。如果我们想做一些复杂的事情呢?那就需要第二种思路了,串联不同的代码。这种思路其实也很简单,顺着刚才我们的攻击,如果CPU执行到第6页的第101行后面是什么呢?是一般函数结束的返回指令,它居然又傻傻的回到栈里找地址准备跳过去了!那我们一定不能浪费这个大好机会啊,我们再写一个地址等着他。也就是说,只要我们跳转的函数会返回,并且我们同时写入多个伪造的函数地址,我们就可以持续的控制CPU执行的代码,这就达到了串联不同代码的能力了。

这种攻击被称为ROP (Return-Oriented Programming)。这就很厉害了,你可以组织任意多个函数来构成某种攻击。有没有注意到?其实最后一次调用写得是“另一段代码”而不是“另一个函数”。因为其实我们并不一定需要一个完整的函数,只需要一段代码的结束是返回指令即可。那么内存中有茫茫多的代码,也就是无数种选择可以作为我们的攻击工具了。很轻松就可以做到图灵完备,白话来说,就是在当前的计算环境中可以执行任意的算法,“为所欲为”。也就是本章开头提到的任意代码执行能力。

思路打开

让我们回顾一下代码复用攻击的关键前提,并且尝试打开一下思路。

  • 首先,由于目前内存已经标记了每个页是代码还是数据,因此直接把代码当成输入塞到栈中的方法不再奏效。
  • 因此,我们需要使用内存中已经存在的代码。而控制CPU使用哪些代码,必须由我们来控制。这就需要:
  • 某些控制流转移的指令(跳转/分支指令)的目的地址由某些我们能控制的数据所决定
    • 这些指令中,最常见的就是返回指令,它从栈上读取地址并跳转过去。
    • 另一类指令,叫做间接跳转指令,它从某个地址上读取一个指令地址并跳转过去(这里“间接”表示地址在某个内存处写着,而不是写在指令本身里面。),如果这个地址也被我们控制(比如也是在栈上),那么也可以用来做攻击,被称为JOP (Jump-Oriented Programming)。
    • 有一些间接调用指令,也是从某个地址读取函数地址并跳转过去。最常见的用法是C++语言的虚函数场景,他从一个对象的虚函数表指针去找一个函数地址,而这个指针本身位于可写内存中,有可能被缓冲区溢出覆盖掉。这种攻击称为COOP (Counterfeit Object-oriented Programming)。
  • 还有一种思路,就是既然我们可以调用库函数,那我们可以直接通过库函数来修改当前页的属性啦,比如让栈可执行,或者将代码段增加可写属性,再将输入的代码拷贝进去。也就是基于代码复用绕过DEP来构建代码注入攻击

这时候我们再来回顾思考题2.2:

思考

思考题 2.2:

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

其实我们看到,即使你区分了数据和代码,攻击者仍然可以用数据来控制代码的流向来构成攻击。这是几乎无法避免的:

  1. 你无法避免让攻击者输入数据:没有输入数据的程序有什么用呢?(当然你可以对用户做限制,只服务你认为安全的用户。但是如何辨别“安全”的用户又是另一个安全课题。)
  2. 你无法避免让代码的使用依赖于数据:因为这极大的限制了编程的自由度,试想不论输入是什么,处理过程完全一样,这样的程序肯定功能很有限,或者效率极低。(不过有趣的是我们有时候也会故意写出这样的程序,我们有缘的话后面的章节见。)

所以更激进的思考方式是:其实这个世界上并没有所谓的数据和代码的区分,数据其实也是代码:你可以理解为每个程序都是一台功能迥异的计算机,而数据只是专门为这台计算机设计的专门的代码罢了。

概念 一般理解 思路打开
CPU 可以执行代码的机器 平平无奇的CPU
程序代码 由代码组成一些功能 一种抽象的定制化CPU
数据 这些功能的输入 为这种定制化CPU定制的指令

例如,某个文本浏览器(记事本.exe)是一个读取文件并显示到屏幕上的程序。但是读取的文件是不是也是某种指令呢?例如第一行只有一个单词“Hello”,那无非是一个定制化指令序列:

  1. 显示H到屏幕上
  2. 显示e到屏幕上
  3. 显示l到屏幕上
  4. 显示l到屏幕上
  5. 显示o到屏幕上
  6. 换下一行

所以越复杂的程序约可能被攻击,因为它对数据的解读更加复杂。例如Office软件中都是可以内嵌代码的,你以为只是一个文档,其实里面内容是有可能被直接解读成代码的。我记得初中的时候用杀毒软件就扫出过一个jpg中含有病毒,当时就觉得很惊讶:原来图片还可以作为病毒。直到自己跟着教程亲自从一个音频文件攻击软件弹出计算器,才解开当年的疑惑。

这种思路其实也被yuange大大在很多年前就提过,他称为DVE (Data Virtual Execution)技术。不过网上很狭义的理解成了就是对于一个CVE的攻击,然后大家就忘掉本来的事实细节,大吹特吹,神乎其神。实际上它并不应该被称为一种技术,而是一个思路,即控制虚拟机甚至普通程序的控制流的数据都可以当做狭义的PC指针来利用,只需要这种理解下该虚拟机的语义足够丰富能够构成攻击。而且很多情况下,这种方式的攻击(并没有劫持控制流)也经常会被看做是程序本身的逻辑bug,而非一种特定的攻击技术。

所以,思考题的答案也非常明确了:冯诺依曼结构确实是攻击成功的一部分原因,但是并不是根本原因,无论数据和代码是否被严格区分,攻击仍然可能(而且永远)存在。攻击存在的本质原因是绝大多数程序的编写者并没有足够的成本去验证程序的所有可能性的集合空间,因此必然出现一些超出编写者初衷的能力。这些能力中,可以构成攻击的,即为漏洞(否则就是单纯的bug),而构成攻击的种种好用的“套路”,就是各种攻击技术。