一般来说,解一道逆向题,通常考虑使用 ida / ghidra 进行静态逆向分析或者是使用 gdb / x64dbg 来动态调试。有时程序的逻辑会非常繁琐,解题的过程也非常枯燥,这就让人不由得思考一个问题:有没有方法来高效地解题(摸鱼)呢?

恰好最近看到一道 p4 出的一道很有意思的逆向题目,虽然难度不大,但由于题目条件设置的原因,人工求解会变得非常繁琐,相对应地,利用工具就能方便地进行求解。正好可以借此机会,来尝试使用工具高效地对逆向题进行求解。

题目分析

 用 file 命令检查文件,可以看到是一个 64 位的程序:

$ file elementary
elementary: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/l, for GNU/Linux 3.2.0, BuildID[sha1]=d5989f8d294badf15acaa157984968eea75f1fce, not stripped

用 ida64 打开,反编译 main 方法,可以看到程序的逻辑非常简单,根据 checkFlag 函数的返回结果,程序输出 Good job! 或者是 Wrong!

主函数

继续跟进 checkFlag 函数:

checkFlag

可以看到这个方法很长,但其实逻辑还是较为简单的,输入共 104 个字节、 832 个 bit,然后针对其中的一个 bit,会调用 function0function831 共 832 个函数中的一个。观察 function0function831 函数的具体反汇编结果,可以看到,虽然总共有 832 个函数,但实际上函数具体的反汇编结果无非两种,一是直接返回该 bit,二是返回该 bit 的取反值。

function

由此,函数的检查逻辑非常清晰,其实就是 832 个函数分别对 832 个输入的 bit 做校验,验证全部通过的输入即为 flag。那么,开始手动求解,832个函数很快的(不是)。我们下面来考虑如何利用工具对本题进行求解:

ida

作为一款强大的二进制分析工具,ida 提供了相当之多的功能,比如说 File->Produce file 可以导出程序相关的文件,包括反汇编的 C 代码:

produce

看到这里,最直接的一个思路就是通过对 ida 生成的 C 代码进行修改,将对 bit 的验证修改为对 bit 的赋值,在执行完 832 次赋值后就会得到最终的 flag。

观察其中一条判断语句,可以发现它要求对应的函数 function572 针对输入返回 0:

if ( (unsigned int)function572((a1[85] >> 2) & 1) )
    return 0LL;

所以只需要判断在输入为 1 的情况下,该函数的返回是否为 0,如果为 0,说明该 bit 的值应该是 1,反之则为 0:

if(function572(1) == 0)
    a1[85] |= (1<<2);

可以利用 python 编写脚本,完成全部判断语句到赋值语句到转换:

def trans_func(l):
   sp = l.index('function')
   ep = l.index('(', sp)
   func = l[sp:ep]
   if '[' not in l:
       index = 0
   else:
       sp = l.index('[')
       ep = l.index(']', sp)
       index = int(l[sp+1:ep])
   if ">>" not in l:
       offset = 0
   else:
       sp = l.index(">>")
       ep = l.index(")", sp)
       offset = int(l[sp+3:ep])
   return "if({}(1) == 0)\n    a1[{}] |= (1<<{});".format(func, index, offset)

# sample
trans_func('if ( (unsigned int)function572((a1[85] >> 2) & 1) )')
# 'if(function572(1) == 0)\n    a1[85] |= (1<<2);'

通过对 ida 导出的 c 源码进行修改,最终生成 solve.c 文件如下,编译运行,即可得到 flag:

#include <stdio.h>
#inclue <string.h>

int main(int argc, const char **argv, const char **envp);
int function0(int a1);
int function1(int a1);
...
int function830(int a1);
int function831(int a1);
int checkFlag(char *a1);

int main(int argc, const char **argv, const char **envp) {
  char v4[104];
  memset(v4, 0, 104);
  if (checkFlag(v4))
    printf("%s\n", v4);
  return 0;
}

int function0(int a1) {
  return a1 ^ 1u;
}
...
int function831(int a1) {
  return a1 ^ 1u;
}

