二进制安全之自控制流劫持混淆及其恢复研究

admin 2024年5月6日16:29:03评论6 views字数 8686阅读28分57秒阅读模式

控制流劫持攻击是一种通过构造特定攻击载体来非法篡改进程中的控制数据、从而改变进程的控制流程并执行特定恶意代码的攻击方式。在CTF各方向中,一般是PWN方向的题目中常包含的知识点,入门类型的PWN题目中一般是通过栈溢出修改函数的返回地址。然而,简单的栈溢出实现控制流劫持攻击的方式渐渐淡出PWN的题目范围,Reverse方向的创新融合又将其以一种新方式放进了题目中:自控制流劫持混淆——程序自己修改自己函数的返回地址来对自身控制流进行“劫持”,从而达到混淆程序的目的,一定程度上加大了对程序的逆向成本。

通过栈溢出实现的控制流劫持攻击

控制流劫持攻击通常利用缓冲区溢出等软件漏洞达到攻击目的,很经典的控制流劫持攻击就是通过栈溢出修改函数的返回地址。函数栈帧的结构大致如下:

                  +----------+ <-+ rsp
                  |   ...    |
                  +----------+
                  | Stack... |
                  +----------+ <-+ rbp
  qword ptr [rbp] | Old rbp  |
                  +----------+
qword ptr [rbp+8] | Ret Addr |
                  +----------+

我们知道call指令相当于先将函数的返回地址压入栈、接着跳转到函数地址执行,那么函数栈帧最底层应该为该函数执行完的返回地址(Ret Addr)。而在函数开始执行时,会将上一层函数的栈帧基址指针指向的地址压入栈中(push rbp),并将当前栈基址设置为栈指针指向的地址(mov rbp, rsp),然后正式开始执行函数的主体内容。故函数栈帧第二层为上一层函数的rbp,而第三层往后是函数自己的栈空间。在程序开启Canary保护是,第三层通常是针对栈溢出进行检测和防护的Canary,这里不是后面讨论的重点,便不详细展开。

如果Canary保护未开启,通过栈溢出可以很简单地从函数自己的栈空间中一路溢出到Old RBP甚至Ret Addr,当能用攻击数据覆盖Ret Addr时,就可以实现控制流劫持,让函数在return时返回到攻击数据中构造的地址。

RE中的自控制流劫持混淆

当控制流劫持攻击来到Reverse方向中,就成为了函数自己实现控制流劫持从而达到控制流混淆效果的一种软件保护方式。这类题目通常有如下汇编语句:

二进制安全之自控制流劫持混淆及其恢复研究

这些是直接对qword ptr [rbp+8]进行赋值的汇编语句,联系前面提到的函数栈帧结构可以看到,qword ptr [rbp+8]在64位程序中指的就是Ret Addr这一层,这里实现了函数自己修改Ret Addr的效果,从而“劫持”自身的控制流,让函数在return时跳转到新地址。例如在这里,该函数执行完以后,会跳转到0x402AC6继续执行。

这类题目通过人工跟踪控制流固然可行,但当控制流很长的时候人工跟踪就会比较费时费精力。接下来通过一道例题简单介绍其反混淆的思路(抛砖引玉)。

例题解析

该x86-64例题的主函数如下:
int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  int i; // [rsp+0h] [rbp-10h]

  puts("Please input your flag:");
  __isoc99_scanf("%42s", input);
  encrypt();
  for ( i = 0; input[i] == cip[i] && i <= 41; ++i )
    ;
  if ( i == 42 )
    puts("Correct!");
  else
    puts("Wrong flag.");
  exit(0);
}
主函数比较简单,主要是在接收输入的字符串input后调用了encrypt(),接着对比inputcip,如果全部相同那么输出Correct!,即拿到了flag。
encrypt()开始,后续的每个函数基本都使用了自控制流劫持混淆的保护。流程比较短,可以直接人工跟踪,流程图如下:

二进制安全之自控制流劫持混淆及其恢复研究

可以看到func201会出现分叉点,如果x != 0x2A,那么跳回encrypt重新执行;反之则跳回主函数中encrypt执行后的地址。这里通过函数自劫持控制流的方式实现了一个小循环。
将关键的汇编集合起来就是:
; encrypt 花指令可无视
mov [rbp+var_9], 0C3h
lea rax, [rbp+var_9]
call rax
; func53
mov eax, cs:x
mov edx, eax
lea rax, input
movzx edx, byte ptr [rdx+rax]
mov eax, cs:x
mov ecx, edx
xor ecx, 53h
mov edx, eax
lea rax, input
mov [rdx+rax], cl
; func111
mov eax, cs:x
mov edx, eax
lea rax, input
movzx eax, byte ptr [rdx+rax]
mov ecx, cs:x
lea edx, [rax+11h]
mov ecx, ecx
lea rax, input
mov [rcx+rax], dl
; func200
mov eax, cs:x
add eax, 1
mov cs:x, eax
; func201
mov eax, cs:x
cmp eax, 2Ah ; '*'
jnz 0x40D187
jmp 0x40D22C

