一般来说,解一道逆向题,通常考虑使用 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
函数:
可以看到这个方法很长,但其实逻辑还是较为简单的,输入共 104 个字节、 832 个 bit,然后针对其中的一个 bit,会调用 function0
到 function831
共 832 个函数中的一个。观察 function0
到 function831
函数的具体反汇编结果,可以看到,虽然总共有 832 个函数,但实际上函数具体的反汇编结果无非两种,一是直接返回该 bit,二是返回该 bit 的取反值。
由此,函数的检查逻辑非常清晰,其实就是 832 个函数分别对 832 个输入的 bit 做校验,验证全部通过的输入即为 flag。那么,开始手动求解,832个函数很快的(不是)。我们下面来考虑如何利用工具对本题进行求解:
ida
作为一款强大的二进制分析工具,ida 提供了相当之多的功能,比如说 File
->Produce file
可以导出程序相关的文件,包括反汇编的 C 代码:
看到这里,最直接的一个思路就是通过对 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 终端下运行,运行结果如下:
angr
作为大名鼎鼎的符号执行工具,angr 也是解逆向题的神兵利器。有些时候甚至题目还没逆完说不定就用 angr 跑出结果了,用 angr 解题实乃摸鱼的不二法宝。
使用 angr 求解通常需要以下两个步骤:
- 确认 flag 的长度,建立约束条件
- 明确需要执行到的分支和不想执行到的分支
对本题来说,很明显 flag 长度为 832 个 bit,因为共有 832 个函数;然后希望执行到的分支地址为 0x773
,输出 Good job!
,不想执行到的分支地址为 0x786
,最终会输出 Wrong!
。
另外,在 checkFlag
函数中,同样存在着大量我们不想执行的分支,比如会执行 return 0
的任意一个分支,相应的汇编代码如下:
为了加快求解速度,我们需要获得所有 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,还是非常高效的:
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 的爆破效率更高,但为了方便脚本的编写,我们对一个字节进行整体爆破,所以脚本运行需要更长的一段时间,最后结果如下:
总结
用工具解题,跑不出来不亏,跑得出来血赚