-
-
[原创]2020网鼎杯 青龙组 Android逆向题 rev01 WP
-
发表于:
2020-5-30 18:27
8642
-
[原创]2020网鼎杯 青龙组 Android逆向题 rev01 WP
2020网鼎杯 青龙组 Android逆向题 rev01 WP
作者:sqdebug
工具:jeb、IDA
调试手机:Nexus5 Android 6.0
废话不多说,apk拖到jeb中
很简单,输入的字符串去掉开头的flag{和结尾的}后调用checkFlag,这是native函数。
上IDA,搜导出表这个函数名,没有,考虑在JNI_OnLoad中动态注册的,为了省事,直接上大牛写的frida hook脚本直接打印实际函数offset,frida hook RegisterNative github地址: https://github.com/lasting-yang/frida_hook_libart/blob/master/hook_RegisterNatives.js,
IDA打开libcm1.so,直接定位到0xa295,通过地址最后一位为1同时知道为Thumb汇编模式。函数采用类ollvm的形式被完全混淆,采用间接跳转表加pc的形式来跳转,因此IDA无法F5,我选择硬刚汇编,以下全部采用调试来理解程序,然后在静态汇编截图中说明各个关键位置点。
函数一开始就间接计算了跳转地址,0xA2C0处跳转的地址的计算方式为0xCAC04109+0x35406308=0x0000A411 (高位1舍去),来到0xA411,原来是GetStringUTFLength
以下我就不再细说这种寻找方式了,各位静态计算配合动态调试就能找到跳转实际地址。
函数首先计算了一下参数字符串的长度,然后判断是否小于0xf,小于就返回,不进行下一步。
如果大于等于0xf,就到了这里
可以看到函数通过GetStringUTFChars返回参数字符串的指针,然后在0xA3C8处进行了关键的BLX跳转执行返回后,将结果保存到栈变量中后就用ReleaseStringUTFChars释放了字符串,而函数最终以这个栈变量值作为返回。
因此函数关键点就在0xA3C8处跳转后的函数中,返回值为1就为正确flag。
通过静态计算或者动态调试来到跳转后的函数中(我将它命名为calc),地址为0xA420
注意函数将字符串指针赋给R4,记住这个。然后来到0xA45C处的一个特别大的函数,期初我百思不得其解的不知道这个函数是干嘛的,后来才反应过来,这个是反调试函数。它循环比较了calc的各个字节码是否等于软件断点值,来判断是否在calc中下了断点。返回1为检测到被调试,返回0为正常。
注意上面调试图中的左边的绿色箭头,anti_debugger返回0时(正常未被检测到调试断点),后面的pc间接跳转指令是略过几条指令的。
然后来到了0xA492处,通过我右边写的注释大家也知道这是求现在时间的毫秒值了,那么实际是怎么算的呢,我们接下来看。
进入到0xA492跳转后的函数中,
原来是直接调用了syscall,0x4e为gettimeofday的系统调用号,时间最终存放在栈变量timeval中。大家知道这个系统调用的时间结构体为:
因此栈变量timeval中存放的就是这个结构体。
之后这个函数在0xAD70处将时间秒数放到R2中,时间微秒数放到R0中。然后进行了一个非常骚的操作,之后我才反应过来,它进行了编译器除法优化(除法转乘法),具体大家可以看老钱的《C++反汇编与逆向分析技术揭秘》的相关章节或者一本英文原版书《Hacker's Delight 2nd Edition》,或者这篇文章https://bbs.pediy.com/thread-116974.htm
,我在这里大概说一下这操作的意义:
上面图片来源于《Hacker's Delight 2nd Edition》,在0xAD84处,时间微秒数乘以0x10624DD3,然后算术右移6位,通过上面《Hacker's Delight 2nd Edition》的截图可知,移3位为除以125,因此移6位就是125222=1000,就是除以1000,也就是时间微秒数除以1000。然后在0xAD96处,时间秒数乘以1000,然后加上了上面所得。总结就是【秒数1000 + 微秒数/1000】算得整体64位毫秒数,其中R0为低32位,R1为高32位。
之后调用了stl函数来进行一些操作,比如vector(这是record函数的传出参数),然后调用stl的string构造函数初始化我们传过来的字符串,还记得我前面让记住的R4吧。
调用完record后可以看到就析构了string,因此可以预见record为关键点。
进入record,在0x87F2处得到了string对象的C字符串指针,也就是我们的参数字符串。然后进入了一个很迷的函数中,这里先卖个关子,后面再说这是什么函数。
这个函数需要完全调试来理解,我这里仅截图静态汇编代码关键点。
函数的输入C字符串指针在R4寄存器中,记住这个。
然后取第一个输入字符
判断了第一个字符是否是几种特殊字符。
然后循环判断字符是否是字符‘1’,每次从参数字符串中取一个字符。通过这个‘1’各位能猜到这个是什么算法嘛,哈哈。
不是字符‘1’后就来到这里:
哈哈,通过我的注释各位也知道了这个是base58解密算法,也就是比特币地址的算法。
在地址0x7CC4处计算了字符串的长度,然后放到R9中。
其中计算变换长度也使用了编译器除法优化,大家参照这个比特币base58的github源码来理解,bitcoin github:https://github.com/bitcoin/bitcoin/blob/master/src/base58.cpp
你看我上面第一个红框处的计算变换长度,是不是和我下面的汇编计算一模一样。
在0x7CDA处计算了字符串长度乘以733,然后结果进行编译器除法优化,然后在0x7CE8处R1右移6位后的值就是除以1000后的值,然后再加1,因此最后R1中的值就为base58解密后字符串的长度。
以下通过上面我第二个红框标注的位置在汇编中的实际汇编来再次印证这是base58解密算法。
在0x7DF2处,是不是就是求的carry += 58 * (*it),然后在0x7DFE写入的时候它只写了最低一个字节,这是不是就是it = carry % 256,然后在0x7E0E-0x7E18处还是进行的编译器除法优化,只不过这次除数是2的幂,这操作是不是就是carry /= 256。
通过上面的这些都说明这个是base58解密算法,或者调试时看到这个表,也能知道这个是base58解密算法。
这个base58算法调试还需要各位自己来调试理解,我这里不在过多说明,咱们接下来看后面的部分。
record函数执行完也就是进行完base58解密后来到了这里:
原来是取得了vector中base58解密后的字符串的指针和大小。然后在0x A512处进行第二次变换。
上面是这个函数的F5代码,下面我大概说一下这个函数的每个子函数的功能。
首先0x774A处清空一个256字节的缓冲区,然后来到地址0x7770处,进入后为:
在地址0x77F0处的R1为.rodata段的全局数组,我将它命名为buf1。
在地址0x77F4处进入convert_copy函数后的关键点如下:
大家看懂意思了没,这段意思就是将上面说的buf1数组的前0x11字节和buf1+0x1B地址处开始的0x11字节的值分别异或,然后存入地址0x77EC处R0所代表的key1所示的位置,相关截图如下:
然后在地址0x77FC处通过memcpy将这个0x11字节的key1值拷贝到栈上,并在地址0x780A处计算这个key1的长度。
然后来到如下图所示的地方,这里是初始化这个256字节表的地方,初始化为0、1、2、……、256。
接下来来到下图所示位置准备变换这个表:
变换后的表为下图所示:
这个变换算法没有外部输入的参与,因此变换结果是固定的,我就不详解了,各位自己看上面截图中的汇编就好了,以上就是init_table_256函数的全部内容。
接下来就执行到地址0x778A处的BLX了,进入后来到函数convert_buf。进入前参数注意一下,R1为这个已经变换后的256字节的表地址,R2为base58 decode后的字符串首指针,R3为base58 decode后的长度。
以下只截取关键点,具体请各位动态调试理解。
在0x7A32处将base58 decode后的字符串指针值和长度入栈。
在地址0x7AEA处取出了base58 decode后的字符串长度,然后在0x7AF0处与循环变量判断,那么很明显,这段的意思是循环base58 decode后的字符串长度那么多次。
然后接下来的每次循环的意思也比较容易理解,每一次循环,多次索引256字节表中的特定位置,然后取出这个字节后和base58 decode的buf的相应字节异或,然后写回到base58 decode buf中。
因为没有外部输入的参与,唯一参与输入的为base58 decode的长度,因此可以预见到,当base decode的字符串长度相同时,异或的那个字符串数组也是相同的。即地址0x7B44处的R2值不以输入字符串不同而不同,只要输入字符串长度相同,经过base58 decode后字符串大小也相同,那么循环每次的异或值亦是相同的。这样就分析完成这个函数了。这里也是本程序的第二阶段的输入变换点。
我们继续下面的函数分析,来到如下图所示:
还记得我们曾经计算过当前的毫秒数吧,这是第二次取得当前时间的毫秒数,然后判断执行时间,以下大概说一下流程:执行完取当前时间后,在地址0xA552处计算当前秒数1000,然后结果的低32位-R9(之前的时间毫秒数低32位),结果的高32位-之前的时间毫秒数高32位(注意CPSR的C标志位参与运算)。然后在地址0xA54C、0xA55C-0xA55E计算微秒数/1000,然后在地址0xA562处计算之前的差值+微秒数/1000的低32位,在地址0xA568处计算之前的差值+微秒数/1000的高32位。即地址0x A562处的目的操作数R0为执行时间毫秒数的低32为差值,地址0xA568处的目的操作数R1为执行时间毫秒数的高32为差值。
然后用3000-R0,然后0-R1,这个意思很明显了,就是比较运行时间差值是否大于3000毫秒,也就是3秒。这是这个程序的第二个反调试点。
如果间隔小于3s,就到了下一关键点:
它将第二阶段输入变换后缓冲区中的字符串经过了一定的变换再次写入原位置,这里我就不再说明了,算法自己看汇编吧,我直接截图Python的等价代码在下面。
经过了上面的三次变换,接下来终于到了最终的比较阶段了,我们来到地址0xA5EA处,这个进入后就是比较函数了,它比较了我们经过3次变换后的缓冲区的每个字符的值和已知的一组值的每位是否相同。相关截图如下:
比较key截图如下:
之后完成比较后就一切都完成了,析构vector后就返回了。
通过以上所有就逆向出了程序的所有逻辑,我们接下来求算法的逆。
首先我先总结一下,第一次变换为标准的base58解密算法,第二次为表变换算法,值得注意的是,输入长度一定,即使字符串完全不同异或的值也完全相同,这在上面也已经说明过,第三次为自定义的算法,算法已在上面截图,然后最后和cmp_key比较。
其中最后比较的字符串cmp_key长度为22字节,而第二、三次字符串变换不影响字符串长度,只有第一次的base58解密算法影响字符串长度,我们看一下base58加密算法的长度计算方法,逆推出输入字符串的长度,又因为第二次变换字符串只要长度相同,异或的值就是相同的,那么我们通过输入这么长的任意符合base58标准的加密字符串并配合动态调试给上面的相关地址下断点直接得到第二次变换异或的值,这样就不需要理解那个表变换算法了。
base58加密长度计算方式如下:
因此输入字符串长度的计算方法为22*138/100+1=31,也就是输入是31字节。我们随便输入31字节的符合base58标准的加密字符串加上前面的‘flag{’和最后的‘}’,开启动态调试,在下面截图中的地址下断点,得到22个第二次变换的每次的异或值。即下图中断点处的目标寄存器R2的值。
为了不让篇幅过长,我仅截图3次动态调试时断点处的值,就不把22次循环的值全部截图了。
所以我们得到第二次所异或的值分别是0x39, 0x88, 0x79, 0xc5, 0x7e, 0x32, 0xfd, 0x0a, 0x20, 0x01, 0xea, 0x5a, 0xa4, 0x95, 0xd6, 0x36, 0xf6, 0xea, 0x73, 0x23, 0xaa, 0x9c。
而第三次变换已在上面用Python实现,为避免求算法的逆太麻烦,我们直接暴力枚举,从0x0-0xff,每位带到上面Python中,结果等于cmp_key的相应位的即为正确值。
说了这么多,最后求flag的Python脚本如下:
执行后为:
因此flag为flag{292VujXJgRwEmPdkcSNr9vpRs6wcbQZ}
附件重新上传。。。
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2020-6-7 13:57
被sqdebug编辑
,原因: