使用符号执行驯服受nanomite保护的MIPS二进制文件:No Such Crackme

本文详细介绍了如何通过符号执行技术分析并破解一个受nanomite保护的MIPS二进制文件,包括环境搭建、父进程行为分析、符号执行引擎构建以及最终密钥生成过程。

使用符号执行驯服受nanomite保护的MIPS二进制文件:No Such Crackme

逆向工程:龙潭虎穴

MIPS 101

此挑战的第一个有趣细节是它是一个MIPS二进制文件;对我来说这确实有些异国情调。我主要看Intel汇编,所以有机会研究一个未知架构总是很吸引人。你知道这就像发现一个新玩具,所以我忍不住开始阅读MIPS基础知识。

这部分仅描述您需要理解和破解二进制文件的基本信息;正如我所说,我根本不是MIPS专家。根据我所见,这与您在Intel x86 CPU上看到的非常相似:

  • 它是小端序(注意也存在大端序版本,但本文不会涉及),
  • 它有更多的通用寄存器,
  • 调用约定类似于__fastcall:通过寄存器传递参数,并在$v0中获取函数返回值,
  • 与x86不同,MIPS是RISC,因此更容易上手(相信我),
  • 当然,有IDA处理器,
  • Linux和常规工具也适用于MIPS,因此我们可以使用习惯的“正常”工具,
  • 它也使用堆栈,但比x86少得多,因为大多数操作都在寄存器中(至少在此挑战中)。

设置适当的调试环境

这个问题的答案是Qemu,正如预期的那样。您甚至可以在aurel32的网站上下载已完全准备就绪且可用的Debian镜像。

1
2
3
4
5
overclok@wildout:~/chall/nsc2014$ wget https://people.debian.org/~aurel32/qemu/mipsel/debian_wheezy_mipsel_standard.qcow2
overclok@wildout:~/chall/nsc2014$ wget https://people.debian.org/~aurel32/qemu/mipsel/vmlinux-3.2.0-4-4kc-malta
overclok@wildout:~/chall/nsc2014$ cat start_vm.sh
qemu-system-mipsel -M malta -kernel vmlinux-3.2.0-4-4kc-malta -hda debian_wheezy_mipsel_standard.qcow2 -vga none -append "root=/dev/sda1 console=tty0" -nographic
overclok@wildout:~/chall/nsc2014$ ./start_vm.sh

在虚拟环境中自由安装您的必需品,一些工具可能会派上用场(尽管安装它们可能需要一些时间):

1
2
3
4
root@debian-mipsel:~# aptitude install strace gdb gcc python
root@debian-mipsel:~# wget https://raw.githubusercontent.com/zcutlip/gdbinit-mips/master/gdbinit-mips
root@debian-mipsel:~# mv gdbinit-mips ~/.gdbinit
root@debian-mipsel:~# gdb -q /home/user/crackmips

最终您应该能够运行这个野兽:

1
2
3
4
root@debian-mipsel:~# /home/user/crackmips
usage: /home/user/crackmips password
root@debian-mipsel:~# /home/user/crackmips 'doar-e ftw'
WRONG PASSWORD

大局观

现在我们有了启动和调试挑战的方法,我们可以在IDA中打开二进制文件并开始理解使用了哪种保护方案。此时,我们真的对细节不感兴趣:我们只想了解它是如何工作的,以及我们需要针对哪些部分来获得“好孩子”消息。

在IDA中花了一些时间后,二进制文件的工作方式如下:

  • 检查用户是否提供了一个参数:序列号
  • 检查提供的序列号是否为48个字符长
  • 将字符串转换为6个DWORD(注意:转换有点奇怪,请确保验证您的算法)
  • 进程分叉为两个:
    • [父进程] 似乎以某种方式驱动子进程,稍后会详细说明
    • [子进程] 执行一大段代码,修改(原地)6个原始DWORD,然后将它们与以下字符串进行比较 [ Synacktiv + NSC = <3 ]
    • [子进程] 如果比较成功,您就赢了,否则就输了

