0%

Sequence Coverage Directed Greybox Fuzzing

本文介绍一篇来自CCF B类会议 ICPC 2019的关于定向模糊测试的文章。

简介

现有的定向模糊测试不足够高效。定向符号执行,比如BugRedux,花很多时间在很重的程序分析和约束求解上。定向模糊测试,比如AFLGo,在运行时做得很好。但是插桩过程中的距离计算花费了很大的开销。

这篇文章提出了一种序列覆盖率的定向模糊测试,能够按顺序到达目标行。并且和AFLGo和BugRedux这两个工具进行了对比实验。

背景

fuzzing是一种高效的挖漏洞的技术。但是fuzzing会花费很多时间在和bug不相关的代码上。因此,有定向fuzzing这样的技术来专门针对某块代码区域进行测试。

然而目前大多数的定向fuzzer都是基于符号执行的白盒fuzzer。他们把可达问题转换为迭代式的约束求解问题,花费了很多运行时间在重量级的程序分析和约束求解上。因此虽然有效,但是不高效。

灰盒fuzzer,像AFLGo这样的,把可达性问题转化为优化问题。然而,AFLGo为了提高运行时的效率,把很多程序分析都放在了插桩部分,导致插桩部分比较耗时。如果用户的计算资源比较有限,这个开销就会成为问题。

所以,本文提出了一种轻量级的定向fuzzing技术。包括在插桩部分也更加的轻量级。

方法

由于文中用了很多简称,所以先记录于此:

  • SS:statement sequence(语句序列)
  • BLS:basic block location sequence(转化为基本块位置序列)
  • TBBET:target basic block execution trace

在每个目标基本块的入口,会添加如下指令:

  • 获取共享内存的指针M
  • 读取M0的值,并为其加1,i.e. i = i + 1
  • 将当前基本块的ID存入Mi
  • 把第二步得到的值再存入M0, i.e. M0=i

在fuzz的过程中,当种子探索到目标基本块的时候,插桩的代码就会执行,因此,基本块的Trace就会被存储在共享内存中。

fuzzing的算法如下,主要分为两个部分,计算序列覆盖率(2-13行)和能量调度(14-22行)。

image-20220131164840556

计算序列覆盖率的算法如下,它的主要思想是要找到种子执行的Trace和预设的序列的最长公共子序列。

其中变量maxSClen记录最大的序列覆盖率的长度。

遍历动态执行的trace的每个基本块ti,如果ti等于当前的目标,curSClen的值加一。

image-20220131164900925

举个例子,BLS:<b0,b1,b3,b5> , TBBET:<b0,b5,b0,b1,b5>,上面计算序列覆盖率的算法其实就是找最长公共子序列。最后的序列覆盖率为3/4,3为TBBET按顺序覆盖BLS基本块的数目,4为BLS集合中的基本块数目。

种子能量调度:用的模拟退火算法来做

温度T初始值为T0 = 1,然后以指数降温。

$T = T_{0} \times \alpha^{k} $

其中α是在0.8到0.99之间的常数。k是温度循环。温度的阈值Tk被设为0.05。也就是说,如果Tk大于0.05,属于exploration stage,也就是探索阶段。这一阶段SCDF会去尽可能地生成更多随机的输入。反之,就是进入exploitation stage,会去尽可能地生成更多高序列覆盖率的输入。

由于fuzzing的限制是时间,时间会被用来调整温度循环k:

$k/k_{x} = t/t_{x}$

其中kx和tx是当温度到达了Tk的温度循环和时间。

然后根据k的关系,可以推导出温度T和时间的关系。

$T_{k}=0.05=\alpha^{k_{x}}$

$T=\alpha^{k}=\alpha^{\frac{t}{t_{x}} \times \frac{log(0.05)}{log(\alpha)}}=20^{-\frac{t}{t^{x}}}$

和AFLGo的模拟退火算法类似,给定一个种子s,它的序列覆盖率是seqcov,那么它覆盖给定行号序列的能力是:

$capcov = seqcov * (1 - T) + 0.5 * T$

最后的能量就为:

$Lenergy = energy * 2.0^{(capcov-0.2)*10}$

结果

用clang static analyzer跑了下libming 0.4.8的几个CVE。

实验部分主要涉及三个方面的应用效果,并且和AFLGo和BugRedux这两个工具进行了对比:

  • True positives verification
  • Crash reproduction
  • Bug exposure

True positives verification

在软件开发周期里,开发者和测试人员通常都会用分析工具来发现bugs或者漏洞。静态分析是一种很热门的分析技术来找到结构化的错误和安全漏洞。由于静态分析并不执行程序,所以会有比较高的误报,需要人工去验证分析结果。由于定向执行的特性,定向模糊测试技术可以用来自动验证bugs。具体来说,静态分析器产生的结果都是一些程序里潜在的bugs或者漏洞,这些可以转化为定向模糊测试的目标语句,然后用定向fuzzing技术去生成测试样例去验证。

作者实验使用了libming 0.4.8,一个处理SWF文件的C语言库。然后用clang static analyzer(CSA)去分析libming,获取一些目标行号。然后把这些行号送给AFLGo和Lolly。在实验过程中,CSA的结果没用特意的去过滤,所以静态器给的结果里可能会有很多误报和不可达路径的结果。

为了评估两个fuzzer的有效性,使用相同的目标行号喂给定向fuzzer,然后看能否触发相同的CVE漏洞。并且统计两个fuzzer的插桩时间和运行时间,重复实验二十次,取平均值。

实验用的CVE如下:

image-20220215172511929

插桩时间的话,AFLGo用了近2个小时,而lolly只用了40多秒,AFL只需要39秒。

运行过程中,设定运行5个小时。结果如下表。TTE表示发现给定漏洞所需的时间。Factor是AFLGo的TTE与lolly的比值。A12是衡量随机算法的一个指标。给定一个性能衡量的指标(比如TTE),A12能够测量lolly得到好的TTE的值的概率比AFLGo高。

image-20220215173946168

Crash reproduction

现在软件系统里因为各种原因可能有潜在的bug或者漏洞。当用户使用软件遇到崩溃时,可以提假崩溃报告到开发者。一个崩溃报告通常包含崩溃的信息,比如memory dumps或者调用栈。定向模糊测试可以利用这些信息来复现crash。这里将lolly和AFLGo和BugRedux进行对比。BugRedux是基于KLEE实现的白盒定向fuzzer。给定一个程序的目标行号,BugRedux可以生成测试样例,按顺序执行这些目标。

这个实验用的测试程序都是BugRedux使用的。设定运行时间为24h,如果工具能够复现bug,就标记为✔,反之就标记为×。sed.fault1这个crash需要同时fuzz两个输入文件,这是AFLGo和Lolly都做不到的。其他的测试程序里,AFLGo和lolly都可以复现。

image-20220216105130990

然后进一步对比了AFLGo和Lolly的插桩时间和运行时间。

image-20220216133747917

Bug exposure

然后用clang去分析Binutils,libxml和libming这些软件。发现了如下漏洞。

image-20220216134043734

总结

文章是一种相比AFLGo更轻量级的定向模糊测试,节省了更多的插桩开销时间。同时也考虑了给定行号的顺序关系。后续作者在此基础上又进行了优化,文章名为:Sequence Directed Hybrid Fuzzing

Regression Greybox Fuzzing

CCS 2021 的一篇关于模糊测试的文章,主要是利用git commit 的日志来去指导模糊测试,去测试最近变化过的地方。

简介

What you change is what you fuzz !在OSSFuzz里生成的报告里,大约77%的漏洞报告都是回归型的。对于一个新加进来的项目,每天可能会有2-3个漏洞报告。在刚开始这段漏洞爆发期过去后,后续每周有漏洞的频率大概是一周会有3-4个bug。这种常数的增长速率只能用回归率来解释。作者画了个漏洞是回归的概率与漏洞数目的图,从图上可以看出,后期产生的bug,有非常大的概率是回归的。漏洞是回归的意思就是漏洞是由最近的修改导致的。

image-20220120202319728

文章基于这个发现提出了一种回归灰盒测试。能够针对最近变化频繁的代码进行测试。然而,对于任意的软件,是不可能对于每个commit进行单独测试。因此,回归灰盒测试能够同时fuzz 所有的commits,但是最近提出的commit具有更高的优先级。作者观察到大部分的代码都不会变化,而且相当的老。所以,实现了一种方法去加强感兴趣代码的fuzz力度。同样也修改了能量调度算法,并且引入了蚁群算法来分配更多的能量到那些会生成更多有趣输入的种子上去。

背景

现有的工具可以对单个commit进行fuzz,但是如果只对单个commit进行fuzz,可能会丢失掉没有测试的commit、跨越多个commit引起的漏洞。例如,早期的commit引入了一个漏洞,后期的commit用了这个数组,导致了漏洞,这种情况只对一个commit进行fuzz的AFLGo就没有办法解决。

而且对所有commit一次次地去fuzz,也是非常耗时的。

方法

改了三个部分:

  • efficient fitness function
    • 每个基本块都是目标,只是权重不同
    • 插桩实现,能够计算每个基本块的权重,运行时开销很小
    • 运行时,基于输入经过的权重,计算每个输入的fitness 值,并且用归一化处理。
  • amplifying weak signals
    • 只有一小部分的基本块频繁地变化,因此,如果直接计算输入经过的基本块的平均权重的话,高频的基本块能起到的作用很小
    • 因此,采用对数和倒数的方法来加强这部分权重
  • byte-level power schedule
    • 发现seed中的大部分字节对结果没啥影响
    • 采用蚁群算法分配更多的能量到相应的字节

Code History-based Instrumentation

理论上,要计算一个输入执行的代码有多老或者有多经常被修改,我们需要在执行的时候进行计算。计算的具体实现就是对代码进行插桩。插桩的算法如下:

image-20220121111231089

对于每个基本块BB,计算基本块的age和churn,然后使用AMPLIFY函数进行放大。修正全局变量阿尔法和count的值。

第三行的LASTCHANGED,计算基本块的age,age表示这个基本块最近被修改的时间。具体来说,是使用git blame去识别给定行L的commit C,然后提取C的日期,计算从commit C到当前commit经过了多少时间。基本块的age就是基本块里所有行的平均age。

第四行的NUMBEROFCHANGES,对于所有的基本块,RGF会去计算churn值。churn值指的是基本块被修改的频率。简单来说,就是计算commit C’ 的次数。C‘ 指的是连续两个版本R和R’的语法不同。

第五行的AMPLIFY,是放大age和churn的函数。作者观察到有很大一部分的基础基本块是不会被修改的。如果我们只计算基本块的均值,就会导致一些有趣的基本块的值特别小。最近的修改或者频繁地修改都会被忽略掉。作者做了几个实验来确定amplify函数,认为下面的amplify函数是最有效的。

$age’ = \frac{1}{age}$

$churn’ = log(churn)$

对于6-8行的inject,对于所有的基本块BB,插桩pass会在基本块的末端插入新的指令。主要是用来累加放大后的age和churn,以及计算执行的基本块的数目。

Simulated Annealing-based Power Schedule

回归灰盒测试(RGF)是一个优化问题,需要平衡exploration和exploitation。如果RGF只做探索,而不使用age和churn值,就和普通的灰盒测试没什么区别了。如果RGF只关心最优化的种子,就会失去探索其他漏洞的机会。全局优化技术就是用来处理这个trade-off的。

这篇文章处理这个全局优化问题,采用的是模拟退火算法。开始于探索阶段,然后迅速地切换到利用阶段。在灰盒测试中,,我们可以使用能量调度来调整这些搜索参数。能量调度对所有的种子赋予能量,一个种子的能量决定了用这个种子fuzzing的时间。

下面的种子调度算法,实现了:给定插桩的程序,种子库和输入,计算输入的能量。

image-20220122140823785

第1行是前面插桩计算的阿尔法值,然后第2行对他求每个基本块的平均。第3行做归一化,将阿尔法值归到0-1之间。

第4行的ω是来处理探索(exploration)和利用(exploitation)的平衡的。其中$T_{exp} = 0.05^{t.selectd}$是温度函数。在探索阶段,过低和过高的fitness的种子所获取的权重是一样的。比如,当一个种子没有被选择过,ω等于0.5,而当种子被选择了很多次之后,ω会接近归一化后的阿尔法值。

第5行是能量调度算法。基于前面的ω值来计算种子t的能量。其中$p_{afl}$指的是这个种子之前赋予过的能量。r是用来确定能量p的范围在$[2^{-r},2^{r}]$之间。

Ant Colony Optimisation (ACO)-based Byte-Level Power Schedule

污点分析经常用于分析哪些输入字节能够影响给定的代码位置,比如angora,vulscope等等fuzzer都用到了这种方法。然而,在这个场景里,不适合用污点分析。一是,污点分析的性能开销比较大。二是,这篇文章没有给出具体的位置给污点分析用来分析。

本文采用的是迭代式学习输入字节的分布,看哪部分的输入字节会让程序执行到有趣的地方。简单来说,就是计算每个字节的得分,分数越高越有可能到达感兴趣的地方。

image-20220122151209957

要学习输入字节的分布的难点在于后续fuzzing迭代的字节选择依赖于前期fuzzing迭代时的字节选择。比如,在早期的迭代过程中,我们选了前面三个字节,并且成功了,就会导致前三个字节的得分变高,并且不断重复,就会导致前三个字节的权重变得很高。

为了解决这个问题,文中提出了一种蚁群优化算法。蚂蚁在他们的路径上留下信息素,使得其他的蚂蚁会沿着这条路径一块走。成功的路径会有更多的蚂蚁走,并且留下更多的信息素。然而,随着时间的过去,信息素会逐渐挥发,然后就给了新的机会去发现更好的路径。这里也类似,RGF基于输入的fitness,给输入的某个字节一个分数。然后,每个时间间隔过去后,所有字节的分数都会乘以0.9来模拟逐渐挥发。

