经典符号执行与动态符号执行

符号执行作为一种生成高覆盖率测试用例集和发现复杂软件中深层错误的有效技术,受到了广泛的关注。
符号执行在软件测试环境中的一个关键目标是在给定的时间内探索尽可能多的不同程序路径,并且对于每个路径:(1)生成一组执行该路径的具体输入值;(2)检查各种错误的存在,包括违反断言、未捕获的异常、安全漏洞和内存损坏。生成具体测试输入的能力是符号执行的主要优势之一:从测试生成的角度来看,符号执行允许创建高覆盖率的测试套件,而从发现bug的角度来看,符号执行为开发人员提供了触发bug的具体输入,可以独立于生成它的符号执行工具来确认和调试错误。

1、经典符号执行的概述

符号执行的关键思想是使用符号值而不是具体的数据值作为输入,并将程序变量的值表示为符号输入值上的符号表达式。因此,程序计算的输出值表示为符号输入值的函数。在软件测试中,符号执行用于为程序的每条执行路径生成测试输入。执行路径是由true和false组成的序列,其中第i个位置的值为true(或false)表示在执行路径上遇到的第i个条件语句经过了then(或else)分支。程序的所有执行路径都可以用一棵树表示,称为执行树(execution tree)。例如,下面的图1中的函数testme()有三个执行路径,形成了图2中所示的执行树。这些路径可以通过在输入{x = 0, y = 1}、{x = 2, y = 1}和{x = 30, y = 15}上运行程序来执行。目标是生成这样一组输入,以便通过在这些输入上运行程序,可以恰好一次性地探索取决于符号输入值的所有执行路径,或者在给定的时间预算内尽可能多地探索这些路径。
图1 示例程序

图1 示例程序代码
图2 示例程序的执行树
图2 示例程序的执行树

符号执行维护一个符号状态σ(将变量映射到符号表达式)和一个符号路径约束PC(符号表达式上无量词的一阶公式)。在符号执行开始时,σ初始化为一个空的map,而PC初始化为true。σ和PC都会在符号执行过程中更新。在沿着程序执行路径的符号执行结束时,使用约束求解器求解PC以生成具体的输入值。如果程序在这些具体的输入值上执行,它将采取与符号执行完全相同的路径,并以相同的方式结束。
例如,图1中代码的符号执行从一个空的符号状态开始,符号路径约束为true。在每条接收程序输入的读取语句var = sym_input()中,符号执行都会将var → s映射到σ,其中s是一个新的(不受约束的)符号值。例如,符号执行main()函数的前两行(第16-17行)得到σ = {x → x0, y → y0},其中x0, y0是两个最初不受约束的符号值。在每次赋值v = e时,符号执行通过将v映射到σ(e)来更新σ, σ是在当前符号状态下通过计算e得到的符号表达式。例如,在执行第6行之后,σ = {x → x0, y → y0, z → 2y0}。
在每个条件语句 if (e) S1 else S2,路径约束 PC 被更新为 PC ∧ σ(e)( then分支),并且创建并初始化一个新的路径约束 PC′,其为 PC ∧ ¬σ(e)( else分支)。如果PC 是可满足的,那么符号执行将沿着 then分支继续,使用符号状态 σ 和符号路径约束 PC。类似地,如果 PC′ 是可满足的,那么将创建另一个符号执行实例,具有符号状态 σ 和符号路径约束 PC′,它沿着 else分支继续执行;与具体执行不同,两个分支都可以被执行,导致两个执行路径。如果 PC 或 PC′ 中的任何一个不可满足,符号执行将沿着相应的路径终止。例如,在示例代码的第7行之后,将创建两个符号执行实例,其路径约束分别为 x0 = 2y0 和 x0 ≠ 2y0。类似地,在第8行之后,将创建两个符号执行实例,其路径约束分别为 (x0 = 2y0) ∧ (x0 > y0 + 10) 和 (x0 = 2y0) ∧ (x0 ≤ y0 + 10)。
如果符号执行实例遇到exit语句或error(例如,程序崩溃或违反断言),则当前符号执行实例将被终止,并使用现成的约束求解器生成当前符号路径约束的满足赋值。满足赋值形成测试输入:如果程序在这些具体输入值上执行,它将沿着与符号执行相同的路径,并以相同的方式终止。例如,在我们的示例代码中,我们得到了 三个符号执行实例,它们分别产生了测试输入 {x = 0, y = 1}、{x = 2, y = 1} 和 {x = 30, y = 15}。

