2020网鼎杯 青龙组 rev01 writeup
简介
青龙组(第一场比赛)的逆向题一共四道,两道PE两道android。rev01场上没有人做出来,这里场下补了这道题,分享一下解题思路。
jadx打开apk文件,可以看到验证的逻辑很清晰,app输入框输入flag,首先验证长度和格式,之后调用native函数checkFlag进行验证。
解题流程
JNI_onLoad动态注册
IDA 打开libcm1.so,并没有找到checkFlag函数,只有一个JNI_onLoad函数。进一步查询之后发现这是native函数动态注册的方法。
JNI_onLoad动态注册的实践方法参考下面的代码,最终调用(*env)->RegisterNatives函数实现动态注册,该函数的参数JNINativeMethod结构体数组指示了注册函数的函数名、函数参数类型和函数地址。我们只要得到这个结构体数组就能够确定动态注册的函数了。
//代码出处 https://blog.csdn.net/hk9259/article/details/43309361
JNIEXPORT jstring JNICALL native_hello(JNIEnv *env, jclass clazz)
{
//动态注册的native函数
}
// Java和JNI函数的绑定表
static JNINativeMethod method_table[] = {
{ "HelloLoad", "()Ljava/lang/String;", (void*)native_hello },
};
// 注册native方法到java中
static int registerNativeMethods(JNIEnv* env, const char* className,
JNINativeMethod* gMethods, int numMethods)
{
//...
if ((*env)->RegisterNatives(env, clazz, gMethods, numMethods) < 0) {
return JNI_FALSE;
}
//...
}
int register_ndk_load(JNIEnv *env)
{
// 调用注册方法
return registerNativeMethods(env, JNIREG_CLASS,
method_table, NELEM(method_table));
}
JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved)
{
//...
register_ndk_load(env);
//...
}
但是这道题的native函数加入了代码混淆,看起来像是一种控制流平坦化混淆,导致很难分析JNI_onLoad函数。
JNI_onLoad的部分汇编代码,这部分实现了控制流平坦化的分发器,BR是ARM汇编的跳转指令。
静态分析很难下手的情况下,考虑用动态的方法去做,JNI_onLoad动态注册的相关研究很多,其中有人开发了基于frida的hook脚本,直接获得动态注册的函数名和地址。脚本地址在这里:https://github.com/lasting-yang/frida_hook_libart。脚本的原理是hook libart.so的RegisterNatives函数,从而截获JNINativeMethod
结构体。
Frida动态调试环境搭建
首先需要准备一台已root的android设备和一台调试主机。
在调试PC上,Frida作者推荐使用pip进行安装,输入下面的命令
pip install frida-tools
安装完成后,检测frida是否安装成功。
λ frida --version
12.7.11
之后需要在android设备上运行对应版本的frida-server,在frida的github release页面有很多很多的版本(这里吐槽一下开发frida的老哥,实在是太勤劳了),找到对应frida版本和设备的frida-server下载。例如,我的frida是12.7.11,设备是arm64位的Nexus 6p,那么需要下载frida-server-12.7.11-android-arm64.xz。
解压frida-server,使用android的adb上传到设备上,以root权限运行。
adb push ./frida_server_arm64 /data/local/tmp
adb shell
su
cd /data/local/tmp
./frida-server-arm64
之后还需要用adb做把frida用的端口转出,执行下面两条命令
adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043
frida_hook_libart
安装配置好Frida、frida-server和frida_hook_libart脚本之后,在主机执行
frida -U --no-pause -f com.ichunqiu.rev01 -l hook_RegisterNatives.js
这样我们在设备上运行rev01,主机的frida就成功的输出了checkFlag的函数地址,可以看到下图的offset: 0x1004c就是checkFlag函数的偏移。
这里还有一个坑,网上的资料大部分都讲registerNatives函数在libdvm.so中,可是新版的android已经没有libdvm了,现在这些函数都在libart当中。
checkFlag函数分析
IDA反编译checkFlag之后,发现和JNI_onLoad一样加了代码混淆,确实是绕不过去了。静态分析时,跳转代码的地址很难确定,这里搭建了IDA+android设备的远程调试环境。将IDA的android_server64拷贝到设备目录下,以root权限运行,之后把端口转出就可以调试了。
接下来就需要动态调试了,主要是在关键代码下断点,追踪程序对输入字符串的处理。此外还要考虑到程序的控制流平坦化,最好用笔记下函数的调用关系和控制流平坦化后代码块的调用关系,不然很容易跟丢函数。
base58在调试的时候主要是通过发现0x3a这个关键的数和offset_2B9E0数组来判断;RC4函数的初始化过程比较明显,调试的时候发现初始化了一个从0x00-0xFF的数组,基本就确定是RC4了,再去查看RC4初始化函数的参数就能RC4的密钥。最后的字符串对比逻辑也比较容易找到。
checkFlag函数的伪代码如下,忽略的很多细节上的操作:
checkFlag(input_string):
vector v = base58_decode(input_string)
s[256] //rc4数组
k[16] //rc4密钥
for i 0->16:
k[i] = offset_2b9cb[i] ^ offset_2b9af[i]
plain = string(v) //把vector中存储的数据以字符串的形式放在plain中
rc4_init(s, k)
rc4_encrypt(plain)
cipher = offset_48830 //做最终对比的结果值
if plain == cipher:
return 1 //通过校验
else:
return 0
那么最后编写python脚本就得到了flag
from Crypto.Cipher import ARC4
tbl = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F, 0x10, 0xFF, 0x11, 0x12, 0x13, 0x14, 0x15, 0xFF,
0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F,
0x20, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x21, 0x22, 0x23,
0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, 0x2B, 0xFF, 0x2C,
0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36,
0x37, 0x38, 0x39, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]
key = 'F2 B5 A4 0D FE A8 3A 4E B4 7A AB A1 E6 C9 ED 77'.replace(' ', '').decode('hex')
res = '34 93 C5 DD DA 0F BB 94 37 BB D6 DE EA F3 53 41 56 C9 5F 42 E7 F6'.replace(' ', '').decode('hex')
cipher = ARC4.new(key)
c = cipher.encrypt(res)
print c.encode('hex')
n = int(c.encode('hex'), 16)
print n
flag = ''
def reverse_tbl(x):
for i in xrange(0xff):
if tbl[i] == x:
return chr(i)
return -1
def base58_encode(hexstr_input):
n = int(hexstr_input, 16)
res = ''
while True:
if n == 0:
break
t = n % 0x3a
n /= 0x3a
res = reverse_tbl(t) + res
return res
def base58_decode(b58_str):
res = 0
for i in xrange(len(b58_str)):
tmp = tbl[ord(b58_str[i])]
res *= 0x3a
res += tmp
return hex(res)
s = base58_encode(c.encode('hex'))
print s
print base58_decode(s)
总结
这道题的主要难点在动态调试ARM64平台的混淆代码,没有处理的脚本只能手动调试。ARM64的汇编代码很复杂,这里被坑的很深,比如下面的两条汇编,如果不查文档的话可能理解为ADD和OR,意思就完全错了。
MADD
EOR
除了最开始的JNI_onLoad用脚本比较快速解决之外,主要还是靠人肉逆向做题。如果有什么好的解决ARM代码混淆的思路欢迎一起讨论。
参考文献
[1] 抖音火山视频的Native注册混淆函数获取方法 http://www.520monkey.com/archives/1289
[2] Android逆向新手答疑解惑篇——JNI与动态注册 https://bbs.pediy.com/thread-224672.htm
[3] ARM64 OLLVM反混淆 https://bbs.pediy.com/thread-252321.htm
[4] ARM汇编代码的官方手册 http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.dui0802b
[5] Android JNI动态注册Native 方法 https://blog.csdn.net/hk9259/article/details/43309361