具体的步骤可以分为以下四步:

  1. 当一个新的输入t加入到种子语料库中
    • 计算种子t的fitness分数(归一化的阿尔法值)
    • 将种子t的所有字节的分数置为0
  2. 通过fuzzing种子t生成了一个新的输入t‘
    • 计算输入t’的fitness分数
    • 找到t与t‘不同的字节
    • 如果t’的fitness分数高于t,就累加一定的分数到这些不同的字节上
  3. 当种子t被选为fuzzing时,以概率来fuzz每个字节,字节的分数越大,变异的概率越大。
  4. 在固定的时间间隔,RGF对所有的字节乘以小于1的数,来降低所有旧字节的分数。

结果

文章的假设是去fuzz最近变化的代码和变化频繁的代码,能够提高挖到回归漏洞的效率。因此,实验也是去验证这一点。所以,文章提出了三个研究问题。

RQ1:这种指导方式能不能提高灰盒测试工具的效率。(与AFL做对比实验)

RQ2:哪种启发式的指导的贡献更大?

  • guided by age
  • guiided by churn
  • guided by both

RQ3:crash的位置到底和最近变化、频繁变化这两个因素有没有关系?

RQ1的结果:1部分是AFL和AFLChurn在第一个cycle就找到了漏洞。而aflchurn需要跑完第1个cycle的指导作用才有用。3部分是两个都没跑出来crash。所以能看的是第2部分。看第2部分的结果,可以看到aflchurn是要优于第AFL的。

image-20220122153805252

RQ2的结果:从表格上看,没有说哪方赢了很多。从某些特例来看,其中一者具有更好的效果。所以才需要将这两种启发式方法结合起来。

image-20220122154302853

RQ3:给了很多图表,这里就简单说下结论:栈轨迹里的许多元素要比其他地方的代码变化得更多。在很多情况下,有一两个栈元素在代码中基本没有什么变化。大部分的元素都变化了相当多次。并且regressions和non-regressions之间并没有太大的区别。

总结

看了这篇文章,基本上理解了模糊测试的文章需要改哪些部分可以应用到别的一个场景。(插桩、能量调度、fuzz哪些输入字节)

总体来说,是一篇原理不是很难,但是思路很清奇的文章。而且文章开源了代码和实验框架可供学习,太赞了!

One Fuzz Doesn’t Fit All: Optimizing Directed Fuzzing via Target-tailored Program State Restriction

论文基本信息

发表会议或期刊(简称,CCF级别):ACSAC 2022
论文标题(论文简称):One Fuzz Doesn’t Fit All: Optimizing Directed Fuzzing via Target-tailored Program State Restriction
论文作者所在团队:Hexhive
论文一句话概述:论文提出XX方法,解决了XX问题。
论文实验情况:实验工具(是否开源),实验测试集(是否开源),实验环境,实验规模,简要过程

开源于:https://github.com/HexHive/SieveFuzz

论文概述

众所周知,fuzzing是发现软件漏洞的一种实用技术。然而,有时候,我们只需要关注特定的代码区域有没有漏洞,比如漏洞复现,补丁或者回归测试。这种需求催生了定向模糊测试。给定一些目标位置(行号),定向模糊测试通过距离最小化策略来让fuzzing往这些地方探索。距离最小化策略是找到离目标近的测试样例,然后随机地变异这些测试样例。然而,这个策略被应用到所有的测试样例,不管样例是否到达了目标,这就会导致浪费很多时间计算没有到达目标的测试样例的距离。要加速定向模糊测试就需要对目标可达的路径进行优先级排序。

因此,本文提出了tripwiring(绊绳,一种陷阱,可以让路过的东西摔倒),一种轻量级的技术过滤不会到达目标的路径。通过限制模糊测试探索只会到达目标的路径,可以减小模糊测试的噪声,从而减小了插桩和距离最小化策略的开销,能够提升定向模糊测试器高达99x的测试样例的吞吐量。

实验评估方面,与AFLGo,Beacon和AFL++进行了对比,在9个benchmark上触发的漏洞要比这些工具多47%,也快117%。

研究背景

现有的定向模糊测试使用距离最小化策略来让fuzzing探索预定义的目标位置。为了实现定向性,距离最小化策略计算每个生成的测试样例到目标位置的距离。然而距离测量是在运行时,并且是对所有种子实施的。而大多数的种子并不会到达目标,就会引入过多的开销。

而且,距离最小化策略并不适合那些很容易到达的目标。对于这些容易到达的目标,距离最小化的开销很大。没必要对无法到达目标的路径进行分析。

相关工作

文章主要介绍了两类工作,一个是定向模糊测试,另一个是提高fuzzing性能的。

定向模糊测试这块提到了AFLGO,Hawkeye,ParmeSan,UAFuzz,UAFL,BEACON。

提高fuzzing性能的提到了AFL-Dyninst,AFL-QEMU,RetroWrite,ZAFL。

原理

image-20230108173457057

宏观层次上,用传统的控制流和路径检测方法识别出到达目标位置的路径,然后修改覆盖率导向的模糊测试让其只探索这些区域,从而实现定向性。

文章的核心思想是认为定向模糊测试浪费过多的时间在目标不可到达的路径上,所以可以先发制人地终止模糊测试探索目标不可达的区域。这么做有两点好处:

  • fuzzer里超过90%的运行时间就不会花在计算代码覆盖率和目标距离上。
  • 过滤掉这些路径,fuzzer就不会浪费资源在处理这些测试样例上。

识别不可达区域

识别不可达区域的算法如下,首先用目标位置的入口结点来初始化worklist,同时allow list也初始化为空集。然后,对于worklist中的每个元素,做如下操作:

  • 在过程间控制流图上,找到每个节点的来边
  • 对于每条边,检查它的源和对应于过程间控制流图上的节点
  • 使用调用图检查从源能否到目标,如果可以,就把源加在allow list里,对应节点加在worklist里。

所有在allow list外的节点就会被判定为不必要的。由于过程间控制流图对上下文不敏感,我们的分析可能会多包含一些目标相关的代码区域。为了缓解这个问题,整合了函数可达性分析的结果。

image-20230108185845593

同时,由于使用静态分析来生成ICFG和CG,另一个挑战是处理间接跳转。求解间接跳转的静态方法有指向分析和值集分析,但是这种静态的方法会over-approximate候选的目标。从而不能保证fuzzing的定向性。

为了缓解这个问题,文章用每个新覆盖的间接分支来更新调用图。有了新信息后,再重新用上面的分析方法来更新可达的区域。虽然这种重分析技术会增加运行时开销,但是带来的好处是覆盖率的指数下降,所以不会有很明显的性能下降。

虽然是动态解决间接调用问题的,目标位置在一开始的可达性分析里可能会被忽略。然而,作者观察到用其他模糊测试器生成种子的trace来进行分析就够了。

实现

基于AFL++实现。为了实现按需的可达性分析,实现了fuzzer和分析模块的client-server的通信机制。静态分析基于SVF框架。

总体框架可以用一个状态机来表示。

INIT:一开始,确定target是否在初始的ICFG和CG上可达。如果是不可达,有可能静态没有识别到一些间接跳转的。因此转到EXP状态去探索。利用EXP过程跑的动态信息来更新ICFG和CG。不过要尽可能避免EXP过程,所以一开始会利用初始种子来更新这些间接跳转。

EXP:如果目标是不可达的,就使用非定向,non-tripwired的模糊测试来多样化可选的种子。一旦发现有一条路径可以到达目标,就退出这个阶段,跳到FUZZ阶段。

FUZZ:一旦确定target是可达的,tripwired-directed fuzzing就开始了。

image-20230108195707674

为了随时更新间接跳转,作者实现了按需的可达性分析。也就是引入了通信机制,一旦发现新的间接边,就暂停fuzzer,等待静态分析模块反馈结果。不过这个切换实际运行时并不多。

提前终止部分的实现是:分配给每个代码区域一个唯一的数字ID,然后用ID去hook每个区域调用运行库的开始。然后把这个库链接到被插桩的程序,利用它来强制终止执行。

同时也会维护一个动态的bitmap,每个bit和每个ID映射。如果bit是unset,和这个bit相对应的函数就不会被执行。间接跳转追踪的部分,对所有间接跳转分支进行插桩,去获取这些边的目的地。

实验结果及分析

主要回答这三个问题:

  • RQ1:Is tripwiring effective and fast at restricting fuzzing-reachable search space?,
  • RQ2:Do the benefits of tripwiring improve directed fuzzing effectiveness and speed?
  • RQ3:Are there properties that make a target location well suited to tripwiring?

对于RQ1,平均消除了29%的空间。这让我有点意外,比我想象中的少很多。表格中也给出了初始分析的时间和重分析的时间,开销是比较小的。

image-20230109195140486

对于RQ2,触发漏洞的时间比AFLGO快3.49x,比AFL++快8.36x,比Beacon快2.82x

image-20230109195933269

image-20230109200808145

对于RQ3,作者得出两个结论:

  • 可以用“不能到达目标位置的代码数量”来识别目标是否disjoint。也就是脱节。
  • 对于这类脱节的目标,用文章的方法要比距离最小化的方法好。

自己的感受

这篇文章的思路和Beacon有些相似,但是,是通过动态的方法解决的。

而且实现上有几个点挺有意思的。比如,动态方法解决间接调用追踪的问题,通过链接库来让程序提前终止等等。

并且发现开源的仓库给的脚本特别详细,所有的数据应该是都开源了。有机会看一看他的仓库。

最后就是这篇文章写作上用词用得比较巧,有很多表达是值得收藏的。

Learning to Explore Paths for Symbolic Execution

CCS 2021年的一篇关于符号执行路径选择优化的论文。

简介

符号执行是很强力的技术,能够生成测试样例来让程序执行想要的路径。然而,符号执行的扩展性受限于路径爆炸问题。因此,要提高符号执行的有效性,就要让符号执行能够选择正确的符号状态。

文章提出了一种基于学习的技术,能够有效地选择合适的状态来缓解路径爆炸问题。learch能够直接估计每个状态对最大化覆盖率这一目标的贡献。而不是像传统的启发式方法基于简单的策略。而且,learch利用了现有的启发式方法来生成训练数据和提取特征。因此,learch从其他专家设计的启发式方法中受益颇多。

在klee中实现了learch,同时评估了许多真实程序,结果表明,learch是有效的:能够覆盖更多的代码,比现有的启发式方法或者启发式的组合能检测出更多的bug。同时,我们也发现利用learch生成的测试样例作为fuzz的初始种子能够找到更多的路径和bug。

看了摘要以后有几个比较感兴趣的问题:

  1. 什么叫做迭代式学习?
  2. 他是怎么生成数据集和提取特征的?
  3. 最后这个作为fuzz的初始种子的实验是怎么做的?

Motivation

文章阐述motivation的思路是:符号执行根本的问题(路径爆炸),现有的启发式方法无法解决。

路径爆炸体现在这张图,候选的符号执行状态非常的多。

image-20211213205132800

现有启发式的局限体现在:没有任何的启发式方法要优于其他的启发式方法,每种启发式方法都覆盖了程序的各个部分,可以说是各有优缺点。组合起来的启发式方法效果不错。

image-20211213205224447

基于组合启发式方法效果不错这一点,作者认为需要用一种学习的策略来有效地组合这几种启发式方法。

方法

文章的核心是使用机器学习的回归模型评估状态的对于最大化覆盖率这个目标的贡献值。基于这个模型,符号执行选择贡献值最大的状态。

文章采用的是迭代式学习策略,在每轮迭代中,用符号执行对训练程序跑一下。一开始使用不同的状态选择策略来生成一些测试样例。然后对每个已经探索过的状态,我们提取他的特征(包括启发式方法用到的那些特征),然后计算他们的reward,也就是前面提到的贡献值。

这就生成了监督的数据集并且俘获到了每个策略的行为。最后,再训练回归模型来让模型能够准确地对每个状态进行估计。

image-20211213192713177

方法

符号执行的目标:在最短的时间达到最大的覆盖率

image-20211213211024776

reward的计算:从state状态生成的测试样例的覆盖率除以在状态d的时间

image-20211213211048353

标签是reward,计算方式是覆盖的行除以每个状态的时间。

设计的特征:

image-20211215183459096

标记了五种安全违反:

  • 整数溢出
  • Oversized shift
  • out-of-bounds array reads/writes
  • pointer overflow
  • null deference

实验结果

主要回答这么几个问题:

  • Can Learch cover more code than existing manual heuristics?

  • Can Learch discover more security violations?

  • Can Learch generate better initial seeds for fuzzing?

  • What is the impact of Learch’s design choices?

数据集选择:

  • coreutils是许多符号执行工作评测的程序集
  • 程序的输入格式各种各样,并且广泛应用在fuzz和符号执行这些工作里

对比的启发式方法:

  • rss:random state search
  • rps:random path search
  • nurs cplint and nurs depth:non-uniform random search
  • sgs:subpath-guide search
  • portfolio

覆盖率

image-20211215185043665

发现的安全违反

image-20211215185301761

结合模糊测试的效果

image-20211215185335767

影响(挖到的真实漏洞)

发现了46个潜在漏洞,并且13个被认定为是真的bug。

相关工作

主要提到了符号执行、混合测试和机器学习应用到程序分析的一些工作。因为这方面都有些了解了,这里就不再详细介绍了,感兴趣的可以去看论文。

SyML: Guiding Symbolic Execution Toward Vulnerable States Through Pattern Learning

RAID 2021

开源:https://github.com/ucsb-seclab/syml

背景

在二进制程序里探索很多的执行路径对于发现新的漏洞是很重要的。动态符号执行能够触发复杂的输入条件,并且能够精确地探索程序,同时提供crash的可复现性和语义信息。然而,要扩展这种分析方式到复杂的二进制程序里是很困难的。目前的方法有很严重的路径爆炸问题。尽管现在有很多方式提出来解决这个问题,但现阶段这个挑战仍然很难解决,并且通过这种技术发现的漏洞都很少。

这篇文章的工作重心就是尝试去解决符号执行的路径爆炸问题。

方法

数据集(放实验讲吧)

选择CGC数据集,包含232个漏洞程序,其中有超过400个漏洞输入能够触发各种漏洞。然而,由于DSE引擎的问题,有些漏洞无法分析。最后就只剩下包含120多个漏洞的75个二进制可以用。

作者还列举了选测试集的一些标准,包含volume、variety、consistency、complexity、confidence这些指标。