void testme_inf() {
	int sum = 0;
	int N = sym input();
	while (N > 0) {
		sum = sum + N;
		N = sym input();
	}
}

包含循环或递归的代码的符号执行可能导致无限数量的路径,如果循环或递归的终止条件是符号的话。上面的代码有无限数量的执行路径,其中每个执行路径都是一系列任意数量的 true,后跟一个 false,或者是一系列无限数量的 true。具有一系列 n 个 true 后跟一个 false 的路径的符号路径约束是:
( ⋀ i ∈ [ 1 , n ] N i > 0 ) ∧ N n + 1 ≤ 0 (\bigwedge_{i\in \left [ 1,n \right ] }^{ } N_i>0)\wedge N_{n+1}\le 0 (i[1,n]Ni>0)Nn+10
其中每个 N i N_i Ni都是一个新的符号值,而执行结束时的符号状态为 { N ⟼ N n + 1 , s u m ⟼ ∑ i ∈ [ 1 , n ] N i } \left \{ N \longmapsto N_{n+1}, sum\longmapsto {\textstyle \sum_{i\in [1,n]}^{}}N_i \right \} {NNn+1,sumi[1,n]Ni}。在实践中,需要对搜索设置限制,例如超时、路径数量限制、循环迭代次数限制或探索深度限制。
经典符号执行的主要缺点是:如果执行路径上的符号路径约束包含约束求解器无法(有效地)解决的公式,则无法生成输入。例如,考虑在图1中对代码的两个变体执行符号执行:在一个变体中,我们修改了twice函数,如下面的代码所示:

int twice (int v){
	return (v∗v) % 50;
}

在另一个变体中,我们假设函数twice的代码不可用。假设我们的约束求解器无法处理非线性算术。对于第一个变体,符号执行将在第一个条件语句执行后生成路径约束 x0 ≠ (y0y0) mod 50x0 = (y0y0) mod 50。对于第二个变体,符号执行将生成路径约束 x0 ≠ twice(y0)x0 = twice(y0),其中twice是一个未解释的函数。由于约束求解器无法解决这些约束中的任何一个,符号执行将无法为修改后的程序生成任何输入。动态符号执行缓解了这个问题,并为修改后的程序生成了至少一些输入。

2、动态符号执行

动态符号执行混合了具体执行和符号执行。
混合执行测试(Concolic Testing): Concolic Testing维护一个具体状态和一个符号状态:具体状态将所有变量映射到它们的具体值;符号状态只映射具有非具体值的变量。与经典的符号执行不同,混合执行在执行过程中会维护整个程序的具体状态,因此它的输入需要初始的具体值。Concolic Testing从一些给定的或随机的输入开始执行程序,在执行过程中,在条件语句处收集输入的符号约束,然后使用约束求解器推断前一个输入的变体,以便将程序的下一个执行转向另一个执行路径。系统地或启发式地重复此过程,直到探索所有执行路径,满足用户定义的覆盖标准,或时间预算到期。
对于图1中的示例程序,混合执行将生成一些随机输入,比如 {x = 22, y = 7},并在具体符号两方面执行程序。具体执行将在第7行选择else分支,而符号执行将生成路径约束 x₀ ≠ 2y₀ 沿着具体执行路径。混合执行测试否定路径约束中的一个合取项,并解决 x₀ = 2y₀ 以获得测试输入 {x = 2, y = 1};这个新输入将强制程序沿着不同的执行路径执行。混合执行测试在这个新的测试输入上重复进行具体和符号两方面的执行。执行将沿着与之前不同的路径进行——在这次执行中,在第7行选择then分支,在第8行选择else分支。与之前的执行一样,混合执行测试还在这个具体执行中执行符号执行,并生成路径约束 (x₀ = 2y₀) ∧ (x₀ ≤ y₀ + 10) 。混合执行测试将生成一个新的测试输入,迫使程序沿着以前未执行的执行路径执行。它通过否定合取项(x₀ ≤ y₀ + 10)并解决约束(x₀ = 2y₀) ∧ (x₀ > y₀ + 10)来获得测试输入{x = 30, y = 15}。程序使用这个新输入达到ERROR语句。在程序的第三次执行后,混合执行测试将报告已经探索了程序的所有执行路径,并终止了测试输入的生成。
执行生成测试(EGT): 执行生成测试的工作原理是区分程序的具体状态和符号状态。为此,执行生成测试通过在每个操作之前动态检查涉及的值是否全部是具体值,将具体和符号执行混合在一起。如果是,则操作会像在原始程序中一样执行。否则,如果至少有一个值是符号的,则通过更新当前路径的路径条件进行符号执行。例如,如果图1中的第17行更改为 y = 10,那么第6行将简单地调用具有具体参数20的函数 twice(),该调用将像原始程序中一样执行(twice 可能对其输入执行任意复杂的操作,但这不会对符号执行造成任何压力,因为调用将以具体方式执行)。然后,第7行上的分支将变成if (20 == x),符号执行将同时遵循分支的then一侧(在这一侧添加约束x = 20)和else一侧(在这一侧添加约束x ≠ 20)。请注意,在then路径上,第8行的条件变成if (x > 20),因此在这个路径上,由于x被约束为具由于x被约束为具有值20,因此其then一侧是不可行的。