基本上,我们需要找到6个输入DWORD,它们将在输出中生成以下值:0x7953205b、0x6b63616e、0x20766974、0x534e202b、0x203d2043、0x5d20333c。我们还知道父进程将与子进程交互,因此我们需要研究两者的代码以确保正确理解挑战。

如果您更喜欢代码,以下是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
int main(int argc, char *argv[])
{
    DWORD serial_dwords[6] = {0};
    if(argc != 2)
        Usage();

    // 转换
    a2i(argv[1], serial_dwords);

    pid_t pid = fork();
    if(pid != 0)
    {
        // 父进程
        // 这里有很多事情发生,我们稍后会看到
    }
    else
    {
        // 子进程
        // 这里有很多事情发生,我们稍后会看到

        char *clear = (char*)serial_dwords;
        bool win = memcmp(clear, "[ Synacktiv + NSC = <3 ]", 48);
        if(win)
            GoodBoy();
        else
            BadBoy();
    }
}

动手实践

父进程负责

在了解大局后,我做的第一件事是查看父进程的代码。为什么?代码似乎比子进程的代码简单一些,所以我认为研究父进程更有助于理解我们需要颠覆的保护类型。

您甚至可以使用strace来更清楚地了解使用的系统调用:

1
root@debian-mipsel:~# strace -i /home/user/crackmips $(python -c 'print "1"*48')

这是一个我完全没有预料到的有趣输出。我们在这里看到的是父进程通过修改(可能,我们稍后会找出)其上下文来驱动子进程,每次子进程触发SIGTRAP时(注意waitpid的第二个参数)。

从这里开始,如果您相当熟悉不同类型的软件保护(我不是说我是这个领域的专家,但我恰好知道这个 :-P),您几乎可以猜到那是什么:nanomites!

Nanomites 101

Nanomites是一种相当不错的保护。不过,这是一个相当通用的名称;您真的可以以任何您喜欢的方式使用这种保护方案:您的想象力是唯一的限制。老实说,这是我第一次在Unix系统上看到这种保护实现;真是个好惊喜!

它通常这样工作:

  • 您有两个进程:一个驱动进程和一个被驱动进程;一个父进程和一个子进程
  • 驱动进程使用目标平台上可用的调试API(这里是ptrace,Windows上是CreateProcess/DebugActiveProcess)附加到被驱动进程
  • 请注意,根据设计,您将无法附加到子进程,因为Windows和Linux都阻止了这一点(根据设计):有些人称这部分为DebugBlocker
  • 不过,您将能够调试驱动进程

通常有趣的代码在子进程中,但再次强调,您可以做任何您想做的事情。基本上,如果您想要一个有效的保护,有两个规则:

  • 确保被驱动进程没有其驱动进程就无法运行,并且它们彼此紧密绑定
  • 保护的强度在于两个进程之间的这种强大/紧密绑定
  • 设计您的算法,使得移除驱动进程真的非常困难/痛苦/让攻击者发疯

被驱动进程可以通过例如int3/break指令触发SIGTRAP来调用/通知驱动进程

正如我所说,我将这种保护方案更多地视为一个配方:您真的可以随意定制它。如果您想了解更多关于这个主题的内容,这里有一个您应该查看的链接列表:

父进程的工作方式