手动反编译成C即:

for (x = 0; x < 0x2A; x++) {
    input[x] ^= 0x53;
    input[x] += 0x11;
}

那么题目很简单,就是反过来计算input即可。解题脚本:

cip = [0x460x500x430x450x390x7C0x750x760x480x7B0x730x410x410x8F0x430x460x780x730x8F0x770x410x760x480x8F0x420x430x730x420x8F0x710x460x7C0x730x7B0x760x430x480x740x770x410x7C0x3F]

flag = []
for x in cip:
    flag.append(((x-17)&0xFF) ^ 0x53)
print(bytes(flag))

# b'flag{876d91cc-af41-5c6d-ba1b-3f8196ad05c8}'

反混淆初探

如果流程很长,用人工跟踪得到控制流的方式显然很麻烦,去混淆的思路主要有以下两种:
  • 将各函数的关键汇编语句相连成同一个函数,直接反编译该函数进行分析。

  • 将对qword ptr [rbp+8]的赋值语句patch成jmp 地址+nops的形式,将原本的函数拆解成代码块,通过IDA free重新分析让这些代码块整合到同一个函数中。

第一种方法的流程大致是将各函数的关键汇编语句提取出来并记录调用关系,然后将它们按调用关系进行拼接。但是拼接过程中需要注意许多细节,比如调整call等指令中的偏移、相对跳转(短跳)时需要随着中间语句长度变化(如拼接进新函数的关键汇编语句时)进行偏移的更新等。这种调整比较复杂,需要比较系统的处理才能完美解决。
第二种方法就是简单的扫描汇编语句,碰到对qword ptr [rbp+8]进行赋值的语句直接patch成jmp 对应地址,其余字节nop掉,这样就能很简单地得出反编译结果,也不用对偏移细节做调整。缺点也是显而易见的:程序中的无效指令(nop)很多,降低了程序对代码的空间利用率;如果在对Ret Addr修改后还进行了一些操作,那么这些操作也会因为前面的直接跳转而导致被舍弃,与原程序的运行结果可能会有所不同。
本题中对Ret Addr修改后都是直接返回,所以采用第二种方式即可轻松反混淆。这里采用对二进制进行解析的lief库、用于反汇编的capstone库及用于汇编的keystone库进行脚本编写:
import lief
from capstone import *
from keystone import *
import re

FUNC = [('endbr64'''4),
    ('push''rbp'1),
    ('mov''rbp, rsp'3),
    ('sub''rsp, 0x10'4),
    ('mov''rax, qword ptr fs:[0x28]'9),
    ('mov''qword ptr [rbp - 8], rax'4),
    ('xor''eax, eax'2),
    # ......
    ('mov''rax, qword ptr [rbp - 8]'4),
    ('xor''rax, qword ptr fs:[0x28]'9),
    ('je''0x05'2),
    ('call''0x401080'5),
    ('leave'''1),
    ('ret'''1)
]
FUNC_SZ = (76)
MASK = [-10xff0xffff-10xffffffff-1-1-10xffffffffffffffff]