混合执行测试和执行生成测试是现代符号执行技术的两个实例,它们能够混合具体执行和符号执行,被统称动态符号执行(dynamic symbolic execution)

动态符号执行中的不精确性与完备性的权衡:混合具体执行和符号执行的一个关键优势在于,由于与外部代码的交互或约束求解超时而导致的不精确性可以通过使用具体值来缓解。

3、关键挑战和解决措施

(1)路径爆炸
符号执行的一个关键挑战是路径爆炸,程序的路径数量通常与代码中静态分支的数量呈指数级增长。因此,给定固定的时间预算,首先探索最相关的路径是至关重要的。符号执行隐式地过滤掉了不依赖符号输入和在当前路径约束下不可行的所有路径。尽管有这种过滤,路径爆炸仍然是符号执行面临的最大挑战之一。有两种关键方法被用来解决这个问题:

  • 启发式技术:启发式地优先探索最有希望的路径 搜索启发式是符号执行工具用于确定路径探索优先级的关键机制。大多数启发式算法专注于实现较高的语句和分支覆盖率,但它们也可以用于优化其他所需的条件。以下是一些启发式方法:
    (a)使用静态控制流图(CFG)来指导探索从未覆盖的指令到最近的路径
    (b)基于随即探索的启发式方法,从程序的开头开始,并在每个符号分支处,对于两侧都是可行的,随机选择要探索的一侧分支。
    (c)将符号探索和随机测试交叉使用,这种方法结合随机测试迅速达到深层执行状态的能力,以及符号执行在给定邻域内彻底探索状态的强大能力。
    (d)符号执行与进化搜索相结合,符号执行与进化搜索相结合,其中使用适当的适应性函数驱动输入空间的探索。例如,Austin工具结合了基于搜索的软件测试,使用适当的适应性函数驱动测试输入空间的进化搜索,以及动态符号执行,以充分利用两者的优势。搜索导向的软件测试的有效性取决于其适应性函数的质量。
  • 程序分析技术:使用可靠的程序分析技术来降低路径探索的复杂性 解决路径爆炸问题的另一个关键方法是综合运用程序分析软件验证的各种思想,以合理的方式降低路径探索的复杂性。
    (a)一种可以用来减少探索路径数量的简单方法是静态地合并程序路径,使用select表达式,然后直接传递给约束求解器。虽然这种方法在许多情况下是有效的,但会将复杂性传递给了约束求解器,增加约束求解器的压力。
    (b)另一种组合技术通过在后续计算中缓存和重用对低级函数的分析来改进符号执行,为每个测试的函数计算函数摘要,摘要用输入和输出的前后条件描述,并在更高级的函数中重用这些摘要。
    (c)避免重复探索代码的一种方法是在探索过程中自动剪枝冗余路径。例如,RWset技术利用了这样一个关键洞察:如果程序路径到达与先前探索的路径相同的程序点,带有相同的符号约束,那么该路径将继续从那一点执行完全相同,因此可以被丢弃。
    (2)约束求解
    约束求解仍然是符号执行的关键瓶颈之一,通常主导运行时。事实上,符号执行在某些程序上无法扩展的一个关键原因是程序的代码生成了求解器无法求解的查询。约束求解的优化主要有两个方面:
  • 无关的约束消除: 在符号执行中,绝大多数查询是为了确定是否采用某个分支方向的可行性。例如,在符号执行的共轭变体中,现有路径约束的一个分支谓词被否定,然后检查生成的约束集以确定程序是否可以采用分支的另一侧,对应于否定的约束。一般来说,程序分支仅取决于少量的程序变量,因此仅取决于路径条件中的少量约束。一种有效的优化是从路径条件中删除那些在决定当前分支结果时无关紧要的约束。例如,假设当前执行的路径条件为 (x + y > 10) ∧ (z > 0) ∧ (y < 12) ∧ (z − x = 0),并且假设我们要通过解决 (x + y > 10) ∧ (z > 0) ∧ ¬(y < 12) 生成新的输入,其中 ¬(y < 12) 是我们试图确定可行性的否定分支条件。我们可以消除对 z 的约束,因为这个约束不能影响 y < 12 分支的结果。简化后的约束集的解将给出 x 和 y 的新值,我们可以使用当前执行的 z 的值来生成新的输入。
  • 增量求解: 在符号执行过程中生成的约束集的一个重要特征是,约束条件是根据程序源代码中的一组固定的静态分支表达的,因此许多路径具有相似的约束集。我们可以通过重用先前类似查询的结果来提高约束求解的速度,其中CUTE和KLEE使用这种措施来优化约束求解,如KLEE使用的反例缓存。在KLEE中,所有查询结果都存储在一个缓存中,该缓存将约束集映射到具体变量分配(如果约束集不可满足,则映射到特殊的无解标志)。例如,该缓存中的一个映射可以是 (x + y < 10) ∧ (x > 5) ⇒ {x = 6, y = 3}。使用这些映射,KLEE 可以快速回答多种相似查询,涉及已经缓存的约束集的子集和超集。例如,如果遇到已缓存的约束集的子集,KLEE 可以简单地返回缓存的解决方案,因为从约束集中移除约束不会使现有解失效。如果遇到已缓存的约束集的超集,KLEE 可以快速检查缓存的解是否仍然有效,通过将这些值代入超集中。例如,KLEE 可以快速检查 {x = 6, y = 3} 是否仍然是查询 (x + y < 10) ∧ (x > 5) ∧ (y ≥ 0) 的有效解,这是 (x + y < 10) ∧ (x > 5) 的超集。