现在是时候详细研究父进程了;以下是它的工作方式:

  • 它做的第一件事是waitpid,直到其子进程触发SIGTRAP
  • 驱动进程检索子进程的CPU上下文,更具体地说是其程序计数器:$pc
  • 然后我们有一个巨大的算术计算块。但在花了一些时间研究它之后,我们可以将这个巨大块视为一个黑盒函数,它接受两个参数:子进程的程序计数器和某种计数器值(因为此代码将在循环中执行,对于每个SIGTRAP,此变量将递增)。它生成一个单一输出,这是一个32位值,我称之为第一个魔法值。
    • 不过,让我们不要关注这个块实际上在做什么,我们将在下一部分开发一些工具来处理它 :-) 所以让我们继续前进!
  • 然后使用这个魔法值在QWORDs数组(606个QWORD,是子进程中break指令数量的6倍——稍后您会理解这一点)中查找特定条目。基本上,代码将循环遍历此数组的每个QWORD,直到找到一个高DWORD等于魔法值的QWORD。从那里您得到另一个魔法值,即匹配QWORD的最低DWORD。
  • 使用另一个巨大的算术计算块。与第一个类似,我们可以将其视为一个黑盒函数,有两个输入:第二个魔法值和轮次索引(子进程执行其代码6次,因此此轮次索引将从0开始直到5——再次强调,当我们查看子进程时,这会更清楚一些,所以请记住这个细节)。此函数的输出是一个32位值。再次强调,不要研究这个块,我们不需要它。
  • 生成的值实际上是子进程内部的有效代码地址;因此在计算之后,父进程将修改先前检索到的CPU上下文中的程序计数器。完成后,它调用带有SETREGS的ptrace来设置子进程的新CPU上下文。

这大致是每次子进程遇到break指令时将执行的内容;父进程绝对在驱动子进程。我们现在可以感觉到,子进程将通过其父进程跳转到内存中不一定连续的代码块,因此按IDA中的顺序研究子进程代码是相当无意义的,因为这些基本块不会按此顺序执行。

长话短说,nanomites被用作某种运行时代码流加扰原语,这不是很令人兴奋吗?告诉过您@elvanderb是疯狂的 :-)。

准备工具:编写符号执行引擎

在这一点上,我可以向您保证我们需要一些工具:我们已经研究了二进制文件,我们知道主要部分如何工作,我们只需要提取由子进程程序计数器计算和序列验证算法使用的不同方程/公式。基本上,该引擎将有助于研究父进程和子进程。

如果您不太熟悉符号执行,我建议您花点时间阅读Breaking Kryptonite’s Obfuscation: A Static Analysis Approach Relying on Symbolic Execution并查看z3-playground,如果您不太熟悉Z3及其Python绑定。

这次我决定不将该引擎构建为IDA Python脚本,而是自己完成所有工作。不过不要害怕,即使听起来很可怕,但真的不是:挑战是这类事情的完美环境。它不使用大量指令,我们不需要支持分支,并且几乎只使用算术指令。

我还选择以某种方式实现此引擎,以便我们也可以将其用作简单的模拟器。您甚至可以根据需要将其用作反编译器!对我们来说,另外两个有趣的点是:

  • 一旦我们在符号引擎中运行一段代码,我们将提取某些计算/公式。感谢Microsoft的Z3,我们将能够检索生成特定输出值的输入值:这基本上是使用求解器和符号变量所获得的。
  • 但另一个有趣的点是,您仍然可以将提取的Z3表达式用作某种黑盒函数。您知道函数在做什么,但不知道如何做;并且您对如何做不感兴趣。您知道输入和输出。要获得具体的输出值,您只需用具体值替换符号变量。这非常方便,特别是当您不仅对找到生成特定输出值的输入值感兴趣时;有时您只想双向进行 :-)。

无论如何,在这段漫长的理论演讲之后,让我们看一些代码。引擎的第一个重要工作是能够解析MIPS汇编:幸运的是,这对我们来说真的很容易。我们直接将从IDA复制的纯文本MIPS反汇编提供给我们的引擎:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def _parse_line(self, line):
  addr_seg, instr, rest = line.split(None, 2)
  args = rest.split(',')
  for i in range(len(args)):
    if '#' in args[i]:
        args[i], _ = args[i].split(None, 1)

  a0, a1, a2 = map(
    lambda x: x.strip().replace('$', '') if x is not None else x,
    args + [None]*(3 - len(args))
  )
  _, addr = addr_seg.split(':')
  return int(addr, 16), instr, a0, a1, a2