整体框架

整体可以分为特征提取、数据清洗、训练模型、导向符号执行这几个部分。

image-20211206153130549

特征提取

特征提取包含三个步骤:

  1. 具体追踪
    • 用会崩溃的输入在QEMU里运行漏洞二进制,收集导致crash的trace。
  2. 静态分析
    • 收集全局的静态信息,包括CFG等,来支持后续的分析
  3. 动态符号追踪
    • 然后沿着记录的trace去符号执行

提取的特征如下:

image-20211207195051499

数据清洗

去除缺失值、异常值、离群值和重复值

训练模型

采用四个指标来评估分类模型:

  • F1-score:
  • 准确率
  • trace覆盖率
  • 判分时间

导向符号执行

由于预测不一定准确,并且搜索空间很大,因此需要使用一些策略来最大化利用状态预测的结果。

探索策略如下:

image-20211206162035958

性能考量

  1. 初始开销。为了避免在探索阶段引入额外开销,这里提前从上下文信息里计算某些特征来作为初始开销,比如connectivity,centrality,function_size,function complexity,components information,community partition这些。
  2. 特征数目。特征数目太多会导致计算开销增加或者模型准确率降低。因此,使用information gain来手动检查所有的特征,确保他们都是相关的特征。

结果

模型准确度和特征评估。

选择了几个常见的机器学习模型来进行训练。所有的模型都是调用scikit-learn来实现的。下面的数据都是使用交叉验证来验证的,在每次验证回合,将一个二进制取出来,然后其他的二进制用来训练,得到F1,准确率等指标。下面的表格基本说明他提的特征是没啥问题的。

实际上他的特征也不是一次就提取正确的。这个结果应该是他把系统调用等一些特征移出去得到的结果。最后考虑模型下面的指标,选择了XGBoost作为模型来预测状态。

image-20211206201122540

和现有工作做对比

作者将自己的工作和现有的先进路径选择策略做了对比。这些策略包含KLEE Coverage(2008),KLEE Random(2008),AEG Loop Exhaustion(2011)。感觉都挺老的,说明现在没有什么更先进的路径选择策略。

从结果来看,作者的方法更加通用,能测试更多的有漏洞的二进制。但也不是所有的都能跑通。有的时间上也比传统的启发式方法要更慢。

image-20211206202852245

作者也整理了一下时间维度的对比。随着时间的增加,作者的方法能跑出更多的crash。在相同时间内能跑出的crash的数量和其他传统方法差不多。另外,这种路径选择方式更加通用,不局限于特定的漏洞种类。

image-20211206203433675

score 分析

有点不太清楚这个score是什么,是如何计算的?

在探索时分析syml的score的分布情况,期望在探索到漏洞时候分数增加,反之则减小。作者发现这里分数分布和漏洞发现的模式很相同。在刚开始,分数很不稳定,也偏低。分数在崩溃点之后会进入一个谷底,然后又是一段平稳期。随后,分数又变高了,意味着程序在离崩溃点很近的位置或者其他有趣的地方开始探索了。

image-20211206205106545

应用到真实程序里

作者将方法应用到三个真实的linux二进制里,asp2php,o3read,ringtonetools。这三个程序代码量都不大,万行左右的代码量级。

在真实程序里测试的结果不是很理想。数据都偏低。因此,作者将CGC上训练好的模型和linux程序一起学习,性能得到了一定的提升。但还是属于一个比较偏低的水平。

image-20211206210615013

横坐标表示程序执行的情况。0表示刚从程序开始运行,100表示运行到漏洞点了。

image-20211206210926417

讨论

作者认为这是第一个机器学习方法应用到符号执行引擎里的分支选择里。刚好今年的CCS 2021年也出了一篇类似的,通过提取分支的特征,来训练机器学习模型,从而进一步去指导符号执行。

最大的缺陷就是在真实的linux程序上的效果很差。Learning to Explore Paths for Symbolic Execution

结论

将符号执行里的分支选择问题,当作是二分类问题来看待。

利用现有的PoC的trace,沿着trace进行符号执行来收集特征信息,训练好模型,利用训练好的模型来选择状态。

相比启发式规则,更加通用。能够挖掘漏洞的类型取决于训练的数据集。

研究方法看起来相对复杂。但理清了思路感觉还是比较有创新性的。

如果能够应用到更多的真实程序里,应该能够发顶会。


其他:

RAID 属于安全领域的四小顶会。也算是个水平比较高的会议了。

讲这篇B会是想让大家感受下B会的论文的水平是怎么样的。

总的来说,B会的论文,可能实验效果在真实程序上不理想,但至少实验是相对来说比较完整的。各个小实验里能够支撑论文的点,并且都有些很有意思的发现。

angr模拟执行源码解析

前言

angr是很有名的二进制符号执行工具,网上有许多关于angr的源码解析的文章。但是好像还没有关于angr模拟执行模块的解析。而模拟执行部分也是angr中相当重要的一个部分。因此,本文将解析angr模拟执行部分的源码,来帮助大家了解angr模拟执行的基本原理。

概述

当我们用angr去符号执行的时候,最基本的几个操作如下面代码所示:导入代码(第1行)、导入二进制(第2行)、确定初始状态(第3行)、构建simulation_manager对象(第4行)、符号执行(第5行)。而到底angr是怎么符号执行的呢?因此就需要深入simulation_manager的源码去一探究竟了。

1
2
3
4
5
import angr
p = angr.Project("xxxx")
entry_state = p.factory.entry_state()
simgr = p.factory.simgr(entry_state)
simgr.explore(find=xxxx)

simulation_manager这个类位于angr/sim_manager.py文件里。

simulation_manager是angr中模拟执行管理器。主要的操作对象是程序的状态对象(sim_state)。状态都被放在stash里,可以往前执行、过滤、合并或者移到别的stash里。stash里可以理解为是放状态的一个列表,stash有这么几种,分别表示状态的状态:

  • active:保存接下来要执行的状态
  • deadended:由于某些原因不能再继续执行下去,比如没有合法的指令、下个节点的状态不可解,或者有一个非法的指令指针。
  • pruned:当使用lazy_sovles的策略时,只有在必要的时候才去检查状态是否可解。当发现一个不可求解的节点后,将其后面的节点都优化掉,放在pruned里。
  • unconstrained:比如PC被用户数据或者其他类型的符号变量所控制,导致不知道执行哪个指令。
  • unsat:不可求解的状态。比如,输入同时为AAAA和BBBB。

接下来看看源码,源码中提示我们看simulation_manager的三个重要方法:step、explore、use_technique。

use_technique

angr里有自带很多启发式的路径探索方法。这个函数就是让simulation_manager能够调用外部写好的启发式路径搜索方法。官方给出的几个样例里,除了经典的深度优先搜索、也有检测内存使用情况、CMU论文里的Veritest(合并循环的状态)等等策略。

代码首先先判断tech是否属于ExplorationTechnique这个类。然后setup方法开始初始化。然后把tech防到techniques列表中去,这也意味着可以使用多种策略。这里的hookset暂时没有看懂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def use_technique(self, tech):
"""
Use an exploration technique with this SimulationManager.
Techniques can be found in :mod:`angr.exploration_techniques`.
:param tech: An ExplorationTechnique object that contains code to modify
this SimulationManager's behavior.
:type tech: ExplorationTechnique
:return: The technique that was added, for convenience
"""
if not isinstance(tech, ExplorationTechnique):
raise SimulationManagerError

# XXX: as promised
tech.project = self._project
tech.setup(self)

HookSet.install_hooks(self, **tech._get_hooks())
self._techniques.append(tech)
return tech

explore

先来看看看explore函数的参数,有stash,n,find,avoid等参数。explore函数的功能是从某个类型的stash,比如active,开始寻找满足find条件的,需要避免avoid条件的状态,直到找了n次,或者找到了num_find个状态。然后找到的状态都会塞到find_stash里,筛选的状态都会放在avoid_stash里。

其中find和avoid参数可以是一个地址,或者一堆地址的集合或者列表,甚至可以是一个函数,以状态为输入,输出True 或者False,来表示该状态是否是要寻找的状态。如果angr的CFG作为cfg的参数并且find是一个地址或者一个列表或者集合,那么到达不了目标状态的状态就会先把提前筛选掉。

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
34
35
36
37
38
39
40
41
42
def explore(self, stash='active', n=None, find=None, avoid=None, find_stash='found', avoid_stash='avoid', cfg=None,
num_find=1, **kwargs):
"""
Tick stash "stash" forward (up to "n" times or until "num_find" states are found), looking for condition "find",
avoiding condition "avoid". Stores found states into "find_stash' and avoided states into "avoid_stash".
The "find" and "avoid" parameters may be any of:
- An address to find
- A set or list of addresses to find
- A function that takes a state and returns whether or not it matches.
If an angr CFG is passed in as the "cfg" parameter and "find" is either a number or a list or a set, then
any states which cannot possibly reach a success state without going through a failure state will be
preemptively avoided.
"""
num_find += len(self._stashes[find_stash]) if find_stash in self._stashes else 0
tech = self.use_technique(Explorer(find, avoid, find_stash, avoid_stash, cfg, num_find))

# Modify first Veritesting so that they can work together.
deviation_filter_saved = None
for t in self._techniques:
if isinstance(t,Veritesting):
deviation_filter_saved = t.options.get("deviation_filter",None)
if deviation_filter_saved is not None:
t.options["deviation_filter"] = lambda s: tech.find(s) or tech.avoid(s) or deviation_filter_saved(s)
else:
t.options["deviation_filter"] = lambda s: tech.find(s) or tech.avoid(s)
break

try:
self.run(stash=stash, n=n, **kwargs)
finally:
self.remove_technique(tech)

for t in self._techniques:
if isinstance(t,Veritesting):
if deviation_filter_saved is None:
del t.options["deviation_filter"]
else:
t.options["deviation_filter"] = deviation_filter_saved
break

return self

宏观来看explore函数分为三部分:初始化,兼容veritest策略,探索(run)。兼容veritest策略的代码占了很多,对于理解veritest策略与其他策略的关系很有帮助,但是对我们理解符号执行帮助较小,这里就不赘述了。

首先,更新num_find的参数为设定的num_find参数加上找到的状态。接着,用传入的参数find,avoid等生成Explorer对象,然后再用use_technique方法生成一个tech对象。这里为什么要生成Explore对象,然后再用use_technique方法?

Explorer对象继承了ExplorationTechnique类,所以他也是一种探索策略,并且是一种最基础的策略。

而符号执行过程中,可以使用多种策略,那么如何综合这些策略呢?angr是把他们都放在了simulation_manager里的_.techniques列表里,而use_technique方法的作用正是把策略对象放进这个techniques列表里。

1
2
num_find += len(self._stashes[find_stash]) if find_stash in self._stashes else 0
tech = self.use_technique(Explorer(find, avoid, find_stash, avoid_stash, cfg, num_find))

初始化后,接下来就是去探索状态部分。简单的一个try,finally语句。不论run的结果如何,最后都把基础探索策略移出_techniques列表里。

1
2
3
4
try:
self.run(stash=stash, n=n, **kwargs)
finally:
self.remove_technique(tech)

run函数的代码如下,思路很简单,根据当前的探索策略,一直探索,直到到达一个完整的状态。如果策略里没定义完整的策略,那就把stash里的状态都跑完。run里涉及到了后面会讲的step函数,这里可以先简单理解为单步符号执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def run(self, stash='active', n=None, until=None, **kwargs):
"""
Run until the SimulationManager has reached a completed state, according to
the current exploration techniques. If no exploration techniques that define a completion
state are being used, run until there is nothing left to run.
:param stash: Operate on this stash
:param n: Step at most this many times
:param until: If provided, should be a function that takes a SimulationManager and
returns True or False. Stepping will terminate when it is True.
:return: The simulation manager, for chaining.
:rtype: SimulationManager
"""
for _ in (itertools.count() if n is None else range(0, n)):
if not self.complete() and self._stashes[stash]:
self.step(stash=stash, **kwargs)
if not (until and until(self)):
continue
break
return self

step

最后就是这个比较复杂的step函数了,可以看作是符号执行的基本单元了。相比explore函数的参数多了selector_func,step_func,successor_func,filter_func,until。这些参数的意思代码注释写得比较清楚了,就简单翻译一下。这些参数都是一个以状态为输入,返回各种东西(比如bool值,后继节点等)的一个函数,类似下面的代码。

1
2
3
4
5
def fun(state):
if state.addr == xxxx:
return True
else:
return False
  1. selector_func:如果为True,将会继续步进,反之会被保留。
  2. successor_func:返回的是后继节点,后面将会使用这些后继节点去符号执行。反之,则是使用project.factory.successors的后继节点。
  3. filter_func:返回的是stash的名字。filter_func的主要作用是给状态分类,分到各个stash里去。
  4. step_func:与前面参数不同,输入是为simulation_manger对象,并返回simulation_manager对象。这个函数会在simulation_manager对象每次step的时候被调用。
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def step(self, stash='active', n=None, selector_func=None, step_func=None,
successor_func=None, until=None, filter_func=None, **run_args):
"""
Step a stash of states forward and categorize the successors appropriately.
The parameters to this function allow you to control everything about the stepping and
categorization process.
:param stash: The name of the stash to step (default: 'active')
:param selector_func: If provided, should be a function that takes a state and returns a
boolean. If True, the state will be stepped. Otherwise, it will be
kept as-is.
:param step_func: If provided, should be a function that takes a SimulationManager and
returns a SimulationManager. Will be called with the SimulationManager
at every step. Note that this function should not actually perform any
stepping - it is meant to be a maintenance function called after each step.
:param successor_func: If provided, should be a function that takes a state and return its successors.
Otherwise, project.factory.successors will be used.
:param filter_func: If provided, should be a function that takes a state and return the name
of the stash, to which the state should be moved.
:param until: (DEPRECATED) If provided, should be a function that takes a SimulationManager and
returns True or False. Stepping will terminate when it is True.
:param n: (DEPRECATED) The number of times to step (default: 1 if "until" is not provided)
Additionally, you can pass in any of the following keyword args for project.factory.successors:
:param jumpkind: The jumpkind of the previous exit
:param addr: An address to execute at instead of the state's ip.
:param stmt_whitelist: A list of stmt indexes to which to confine execution.
:param last_stmt: A statement index at which to stop execution.
:param thumb: Whether the block should be lifted in ARM's THUMB mode.
:param backup_state: A state to read bytes from instead of using project memory.
:param opt_level: The VEX optimization level to use.
:param insn_bytes: A string of bytes to use for the block instead of the project.
:param size: The maximum size of the block, in bytes.
:param num_inst: The maximum number of instructions.
:param traceflags: traceflags to be passed to VEX. Default: 0
:returns: The simulation manager, for chaining.
:rtype: SimulationManager
"""
l.info("Stepping %s of %s", stash, self)
# 8<----------------- Compatibility layer -----------------
if n is not None or until is not None:
if once('simgr_step_n_until'):
print("\x1b[31;1mDeprecation warning: the use of `n` and `until` arguments is deprecated. "
"Consider using simgr.run() with the same arguments if you want to specify "
"a number of steps or an additional condition on when to stop the execution.\x1b[0m")
return self.run(stash, n, until, selector_func=selector_func, step_func=step_func,
successor_func=successor_func, filter_func=filter_func, **run_args)
# ------------------ Compatibility layer ---------------->8
bucket = defaultdict(list)

