RAP-optimizations/doc/PAX-rap-optimizations-paper/paper.md

14 KiB
Raw Blame History

documentclass
ctexart

PaX/Grsecurity RAP及其优化

zet

zet (feqin1023@gmail.com), HardenedLinux.org, GuangZhou, China

简介

Return-Oriented Programming (ROP)是一种比较高级攻击的方式能够利用现有代码通过找到的gadgets串来执行攻击者想进行的 任何操作现有的防御手段只有Control-flow integrityCFI是针对这种攻击进行防御的。此外常规针对存储破坏的防御canary, NX, ASLR等对于这种攻击无效。

PaX/Grsecurity RAP是一种CFI的实现方式本文描述了RAP的实现以及针对RAP的优化以及重新实现的hl-cfi的实现。

1 导引

Return-Oriented Programming (ROP)[@Krahmer05]是一种比较高级攻击的方式是code-reuse attack的一种能够利用现有代码通过找 到的gadgets串来执行攻击者想进行的任何操作已经有研究者表明gadgets是图灵完备的[@Shacham07]。现有的防御手段只有 Control-flow integrity(CFI)是针对这种攻击进行防御的。此外常规针对存储破坏的防御canary, NX, ASLR等对于这种攻击无效。

自从CFI[@Abadi05]提出以来各种实现层出不群包括gcc upstream里的vtv以及LLVM cfi。不过这些实现有明显的硬伤vtv防御 virtual tableLLVM cfi只是一个forward cfi实现没有backward部分。比较而言PaX/Grsecurity RAP[@PaX18]对于kernel级别的 防护是最理想的选择不经包括forward而且包括backward实现backward cfi的实现应该是业界第一个也是唯一一个可以进入生产环境 的。

贡献

本文主要是贡献是从编译器角度分析PaX RAP以及Hardlinux hl-cfi[@Hardenedlinux18]的改进以及实现。

内容

本文的内容安排包括2 背景3 算法与实现4 结论以及未来工作。

2 背景

Return-Oriented Programming (ROP)[@Krahmer05]是return-into-libc[@Pincus04]的一种一般形式也就是最终的目标不是进入libc。 ROP的攻击由串接执行一连串的gadgets进行gadgets是一些几条汇编语言的代码片段一般来说以 ret 指令结尾因为ROP gadgets 是图灵完备的[@Shacham07],理论上来说,ROP可以按照攻击者的目的来做任何计算。

下图简要描述了一个常规的ROP攻击图示图片来源于[@Carlini14]。左边的Stack是用户stack初始化为gadgets的地址右边的 Instructions以ret结尾就是gadgets由左边stack处的地址实线指向。这个攻击的结果是将数0x32400加到地址0x4a304120所存储的值。

ROP图示{图1: ROP图示}

由于这个攻击可以看到ROP发生的根本原因就是 call/jmp/ret 违反了原始代码控制流。基于这个因素所以有研究者提出了 Control-flow integrityCFI)[@Abadi05]。原理就是在编译器编译最开始的未经过破坏的源代码的时候就分析控制流, 在 call/jmp/ret 发生的地方插入检测代码,在代码运行的时候来判断报错是否有攻击行为发生。

关于ROP更详细的描述可以参考[@Krahmer05][@Shacham07][@Carlini14]。

3 算法与实现

下面将描述PaX RAP的算法及其实现分析这种方式带来的性能损耗和Hardenedlinux社区所做的优化改进。

由上一节背景的描述可知ROP发生的根源就是因为违反了原始代码的控制流。所以对于相应的检测防御也很简单在原始控制流转移的 地方由编译器插入检测代码。这其中就会有一个trade-off的考虑:怎么样在保证一定精度的情况下作出防御?

对于控制流的转移可以分为:

  1. 直接调用的call/jmp
  2. 间接调用的call/jmp - RAP forward cfi
  3. 返回 - RAP backward cfi

对于情况1来说不需要考虑因为直接调用都是位于 .text只读的section不会发生控制流ROP的攻击。所以ROP的防御就是针对情况2和3。

理论上来说最理想的防御当然是:对于每个会被间接调用的函数在进入该函数的时候作出检测,对于每个 ret 作出检测。对于情况3的 ret 来说可以确定有 ret 就意味着返回也就是我们必须处理的检测点也就是backward cfi的实现点也就是对于所有 ret 都需要处理。但是对于情况2来说复杂了许多所有优化以及考虑都在于情况2里面。对于会被间接调用的函数这个问题是一个NP问题是 一个典型的pointer analysis的问题也就是求解一个函数指针的指向范围是什么相对地也就是求一个函数是不是会被间接调用也就是 有没有被函数指针所指向这个问题在编译器领域大概伴随着优化编译器的最开始研究大约是1970年一直进行到现在。