class binFix():
    def __init__(self, fn):
        self.cs = Cs(CS_ARCH_X86, CS_MODE_64)
        self.ks = Ks(KS_ARCH_X86, KS_MODE_64)
        self.fn = fn
        self.bin = lief.parse(fn)
        self.funcMap = {}
    def get_asm(self):
        binary = self.bin
        for f in binary.functions:
            if f.size == 0:
                continue
            fb = binary.get_content_from_virtual_address(f.address, f.size).tobytes()
            asms = list(self.cs.disasm(fb, 0))
            asmTp = list(zip([x.mnemonic for x in asms], [x.op_str for x in asms], [x.size for x in asms]))
            if len(asms) <= 13 or asmTp[:FUNC_SZ[0]] != FUNC[:FUNC_SZ[0]] or asmTp[-2:] != FUNC[-2:] or asmTp[-FUNC_SZ[1]:-4] != FUNC[-FUNC_SZ[1]:-4]:
                continue
            retn = [-1] * 8
            for asm in asms:
                addr = asm.address
                size = asm.size
                opcode = asm.mnemonic
                operand = asm.op_str
                reobj = None
                curAddr = f.address + addr
                if (f.name.startswith("func"or f.name == "encrypt"and (
                  (opcode, operand, size) in FUNC or
                  (opcode == "call" and operand.startswith("0x"and (int(operand, 16)+f.address) & MASK[4] == 0x401080or
                  (opcode == "je" and size == 2 and (int(operand, 16)+f.address) & MASK[1] == 0x5)):
                    self.bin.patch_address(curAddr, [0x90] * size) # nop's opcode
                    continue
                reobj = re.match(r'byte ptr [rbp + (0x[0-9a-f]*|[0-9])], (0x[0-9a-f]*|[0-9])', operand)
                if reobj is not None:
                    t = reobj.groups()
                    offset, value = [int(x, 16for x in t]
                    # assert retn_address set in 3 consecutive instructions
                    retn[offset-8] = value
                    if -1 not in retn:
                        nextAddr = int.from_bytes(bytes(retn), 'little')
                        curAddr -= 1
                        jmp, _ = self.ks.asm("jmpt"+hex(nextAddr), curAddr)
                        self.bin.patch_address(curAddr, list(jmp))
                        retn[:3] = [-1] * 3
                        if hex(f.address) in self.funcMap.keys():
                            self.funcMap[hex(f.address)].append(hex(nextAddr))
                        else:
                            self.funcMap.update({hex(f.address): [hex(nextAddr)]})
                    else:
                        self.bin.patch_address(curAddr, [0x90] * size)
        return
    def draw_call_table(self, start=None):
        from graphviz import Digraph
        dg = Digraph(format="png")
        def func_name_node(addrHex):
            try:
                name = next(x.name for x in self.bin.functions if x.address == int(addrHex, 16))
                dg.node(addrHex, name)
            except StopIteration:
                try:
                    name = next(x.name for x in self.bin.functions if x.address < int(addrHex, 16) < x.address + x.size)
                    dg.node(addrHex, f"{name}{addrHex}")
                except StopIteration:
                    dg.node(addrHex, addrHex)
            return
        if start is None:
            for k, v in self.funcMap.items():
                func_name_node(k)
                for x in v:
                    func_name_node(x)
                    dg.edge(k, x)
        else:
            visited = []
            def rec_draw(cur):
                visited.append(cur)
                if cur in self.funcMap.keys():
                    for nxt in self.funcMap[cur]:
                        func_name_node(nxt)
                        dg.edge(cur, nxt)
                        if nxt not in visited:
                            rec_draw(nxt)
            func_name_node(start)
            rec_draw(start)
        dg.view()
        return
    def fix_binary(self, newfn=None):
        if newfn is None:
            newfn = self.fn + "_fixed"
        # clear functions' symbols
        for tmpsym in [i for i in self.bin.symbols if i.name.startswith("func"or i.name == "encrypt"]:
            tmpsym.name, tmpsym.size, tmpsym.value = ""00
        self.bin.write(newfn)

if __name__ == '__main__':
    bf = binFix("ropmaster")
    bf.get_asm()
    bf.draw_call_table("0x40d187"# encrypt()'s address
    # drawing must be done before fixing, or symbols will gone and draw none.
    bf.fix_binary()
脚本流程大致为:
  1. 扫描汇编,如扫描到形如mov byte ptr [rbp+8], 0xC6的多条汇编语句,将其连接计算跳转后的新地址并将多余字节nop掉。

  2. 以某地址为起点画出调用关系图,也可以画全部该类带混淆的函数的调用关系图,但本题中干扰函数较多,直接画以encrypt为起点的调用关系图,帮助分析。

  3. 将二进制的符号表剔除(让反编译软件在分析的时候不将它们分析为函数,反而分析成代码块),重新保存二进制。

修复后的二进制进行了一些手动微调(将花指令nop;将调用encrypt改为跳转到encrypt从而将后续的跳转块全部拼进main函数中)拖入IDA free中是:

二进制安全之自控制流劫持混淆及其恢复研究

可以看到这个修复后的二进制中,代码逻辑已经很明显了,成功达到了去除混淆的目的。

原文始发于微信公众号(山石网科安全技术研究院):二进制安全之自控制流劫持混淆及其恢复研究

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年5月6日16:29:03
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   二进制安全之自控制流劫持混淆及其恢复研究http://cn-sec.com/archives/2712003.html

发表评论

匿名网友 填写信息