int checkFlag(char *a1) {
    if(function0(1) == 0)
        a1[64] |= (1<<0);
    ...
    if(function831(1) == 0)
        a1[20] |= (1<<4);
}
$ gcc solve.c
$ ./a.out
p4{I_really_hope_you_automated_this_somehow_otherwise_it_might_be_a_bit_frustrating_to_do_this_manually}

idapython

既然能通过 ida 导出的 c 文件和 python 相结合的方式解题,那么能否使用 idapython 直接完成本题呢?答案是肯定的,需要利用 idapython 提供的三个函数:

  • Functions :遍历所有的函数(返回的是函数的入口地址)
  • get_func_name:根据地址返回函数名
  • decompile:反编译函数

具体的操作和之前的解法在思路上较为相似,首先遍历所有的函数(function0 - function831),对函数进行解析(判断是否取反);然后反编译 checkFlag 函数,通过解析对各个函数的调用顺序,对 flag 变量进行赋值。

相应代码如下(用正则匹配函数和相应的返回):

import re
from idaapi import decompile
from idautils import Functions

funcs = {}
flag = [0 for i in range(104)]

for x in Functions():
    fn = get_func_name(x)
    if fn.find('function') != -1:
        v = int(str(decompile(x)).find('^')!=-1)
        funcs[fn] = v
                
checkFlag_addr = 0xceb7c
codes = str(decompile(checkFlag_addr)).split('\n')[2👎2]

for code in codes:
    fn = re.search('function[0-9]+', code)
    fn = fn.group(0)
    idx = re.search('a1\[([0-9]+)', code)
    idx = 0 if not idx else int(idx.group(1))
    bit = re.search('>> ([0-9]+)', code)
    bit = 0 if not bit else int(bit.group(1))

    flag[idx] += funcs[fn] << bit

print(''.join(map(chr, flag)))

直接在 ida 内部的 python 终端下运行,运行结果如下:

IDA python

angr

作为大名鼎鼎的符号执行工具,angr 也是解逆向题的神兵利器。有些时候甚至题目还没逆完说不定就用 angr 跑出结果了,用 angr 解题实乃摸鱼的不二法宝。

使用 angr 求解通常需要以下两个步骤:

  1. 确认 flag 的长度,建立约束条件
  2. 明确需要执行到的分支和不想执行到的分支

对本题来说,很明显 flag 长度为 832 个 bit,因为共有 832 个函数;然后希望执行到的分支地址为 0x773,输出 Good job!,不想执行到的分支地址为 0x786,最终会输出 Wrong!

另外,在 checkFlag 函数中,同样存在着大量我们不想执行的分支,比如会执行 return 0 的任意一个分支,相应的汇编代码如下:

branch

为了加快求解速度,我们需要获得所有 mov eax,0 相关指令的地址,这些都不属于我们想要执行的分支。使用 linux 命令提取如下:

# 反编译,过滤得到相应指令
$ objdump -d elementary | grep "\$0x0,%eax" > asm.txt
# 提取地址
$ awk '{ print $1 }' asm.txt | cut -d':' -f1 > avoid_addr
# 过滤前几个无用地址
$ tail -n +6 avoid_addr > avoid_addr

提取了相应地址后,即可编写相应解题脚本,脚本参考:https://gist.github.com/0xcpu/bad0b86f2a52b65ce4af06008d58a4c7

import angr
import claripy

# angr 使用 0x400000 作为基地址,所以需要加上相应偏移
start = 0x40071a
find = 0x400773
avoid = [0x400786]

flag_length = 104
# 不希望执行的分支
with open('./avoid_addr') as f:
    lines = f.readlines()
    lines = [line.strip() for line in lines]
    for line in lines:
        avoid.append(0x400000 + int(line, 16))

def main():
    # 初始化项目
    p = angr.Project('./elementary')
    # 设置 flag
    flag = claripy.BVS('flag', flag_length * 8)
    state = p.factory.blank_state(addr=start, stdin=flag, add_options={angr.options.LAZY_SOLVES})
    # 输入字符的约束条件,必须是可打印字符
    for c in flag.chop(8):
        state.solver.add(state.solver.And(c <= '~', c >= ' '))

    ex = p.factory.simulation_manager(state)
    ex.use_technique(angr.exploration_techniques.Explorer(find=find, avoid=avoid))
    # 运行 
    ex.run()
    # 输出结果
    print(ex.found[0].posix.dumps(0).decode('utf-8'))