PaX RAP是使用gcc plugin[@gccinternals]来实现的gcc plugin是gcc提供给第三方程序的一个钩子可以回调第三方的代码可以在 gcc内部满足条件的时候输出信息可以在gcc某个pass之后输出信息也可以调用第三方的代码作为某个pass只要根据规范提供给gcc想 插入的pass点就行了。其他的详细功能请参考gcc-internals[@gccinternals]或者gcc代码。

RAP forward cfi

RAP的实现是做为几个gcc pass通过gcc plugin插入gcc来进行的。由上一段的分析知道求解精确的函数是否被间接调用以及函数指针的指 向范围是一个NP问题所以学术界和工程界有数不清的学术paper以及实现出现在gcc内部的实现来说有一个flow-insensitive analysis 和一个比较精确的flow-sensitive的算法insensitive的算法非常简单就是根据编译器的分析以及函数的声明来判断函数有没有可能 被间接调用代码如下比如判断是不是static有没有取过函数的地址。

flow-insensitive analysis :

static inline bool
may_be_aliased (const_tree var) {
  return (TREE_CODE (var) != CONST_DECL  
    && !((TREE_STATIC (var) || TREE_PUBLIC (var) || DECL_EXTERNAL (var)) 
    && TREE_READONLY (var)
    && !TYPE_NEEDS_CONSTRUCTING (TREE_TYPE (var)))
    && (TREE_PUBLIC (var) || DECL_EXTERNAL (var) || TREE_ADDRESSABLE (var)));
}

flow-sensitive analysis算法无数gcc的实现基本上是描述于paper[@Hardekopf09]里的算法在gcc summit也有开发者的 paper[@Berlin05]描述。是基于Constraint set的一类算法只不过是在gcc中间代码tree-ssa的基础上针对ssa做了改进这样基于 ssa的一个稀疏算法。详细的实现参考[@Hardekopf09][@Berlin05]。这是一个流敏感的算法也就是对于block内部的语句有分析也会 进入函数的内部。总是是一个比较精确的算法。

介绍完RAP遇到的难点接着介绍RAP对间接函数的处理RAP做了一个更粗略的trade-off当函数被间接调用的时候当前的调用点 除了函数指针没有任何信息这里dereference函数指针会得到函数类型函数类型和函数指针的指向是静态分析唯一能够知道的信息 RAP放弃了函数指针的指向只抽取了函数的类型对得到的函数类型做hash运算然后得到一个hash值这个hash值就是RAP间接检测 的标准。hash值的计算算法是SipHash-2-4[@Aumasson12],函数类型则是按照标准规范包括返回值和参数[@C11-standard]。接着在间接 调用的地方插入检测代码。RAP的检测代码插入在gcc tree-ssa[@Novillo03][@Novillo04]的表示层,在gcc内部表示来说tree-ssa之前是 tree-ast[@Merrill03],而之后是rtl[@gccinternals]。tree-ssa是几乎整个gcc重要优化pass的实现点。

PaX RAP forward cfi alogrithms:

for all gimple code of all function
  hashValue = computeHash(currentFunctionType);
  insertHashValueBeforeCurrentFunction(hashValue);
  if (isFunctionPointer(currentPointer))
    functionType = *currentPointer;
    hashValue = computeHash(functionType);
    insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue);
insertHashValueBeforeCurrentFunction(hashValue)
  fprintf(asm_out_file, "\t.quad %#llx", hashValue);

insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue);
  value = (long)*((long *)currentPointer - 8);
    if (value == hashValue)
      currentPointer();
    else
      catchAttack();

上面给出的是RAP forward cfi实现的算法伪代码在RAP的实现上有三个问题

  1. 因为RAP没有使用pointer analysis所以会对全部函数都输出hash值。
  2. 上面的伪代码在RAP的实现上是在tree-ssa表示层所以其实是几条gimple等效的伪代码但是RAP的实现上这些gimple代码是作为一个整体插入gcc的而且插入的位置是gcc的所有tree-ssa优化pass之后也就是gcc根本不会感知到这些代码的存在也就是说gcc不会去分析这些代码也不会去优化这些代码。
  3. 安全上的考虑RAP的实现是针对函数类型编码hash所有函数类型都是同一个hash值所以这是一个可能的漏洞。

基于以上的三个问题所以我们给出了Hardenedlinux hl-cfi的实现(hl的意思high level)。

hl-cfi alogrithms:

for all gimple code of all function
  hashValue = computeHash(currentFunctionType);
  if (isCurrentFunctionMaybeAttacked(currentFunction))
    insertHashValueBeforeCurrentFunction(hashValue);
  if (isFunctionPointer(currentPointer))
    functionType = *currentPointer;
      hashValue = computeHash(functionType);
      insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue);