从这里您拥有所需的所有信息:指令及其操作数(如果操作数不存在则为None,因为最多可以有3个操作数)。接下来的重要工作是处理不同类型的操作数;以下是我在挑战中遇到的那些:

  • 通用目的寄存器,
  • 堆栈变量,
  • 立即值。

为了处理/转换这些,我创建了一堆枯燥/辅助函数:

 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
def _is_gpr(self, x):
  '''是有效的GPR名称吗?'''
  return x in self.gpr

def _is_imm(self, x):
  '''是有效的立即值吗?'''
  x = x.replace('loc_', '0x')
  try:
    int(x, 0)
    return True
  except:
    return False

def _to_imm(self, x):
  '''从字符串立即值获取整数'''
  if self._is_imm(x):
    x = x.replace('loc_', '0x')
    return int(x, 0)
  return None

def _is_memderef(self, x):
  '''是内存解引用吗?'''
  return '(' in x and ')' in x

def is_stackvar(self, x):
  '''是堆栈变量吗?'''
  return ('(fp)' in x and '+' in x) or ('var_' in x and '+' in x)

def to_stackvar(self, x):
  '''获取堆栈变量名称'''
  _, var_name = x.split('+')
  return var_name.replace('(fp)', '')

最后,我们必须处理每个不同的指令及其编码。当然,您只需要实现您想要的指令:很可能是您感兴趣的代码中使用的那些。简而言之,这是引擎的核心。如果您喜欢在袖子里有一个反编译器,您也可以使用它来输出有效的Python/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
def step(self):
  '''这是引擎的核心——您应该在这里实现
  您想要模拟的所有指令的语义。'''
  line = self.code[self.pc]
  addr, instr, a0, a1, a2 = self._parse_line(line)
  if instr == 'sw':
    if self._is_gpr(a0) and self.is_stackvar(a1) and a2 is None:
      var_name = self.to_stackvar(a1)
      self.logger.info('%s = $%s', var_name, a0)
      self.stack[var_name] = self.gpr[a0]
    elif self._is_gpr(a0) and self._is_memderef(a1) and a2 is None:
      idx, base = a1.split('(')
      base = base.replace('$', '').replace(')', '')
      computed_address = self.gpr[base] + self._to_imm(idx)
      self.logger.info('[%s + %s] = $%s', base, idx, a0)
      self.mem[computed_address] = self.gpr[a0]
    else:
      raise Exception('sw not implemented')
  elif instr == 'lw':
    if self._is_gpr(a0) and self.is_stackvar(a1) and a2 is None:
      var_name = self.to_stackvar(a1)
      if var_name not in self.stack:
        self.logger.info(' WARNING: Assuming %s was 0', (var_name, ))
        self.stack[var_name] = 0
      self.logger.info('$%s = %s', a0, var_name)
      self.gpr[a0] = self.stack[var_name]
    elif self._is_gpr(a0) and self._is_memderef(a1) and a2 is None:
      idx, base = a1.split('(')
      base = base.replace('$', '').replace(')', '')
      computed_address = self.gpr[base] + self._to_imm(idx)
      if computed_address not in self.mem:
        value = raw_input(' WARNING %.8x is not in your memory store -- what value is there @0x%.8x?' % (computed_address, computed_address))
      else:
        value = self.mem[computed_address]
      self.logger.info('$%s = [%s+%s]', a0, idx, base)
      self.gpr[a0] = value
    else:
      raise Exception('lw not implemented')
[...]

第一级if处理不同的指令,第二级if处理指令可以具有的不同编码。self.logger东西只是我保存执行跟踪到文件的方式,以保持控制台清洁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def __init__(self, trace_name):
  self.gpr = {
    'zero' : 0,
    'at' : 0,
    'v0' : 0,
    'v1' : 0,
# [...]
    'lo' : 0,
    'hi' : 0
  }

  self.stack = {}
  self.pc = 0
  self.code = []
  self.mem = {}
  self.stack_offsets = {}
  self.debug = False
  self.enable_z3 = False

  if os.path.exists('traces') == False:
      os.mkdir('traces')

  self.logger =
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计