前前前一阵子打了Codegate CTF 2024 Preliminary,有个逆向题(prism_messenger)是Rust+OLLVM平坦化,真的是天才组合啊😅!恶心到我了,同时直接把D810给干崩溃了,qiling的模拟执行也不好使了,事后不得不多瞅了它几眼。

特点

看起来这个Rust程序像是通过源代码级别的宏展开生成的平坦化代码,它有很多跟一般平坦化程序不一样的特点,大部分特点很像是先平坦化后再被编译器优化所得到的结果。以去平坦化的三个步骤来考察:

识别不同块的功能

在这里也就是说识别一个块是分配块、预分配块、虚假块、还是真实块的问题。

首先预分配块可以说是不存在的,或者说跟分配块所合并了,因为大部分真实块到最后会直接跳转到分配块,而不会经过预分配块的中转。也就是说,分配块的前继都是各个真实块,已经没有统一的预分配块了。

其次,真实块也不再是单独一块,而是被分割成好几个子块,有些子块被多次引用,很像是编译优化压缩函数大小导致的。这就为识别谁是真实块带来了很大的挑战。

image-20240705235018262

识别真实块的后继块

真实块中的条件分支可能不再单纯只是根据条件不同修改状态变量实现真实块之间的条件跳转,而是属于真实逻辑的一部分:

image-20240706000158968

一个真实块可能会有不止两处的状态变量修改:

image-20240706000453327

甚至还能碰上switch语句:

image-20240706000556612

对函数进行patch,重建控制流

因为真实块不再那么有规律,所以在哪里进行patch、patch的跳转地址应该是哪里等就是难题了。

如何应对?

识别不同块的功能

这个还是比较容易处理的。对于预分配块和分配块,如果将分配块定义为初始块的唯一后继,预分配块是入度最多的块的话,那么这两者在这里就是重合的。

对于真实块,单从模式匹配上很难确定,因为它内部不同,后继也可能是真实块的子块,一般的匹配行不通。但是,可以发现虚假块的指令模式是固定的,所以我们可以先区分其它种类的块,最后剩下的就是真实块了!

并且,虚假块的后继如果是真实块的话,那么这个真实块就是一个“完整的”真实块的起始子块,我们可以从这个子块出发,BFS遍历收集它的后继,直到返回块或分配块,这样就可以完整收集齐一个“完整的”真实块了!

识别真实块的后继块

这个问题要突破的点在于:

  • 如何识别一个“完整的”真实块中所有有关状态变量修改的地方,即跳转到其它“完整的”真实块前的处理?
  • 如何依次执行这些修改的地方,触发不同的分支,收集不同“完整的”真实块之间连接?

对于前者,大概需要很多的静态分析和启发式规则来识别;对于后者,大概需要对条件树做DFS遍历才行。具体的好想法我也没什么思路。。

对函数进行patch,重建控制流

首先对于patch后跳转的位置,自然就是不同的“完整的”真实块的起始子块的地址了。

对于patch的区域,在这个x64程序中可以依照以下规则:

  • patch时,保留比较指令和set指令,根据cmov指令确定最终的跳转指令,跳转地址就是某个真实块组的起始地址,中间的指令应该可以直接nop
  • patch区域可以由上面的方法决定。如何确定一个区域可以patch:比较指令为开始,cmov指令为结尾,最后cmov指令应该是cmovxx reg, xxx。具体哪个reg可以通过查找当前及后继块的jmp pre_dispatcher指令,然后查看前一条指令是否是xxx [disp], reg,其中reg就是我们要的。
  • 对于没有cmov指令的真实块组,视为jmp块,找到jmp pre_dispatcher,patch它及其前一条指令(保证有足够空间patch)

具体实现

目前只实现了第一个步骤,也就是区分不同块的功能以及收集“完整的”真实块,具体可以查看BlocksPiece类型的相关引用:https://github.com/JANlittle/qiling/blob/rust-ollvm/qiling/extensions/idaplugin/qilingida.py

为什么没实现后面的步骤?因为没时间弄了,以及我很怀疑谁会在生产环境里整出Rust+OLLVM平坦化的抽象玩意。。等什么时候生产环境里真的时不时有这东西的时候再说吧。