首页
论坛
课程
招聘
通过DWARF Expression将代码隐藏在栈展开过程中
2022-3-15 23:22 10660

通过DWARF Expression将代码隐藏在栈展开过程中

2022-3-15 23:22
10660

引言

当触发一个异常时,程序会沿着调用链不断向上进行栈展开,直到寻找到能处理这个异常的catch块。栈展开的过程也是一个程序运行状态恢复的过程,考虑如下调用链:f1 -> f2 -> f3,在f3发生了一个异常,在f1中的catch块能处理这个异常。为了能正常执行f1中的catch块,有必要恢复一些基本的程序状态,比如RBP与RSP寄存器,否则f1中的catch块就不能正常访问局部变量,即无法正常执行。

 

在类Unix系统中,由g++/clang++编译出的程序,是使用DWARF调试信息完成这个恢复过程的。早期的DWARF标准[1]只支持一些简单的原语以恢复运行状态,在DWARF 3标准中引入了一个DWARF Expression,可以将它理解为一个基于栈的虚拟机,由标准C++库解释执行它,它包含一系列的字节码,提供了读取程序运行内存的Handle,但不支持写入程序运行内存。在运行完一个DWARF Expression后,将处于栈顶的值作为这个DWARF Expression的值,可以将这个值赋值给寄存器。这个虚拟机提供了完备的算术,寻址,栈操作,乃至于流程转移指令,因此,它是图灵完备的。这些指向一个事实:我们可以将恶意代码/混淆代码转换成DWARF Expression,嵌入到程序中,在栈展开的过程中由标准C++库解释执行它们。

 

本文实现了一个DEMO,它可以自动化地将一个不包含内存访问指令与CALL指令的基本块转换为DWARF Expression,嵌入到程序中。

背景知识

clang++编译命令

  • 将.cpp文件编译为.s文件(汇编文件):clang++ -S -c x1.cpp x2.cpp ...
  • 将.s文件编译为可执行文件:clang++ x1.s x2.s ... -o a.elf
  • 将.bc文件编译为.s文件:llc x1.bc

理论上确实可以指定输出的汇编文件格式为Intel格式(AT&T属实是有点反人类了),但是我在clang++ 9上进行操作时,发现clang++ 9无法正常编译intel格式的.s文件。高版本的clang++我并没有测试过。

栈展开过程

CFA

CFA的全称是Canonical Frame Address,它的值是在执行(不是执行完)当前函数(callee)的caller的call指令时的RSP值。用一段代码说明如下:

1
2
3
4
5
6
7
caller:
push arg1    -->    RSP = 0xFFF8
push arg2    -->    RSP = 0xFFF0  (执行call指令时的RSP值在这
call callee  -->    RSP = 0xFFE8
 
callee:
push rbp     -->    CFA = 0xFFF0

当然这个例子有点问题,在x64 ABI中,栈参数的传递不再是使用push指令,而是使用mov [rsp + XX],arg的形式,RSP的值在函数运行过程中是不会改变的。

 

下图是一个包含了异常处理的函数的汇编表示。其中.cfi_startproc与.cfi_endproc指令会成对出现在函数的开头与末尾。.cfi_def_cfa_offset 16表示调整当前的CFA计算方式为CFA = RSP + 16,RSP是默认的参与计算的寄存器,也可以通过.cfi_def_cfa REG,OFFSET调整CFA计算方式为CFA = REG + OFFSET。以.cfi开头的指令都是伪指令,它们不会被编译成机器码出现在代码段中,而是被储存在.eh_frame块中。

 

.cfi_startproc指令实际上蕴含了一条默认的指令:.cfi_def_cfa_offset 8,也就是说,对于任意函数而言,它的初始CFA计算方式一定是CFA = RSP + 8,在RSP的值发生变化后,就会使用.cfi指令修改CFA的计算方式,比如在本例中,在执行完pushq %rax后,RSP的值发生了变化,于是使用.cfi_def_cfa_offset 16指令修改当前CFA的计算方式为CFA = RSP + 16。

 

关于.cfi系列指令,可以参考这份文档[2]。

 

图片描述

恢复其它寄存器

为了能正常执行catch块的代码,我们不仅仅需要恢复出CFA的值,还需要恢复出其它寄存器,比如最基本的RBP寄存器,没有它,我们可能无法访问局部变量与参数。如下图所示,.cfi_offset %rbp,-16定义了RBP = QWORD PTR [CFA - 16],也就是说,caller的RBP寄存器的值被保存在[CFA - 16]这个地址中。还可以从其它寄存器值恢复某个寄存器的值,比如.cfi_register r1, r2,表示caller的r1寄存器的值储存在r2中。

 

图片描述

DWARF Expression

.cfi系列指令的表达能力是很有限的,它只能使用REG + OFFSET的形式恢复CFA,只能使用[CFA + OFFSET]与REG1 = REG2的形式恢复其它寄存器。为了解决这个问题,DWARF 3标准引入了DWARF Expression。它是一个支持一系列操作的基于栈的虚拟机,关于它所支持的操作可以参考DWARF标准[1]的2.5.1节(General Operations)

 

DWARF Expression支持的操作可以分为编码(入栈立即数),寄存器寻址(入栈 REG + OFFSET),栈操作(SWAP,POP等操作),算术运算,流程转移。我简单介绍一些常用的操作。

编码(入栈立即数)

对于一条push imm32指令而言,立即数imm32的编码可以使用两种方式:1. 标准补码编码;2. U/SLEB128编码[3]。LEB128编码包含ULEB128与SLEB128,分别编码无符号数与有符号数,它的编码与解码过程在WIKI百科上的描述很详细,我就不在此赘述了。

 

虚拟机中单位元素的长度与当前机器上地址的长度相等,比如在AMD64架构下,地址的长度是int64,那么虚拟机中单位元素就是int64。

  • DW_OP_const1(2, 4, 8)u OP1(u_int8, u_int16, u_int32, u_int64)
    这4条指令都包含操作数OP1,OP1使用补码编码,语义都是将OP1压入栈中。如果OP1的长度小于单位元素长度,使用0补齐高位。
  • DW_OP_const1(2, 4, 8)s OP1(int8, int16, int32, int64)
    与上一条指令基本相同,区别是这条指令压入的是有符号数,而上一条指令压入的是无符号数。
  • DW_OP_constu OP1(ULEB128)
    这条指令包含操作数OP1,OP1使用ULEB128编码,它向栈中压入OP1。
  • DW_OP_consts OP1(SLEB128)
    与上一条指令基本相同,区别是OP1使用SLEB128编码,压入的是有符号数。

寄存器寻址

  • DW_OP_bregn OP1(SLEB128)
    n的取值可以是0-31,代表着寄存器的编号。AMD64环境中寄存器编号如下图所示。这条指令向栈中压入 REG + OP1,REG是由n指定的。注意压入的仅仅是地址,而不存在解引用的过程,解引用操作需要使用DW_OP_deref指令。

    图片描述

  • DW_OP_bregx OP1(LEB128), OP2(SLEB128)
    这条指令与上一条基本相同,不同的是REG使用操作数OP1指定了,OP1是寄存器编号。这条指令向栈中压入 REG + OP2,REG由OP1指定。

栈操作

  • DW_OP_drop
    从栈中弹出栈顶元素
  • DW_OP_pick OP1(u_int8)
    复制一份栈中第OP1个元素压入栈顶。栈中元素的编号从0开始,0是栈顶。
  • DW_OP_swap
    交换栈顶两个元素
  • DW_OP_deref
    弹出栈顶元素作为地址,解引用这个地址,值压入栈顶。
  • DW_OP_deref_size OP1(u_int8)
    与上一条指令基本相同,不同的是上一条指令无法控制读取的长度,只能是栈的单位元素的长度,这条指令可以控制读取长度。操作数OP1指示读取长度,是字节数。如果小于栈单位元素长度,用0补齐高位;如果大于栈单位元素长度则会报错。

算数运算指令

  • DW_OP_plus
    弹出栈顶两个元素,相加,值压入栈顶。
  • DW_OP_neg
    弹出栈顶元素,取负,值压入栈顶。注意虚拟机中没有sub操作,因此使用DW_OP_neg, DW_OP_plus来表示减法操作,即弹出栈顶两个元素,用栈顶第二个元素减去第一个元素,值压入栈顶。
  • DW_OP_mul
    乘法
  • D W_OP_mod
    求模
  • DW_OP_or, and, not, xor
    与,或,非,异或
  • DW_OP_shr, shl;ashr
    逻辑右移,左移;算数右移

控制流转移指令

  • DW_OP_le, ge, eq, lt, gt, ne
    弹出栈顶两个元素,栈顶第二个元素记为O2,栈顶元素记为O1
    比较两个操作数,O2 le, ge, eq, ... O1
    如果该表达式成立,把1压入栈顶,否则把0压入栈顶。
    比较是有符号形式的,le(less equal, <=), gt(great than, >)...
    可以将无符号数的比较转为有符号数的比较,如比较无符号数U1,U2大小,可以转换为 U1 - U2 与 0 的有符号大小比较。
  • DW_OP_bra OP1(int16_t)
    操作数OP1使用补码编码,并且是2字节大小。弹出栈顶元素,如果它非0,则跳转到OP1处执行,OP1表示偏移地址,它是从OP1后面开始算的。比如 DW_OP_bra 10 的字节码是 0x28 0x0A 0x00 ,假设 0x28 是第0个字节,0x00是第2个字节,那么跳转的目标就是第12个字节
  • DW_OP_skip OP1(int16_t)
    与上一条指令基本相同,区别是这条指令是无条件跳转。

在我们编写完一个DWARF Expression后,还需要通过DW_CFA_val_expression指令将这个DWARF Expression计算的值赋值给特定寄存器。

  • DW_CFA_val_expression REG(ULEB128), LENGTH_OF_EXPRESSION(ULEB128), EXPRESSION_CODE...
    REG是寄存器编号,LENGTH_OF_EXPRESSION是编写的Expression的字节码的长度,后面跟着的就是Expression的字节码。

DWARF Expression 存在的局限性

  • 不能在虚拟机内写入程序运行内存,而只能读取程序运行内存。也就是说,如果我们要写入内存,只能在退出虚拟机后写入。
  • DWARF Expression并不能”恢复“所有寄存器。严格来说并不是不能”恢复“所有寄存器,而是恢复之后又会调用一些C++标准库函数导致恢复的值又被破坏掉了,最终我们在catch块中没办法得到恢复的值。RBX,R12-R15寄存器是AMD64 ABI定义的由被调用者保护的寄存器,因此它们的值一定可以在catch块中被接收到。我个人建议使用这5个寄存器作为退出虚拟机时的输出,退出后可以将它们的值写入内存或者其它寄存器值。

方法

将汇编代码转换为DWARF Expression

注意DWARF Expression的局限性,它不能写入内存,并且不能“写入”保护寄存器以外的寄存器。我实现了一个初步的Demo,它可以将一个不包含内存访问指令与CALL指令的基本块从汇编代码转换为DWARF Expression。

 

由于保护寄存器以外的寄存器都是易失的,我的想法是被混淆的代码片段的输入(引用先于赋值)寄存器和输出寄存器都应该是保护寄存器的子集。为了让编译器生成这样的代码,我使用LLVM PASS实现了一种插桩方式,如下所示。

1
2
3
4
5
handle1()
 
需要混淆的代码片段
 
handle2()

由于代码片段的前面有对handle1函数的调用,因此编译器会将代码片段要引用的寄存器都转存在保护寄存器中;由于代码片段的后面有对handl2函数的调用,因此编译器也会将代码片段的输出全部放在保护寄存器中。这就生成了符合我们要求的代码。

 

而后,我的转换方案包含3步:

  1. 将汇编代码转换为二叉表达式树
  2. 后序遍历二叉树,得到后缀表达式
  3. 按顺序翻译后缀表达式就可以得到DWARF Expression

转换为二叉表达式树

二叉树的节点如下所示:

1
2
3
4
5
6
class ASTNode:
    def __init__(self, value, valtype, left, right):
        self.Value = value    #数值
        self.Type = valtype   #数值类型,可以是寄存器,常量,操作符
        self.Left = left
        self.Right = right

算法主要步骤如下:

  1. 初始化
    初始化寄存器环境regs字典,将所有寄存器的初始值都设置为ASTNode(reg, VAL_REG, None, None)

  2. 解析每一条指令
    如addq %reg1, %reg2指令可以解析为:regs[1] = ASTNode("+", VAL_OP, regs[op[0]], regs[op[1]]);addq $const, %reg1指令可以解析为:regs[1] = ASTNode("+", VAL_OP, regs[op[1]], ASTNode(int(op[0]), VAL_CONST, None, None))

需要注意的是对于addl, imul等指令的语义的设置。对于addl %eax, %ebx而言,ebx = (rax + rbx) & 0xFFFFFFFF,对于imull %eax,%ebx,%ecx而言,ecx = (rax * rbx) & 0xFFFFFFFF。可以只在获取最终结果时进行截断操作,而无需在计算前截断参与运算的寄存器。

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
def Block2AST(block):
    #DO NOT touch the shit of memory access, it's really a nightmare
    if not isHandleable(block):
        return None
 
    # Because we add call in front of block and after block, so, the block should not use regs
    # not included in RBX, R12-R15 before set them
 
    regs = {}
    for reg in REGISTER_NUMBER.keys():
        regs[reg] = ASTNode(REGISTER_NUMBER[reg], VAL_REG, None, None)
 
    for line in block:
        mm = line.split("\t")[0]
        op = line.split("\t")[1]
        if op.find("#") != -1:
            op = op[:op.find("#")]
        op = op.split(",")
        for i in range(len(op)):
            op[i] = op[i].strip()
            op[i] = op[i].replace("%", "")
            op[i] = op[i].replace("$", "")
 
        if mm == 'imulq':
            if len(op) == 3:
                if isREG(op[0]):
                    regs[op[2]] = ASTNode("*", VAL_OP, regs[op[0]], regs[op[1]])
                else:
                    regs[op[2]] = ASTNode("*", VAL_OP, ASTNode(int(op[0]), VAL_CONST, None, None), regs[op[1]])
            elif len(op) == 2:
                if isREG(op[0]):
                    regs[op[1]] = ASTNode("*", VAL_OP, regs[op[0]], regs[op[1]])
                else:
                    regs[op[1]] = ASTNode("*", VAL_OP, ASTNode(int(op[0]), VAL_CONST, None, None), regs[op[1]])
            elif len(op) == 1:
                print("ERROR: IMUL WITH ONLY 1 OP")
                exit(0)
        elif mm == 'movabsq':
            regs[op[1]] = ASTNode(int(op[0]), VAL_CONST, None, None)
        elif mm == 'addq':
            if isREG(op[0]):
                regs[op[1]] = ASTNode("+", VAL_OP, regs[op[0]], regs[op[1]])
            #......省略,模拟指令语义都是一些很dirty的工作
            else:
                regs[op[1]] = ASTNode("+", VAL_OP, ASTNode(int(op[0]), VAL_CONST, None, None), regs[op[1]])
        elif mm == 'movq':
            regs[op[1]] = regs[op[0]]
        elif mm == 'shrq':
            if isREG(op[0]):
                print("ERROR: SHRQ WITH REG OP2")
                exit(0)
            else:
                regs[op[1]] = ASTNode(">>", VAL_OP, regs[op[1]], ASTNode(int(op[0]), VAL_CONST, None, None))
        else:
            print("ERROR: " + mm + " UNSUPPORTED")
    return regs

获取后缀表达式

只是一个简单的后序遍历。

1
2
3
4
5
6
7
def getPostFix(root):
    if root.Left == None and root.Right == None:
        return val2Str(root)
 
    s1 = getPostFix(root.Left)
    s2 = getPostFix(root.Right)
    return s1 + " " + s2 + " " + val2Str(root)

后缀表达式转为DWARF Expression

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
def postFix2DWARF(s):
    code = s.split(" ")
    dwarf = []
    for c in code:
        c = c.strip()
        if is64REG(c):
            dwarf += DW_push_reg(REGISTER_NUMBER[c])
        elif c == '<<':
            dwarf += DW_shl()
        elif c == '>>':
            dwarf += DW_shr()
        elif c == '+':
            dwarf += DW_add()
        elif c == '-':
            dwarf += DW_sub()
        elif c == '&':
            dwarf += DW_and()
        elif c == '|':
            dwarf += DW_or()
        elif c == '^':
            dwarf += DW_xor()
        elif c == '*':
            dwarf += DW_mul()
        elif c == '%':
            dwarf += DW_mod()
        else:
            dwarf += DW_push_imm(int(c))
 
    return dwarf

DW_等函数都是编码字节码的,如下图所示。

 

图片描述

 

还要再套一次DW_CFA_val_expression才形成能嵌入程序的DWARF Expression。

1
2
block_code = postFix2DWARF(post_fix)
block_code = DW_CFA_val_expression(REGISTER_NUMBER[reg], len(block_code)) + block_code

注意,由于clang++与g++的汇编器都没有实现对DWARF Expression的完善支持,所以我们只能使用.cfi_escape指令以直接嵌入字节码的形式插入DWARF Expression。而且.cfi_escape指令一次只能嵌入一个字节,也就是说只能这样嵌入。

 

图片描述

 

而如果我们在DWARF Expression中使用了全局变量的话,很难通过.cfi_escape指令将全局变量的地址编码进DWARF Expression。我搞了一整个下午的教训如下图所示。还是先直接跳过包含内存访问的代码片段吧。

 

图片描述

输出.cfi_escape指令

在模拟执行完代码片段后,我们统计那些值被修改的保护寄存器,它们储存的就是代码片段的输出,输出它们的DWARF Expression。

1
2
3
4
5
6
7
if reg in ['rbx', 'r12', 'r13', 'r14', 'r15']:
    if not (regs[reg].Left == None and regs[reg].Right == None and
            regs[reg].Value == REGISTER_NUMBER[reg] and regs[reg].Type == VAL_REG):
        block_code = postFix2DWARF(post_fix)
        block_code = DW_CFA_val_expression(REGISTER_NUMBER[reg], len(block_code)) + block_code
        print(reg)
        print(".cfi_escape " + str(block_code)[1:len(str(block_code)) - 1])

最后,整个输出如下图所示。其中RESULT是模拟计算二叉表达式树的结果,用它来调试指令语义建模错误比较方便。
图片描述

将DWARF Expression嵌入程序

在上一节中,我们对代码进行了插桩处理。插桩的函数是handle1与handle2,它们的代码如下。我使用noline标记了这些函数防止内联,在catch块中插入了一个对全局变量x赋值的代码,这是防止编译器把catch块优化掉。handle2函数与handle1函数一样,它的包装链条是 handle2 -> wrapper2 -> throw2。

 

图片描述

 

我们要在程序中嵌入DWARF Expression有以下步骤,这些步骤都需要在.s文件中操作,操作完成后再编译为可执行文件

  1. 在wrapper1中插入生成的DWARF Expression。
  2. 删除handle1与handle2之间的待混淆代码片段,再删除对handle2函数的调用

实验

原程序如下图所示。

 

图片描述

 

我对它做了一个代码混淆,简单来说就是if (f(m) == f(934)),f是一个单射函数。然后将桩函数_Z7handle1v与_Z7handle2v之间的24条汇编指令转换成DWARF Expression嵌入程序。

 

图片描述

 

在Wrapper1中插入DWARF Expression

 

图片描述

 

再删除代码片段与对handle2桩函数的调用

 

图片描述

 

使用clang++编译修改后的.s文件,并且验证混淆前后语义是否相等

 

图片描述

 

IDA反编译结果显示代码已经被成功隐藏了

 

图片描述

附件说明

图片描述

 

main.cpp, xjtu_ruicheng_obfs.cpp是源文件,main.s与xjtu_ruicheng_obfs.s是我插入DWARF Expression后的汇编文件

 

原文件.elf是我使用我的LLVM混淆Pass编译出来的,这个Pass过几天我就会放出来

 

转换脚本.py 是将汇编基本块转换为DWARF Expression的脚本

参考文献

[1]DWARF 4 Standard: https://dwarfstd.org/doc/DWARF4.pdf
[2]CFI Directives: https://sourceware.org/binutils/docs/as/CFI-directives.html
[3]LEB128: https://en.wikipedia.org/wiki/LEB128


[2022夏季班]《安卓高级研修班(网课)》月薪两万班招生中~

上传的附件:
收藏
点赞5
打赏
分享
打赏 + 50.00雪花
打赏次数 1 雪花 + 50.00
 
赞赏  Editor   +50.00 2022/03/28 恭喜您获得“雪花”奖励,安全圈有你而精彩!
最新回复 (1)
雪    币: 220
活跃值: 活跃值 (99)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
茶语咖啡 活跃值 2022-3-16 22:52
2
0
足够优秀
游客
登录 | 注册 方可回帖
返回