for state in self._fetch_states(stash=stash):

goto = self.filter(state, filter_func=filter_func)
if isinstance(goto, tuple):
goto, state = goto

if goto not in (None, stash):
bucket[goto].append(state)
continue

if not self.selector(state, selector_func=selector_func):
bucket[stash].append(state)
continue

pre_errored = len(self._errored)

successors = self.step_state(state, successor_func=successor_func, **run_args)
# handle degenerate stepping cases here. desired behavior:
# if a step produced only unsat states, always add them to the unsat stash since this usually indicates a bug
# if a step produced sat states and save_unsat is False, drop the unsats
# if a step produced no successors, period, add the original state to deadended

# first check if anything happened besides unsat. that gates all this behavior
if not any(v for k, v in successors.items() if k != 'unsat') and len(self._errored) == pre_errored:
# then check if there were some unsats
if successors.get('unsat', []):
# only unsats. current setup is acceptable.
pass
else:
# no unsats. we've deadended.
bucket['deadended'].append(state)
continue
else:
# there were sat states. it's okay to drop the unsat ones if the user said so.
if not self._save_unsat:
successors.pop('unsat', None)

for to_stash, successor_states in successors.items():
bucket[to_stash or stash].extend(successor_states)

self._clear_states(stash=stash)
for to_stash, states in bucket.items():
self._store_states(to_stash or stash, states)

if step_func is not None:
return step_func(self)
return self

首先,从stash里取出一个状态,调用filter函数看下该状态最后要去哪个stash里,如果不是当前的stash,则把该状态塞到应该放的stash的地方,然后取下一个状态。调用selector函数,选择要保留的状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
bucket = defaultdict(list)
# 依次从stash里取出状态
for state in self._fetch_states(stash=stash):
goto = self.filter(state, filter_func=filter_func) # 返回的是个元组,(状态该去的stash,状态)
if isinstance(goto, tuple):
goto, state = goto
#如果要去的stash不是当前的stash,也不是None,
if goto not in (None, stash):
# 那么把他放进该去的stash里,就不管他了。也就筛选掉了。
bucket[goto].append(state) #
continue
# 如果selector函数返回False,则需要保留该状态到当前的stash
if not self.selector(state, selector_func=selector_func):
# 保留状态
bucket[stash].append(state)
continue

如果没有触发selector或者filter,就去找后继节点。这里调用了step_state函数。

1
2
3
4
for state in self._fetch_states(stash=stash):
...
successors = self.step_state(state, successor_func=successor_func, **run_args)

step_state函数如下所示,这个函数主要是处理后继节点的状态。将不可解的状态,无约束的状态都放在相应的stash里。

1
2
3
4
5
6
7
8
9
10
11
12
def step_state(self, state, successor_func=None, **run_args):
"""
Don't use this function manually - it is meant to interface with exploration techniques.
"""
try:
successors = self.successors(state, successor_func=successor_func, **run_args)
stashes = {None: successors.flat_successors,
'unsat': successors.unsat_successors,
'unconstrained': successors.unconstrained_successors}
except:
...
return stashes

由于step_state函数可能会发生很多错误,因此后续的代码是去做后继节点错误状态的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
for state in self._fetch_states(stash=stash): 
...
#如果有后继节点有任何一个unsat状态或者发生了新的错误
if not any(v for k, v in successors.items() if k != 'unsat') and len(self._errored) == pre_errored:
#对于unsat状态,就先不管他
if successors.get('unsat', []):
# only unsats. current setup is acceptable.
pass
else:
#如果不是unsat,那说明遇到某些原因终止了,把该状态加到deadended的stash里去。
bucket['deadended'].append(state)
continue
else:
# 如果没有设置保留unsat状态,就把后继节点的unsat状态丢出去。
if not self._save_unsat:
successors.pop('unsat', None)


接下来就把后继节点加到bucket的to_stash或者stash里去。自此,这个for循环就结束了。

1
2
3
4
for state in self._fetch_states(stash=stash): 
...
for to_stash, successor_states in successors.items():
bucket[to_stash or stash].extend(successor_states)

剩下就是一些收尾工作,清空当前stash里的状态,然后再把bucket的内容存到simulation_manager对象的stash里去。

1
2
3
self._clear_states(stash=stash)
for to_stash, states in bucket.items():
self._store_states(to_stash or stash, states)

如果有设置step_func,就去调用step_func。由此也能看到step_func是在step函数最后调用的。

1
2
if step_func is not None:
return step_func(self)

最后用张图来总结。

参考资料:

BovInspector:一个自动化验证缓冲区溢出漏洞的工具

前言

静态分析工具可以很全面地检测软件,但是误报率也很高。人工地去验证这些静态分析报告是费时费力的过程。BovInspector可以去自动化地验证静态分析找出的缓冲区溢出漏洞。本文将介绍BovInspector的基本原理,安装使用方法。

1.简介

BovInspector来源于2020年期刊 Journal of Computer Science and Technology的一篇文章《Automatic Buffer Overflow Warning Validation》,最早是16年软工A类会议ASE上的一篇short paper。

该工具主要是基于KLEE,实现了一个自动验证静态分析报告的工具。换个角度看,也有点像利用静态分析来减少符号执行的路径爆炸问题的工作。

2.原理

工具的基本框架如下图所示(下图来源于论文)。主要工作有以下3点:

a. Warning Reachability Analysis(基于LLVM的PASS实现)

b.Guided Symbolic Execution(基于KLEE实现)

c. Targeted Automatic Repair Suggestions(Python脚本)

image-20210503112111595

下面将主要对Warning Reachability Analysis和Guided Symbolic Execution做重点介绍,并简单介绍自动修补。

2.1 Warning Reachability Analysis

这部分工作主要是确认静态分析找出的缓冲区溢出是否存在一条路径到达程序入口。首先,这部分内容的输入主要有两个,一个是源码,一个是buffer overflow warning。

buffer overflow warning用一个三元组表示(d,[l1,l2,…,ln],o)​,其中d表示buf定义的行号,l1,l2,…,ln表示对buf操作的行号,o表示溢出点。

由于静态分析工具检测的漏洞有可能在路径上是不可达的,因此,作者打算先过滤掉从程序入口到达不了的漏洞警告。具体来说,作者写了一个llvm pass先构建控制流图,再基于调用关系构建过程间的控制流图。然后在过程间的控制流图上,从漏洞点出发,往上追溯buf操作的语句,如果能够追溯到程序入口,说明这个漏洞有可能被触发。

这部分源码主要包含以下组件,核心是buildCFG.cpp

1
2
3
4
5
6
7
8
9
10
11
12
|-- CFGWriter.h //打印CFG
|-- ReverseSearchPath.cpp//逆向搜索路径
|-- ReverseSearchPath.h
|-- buildCFG.cpp //warning reachability analysis的核心
|-- convinent.cpp//包含一些方便的函数,比如获取行号,获取源文件名等等
|-- convinent.h
|-- mylib
| `-- printSTL.h
|-- programCFG.cpp//构建过程间控制流图
|-- programCFG.h
|-- targetPosition.cpp//将缓冲区溢出警告里的行号映射为基本块
`-- targetPosition.h

下面这段是buildCFG.cpp的主要代码,首先构建过程间控制流图(line 4)。

然后基于buffer overflow warning的信息,去获取buf定义、操作、溢出的行号在哪些基本块里(line 5-7)。也就是targetPosition.cpp里的逻辑。

接着从溢出点的基本块和离溢出最近的一个buf操作的基本块开始,搜索这两个基本块的路径(ReverseSearchPath.cpp),直到遍历完所有的target point。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
bool buildCFG::doInitialization(Module &M){ 

errs()<<"build programCFG-------------------------------------------------------------------\n";
new ProgramCFG(M);
std::vector<pair<BasicBlock*,BasicBlock*> > *target_point=NULL;
if(!targetListFileName.empty())
target_point=getTargetPoint(targetListFileName,M);
errs()<<target_point->size()<<"\n";
if(target_point->size()!=0){
errs()<<"the path number is "<<target_point->size()<<"\n";
for(unsigned int i=0;i<target_point->size();i++){
Function *main = M.getFunction("main");
SearchReversePaths(target_point->at(i).first,target_point->at(i).second,&main->getEntryBlock(),M,i);
}
}
else errs()<<"No valid path in checklist!!"<<"\n";
return false;
}

2.2 Guided Symbolic Execution

指导符号执行的目的是验证缓冲区溢出漏洞是否可以被触发。整个流程大致如下图所示。首先,从执行状态池里选择一个状态。接着,执行指令,如果执行到漏洞点,调用验证模块,检测一下当前的状态是否满足漏洞的约束条件,如果满足,则说明是漏洞,反之则是误报。如果执行到分支指令,就复制当前状态,同时筛选状态。最后就是更新状态。

image-20210503190438707

筛选状态的算法如下图所示,图来源于论文。es,es2分别表示分支为true的状态和分支为false状态。因此,l1,l2分别表示es,es2状态的将要执行的下一条指令的行号。如果之前提取的warning路径集合里包含l1或l2,则筛选掉另外一个。但如果路径集合里没有l1,l2,或者都有l1,l2,就不做筛选操作。

image-20210503190756936

不筛选的原因有两个。第一,路径集合里没有l1,l2,有可能是符号执行去探索一些没有出现在警告路径里的库函数调用。第二,路径集合里有l1和l2,可能是溢出点在循环里,这样两个分支就都存在warning路径。

漏洞验证模块是去求解当前状态是否满足人工设定的漏洞约束条件。设定的约束条件如下表所示(表来源于论文)。这部分代码可以查看project/src/klee_symbolic_execution/lib/Core/Executor.cpp里的BFO_Check函数。代码量比较大,大概2000行左右,就不贴出来了。主要思路就是根据各种容易出问题的库函数API,设定缓冲区溢出的约束条件。

image-20210503193633408

2.3 Targeted Automatic Repair Suggestions

BovInspector修补漏洞的策略采用专家知识构建的模板,主要有以下11种修补模式(表源于论文)。具体实现可以去看代码(位于project/src/repair_buffer_overflow/repair.py )

image-20210503192324877

3.安装

想跳过下面的安装步骤,可以直接用我构建好的docker 镜像

1
2
3
docker pull iskindar/bov:v1
docker run -ti --ulimit='stack=-1:-1' --name="bovinspector" iskindar/bov:v1
source /etc/profile #加载环境变量

3.1 源码安装

这里提供了docker的安装方式,因为原工具的开发时间比较早,一些环境比较老旧,用docker方便一点。

构建docker镜像

1
docker run -ti --ulimit='stack=-1:-1' --name="bovinspector" ubuntu:14.04

进入docker容器安装依赖

1
2
3
$ apt-get update
$ apt-get install g++ python curl cmake git bison flex bc libcap-dev
$ apt-get install zlib1g-dev

安装llvm-gcc

1
2
$ wget http://llvm.org/releases/2.9/llvm-gcc4.2-2.9-x86_64-linux.tar.bz2
$ tar -jxvf llvm-gcc4.2-2.9-x86_64-linux.tar.bz2

将llvm-gcc添加到环境变量

1
2
3
$ vim /etc/profile
export PATH=$PATH:/root/llvm-gcc4.2-2.9-x86_64-linux/bin #把这行加入/etc/profile文件末尾
$ source /etc/profile

源码安装llvm-2.9,安好之后,可以把按上面的步骤把llvm-2.9/Release+Asserts/bin加入环境变量

1
2
3
4
5
$ wget http://llvm.org/releases/2.9/llvm-2.9.tgz
$ tar -zxvf llvm-2.9.tgz
$ cd llvm-2.9
$ ./configure --enable-optimized --enable-assertions
$ make

安装minisat(STP需要这个)

1
2
3
4
5
6
7
$ git clone https://github.com/stp/minisat.git
$ cd minisat
$ mkdir build
$ cd build
$ cmake ../
$ make
$ sudo make install

安装STP(约束求解器)

1
2
3
4
5
6
7
$ tar xzfv 2.1.0.tar.gz  
$ cd stp-2.1.0
$ mkdir build
$ cd build
$ cmake ..
$ make
$ sudo make DESTDIR=/root/stp_install install

安装uclibc(KLEE用来模拟库函数的组件)

1
2
3
4
5
$ git clone git://github.com/klee/klee-uclibc.git
$ cd klee-uclibc
$ ./configure --make-llvm-lib
$ make -j2
$ cd ..

安装bovinspector,为方便使用,也可以把project/src/klee_symbolic_execution/Release+Asserts/bin加入环境变量

1
2
3
4
git clone https://github.com/BovInspectorTool1/project.git
cd project/src/klee_symbolic_execution/
./configure --with-llvm=/root/llvm-2.9 --with-stp=/root/stp_install --with-uclibc=/root/klee-uclibc --enable-posix-runtime
make -j $(grep -c processor /proc/cpuinfo) ENABLE_OPTIMIZED=1

