Now You See Me: Real-time Dynamic Function Call Detection
Remarks
Conference: ACSAC 2018
Full Paper: https://syssec.mistakenot.net/papers/acsac18.pdf
Artifact: https://github.com/Frky/iCi
Summary
作者主要解决了识别哪些JMP指令实际上是函数调用的问题
The authors define and address the problem of dynamic function call detection at runtime. The presented approach is a dynamic heuristic-based approach, namely iCi (Intuitive Call Instrumentation), which can efficiently distinguish JMP-based calls from intra-procedural jumps in real-time during the execution of x86-64 binaries without source code, debug information, symbol tables or static analysis. The evaluation is conducted on 98 Coreutils programs, 13 Binutils program, FFmpeg, evince and SPEC2006, produces detailed and compelling results.
Reading Notes
1. Introduction
Q: 为什么需要动态检测函数调用?
A: 很多二进制动态分析技术很大程度上依赖于可靠的函数调用识别;有助于构造精确的Call Graph;对于CFI这种实时分析技术帮助很大。
Q: 目前有哪些可行的解决方案
A: 对于该问题,可能的解决方案包括静态分析和动态分析,iCi属于实时动态分析,并且不需要先验知识。
Q: 本文研究的问题是什么?
A: 本文的研究问题可以形式化表述为:
- 给定二进制程序 B
- 在程序 B 中实现的函数的集合 F(B)
- 二进制程序 B 中的一次执行 e
- 一次程序执行e所执行的指令序列用 I(e) 表示
iCi 解决的问题是:在二进制程序 B 的一次指令执行序列 I(e) ,判断 I(e)中的指令i是否是一次函数调用 ?
由于call指令是函数调用的其中一种表现形式,且容易检测,所以本文的主要工作其实识别哪些JMP指令实际上是函数调用的问题。该工作仅面向编译器生成的代码,不考虑多入口、函数交错(手写汇编才会出现);要求返回地址在栈上,不考虑混淆过的代码。(其研究问题本质上也可以看作是一个type inference的问题,即JMP的操作数的类型是否是函数指针。)
2. Example
让我们考虑一个ffmpeg程序的代码片段,如下图所示,它来自在-O2优化级别使用GCC5.4编译的ffmpeg。尽管未优化的二进制文件中的函数调用主要通过CALL指令实现,但当启用优化时,这种情况会发生变化。例如,公共尾部调用优化发出基于JMP的函数调用。
在一次动态执行中,函数调用的下一条指令实际上是被调用函数的第一条指令,并没有明显的函数标识,如下图所示,因此动态地确定一个JMP指令是否是函数调用是很困难的。
3. Naive Approach
先给出两种naive的实现方式:
- jcall: 将所有的CALL算作函数调用,包括经过.plt的JMP指令
- jmp: 把所有的JMP指令都当成函数调用
如前面所述,本文在这里要解决的问题是动态和实时地捕获对嵌入到给定二进制文件中的任何函数的每个调用。一个显而易见的方法是对每个调用指令进行测试。但是,有些调用不是基于调用指令的。特别是,对动态加载库的每次调用都要经过.plt,它使用JMP指令来实现这一点。为了强调本文解决的问题,作者先提出了两种简单的方法,并证明它们要么产生大量的漏报,要么产生大量的误报(jcall不会产生误报,但会错过一些调用,特别是编译器的优化。jmp不会产生假阴性,但会将每个跳转解释为一个调用,这会导致大量的假阳性。)。
4. Authors’ Approach
对于给定的JMP指令,作者提供了一些启发式方法来决定是否应该将其视为函数调用。此外,作者希望方法是实时的。这意味着,对于每个JMP指令,要在执行时决定如何对其进行分类。这一要求是因为作者希望这种方法适用于实时分析,例如CFI。本文的方法由两个主要部分组成:(1)捕捉明显的函数调用和(2)过滤JMP以确定哪些是函数调用,哪些不是函数调用。每一个部分都可能导致对CALL的检测。每次检测到调用时,我们都会将目标添加到已知函数列表中。因此,该列表在执行过程中得到了增强。虽然这不是本文的目的,但是结果也可以用于检测函数。
(1) Catching obvious calls.
- CALL
- PLT中的JMP:除了PLT内跳转都是
(2) Filtering jumps
下图说明了作者为此对每个JMP指令执行的不同步骤。作者提出了几个基于启发式的检查,可以分为两类:
- exclusion checks(排除检查)-如果其中一个检查失败,则JMP不被视为调用;
- inclusion checks(包含检查)-如果其中一个检查通过,则JMP被视为调用。
iCi在排除检查之后执行包含检查。此外,如果这两类检查都没有得出结论,那么我们将应用一个可以在实现中配置的默认策略(将JMP指令视为非调用。)。
另一个需要注意的重要点是,在执行过程中,iCi会记住以前的决定。特别是,每次检测到调用时(基于调用指令、从.plt跳转或通过包含检查的JMP指令),都会将此调用的目标添加到已知入口点列表中。这一点很重要,因为这意味着执行的次数越多,iCi知道的入口点就越多,检测的准确性和效率也就越高。
iCi会跟踪以下信息:
- 当前程序计数器(PC)%eip,
- 当前JMP的目标
- 当前函数入口点(上一个检出的调用地址,可能不准确)
- 当前栈状态和上次调用的栈状态(栈顶指针)
- 当前返回地址
Exclusion check
- PLT内跳转
- 内部前跳:目标在PC和当前函数入口之间
- 内部后跳:目标在PC和当前函数已知的最高返回位置之间(如果函数边界的信息是错的,会出假阴性,但结果上看这很少发生)
- 栈状态不一致:尾递归调用发生前应该清栈,栈顶只剩返回地址
Inclusion check
- 跳转到已知的入口点:也有可能是从函数的第一条指令开始的循环,但是这样不会符合栈状态一致性的要求,会在之前被排除;函数开始后一般也会先进行一些准备,这种情况很少见
- 外部前跳:目标在当前函数入口点之前;实际上是下一条规则的一个特例
外部后跳 - 目标和PC之间跨入口点:因为所比较的入口点不确定,是最耗时的检测,所以最后进行
缓存结果
- 只缓存排除的结果(跳转语句位置);接受的结果被保存为入口点(跳转目标),之后再遇到需要额外再过一次排除检测
- 默认拒绝时也不添加到缓存,因为可能是信息不充分导致的无法判断,之后可能还有救
函数边界识别
- 识别出一个调用才添加入口点
- 最高返回地址初始化为入口点,同时跟踪RET指令和返回地址栈,以此更新每个函数的返回地址
5. Evaluation
(1)实验配置:
实验将ici对比了一下三种方法:
oracle: a very unefficient symbol-based instrumentation that gives the ground-truth
jcall: instrumentation of every CALL instruction, plus every JMP instruction that comes from the .plt section
jmp: instrumentation of every CALL instruction, plus every JMP instruction (each one is considered as a call)
实验使用的目标程序:
coreutils: 98 programs
binutils: 13 programs
evince: 1 program
ffmpeg: 1 program
SPEC CPU2006 (C/C++): 19 programs
(2)F-Score评价
以下几张图给出了F-Score评价的主要结果。实验结果表明,在coreutils、binutils、evince和ffmpeg上,iCi的f值总是高于其他朴素技术的f值。它还表明,这种方法的准确性在编译时不会受到优化的影响。
作者还在SPEC CPU2006上测试了iCi方法。作者没有用每个优化级别测试SPEC(只用-O2测试),原因有二:
这是编译SPEC CPU2006时的默认选项,配置文件中没有手动更改,
这是最具挑战性的优化级别。
下图为在SPEC CPU2006上的实验结果,iCi的F值几乎全是1.000
(3)Overhead评价
实验还介绍不同实现的性能开销。作者使用jmp实现作为参考。这种选择的原因是,实验表明,要捕获每个调用,需要在调用指令的同时插入JMP指令。然后,开销就变成了区分常规跳转和基于JMP的调用的成本。
(4)Influence of the Compiler
下表给出了使用gcc-6.3和clang-3.8编译的所有Coreutils程序的iCi分数和其他分析。这些实验表明,尽管jmp在两个编译器上获得了不同的结果(例如at-O1,gcc的f得分为0.518,clang的f得分为0.360),jcall和iCi在每个编译器上的结果都非常相似。
(5)主要实验结果
本文的实验表明,jcall和jmp这类简单的调用检测实现都不是非常有效(要么丢失函数调用,要么产生大量误报),特别是在编译器优化(O2,O3)的情况下。另一方面,本文的实现iCi给出了非常准确的结果,因为它能捕获99.99%的函数调用,而没有显著的误报,但是性能上,iCi劣于jcall但优于jmp(因为有排除缓存)。