4、动态符号执行工具
  • DART: DART是第一个将动态测试生成与随机测试和模型检查技术结合起来的混合执行工具,其目标是系统地执行程序的所有(或尽可能多的)执行路径,并在每次执行中检查各种类型的错误。
  • CUTE: CUTE扩展了 DART 以处理使用指针操作操作动态数据结构的多线程程序。在多线程程序中,CUTE 将混合符号执行与动态偏序缩减相结合,以系统地生成测试输入和线程调度。
  • CREST: CREST是一个用于对 C 程序进行混合执行测试的开源工具。CREST 是一个可扩展的平台,用于构建和实验选择要探索的路径的启发式方法。
  • EXE: EXE是一个C语言符号执行工具,用于对复杂软件进行全面的测试,重点是系统代码的测试。为了处理系统代码的复杂性,EXE 对内存进行了位级精度建模。系统代码通常将内存视为无类型字节,并以多种方式观察同一个内存位置。此外,EXE能够快速解决真实代码生成的约束,通过在其专门设计的约束求解器STP中实现的低层次优化的组合,以及一系列更高层次的优化,如缓存和无关约束消除。
  • KLEE: KLEE是基于EXE实现的,构建在 LLVM 编译器基础设施之上。与 EXE 类似,KLEE执行混合的具体/符号执行,以位级准确度对内存进行建模,采用各种约束求解优化,并使用搜索启发式方法以获得高代码覆盖率。KLEE 相对于 EXE 的一个关键改进是它能够存储更多的并发状态,通过利用对象级别的状态共享,而不是像 EXE 中那样在页面级别进行共享。另一个重要的改进是其增强的处理与外部环境的交互能力,例如与从文件系统或网络读取的数据的交互,通过提供设计用于探索与外部世界的所有可能合法交互的模型。由于这些特性,EXE 和 KLEE 已成功用于检查许多不同的软件系统,包括网络服务器、文件系统、设备驱动程序和库代码。
    参考文献:Symbolic Execution for Software Testing:Three Decades Later
  • 17
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值