安装llvm pass

将project/src/build_control_flow_graph移到llvm-2.9/lib/Transform/下,并将文件夹重命名为buildCFG

修改lib/Transform/Makefile为(就是第二行加个buildCFG:

1
2
3
4
5
6
7
8
9
10
11
12
LEVEL = ../..
PARALLEL_DIRS = Utils Instrumentation Scalar InstCombine IPO Hello BuildCFG

include $(LEVEL)/Makefile.config

# No support for plugins on windows targets
ifeq ($(HOST_OS), $(filter $(HOST_OS), Cygwin MingW Minix))
PARALLEL_DIRS := $(filter-out Hello, $(PARALLEL_DIRS))
PARALLEL_DIRS := $(filter-out buildcfg, $(PARALLEL_DIRS))
endif

include $(LEVEL)/Makefile.common

回到llvm-2.9/目录下

1
2
./configure --enable-optimized --enable-assertions  
make

可以看到/root/llvm-2.9/Release+Asserts/lib目录下有buildCFG.so,就说明安装成功了。

4. 使用

论文中给的一个简单的测试程序src.c:

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
34
35
36
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#define MAX_LEN 24
#define MIN_LEN 4
void usage() {
char des_buffer[MIN_LEN];
char* src_buffer="this is an example";
strcpy(des_buffer,src_buffer);
}
int initialize(char* argv_string) {
char mapped_argv[MIN_LEN];
if (strlen(argv_string) >= MAX_LEN)
return 0;
strcpy(mapped_argv,argv_string);
if (argv_string[0] != '-') {
strcat(mapped_argv, "-");
}
return MAX_LEN;
}

int main(int argc, char **argv) {
char* mode = (char*)malloc(MIN_LEN);
int len = 0;
if (strlen(argv[1]) < MIN_LEN)
len = initialize(argv[1]);
if (len > 0) {
mode = (char*)realloc(mode, len);
}
else {
mode[0] = argv[1][0];
}
mode[MIN_LEN-1] = '\0';
free(mode);
return 0;
}

假设用某些静态分析工具跑出的结果checklist_bufferoverflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0
src.c 7 DEF
src.c 9 BOF
END_PATH
1
src.c 22 DEF
src.c 26 N/A
src.c 12 N/A
src.c 15 N/A
src.c 17 BOF
END_PATH
2
src.c 28 DEF
src.c 33 BOF
END_PATH
3
src.c 22 DEF
src.c 26 N/A
src.c 12 N/A
src.c 15 BOF
END_PATH

新建一个文件夹,比如paper_example

1
2
3
mkdir paper_example
vim src.c # 将上面的内容复制进来
vim checklist_bufferoverflow #将上面的内容复制进来

用llvm-gcc生成字节码

1
llvm-gcc --emit-llvm -c -g src.c

构建CFG,生成GuideSrc.txt,programCFG.dot

1
opt -load /root/llvm-2.9/Release+Asserts/lib/buildCFG.so -buildCFG -targetList=./checklist_bufferoverflow src.o

运行klee,验证warning

1
klee --libc=uclibc --posix-runtime --guided-execution src.o --sym-args 1 3 25

最后应该会生成report_BFO.txt和report_NOTBFO.txt文件,内容如下:

report_BFO.txt:真实的缓冲区溢出所在行数

1
src.c_17 strcat

report_NOTBFO.txt:误报的缓冲区溢出漏洞行数

1
src.c_15 strcpy

5. 总结

最后简单总结下,这个工具的思路虽然很简单,但实际实现过程的代码量还是相当大的。另外,可能因为这个工作比较早完成的,一些软件的版本比较老,让我安装起来着实费了不少劲。非常感谢这篇论文作者将工作开源了,让本菜鸡能够学习到如何去修改KLEE实现相应的功能,并加深了对于KLEE工作的理解。

6. 参考

  1. Gao F, Wang L, Li X. BovInspector: Automatic inspection and repair of buffer overflow vulnerabilities. In Proc. the 31st IEEE/ACM Int. Conference on Automated Software Engineering, Sept. 2016, pp.786-791.
  2. bovinspector:https://github.com/BovInspectorTool1/project
  3. bovinspector项目介绍:https://bovinspectortool1.github.io/project/

KLEE 源码阅读笔记

前言

KLEE是基于LLVM的符号执行工具,发表于2008年的OSDI会议上,至今已被引用3000多次,基于该工作的研究也有150多篇,其中有30多个是开源工作。本文从KLEE源码入手,希望能让读者了解klee工作的基本原理。

1. klee 基本框架

KLEE是EXE工作的重新设计。从高层面来说,KLEE像一个符号化进程的操作系统和解释器。程序源码被LLVM编译为类似RISC的虚拟指令集,KLEE直接解释指令,并将指令映射为约束条件。

KLEE的核心是一个解释器,循环地从状态池里选择状态,并在那个状态下符号化地执行每条指令。直到状态池里没有状态了,或者时间超过了用户设定的。

符号执行引擎存储的状态的寄存器、栈或者堆对象,都是表达式形式的,而非普通进程里的具体值。表达式的叶子节点是符号化的变量或者常量。还可以是LLVM中间层语言操作(算术运算、比较、内存访问)的内部节点。

符号执行大部分的指令都很直接。比如要符号执行一个LLVM加法运算。

1
%dst = add i32 %src0, %src1

KLEE直接从src0和src1里取出加数,并且写一个新的表达式Add(%src0,%src1)到dst寄存器里。如果操作数都是具体值,就直接加好,返回一个常数表达式。

条件分支是布尔表达式,并且会根据条件为真或假改变状态的指令指针。KLEE去查询约束求解器来确定当前路径上的分支条件是否恒为真或假。如果恒为真或假,就更新指令指针到预计的位置。反之两种分支都有可能。然后KLEE就会去复制当前状态,这样就可以同时探索两条路径,并且同时更新指令指针和路径条件。

潜在的危险操作也会生成一个分支。比如一个除法操作就会生成一个分支去检测除数是不是0。这些分支的工作方式和普通分支是一样的。当检查成功,即检测出错误的时候,会继续执行条件为假的路径,也就是添加相反的约束,比如使得除数不为0的约束。此外,还会为错误生成测试样例,并且终止检测出错误的状态。

至于其他的危险操作,比如load和store指令,也会去检查。这些例子里,就会去检查地址是不是在合法内存对象里。然而,store和load指令需要额外的编译。最直接的表示内存的方式是flat byte array。这种情况下,load和store就会分别简单地映射为数组读和写表达式。

然而,KLEE使用的约束求解器不能解决这种约束。于是,KLEE将每个要检查代码的内存对象映射到一个单独的STP数组里。这种方式提高了性能,因为这让STP无视了表达式没有引用的数组。

许多操作比如边界检查,对象级的copy-on-write)需要对象特定的信息。如果一个指针指向很多对象,这些操作就很难实现。为了简化实现,KLEE用以下方式回避了这个问题。当一个解引用的指针p指向N个对象时,KLEE复制当前状态N次。在每个状态,KLEE限制指针p在其对象的边界内,然后实现读或写操作。虽然这个方法对于有很大points-to 集合的指针的开销很大,但实际情况中,大部分的符号指针只指向1个对象,所以这个优化还是可以的。

2. klee代码架构

从klee官网的开发者指南,我们可以知道klee源码的大致结构如下:

1
2
3
4
5
6
7
8
9
|-- include //包含公共的头文件
|-- tools //所有KLEE的二进制的main都在这里,有些是python脚本
|-- lib //包含大部分的源码
| |-- Core //包含解释和执行LLVM字节码和KLEE内存模型。
| |-- Expr // klee的表达式库
| |-- Module //包含在执行LLVM字节码前的一些操作代码。比如链接POSIX运行函数等等。
| |-- Solver//包含所有求解器
|-- runtime//包含各种KLEE的运行时支持。
|-- test//包含一些小的C程序和LLVM的字节码,用来给KLEE做回归测试

由上面的结构可以知道,如果要修改KLEE的话,基本上是在lib/core目录下进行修改。现在看看lib/core目录下都有哪些代码。

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
34
35
36
37
38
|-- AddressSpace.cpp
|-- AddressSpace.h
|-- CallPathManager.cpp
|-- CallPathManager.h
|-- Context.cpp
|-- Context.h
|-- CoreStats.cpp
|-- CoreStats.h
|-- ExecutionState.cpp
|-- ExecutionState.h
|-- Executor.cpp
|-- Executor.h
|-- ExecutorUtil.cpp
|-- ExternalDispatcher.cpp
|-- ExternalDispatcher.h
|-- GetElementPtrTypeIterator.h
|-- ImpliedValue.cpp
|-- ImpliedValue.h
|-- Memory.cpp
|-- Memory.h
|-- MemoryManager.cpp
|-- MemoryManager.h
|-- MergeHandler.cpp
|-- MergeHandler.h
|-- PTree.cpp
|-- PTree.h
|-- Searcher.cpp
|-- Searcher.h
|-- SeedInfo.cpp
|-- SeedInfo.h
|-- SpecialFunctionHandler.cpp
|-- SpecialFunctionHandler.h
|-- StatsTracker.cpp
|-- StatsTracker.h
|-- TimingSolver.cpp
|-- TimingSolver.h
|-- UserSearcher.cpp
|-- UserSearcher.h

咋看上去有一点多。去掉头文件就少一半,大概将近二十个代码。这里我们就先从Executor.cpp这里开始看,这里也是解释器的主循环所在的代码。

3. klee的执行器Executor

解释器的主循环代码位于Executor.cpp的run函数里。

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
34
35
36
void Executor::run(ExecutionState &initialState) {
bindModuleConstants();

// Delay init till now so that ticks don't accrue during optimization and such.
timers.reset();

states.insert(&initialState);

if (usingSeeds) {
...
}
searcher = constructUserSearcher(*this);

std::vector<ExecutionState *> newStates(states.begin(), states.end());
searcher->update(0, newStates, std::vector<ExecutionState *>());

// main interpreter loop
while (!states.empty() && !haltExecution) {
ExecutionState &state = searcher->selectState();
KInstruction *ki = state.pc;
stepInstruction(state);

executeInstruction(state, ki);
timers.invoke();
if (::dumpStates) dumpStates();
if (::dumpPTree) dumpPTree();

updateStates(&state);

if (!checkMemoryUsage()) {
// update searchers when states were terminated early due to memory pressure
updateStates(nullptr);
}
}
...
}

从代码注释//main interpreter loop往下看,代码的逻辑基本和前面说的差不多,由搜索器searcher选择一个状态,然后执行指令,并且更新状态。下面我们来看组成这个循环的四个主要函数。

1
2
3
4
selectState();
stepInstruction(state);
executeInstruction(state, ki);
updateStates(&state);

将run函数的大致流程可以用下图表示。

image-20210505204501326

selectState函数在Searcher.cpp里,并且每种search的选择状态实现也不一样。比如DFSSearcer就直接返回状态池的最后一个状态。

1
2
3
ExecutionState &DFSSearcher::selectState() {
return *states.back();
}

BFSSearcher返回第一个状态。

1
2
3
ExecutionState &BFSSearcher::selectState() {
return *states.front();
}

KLEE还实现了很多Searcher,感兴趣地可以去Searcher.cpp代码里看。

下面的代码是stepInstruction,逻辑比较简单,主要就是将PC+1。并且判断一下如果指令数量到达最大指令数量,就将haltExecution标志位置为真。为什么不直接将这个逻辑整合到执行指令里去呢?除了要判断指令的数目这点外,也是为了更好地扩展。实际上就有工作在stepInstuction和executeInstruction之间做了扩展,比如基于klee扩展的一个工作BovInspector就在此处加了一个验证函数BFO_Check函数来验证缓冲区溢出漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
13
void Executor::stepInstruction(ExecutionState &state) {
printDebugInstructions(state);
if (statsTracker)
statsTracker->stepInstruction(state);

++stats::instructions;
++state.steppedInstructions;
state.prevPC = state.pc;
++state.pc;

if (stats::instructions == MaxInstructions)
haltExecution = true;
}

然后是executeInstruction()函数,这个函数代码量比较大,核心就是一个很大的switch语句,对于不同的指令,设置了不同的执行方式。下图展示了KLEE建模的指令。

image-20210505195953723