解决RAP实现上的问题1

isCurrentFunctionMaybeAttacked(currentFunction)
  lookupPointerAnalysis(currentFunction)

解决RAP实现上的问题2

insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue)

  +-------
  stmt1;
  call fptr;
  +-------
     
  change to =>
     
  // insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue)
  +-------
  stmt1;
  lhs_1 = t_;
  ne_expr (lhs_1, s_);  // hsahValueCheck()
  // FALLTHRU
  <bb ???> # true
  cfi_catch();
  <bb ???> # false
  call fptr;
  +--------

insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue)的插入实现为block级别的gimple code而且hl-cfi 的插入点是在tree-ssa pass的最开始处在pass_build_ssa_passes之前所以这里需要实现上的处理需要模拟gcc已经跑过的所有代 码对于一个假想的检测insertHashValueCheckBeforeCurrentIndirectCall()函数的表示在当前pass点是个什么样子然后插入对应的代 码。而且对于hl-cfi来说我们需要使用gcc pointer analysis所以对于isCurrentFunctionMaybeAttacked()的调用又得保证在 pass_ipa_pta(gcc flow-sensitive analysis)之后。cfi的检测代码的优化是一个复杂的问题因为这是一个运行时才能知道的函数指针 值,常规静态分析优化[@Gupta93]基本上没有用。所以hl-cfi的实现相对来说考虑的只能是尽可能利用gcc现有的优化检测代码的hash 常数可以被gcc做register promotion[@Makarov07]以及分析信息传播到整个fucntion里面[@Novillo05]进一步给其他pass提供机会。 在hl-cfi构建block以及edge的时候给gcc提供profile信息[@Hubicka05][@Ramasamy08]使用profile的相关基础代码。给后续的优化 pass比如code placement以及cache优化的pass提供信息。

解决RAP实现问题3

// 这部分代码因为复杂度的原因并没有在hl-cfi里面实现下面只是提供算法。
 
for all gimple code of all function
  if (isCurrentFunctionMaybeAttacked(currentFunction))
    insertHashValueBeforeCurrentFunction(hashValue);
  if (isFunctionPointer(currentPointer))
    if (pointerAliasSetChanged(currentPointer, currentCodePlace))
      functionType = *currentPointer;
        // 根据当前的代码分析结果来给一个变化的seed来得到一个
        // 跟当前函数指针和函数类型相关的hash值。
        hashValue = computeHash(functionType, seed);
        insertCurrentHashValue(currentPointer, functionType, currentCodePlace);
    else
        hashValue = lookupHashValue(currentPointer, fucntionType, currentCodePlace);
        insertHashValueCheckBeforeCurrentIndirectCall(currentPointer, hashValue);

为了解决问题3计算一个动态的hash值根据函数指针的变化而变化。

RAP backward cfi

为了解决 ret 的问题PaX Team提出了世界上大概唯一一个backward cfi的软件实现方案。

for all rtl code of all function
  if (isCurrentRTLIsRet(currentRTL))
    // 调用gcc内置函数得到返回地址。
    retAddress = __builtin_return_address();
    // 在gcc里这个函数类型通过分析可以知道的。
    hashValue = getHash(functionType);
    insertRetCheck(retAddresshashValue);

对于这个检测函数的描述跟RAP实现略有差异RAP比较的是一个hashValue的取反的值。

insertRetCheck(retAddress, hashValue)
  if (long*((long *)retAddress - 8) != hashValue)
    catchAttack();

对于backward cfi来说这大概是业界唯一的实现而且跟RAP forward cfi的一样也是一串没有经过编译器优化的代码直接出现在输出 的object里不过这是唯一的算法实在想不到优化的方案只能期待硬件更新支持backward cfi比如类似ARM PA[@zet17]。

4 结论

对于hl-cfi的实现和RAP的实现在spec CPU2017上做了一个对比测试因为RAP本身是为了linux kernel防御在优化RAP以及开发hl-cfi 的时候为了方便测试以及开发我们抽取出了RAP作为一个独立的项目hl-cfi也是如此正因为这是两个针对kernel的项目因为应 用代码外部library依赖的问题CPU2017的很多测试跑不过跑过的数据如下

RAP hl-cfi hl-cfi - RAP / RAP
2.15 2.35 9.3%

大约有9.3%的性能提升。

由于hl-cfi的实现依赖于pointer analysis如果使用lto[@Glek10]来编译linux kernel相信hl-cfi应该会有一个更佳的表现。lto模 式更好的一个特点是只需要修改linux kernel的编译构建脚本对于hl-cfi本身不需要做改变。期待未来lto的编译能够普及。

引用