if __name__ == '__main__':
    main()

可以看到只用了 64 秒就可以跑出 flag,还是非常高效的:

solve by angr

PIN tool

PIN tool 是 intel 开发的一款二进制程序的插桩分析工具,允许研究人员用不同的 pintool 在二进制程序层面分析程序的执行情况。利用 PIN tool 对程序进行插桩,而后通过侧信道的方式,能直接得到很多题目的答案。

既然考虑到利用 PIN tool 需要侧信道,首先需要思考的问题是,利用什么信息可以推断我们的输入正确。

直接使用统计执行指令数的 pintool - inscount1.so,可以发现在不同的输入下,程序实际执行的指令数不同。

$ ../../../pin -t obj-intel64/inscount1.so  -- /home/syang/CTF/elementary <<< ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~8~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Password: Wrong!
$ cat inscount.out
Count 150764
$ ../../../pin -t obj-intel64/inscount1.so  -- /home/syang/CTF/elementary <<< ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~e~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Password: Wrong!
$ cat inscount.out
Count 153422

那么如何验证该输入是正确还是错误呢?回到 checkFlag 看其逻辑,可以发现只有 function2 函数返回 0 的情况下,程序才会继续往下执行,反之 checkFlag 函数会直接返回。因此,有了以下推论:输入正确 => 程序继续执行 => 执行的指令数更多;输入错误 => 程序提前返回 => 执行的指令数更少。所以可以通过判断不同输入下执行的指令数目多少,来判断我们的输入是否正确。

if ( (unsigned int)function2((a1[64] >> 5) & 1) )
    return 0LL;
if ( (unsigned int)function3((a1[64] >> 4) & 1) )
    return 0LL;

编写利用 pintool 进行侧信道的 python 脚本(需要提取每次爆破的字节顺序):

from subprocess import Popen, PIPE
import string

s = string.ascii_letters + string.digits + "+-_{}"

flag = ["?" for i in range(104)]

indexes = [64, 38, 67, 88, 21, 68, 95, 36, 39, 77, 101, 90, 6, 48, 23, 62, 81, 83, 79, 44, 25, 17, 52, 3, 29, 102, 28, 93, 16, 43, 78, 9, 60, 70, 71, 65, 40, 12, 56, 13, 57, 37, 69, 7, 84, 58, 89, 91, 14, 82, 45, 76, 66, 98, 75, 47, 97, 34, 80, 103, 86, 5, 74, 1, 18, 51, 72, 26, 8, 31, 42, 85, 49, 33, 54, 63, 46, 4, 41, 24, 73, 35, 30, 92, 11, 87, 50, 53, 32, 99, 96, 19, 10, 15, 55, 100, 2, 59, 94, 61, 22, 0, 27, 20]

def use_pintool(test_flag):
    pin_path = "/home/syang/tools/pin/pin"
    pintool_path = "/home/syang/tools/pin/source/tools/ManualExamples/obj-intel64/inscount1.so"
    binary_path = "/home/syang/CTF/elementary"
    p = Popen([pin_path, '-t', pintool_path, '--', binary_path], stdin = PIPE, stdout = PIPE)
    p.stdin.write(bytes(test_flag, encoding='utf-8'))
    p.communicate()
    with open('./inscount.out') as f:
        data = f.read()
        return int(data[len('Count '):])

for i in indexes:
    test = flag[::]
    base_count = use_pintool(''.join(test))
    c = '?'
    for j in s:
        test[i] = j
        test_count = use_pintool(''.join(test))
        # 执行的指令数目最多的输入,才是正确的输入
        if base_count  < test_count:
            c = j
            base_count = test_count

    flag[i] = c
    print(''.join(flag))

针对本题的条件,理论上 1bit 1bit 的爆破效率更高,但为了方便脚本的编写,我们对一个字节进行整体爆破,所以脚本运行需要更长的一段时间,最后结果如下:

pintool crack

总结

用工具解题,跑不出来不亏,跑得出来血赚

参考链接