下面代码节选了内存指令的实现。eval函数的作用是求解表达式。对angr有了解的,可能会发现angr里也有eval这个接口。这部分对指令的建模代码看起来有点晦涩,但大体上可以看出klee对IR层的每条指令都做了细致的处理。

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
void Executor::executeInstruction(ExecutionState &state, KInstruction *ki) {
Instruction *i = ki->inst;
switch (i->getOpcode()) {
...
case Instruction::Alloca: {
AllocaInst *ai = cast<AllocaInst>(i);
unsigned elementSize =
kmodule->targetData->getTypeStoreSize(ai->getAllocatedType());
ref<Expr> size = Expr::createPointer(elementSize);
if (ai->isArrayAllocation()) {
ref<Expr> count = eval(ki, 0, state).value;
count = Expr::createZExtToPointerWidth(count);
size = MulExpr::create(size, count);
}
executeAlloc(state, size, true, ki);
break;
}

case Instruction::Load: {
ref<Expr> base = eval(ki, 0, state).value;
executeMemoryOperation(state, false, base, 0, ki);
break;
}
case Instruction::Store: {
ref<Expr> base = eval(ki, 1, state).value;
ref<Expr> value = eval(ki, 0, state).value;
executeMemoryOperation(state, true, base, value, 0);
break;
}
...

接下来看更新状态的代码。主要逻辑就是把addedStates的状态加到状态池里,removedStates的状态移出状态池里。

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
void Executor::updateStates(ExecutionState *current) {
if (searcher) {
searcher->update(current, addedStates, removedStates);
}

states.insert(addedStates.begin(), addedStates.end());
addedStates.clear();

for (std::vector<ExecutionState *>::iterator it = removedStates.begin(),
ie = removedStates.end();
it != ie; ++it) {
ExecutionState *es = *it;
std::set<ExecutionState*>::iterator it2 = states.find(es);
assert(it2!=states.end());
states.erase(it2);
std::map<ExecutionState*, std::vector<SeedInfo> >::iterator it3 =
seedMap.find(es);
if (it3 != seedMap.end())
seedMap.erase(it3);
processTree->remove(es->ptreeNode);
delete es;
}
removedStates.clear();
}

4. klee的状态

从Executor.cpp的代码里,我们可以发现,klee里一个很重要的概念就是state。解释器里的几个重要流程的操作对象都是状态。接下来看看klee的状态都有哪些部分组成。这部分代码在ExecutionState.cpp。从下面的代码中,可以发现状态里包含了很多信息,包括pc,栈约束条件等等。

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
ExecutionState::ExecutionState(const ExecutionState& state):
pc(state.pc),
prevPC(state.prevPC),
stack(state.stack),
incomingBBIndex(state.incomingBBIndex),
depth(state.depth),
addressSpace(state.addressSpace),
constraints(state.constraints),
pathOS(state.pathOS),
symPathOS(state.symPathOS),
coveredLines(state.coveredLines),
symbolics(state.symbolics),
arrayNames(state.arrayNames),
openMergeStack(state.openMergeStack),
steppedInstructions(state.steppedInstructions),
instsSinceCovNew(state.instsSinceCovNew),
unwindingInformation(state.unwindingInformation
? state.unwindingInformation->clone()
: nullptr),
coveredNew(state.coveredNew),
forkDisabled(state.forkDisabled) {
for (const auto &cur_mergehandler: openMergeStack)
cur_mergehandler->addOpenState(this);
}

关于state的操作有以下几种,这部分里主要还是merge的逻辑比较复杂。

1
2
3
4
5
6
7
branch // 处理分支情况,实际就是复制条件为false的状态
pushFrame
popFrame
addSymbolic
merge //合并状态
dumpStack
addConstraint //调用约束管理器添加约束

5. KLEE内存模型

klee里还有个很重要的概念就是内存模型。在Memory.h里,我们可以看见有memory object的定义,可以知道memory object有以下属性,包括id,地址,内存对象的大小等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MemoryObject(uint64_t _address, unsigned _size, 
bool _isLocal, bool _isGlobal, bool _isFixed,
const llvm::Value *_allocSite,
MemoryManager *_parent)
: id(counter++),
address(_address),
size(_size),
name("unnamed"),
isLocal(_isLocal),
isGlobal(_isGlobal),
isFixed(_isFixed),
isUserSpecified(false),
parent(_parent),
allocSite(_allocSite) {
}

而这些内存对象由memory manager来进行管理,在MemoryManager.h里可以看到memory manger负责分配内存、释放内存等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public:
MemoryManager(ArrayCache *arrayCache);
~MemoryManager();

/**
* Returns memory object which contains a handle to real virtual process
* memory.
*/
MemoryObject *allocate(uint64_t size, bool isLocal, bool isGlobal,
const llvm::Value *allocSite, size_t alignment);
MemoryObject *allocateFixed(uint64_t address, uint64_t size,
const llvm::Value *allocSite);
void deallocate(const MemoryObject *mo);
void markFreed(MemoryObject *mo);
ArrayCache *getArrayCache() const { return arrayCache; }

/*
* Returns the size used by deterministic allocation in bytes
*/
size_t getUsedDeterministicSize();
};

前面提到的基于KLEE的工作BovInspector就在Memory.h里建立缓冲区的模型。下面代码就是BovInspector建立的缓冲区的模型。这也告诉我们,如果想要对什么对象建立模型,可以将代码写在Memory.h里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class BufferObject {
private:
uint64_t startAddr;
uint64_t size;

public:
MemoryObject * mo;
uint64_t getStartAddr();
uint64_t getSize();

void setStartAddr(uint64_t addr);
void setSize(unsigned size);
BufferObject(MemoryObject * mo);
};
}

6. 总结

klee和angr都是符号执行工具,但两者不同的是,angr更像是一个框架,你可以利用框架实现你想要的功能,而klee则是封装比较好的一个工具,可以直接输入命令使用的。另外一个不同点是angr是针对二进制程序操作的,而klee是在LLVM字节码上弄的。

之前对于klee的了解仅仅停留在论文和官方文档,几条命令反复使用。但最近需要修改klee实现想要的功能时,仅仅停留在论文和官方文档就有点不够用了。尝试去看了代码,感觉收益颇丰,看的时候不禁恍然大悟,原来也有我看得懂的地方。

总的来说,klee是基于LLVM的一个很优秀的开源符号执行工具,十几年来都还在更新代码,目前来看功能应该比较完善了,但仍然有可扩展的空间,比如和别的静态分析技术结合,例如SVF(https://github.com/SVF-tools/SVF)。

写的比较粗糙,希望大家能从这篇文章有所收获。

7. 参考资料

0x1. klee官网:http://klee.github.io/

0x2. Cadar C, Dunbar D, Engler D R. Klee: unassisted and automatic generation of high-coverage tests for complex systems programs[C]//OSDI. 2008, 8: 209-224.

0x3. klee源码:https://github.com/klee/klee
0x4. BovInspector : https://github.com/BovInspectorTool1/project

前言

自从DARPA在2016搞了一个CGC比赛之后,关于漏洞自动利用工作的研究也在随后几年多了起来。国内近几年也有类似的比赛出现,比如RHG、BCTF的autopwn。此外,随着软件趋于复杂,模糊测试技术的成熟,漏洞数量也越来越多。要修补所有的漏洞不太现实,而人工去判断漏洞的危害性也是耗时耗力的过程。因此,这也就带动了漏洞自动利用生成(Automatic Exploit Generation)的发展。本文将基于近几年安全顶会上的研究,首先介绍漏洞自动化利用的研究进展,并给出一个漏洞自动利用的基本框架,最后讨论漏洞自动利用的未来研究方向。

1. 背景

近几年来,漏洞的CVE数量越来越多。一方面是因为模糊测试在漏洞挖掘领域取得了比较好的效果,另一方面是软件的越来越复杂,不可避免导致了很多安全漏洞。面对如此多数量的漏洞,对于一个安全团队是很难能够及时修复的。相关研究表明,漏洞修复的周期长达几周或几个月。因此,需要对漏洞修复的优先级进行排序。常用的策略是基于漏洞的可利用性来确定漏洞的优先级。然而,手工确定漏洞的可利用性也是耗时耗力的事情。这也就是漏洞自动化利用工具的意义所在,自动化地评估漏洞的可利用性,进而确定漏洞修复的优先级。

除了确定漏洞修复优先级以外,漏洞自动化利用也能对现有的防御机制进行评估,产生新的防御思路。此外,这类工具也能对CTF比赛或者渗透测试过程提供很大帮助。企业也能使用这类工具来对其系统安全风险预警。

总的来说,研究意义主要分为以下三点。

a. 确定漏洞可利用性
b. 自动化地去评估防御机制,促进防御的研究发展。
c. 辅助渗透测试,比如CTF等等。

2. 研究时间线

最早关于漏洞利用自动生成的研究是2008年的APEG[1]。这篇工作是基于一个打过补丁的程序,来自动生成没打过补丁的程序漏洞利用。应用到实际会有很多受限之处,但是开创了漏洞利用自动生成这个领域。随后,2009年,Heelan[2]的硕士论文是第一个提出给定一个程序的崩溃输入,然后自动生成这个漏洞的利用。后续的工作基本上也是按这个套路不断延申开来。2011年后,先前研究APEG的David团队先后发表了AEG[3],Mayhem[4]等工作。早期的漏洞自动化利用工作基本都是由这个团队做的。到2016年,DARPA开了一个CGC的比赛(自动攻防),Mayhem取得了第一名。之后几年内,相关工作如春笋一般涌出。2016年算是个奇妙的时间点,2016之前的工作大都是围绕栈溢出,格式化字符串漏洞来做。2016年之后大多工作都开始尝试去实现堆漏洞(堆溢出、UAF)的自动化利用。

image-20210504134758216

总结一下,漏洞自动化利用的发展的研究趋势,目前应该还有一些重要的工作需要去做。近几年的工作大都是将符号执行和模糊测试相结合来实现的,仿佛成为了一种范式。自然辩证法里有提到过科学是在范式下解难题。现在看来确实蛮符合科学发展的规律。

3. 基本方法

这里简单介绍几篇论文里是如何实现AEG。并且大家可能会发现,思路都差不多,但研究的侧重点都不尽相同。

3.1 针对栈溢出的AEG

早期的工作主要是针对栈溢出漏洞进行自动化利用,这些研究有Heelan的硕士论文[2], AEG[3],Mayhem[4],CRAX[5]等等

Heelan的工作需要给定一个已知漏洞的崩溃输入和跳转的寄存器,基于动态符号执行来自动化地劫持程序控制流。
具体来说,他使用二进制插桩来做污点传播并收集运行时信息,并通过检查EIP寄存器是否被污点影响来生成exp,同时也考虑了间接影响EIP寄存器的指针损坏的情况。

AEG这篇论文写得很清晰。文章的总体框架如下图所示。首先用gcc和llvm对源码进行预处理,生成能用GCC运行的二进制Bgcc和LLVM分析的字节码Bllvm。基于字节码,AEG用条件符号执行去找漏洞函数,被溢出覆盖的对象,触发bug的路径。同时用动态二进制分析去Bgcc里提取运行时信息,最后生成payload。生成payload后再进行验证,最后输出exploit。(下图来自于论文。)

image-20200907154844716

mayhem这篇文章就是上面AEG在二进制上的逻辑扩展。核心技术是混合符号执行和基于索引的内存模型。用混合符号执行主要是在运行速度和内存要求之间找平衡点。mayhem引入了具体执行来缓解符号执行带来的路径爆炸问题。具体来说,具体执行插桩,做动态污点分析,然后把污点指令流传给SES。SES编译这些指令为中间语言,并且符号化地去执行。简单来说就是具体执行缩小了符号执行需要执行的范围。而基于索引的内存模型主要是去解决产生exploit由于具体的约束条件而不可行,因此引入整个模型来避免索引的约束。(下图来自于论文。)

image-20200907160712400

CRAX主要是针对AEG中的一个缺陷做的改进。AEG收集运行时信息,并且只在漏洞触发的时候计算exp。产生的exp可能会由于漏洞点和触发exp点的传播距离而失效。比如下面的代码。漏洞点在第四行的strcpy,触发漏洞是在第六行函数返回的时候,而这个时候第五行修改了之前计算的exp,就会导致最后的exp失效。(下图来自于论文。)

image-20210304093522324

CRAX的方法和angr文档里的simple AEG的思路很像。第一步是去找符号化的EIP,也就是看输入能否控制程序指针PC。第二步是找到符号化的内存,看看能否注入shellcode。当确定了shellcode的位置后,CRAX会在shellcode前面放很多的滑板指令NOP来扩充shellcode的入口。最后,所有的约束,包括shellcode,NOP sled,EIP寄存器的约束都会送到solver去求解,得到最终的exp。如果得不到解,就会去改变shellcode的位置,直到求出解或者没有可用的符号buffer了。(下图来自于论文。)

image-20210304092057582

3.2 针对堆溢出的AEG

针对堆漏洞的自动化利用工作到2016年以后才陆续开始出现。这也是近几年来的研究热点。我认为有几个原因:

a. 相比栈漏洞,堆漏洞的利用更为复杂,更需要自动化利用工具来对其进行评估
b. 先前的工作主要是针对栈上的漏洞,做堆漏洞的工作在2016年前没有
c. 堆漏洞在这几年增加的要比栈漏洞快

由于堆利用复杂性,有些工作聚焦于堆布局的自动化。比如Usenix18 的SHRIKE[6]和CCS 19 的slake[7],分别是针对解释器和内核的堆漏洞的自动化操纵布局。

SHRIKE是第一个堆布局自动化操纵的研究,其方法是基于伪随机黑盒搜索。SLAKE使用动态和静态分析来分析内核对象和相应的系统调用。然后,对常用的利用方法进行建模,最后实现了一种slab布局调整的方法。

下面介绍的针对各类应用场景堆漏洞的AEG工作,比如浏览器、内核、解释器等。

REVERY[8]

现有的AEG方法通常是探索crashing path来找到可利用的状态,比如由PoC触发的漏洞的路径和生成的利用通常是在一条路径上。然而,1.可利用的状态不一定总在crashing path上。2.并且,现有的方法严重依赖符号执行,并且在路径扩展和利用生成的扩展性不好。

为了解决这两个问题,revery使用了三种技术:

0x1. layout-contributor digraph 来描述漏洞的内存布局和指令
0x2. layout-oriented fuzzing去探索和crashing paths有相同内存布局的路径
0x3. control-flow stitching 来连接crashing paths和diverging path,最后生成利用。

(下图来自于论文。)

image-20210307154143717

HEAPG[9]

由于现有的方法都是通过破坏一个敏感指针然后实现一个内存读写或者间接调用,也就是说敏感指针是劫持控制流的关键。在这个例子里,一旦堆布局准备好,攻击者只要一次就可以构造利用原语。然后实际很多漏洞需要多个步骤才能实现利用。为了实现这点,HEAPG利用专家知识来指导利用生成。具体来说,HEAPG,以crashing input,二进制程序和专家知识作为输入,然后通过利用堆分配器内部的特性,能够对很难利用的漏洞生成利用。(下图来自于论文。)

image-20210307153406563

PrimGen[10]

针对浏览器这类有广大用户人群,且极为复杂的软件,其漏洞利用过程通常是个耗时耗力,多步骤的过程。为了减轻安全人员的工作量,PrimGen自动化了部分浏览器漏洞的利用过程。对于给定的一个漏洞,PrimGen能够自动构造数据对象喂给浏览器导致执行恶意行为。

PrimGen主要分为两部分,第一部分预处理,基于二进制程序生成CFG和SSA,并且收集一些数据,比如函数的entry,寄存器的定义/使用,内存读写和控制流信息。更进一步,PrimGen利用动态分析来获取控制流和内存信息(利用debugger在控制点下断点来执行crashing input。)然后就可以提取动态踪迹和内存信息。

第二部分用datalog-based方法去找控制点后的可控数据。在确定了控制点的位置后,开启分析去找到可到达的sink。基于这些信息,构造了一个图来描述从一个基本块到另外一个基本块的控制流。有了这些图,再利用符号执行去执行到sink的路径,并过滤掉不可解的路径。在这个步骤中,会收集所有与控制数据相关的约束,并且用于构建内存映射表。基于映射表可以指导如何构造对象。基于前面动态分析获得的memory dump,PrimGen可以验证每条与内存映射绑定的路径是否满足利用条件。最后,给定一个VUT的堆喷射模板,PrimGen的模型可以生成喂给浏览器的脚本。(下图来自于论文。)

image-20210504141153860

Gollum[11]

gollum是第一个针对解释器堆溢出漏洞的AEG研究。文章提到大多数的AEG系统针对的是命令行的程序或者类似文件解析器的系统。而解释器和这些程序不一样,并且打破了现有AEG系统的假设。一个假设是利用符号执行可以推断输入文件和目标行为的关系。而解释器的状态空间很大,并且输入程序的值和程序状态有很多非直接的关系。解释器有很多漏洞,但这里针对堆溢出漏洞有2个原因,一是堆漏洞比较常见,二是现有AEG系统做的还不够完善。

文章关于堆布局的自动操作是基于作者之前工作SHRIKE的基础上。另外文章的亮点还在于用了遗传算法来代替之前的伪随机搜索算法。感兴趣的可以去看论文。(下图来自于论文。)

image-20210307153826751

FUZE[12]

FUZE针对的是内核的UAF漏洞。这是篇将符号执行与模糊测试结合的AEG文章。具体来说,从漏洞触发点开始用模糊测试探索路径。然后在指针解引用的地方用符号执行再去探索路径。(下图来自于论文。)

image-20210307172811930

KOOBE[13]

KOOBE针对是内核的Out-of-Bound(OOB)漏洞。针对现有的方法无法完全挖掘漏洞的能力,KOOBE定义了OOB漏洞的能力为三点:能写多远、能写多少、能写什么。并利用OOB漏洞的能力来指导模糊测试。(下图来自于论文。)

image-20210307153714379

3.3 针对其他漏洞的AEG

这篇文章[14]提出了一个完整的自动化目标栈喷射方法来自动化利用内核的未初始化漏洞。目标栈喷射技术包含两点:

a. 确定的栈喷射技术,结合定制的符号执行和模糊测试来识别内核输入,这个内核输入指的是攻击者利用用户程序在确定地执行内核代码路径,从而在内核栈上留下攻击者控制的数据。
b. 内存喷射技术,使用内存占用和污染来可靠地控制内核栈上的一大块空间。

所以,这两个技术概括来说,一个是精准定位,一个是大面积覆盖。并且作者利用这个技术和未初始化漏洞可以实现提权。此外,作者还提供了一种基于编译器的防御机制,来初始化可能不安全的指针类型,并且没有太多的额外性能。文章的结果显示,未初始化使用是一个很严重的攻击,所以未来的防御系统需要考虑这种漏洞,并且系统软件不应该使用未初始化内存来作为随机源。(下图来自于论文。)

image-20210307172942260

4. 评估方法

目前的评估数据集主要有三类:

a. CVE漏洞

b. syzbot平台上的漏洞(谷歌用fuzzer找到的内核漏洞)

c. CTF比赛程序

CVE漏洞的优点在于其是真实存在的漏洞。用CVE来做测试集,工具的实际意义比较大。

大部分的工作用的都是实际的CVE来做评估测试。一些内核漏洞的工作会用到syzbot平台。因为syzbot平台上的漏洞还没经过评估成为CVE,有比较大的评估价值。

Revery[8]和KEPLER[15]用到了一些CTF比赛的数据集。这类数据集的优点前期测试工具比较方便,网上也有相应的exploit。但存在问题是这类程序的规模一般不大,可能无法评估工具能否实用。

image-20200907143212604

5. 未来可能研究方向

如何对漏洞进行建模是这个领域的一个难点。建模时需要考虑漏洞的类型,漏洞程序的类型(浏览器,内核),漏洞的能力(能写多远,能写多少字节,能写什么值)。因为现有的漏洞自动化利用的实现主要是基于符号执行和模糊测试,而这两种技术都存在搜索空间很大的弊病。因此,充分利用漏洞的信息能够在一定程度上降低搜索空间。比如KOOBE这篇文章就是将漏洞能力融入到模糊测试中去,进而提高了模糊测试的效率。

未来的研究可能会针对更多更难利用的漏洞去实现AEG,比如竞争?或者针对更广的应用场景,比如嵌入式设备、固件?此外漏洞自动利用还有个难以解决的问题,就是如何确定漏洞的可利用性的边界。现有的工作对这个问题的回答还不是很好。因为目前的工具不能生成一个漏洞的利用,并不代表这个漏洞是否不可利用,有可能是工具自身的问题。当然这也是个很困难的问题。

6. 开源工具

劫持控制流

PrimGen:浏览器堆漏洞自动化利用(目前只有结果,作者说code will follow)

https://github.com/RUB-SysSec/PrimGen

FUZE:内核UAF漏洞自动化利用

https://github.com/ww9210/Linux_kernel_exploits

Gollum:解释器堆溢出漏洞自动化利用(有链接但暂未开源)

https://github.com/SeanHeelan/ShapeShifter

KOOBE:内核越界写(OOB)漏洞自动化利用

https://github.com/seclab-ucr/KOOBE

rex:angr参加CGC比赛的工具

https://github.com/angr/rex

Zeratool:栈溢出自动化利用工具

https://github.com/ChrisTheCoolHut/Zeratool

堆布局操作

SHRIKE:堆溢出漏洞布局操作自动化

https://github.com/SeanHeelan/HeapLayout

SLAKE:四种漏洞的堆布局操作自动化(暂时不完善)

https://github.com/chenyueqi/SLAKE

绕过防御机制

BOPC:绕过防御机制(数据流攻击自动化)

https://github.com/HexHive/BOPC

KEPLER:绕过防御机制(内核ROP)

https://github.com/ww9210/kepler-cfhp

一些相关资料,及几篇最近的综述

github上关于AEG论文的整理:https://github.com/SCUBSRGroup/Automatic-Exploit-Generation

赵尚儒,李学俊,方越,余媛萍,黄伟豪,陈恺,苏璞睿,张玉清.安全漏洞自动利用综述[J].计算机研究与发展,2019,56(10):2097-2111.

苏璞睿,黄桦烽,余媛萍,张涛.软件漏洞自动利用研究综述[J].广州大学学报(自然科学版),2019,18(03):52-58.

参考资料

[1] Brumley D, Poosankam P, Song D, et al. Automatic patch-based exploit generation is possible: Techniques and implications[C]//2008 IEEE Symposium on Security and Privacy (sp 2008). IEEE, 2008: 143-157.

[2] Heelan S. Automatic generation of control flow hijacking exploits for software vulnerabilities[D]. University of Oxford, 2009.

[3] Avgerinos T, Cha S K, Rebert A, et al. Automatic exploit generation[C]//NDSS. 2011.

[4] Cha S K, Avgerinos T, Rebert A, et al. Unleashing mayhem on binary code[C]//2012 IEEE Symposium on Security and Privacy. IEEE, 2012: 380-394.

[5] Huang S K, Huang M H, Huang P Y, et al. Crax: Software crash analysis for automatic exploit generation by modeling attacks as symbolic continuations[C]//2012 IEEE Sixth International Conference on Software Security and Reliability. IEEE, 2012: 78-87.

[6] Heelan S, Melham T, Kroening D. Automatic heap layout manipulation for exploitation[C]//27th {USENIX} Security Symposium ({USENIX} Security 18). 2018: 763-779.

[7] Chen Y, Xing X. Slake: facilitating slab manipulation for exploiting vulnerabilities in the Linux kernel[C]//Proceedings of the 2019 ACM SIGSAC Conference on Computer and Communications Security. 2019: 1707-1722.

[8] Wang Y, Zhang C, Xiang X, et al. Revery: From proof-of-concept to exploitable[C]//Proceedings of the 2018 ACM SIGSAC Conference on Computer and Communications Security. 2018: 1914-1927.

[9] Zhao Z, Wang Y, Gong X. HAEPG: An Automatic Multi-hop Exploitation Generation Framework[C]//International Conference on Detection of Intrusions and Malware, and Vulnerability Assessment. Springer, Cham, 2020: 89-109.

[10] Garmany B, Stoffel M, Gawlik R, et al. Towards automated generation of exploitation primitives for web browsers[C]//Proceedings of the 34th Annual Computer Security Applications Conference. 2018: 300-312.

[11] Heelan S, Melham T, Kroening D. Gollum: Modular and greybox exploit generation for heap overflows in interpreters[C]//Proceedings of the 2019 ACM SIGSAC Conference on Computer and Communications Security. 2019: 1689-1706.

[12] Wu W, Chen Y, Xu J, et al. {FUZE}: Towards facilitating exploit generation for kernel use-after-free vulnerabilities[C]//27th {USENIX} Security Symposium ({USENIX} Security 18). 2018: 781-797.

[13] Chen W, Zou X, Li G, et al. {KOOBE}: Towards Facilitating Exploit Generation of Kernel Out-Of-Bounds Write Vulnerabilities[C]//29th {USENIX} Security Symposium ({USENIX} Security 20). 2020: 1093-1110.

[14] Lu K, Walter M T, Pfaff D, et al. Unleashing Use-Before-Initialization Vulnerabilities in the Linux Kernel Using Targeted Stack Spraying[C]//NDSS. 2017.

[15] Wu W, Chen Y, Xing X, et al. {KEPLER}: Facilitating Control-flow Hijacking Primitive Evaluation for Linux Kernel Vulnerabilities[C]//28th {USENIX} Security Symposium ({USENIX} Security 19). 2019: 1187-1204.

内存时间安全防御研究进展

前言

系统级别语言以其高性能高效率而广泛应用于各类软件的编写,但同时也饱受内存时间安全问题所扰。 近些年来,像释放后重用漏洞这类的内存时间安全漏洞数量逐渐增多,对软件安全造成了很大的威胁。因而,这些年来涌现了许多关于保障内存时间安全的研究。本文通过归纳总结不同内存时间安全防御的研究,并探讨内存时间安全的未来研究方向。

引言

内存安全问题一直都是计算机安全领域所关注的重点问题。据MITRE统计[1],2019年最高危的前十个CWE中,内存安全类漏洞占了约50%。而导致内存安全漏洞泛滥的原因是大量的软件都是基于不安全的语言,如C和C++。这类语言具有对内存直接操作的特性,从而提高了编程效率和程序的性能,但也正因为能对内存操作,所以引入了许多内存安全问题。而且这类语言编写的代码存在于各类系统级软件中,如操作系统、虚拟机等。用其他相对安全的语言是很难替代这类语言完成这些系统级软件的编写的,因为缺乏对于计算机底层操作的支持。此外,现存的大量软件都存在这类安全问题,重写这些软件也是不太现实的。

内存安全可以分为内存空间安全和内存时间安全。由于内存空间漏洞具有危害大,容易利用等特点,现阶段关于内存空间安全的研究较为成熟。相比之下,关于内存时间安全的研究相对较少。内存时间安全问题主要是由Use After Free(UAF)和Double Free等指针状态类漏洞导致的。攻击者可以利用这样的UAF漏洞操作数据输入来获取整个程序的控制权甚至是整个系统[2]。对于内存空间安全漏洞,使用边界检查的机制能很大程度上缓解,但边界检查对于UAF漏洞无效。UAF漏洞存在因此,未来的计算机系统不仅要考虑内存空间安全,也要考虑到内存时间安全,才能构建完整的安全体系结构。

随着这些年关于内存时间安全的研究增多,要区分不同研究的优劣成了一个相对比较难的问题。因此,本文旨在系统化地整理和评估近些年提出的解决方案。本文首先描述UAF漏洞利用的原理,分析UAF漏洞利用的条件,并根据该条件划分防御方法并对其进行评估。防御方法的评估基于健壮性,兼容性和性能开销。最后本文基于该评估,总结各方法的优劣并提出未来内存时间安全防御的可能研究方向。

背景

悬垂指针与UAF漏洞的关系

悬垂指针指的是指向某对象的指针,当对象释放后,该指针指向的是内存是不确定的。所以称该指针为悬垂指针。

img

图1 Use After Free 漏洞示例代码

UAF漏洞本质上是悬垂指针的重引用。图1展示了一个UAF漏洞代码。其可简化为三步:第一,程序先是在堆上为buf1分配了size大小的空间,之后释放buf1,使得buf1成为悬垂指针,即其指向的内存数据是不可预测的。这块内存有可能被堆管理器回收,也可能被其他数据占用,存在着很大的不可预测性。第二,程序为buf2在堆上分配与buf1相等大小的空间,这里由于buf1的释放和buf2的分配内存的时间间隔较近,且分配的内存大小一致,根据内存分配的原则,有很大可能使得buf2分配到已释放的buf1的内存位置上去。第三,重引用悬垂指针buf1。这里为其赋值为“hack”字符串,由于buf2buf1指向同一块内存,将会导致buf2的值也被篡改为“hack”

内存时间安全漏洞利用方式

内存时间安全威胁主要由UAF漏洞导致,但也包括双重释放漏洞[3]。

img

图2 Use After Free 攻击图解

利用UAF漏洞实施攻击的核心思路是攻击者在已释放区域放置精心构造的数据,当程序重引用悬垂指针时,就会触发攻击。所以如果攻击者在释放区域设置一个虚拟函数表指针,当内存重引用悬垂指针时,就会跳转到攻击者想要执行的代码位置处执行代码,从而使攻击者劫持程序的控制流,如图2所示。另一个利用UAF漏洞的例子是攻击者将用于检查访问合法的root权限标志放在已释放区域,就可以实现权限提升攻击。而且如果漏洞存在于内核中,攻击者将能控制整个系统。此外,还可以利用这类漏洞篡改关键数据,也可以实现非控制数据攻击。因此,UAF漏洞可以作为多种攻击的突破口。

双重释放漏洞,则是UAF漏洞的一个特例,只是用悬垂指针来调用free 函数。在这种案例下,由攻击者控制内容的新对象会被误认为是堆的元数据,从而可以写任意内存[3]。

总之,成功利用UAF漏洞实施攻击需要三个必不可少的元素:一是悬垂指针的产生,二是分配到了悬垂指针所指向的内存空间的对象,三是重引用悬垂指针的指令(读或写)。

内存时间安全的挑战

图1显示的代码只是一种简单的UAF漏洞利用方式,现实生活中的UAF漏洞要更复杂的多。首先,UAF漏洞利用需要的三个元素可以存在于不同的函数和模块中。其次,实际应用运行时,这些操作可能会在不同的线程中发生。比如浏览器需要处理来自JavaScript或者DOM树的各种事件,UI应用需要处理用户产生的事件,然后服务器端还要处理大量的用户请求。基于如此复杂的场景,导致程序员很难避免这类漏洞。最后,指针还可以传播复制到程序的各种地方,加大了悬垂指针发现的难度。

而要放弃使用C或C++这种语言也是不现实的,因为许多底层的系统的实现需要这类语言操作内存的特性。况且,现存的许多软件都是基于这类语言编写的。因此,内存时间安全保障的机制必不可少。

内存时间安全防御方法

要成功利用UAF漏洞实现攻击需要三个要素:悬垂指针、复用已释放的内存,重引用悬垂指针的指令。因此,只要消除其中的某个元素就能达到内存时间安全防御的目的,于是主要方法可分为以下三类:基于消除悬垂指针的方法,基于内存分配的方法,基于防止重引用的方法。实际上有些方法消除了其中的两个或所有元素,这里按时间顺序归类,即某方法消除了第二,三个元素,则将其归为第二类方法。

基于消除悬垂指针的方法

基于指针的方法的核心思路是两步:第一,找到悬垂指针;第二,悬垂指针置空。这类方法有DangNull[4]、FreeSentry[5]、DangSan[6]和PSweeper[7]。DangNull针对内存时间安全的根本原因——悬垂指针,通过指针追踪对象的信息,当对象被释放时,将其指针置空,从而避免后续的潜在威胁。但是,DangNull只能追踪堆上对象的指针,而无视了栈和全局内存上的。FreeSentry改善了这点,可以追踪所有的指针,并降低了运行开销,但是其不支持多线程程序。而多线程程序如服务器,浏览器是UAF漏洞存在的主要地方。因此,研究者基于前面两个的研究,提出了DangSan,使得其可以支持多线程应用。但以上三种方法因为需要维护指针和对象的关系,而导致运行开销很大,而且需要在许多地方加锁来避免应用多线程的竞争。基于这些缺点,PSweeper诞生了,其在并发线程中去迭代地搜索悬垂指针,并使用对象源追踪技术来无效化悬垂指针。PSweeper使用空闲的CPU核来减少安全检查的延迟,相比上面三种方法消耗的CPU资源会更多,但会更加有效。

以上方法都是基于编译器来实现的方法,此外也有基于硬件来清洗悬垂指针的方法,比如BOGO[8]、CHERIvoke[9]。BOGO基于Intel的MPX改进,通过复用MPX的元数据来验证已释放区域的悬垂指针,使之能保护内存时间安全。但是MPX存在开销大、不支持多线程、与ISA组件存在冲突等诸多问题,使得BOGO能否应用实际成了问题。不过这种基于已有硬件来扩展的思路启发了CHERIvoke。其基于CHERI[10]架构,利用内存标记的技术,仅使用1bit的标签元数据,就能在运行时清扫内存将悬垂指针无效化。

基于内存分配的方法

通过避免对象分配复用已被释放对象的内存,来防止UAF漏洞的利用。所以这类方法有Cling[11]、DieHarder[12]和Address Sanitizer[13],其通过修改计算机内存分配的机制,来避免恶意对象复用已释放对象内存空间。

防止对象复用已释放对象内存的一种简单思路是从不使用已释放对象的内存,但如果遇到频繁释放对象内存的程序,就会造成内存的严重浪费。而Cling通过限制内存分配,只允许相同类型的对象之间重用地址空间,因此降低了性能和内存开销,并且保证了类型安全内存复用,防止了大部分的悬垂指针攻击,但不能防止攻击者重用本地堆栈分配的对象来实施攻击。DieHarder和Address Sanitizer都使用了一种延迟-复用技术,防止分配的新对象的内存空间是刚释放的旧对象的空间。但DieHarder与Address Sanitizer的目的不同,DieHarder的目的是为了提供系统运行时的防御,而Address Sanitizer更多是在系统运行前作为调试工具,检测出漏洞。这些系统能够发现非人为的UAF操作,但不适合检测蓄意的攻击[4],比如通过堆喷射来绕过这种防御机制。

此外,也有使用基于页表的技术进行分配内存,如Oscar[14]。其将每个分配的对象放在不同的虚拟页中,当一个对象被释放了,就修改相应的虚拟页的权限,使得悬垂指针无法访问被释放后的内存地址。这种基于页表的方式当分配内存大时,性能开销比较小。但是遇到频繁的小内存分配就会加大性能和内存开销,这是因为每次分配都会赋予一个虚拟页,这就导致TLB的压力,从而造成性能的下降。

除了以上使用软件方式实现的内存分配,使用硬件方法的有REST[15]和Califorms[16]。

REST用8-64B的令牌填充所有释放的内存,并将其置于独立的隔离池中。直到空闲的内存池消耗殆尽,这些隔离的内存才用于重新分配。因此,由于已释放的内存处于黑名单中,此时通过悬垂指针访问都是无效的,从而保证了内存时间安全。Califorms也是使用相同的方式,只不过元数据的粒度处于字节级别,是基于REST方法的改进,整体开销更小,保护面更广,能保护对象内安全(intra-object security)。

基于检测重引用的方法

这类方法聚焦于检测UAF利用的第三步,也是实质上对内存时间安全产生危害的UAF操作,如图1的第11行。这类方法的思路类似锁和钥匙,每次分配的内存都会赋予一个锁,并且每个有效的指向该内存的指针也会赋予一个匹配的钥匙。只有相互匹配的锁与钥匙才能进行合法的操作。而当内存重分配以后,对应的锁也就变了。因此,如果重引用悬空指针的话,就可以视为使用一把旧钥匙去开一把新锁,从而被系统检测出来重引用悬空指针这一操作。

基于软件方法实现这一思路的有CETS[17]和Undangle[18]。

CETS,使用基于身份的方案,为每一个指针分配一个标签,并在指针被重引用时检查标签和其分配的区域,若不匹配则内存访问失败,从而避免悬空指针的重引用。为了应对指针运算的情况,CETS使用了污点传播,使得传播的指针继承了原有的指针元数据,但是事实上传播的指针不一定和原来指针指向相同的对象,这就导致这种方法的假阳性比较高。因此,研究者提出了基于污点追踪的方法Undangle,其从指针分配的位置开始跟踪,避免了当指针以类型不安全的形式复制时丢失元数据的情况,从而达到比较好的保护效果。

然而基于软件方法实现这一思路需要在每次内存访问时都要进行一次检测,从而导致性能开销很大,更适合作为调试工具,而非运行时系统的防御。因此研究者开始着力于减小性能开销,从而诞生了基于硬件的实现方法,诸如Watchdog[19]和WatchdogLite[20]。

Watchdog的检测重引用悬空指针操作和CETS相似,主要是在内存访问检测做了很多优化,包括使用微指令注入、元数据编码、ISA辅助识别和寄存器重命名技术。虽然极大地降低了运行性能开销,然而付出的代价是硬件的复杂性过高。于是Watchdog的研究者提出了WatchdogLite这一改进版本。WatchdogLite通过硬件优化来利用编译器检测指针,从而不需要添加任何新的硬件来保存元数据的状态,降低了硬件复杂性同时也保证较低的运行开销。

未来研究方向

表1总结了上述的方法,其内存开销和运行开销都是基于SPEC 2006测试的。下面将对这三类方法进行讨论。

image-20210418213043430

在消除悬垂指针方法中,又可按照具体实现分为基于硬件和基于软件。基于软件的方法通常是维护指针和对象的关系来置空指针,而要维护指针和对象的关系,就需要存储比较复杂的元数据,从而导致了内存开销过大。文中提到的基于硬件的方法都是基于已提出的硬件结构稍加扩展来保护内存时间安全的。虽然性能开销和内存开销都要相对软件方法优越,但能否应用实际存在比较大的考量。

内存分配方法中,实现方式也可以分为硬件和软件。早期的该类方法主要目的是以低开销的方式增加漏洞利用难度。除了Address Sanitizer是作为检测工具,所以牺牲了运行开销。近期研究使用基于页表的技术,兼容性好,但开销稍微大一些。基于硬件的方法主要使用的隔离缓冲区的技术,本质上也是延迟复用已释放内存的区域。这类方法的优势是可以结合其他技术,构成更强的内存安全防护体系。

检测重引用的方法中,通常需要跟踪指针动态。而指针存在很多不确定性,因为其可能被复制多份传播到程序各处。要提高跟踪准确率,就需要相应的开销来维持,否则只能降低开销,来保证部分安全。该类方法和消除悬垂指针的方法也有些相似,都需要追踪指针动态,不同的是两种方法在指针重引用时做的操作不同,前者是做检测,后者是消除悬空指针。从而导致检测重引用的方法主要花费开销在检测上。

总体来看,基于硬件的方法和基于软件的方法在安全上效果相差不大,但硬件方法在减小开销上更胜一筹,而软件方法在兼容性上具有优势。所以未来的研究可以从软硬件协同防御入手,基于兼容性较好的硬件扩展,来完善硬件缺失的安全功能,使得研究能够应用实际。此外,由于UAF漏洞广泛存在于多线程应用中,而这些研究中只有少部分支持多线程应用。因此,今后的内存时间安全研究也应考虑支持多线程。

总结

本文针对悬垂指针引发的UAF漏洞,综述了内存时间安全的研究现状。首先简述如何利用UAF漏洞实施攻击,突出了UAF漏洞利用的三要素。并以此为据,将内存时间安全研究分为三类,论述了这三类方法的基本思路。接着,基于安全、运行开销、兼容性这三点对各方法进行评估,并提出未来内存时间安全可能的研究方向。

参考文献

[1] “2019 CWE Top25 Most Dangerous Software Errors” MITRE, https://cwe.mitre.org/top25/archive/2019/2019_cwe_top25.html, Sept. 2019.

[2] Xu, Wen, et al. “From collision to exploitation: Unleashing use-after-free vulnerabilities in linux kernel.” Proceedings of the 22nd ACM SIGSAC Conference on Computer and Communications Security. 2015.

[3] Szekeres, Laszlo, et al. “Sok: Eternal war in memory.” 2013 IEEE Symposium on Security and Privacy. IEEE, 2013.

[4] Lee, Byoungyoung, et al. “Preventing Use-after-free with Dangling Pointers Nullification.” NDSS. 2015.

[5] Younan, Yves. “FreeSentry: protecting against use-after-free vulnerabilities due to dangling pointers.” NDSS. 2015.

[6] Van Der Kouwe, Erik, Vinod Nigade, and Cristiano Giuffrida. “Dangsan: Scalable use-after-free detection.” Proceedings of the Twelfth European Conference on Computer Systems. 2017.

[7] Liu, Daiping, Mingwei Zhang, and Haining Wang. “A robust and efficient defense against use-after-free exploits via concurrent pointer sweeping.” Proceedings of the 2018 ACM SIGSAC Conference on Computer and Communications Security. 2018.

[8] Zhang, Tong, Dongyoon Lee, and Changhee Jung. “BOGO: buy spatial memory safety, get temporal memory safety (almost) free.” Proceedings of the Twenty-Fourth International Conference on Architectural Support for Programming Languages and Operating Systems. 2019.

[9] Xia, Hongyan, et al. “CHERIvoke: Characterising Pointer Revocation using CHERI Capabilities for Temporal Memory Safety.” Proceedings of the 52nd Annual IEEE/ACM International Symposium on Microarchitecture. 2019.

[10] Watson, Robert NM, et al. “Cheri: A hybrid capability-system architecture for scalable software compartmentalization.” 2015 IEEE Symposium on Security and Privacy. IEEE, 2015.

[11] Akritidis, Periklis. “Cling: A Memory Allocator to Mitigate Dangling Pointers.” USENIX Security Symposium. 2010.

[12] Novark, Gene, and Emery D. Berger. “DieHarder: securing the heap.” Proceedings of the 17th ACM conference on Computer and communications security. 2010.

[13] Serebryany, Konstantin, et al. “AddressSanitizer: A fast address sanity checker.” Presented as part of the 2012 {USENIX} Annual Technical Conference ({USENIX}{ATC} 12). 2012.

[14] Dang, Thurston HY, Petros Maniatis, and David Wagner. “Oscar: A practical page-permissions-based scheme for thwarting dangling pointers.” 26th {USENIX} Security Symposium ({USENIX} Security 17). 2017.

[15] Sinha, Kanad, and Simha Sethumadhavan. “Practical memory safety with REST.” 2018 ACM/IEEE 45th Annual International Symposium on Computer Architecture (ISCA). IEEE, 2018.

[16] Sasaki, Hiroshi, et al. “Practical byte-granular memory blacklisting using califorms.” Proceedings of the 52nd Annual IEEE/ACM International Symposium on Microarchitecture. 2019.

[17] Nagarakatte, Santosh, et al. “CETS: compiler enforced temporal safety for C.” Proceedings of the 2010 international symposium on Memory management. 2010.

[18] Caballero, Juan, et al. “Undangle: early detection of dangling pointers in use-after-free and double-free vulnerabilities.” Proceedings of the 2012 International Symposium on Software Testing and Analysis. 2012.

[19] Nagarakatte, Santosh, Milo MK Martin, and Steve Zdancewic. “Watchdog: Hardware for safe and secure manual memory management and full memory safety.” 2012 39th Annual International Symposium on Computer Architecture (ISCA). IEEE, 2012.

[20] Nagarakatte, Santosh, Milo MK Martin, and Steve Zdancewic. “Watchdoglite: Hardware-accelerated compiler-based pointer checking.” Proceedings of Annual IEEE/ACM International Symposium on Code Generation and Optimization. 2014.