这篇文章上次修改于 1150 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
致谢
- Lilac,又名:白龙~
龙哥往往一语中的,给我带来了莫大的帮助,非常感谢
前言
本文旨在对getByte算法进行分析与还原,仅供学习交流
本文不会提供完整算法脚本
本文将涉及以下内容:
- OLLVM虚假控制流(OLLVM-BCF)反混淆
- cutter反编译
- md5算法识别
- SHA256算法还原——魔数修改
- Salsa20算法还原——逻辑魔改
- trace_natives与frida-trace搭配使用
- findhash使用
- gettimeofday和lrand48
unidbg模拟调用(下一篇文章)
!!!为了节省版面,文章中重复的hook代码会省略掉,复现时请记得自行补充
环境和工具
名称 | 物料 | 补充 |
---|---|---|
目标方法 | getByte | - |
目标类 | com.tencent.starprotocol.ByteData | - |
目标so | libpoxy_star.so | md5: 889415fb8e886dfdc3fdd405c105d262 |
目标apk | com.tencent.qqlive_V8.3.95.26016.apk | md5: 6d6cd9c0b36c49f17d0f204cf917774e |
frida-server | frida-server-14.2.18-android-arm64 | - |
python | 3.8.5 | 由miniconda创建 |
IDA | IDA Pro 7.5 | 爱盘地址 |
CyberChef | CyberChef | 在线地址 |
findhash | findhash | Github地址 |
trace_natives | trace_natives | Github地址 |
jnitrace | jnitrace | Github地址 |
JNI-Frida-Hook | JNI-Frida-Hook | Github地址 |
hook_RegisterNatives | hook_RegisterNatives | Github地址 |
cutter | cutter | 官方地址 |
测试ROM | QQ1B.200205.002 |
过程
算法稳定主动调用
此处稳定意为固定输入、固定输出
获取输入输出参数
已知目标方法是com.tencent.starprotocol.ByteData.getByte
,要触发这个地方的调用很简单,APP随便打开一个视频即可
首先用objection看一眼
android hooking watch class_method com.tencent.starprotocol.ByteData.getByte --dump-args --dump-return
结果如下(部分参数已和谐)
(agent) [466367] Called com.tencent.starprotocol.ByteData.getByte(android.content.Context, long, long, long, long, java.lang.Object, java.lang.Object, java.lang.Object, java.lang.Object)
(agent) [466367] Arguments com.tencent.starprotocol.ByteData.getByte(com.tencent.qqlive.ona.base.QQLiveApplicationWrapper@bdbc6ce, 1, 0, 0, 0, [Ljava.lang.String;@6e859e6, , ce****************************b2, [B@a368b27)
(agent) [466367] Return Value: [object Object]
显然objection不能直接得到全部参数具体内容,需要手动写个hook脚本
首先准备两个hexdump函数
function jhexdump(array) {
if(!array) return;
console.log("---------jhexdump start---------");
var ptr = Memory.alloc(array.length);
for(var i = 0; i < array.length; ++i)
Memory.writeS8(ptr.add(i), array[i]);
console.log(hexdump(ptr, {offset: 0, length: array.length, header: false, ansi: false}));
console.log("---------jhexdump end---------");
}
function dumpByteArray(obj){
console.log("---------dumpByteArray start---------");
let obj_ptr = ptr(obj.$h).readPointer();
let buf_ptr = obj_ptr.add(Process.pointerSize * 3);
let size = obj_ptr.add(Process.pointerSize * 2).readU32();
console.log(hexdump(buf_ptr, {offset: 0, length: size, header: false, ansi: false}));
console.log("---------dumpByteArray end---------");
}
打印参数
e.g. frida -U -n com.tencent.qqlive -l agent/poxy_star.js -o agent/poxy_star.log
function getByte_LogArgs(){
Java.perform(function(){
let gson = Java.use('com.google.gson.Gson');
let ByteDataCls = Java.use("com.tencent.starprotocol.ByteData");
ByteDataCls.getByte.overload("android.content.Context", "long", "long", "long", "long", "java.lang.Object", "java.lang.Object", "java.lang.Object", "java.lang.Object").implementation = function(context, num1, num2, num3, num4, obj1, obj2, obj3, obj4){
console.log(context, num1, num2, num3, num4);
console.log("obj1", obj1.$className, gson.$new().toJson(obj1))
console.log("obj2", obj2.$className, gson.$new().toJson(obj2))
console.log("obj3", obj3.$className, gson.$new().toJson(obj3))
console.log("obj4", obj4.$className, gson.$new().toJson(obj4))
dumpByteArray(obj4);
let resp = this.getByte(context, num1, num2, num3, num4, obj1, obj2, obj3, obj4)
jhexdump(resp);
return resp
}
})
}
setImmediate(getByte_LogArgs);
点击视频,得到下面的结果
参数拿到后就可以开始主动调用了
frida稳定主动调用
经过测试,发现传入参数固定,返回结果并不固定,这是因为原算法用到了lrand48和gettimeofday
这是怎么发现的呢
当然是根据经验测出来的
一般遇到传入参数固定但是结果变化,统统往时间、随机数上靠
这里先认为是假设,下面进行验证
固定lrand48和gettimeofday返回
lrand48定义如下
那么这里直接将返回设置位7
这里提一点,为了和其他可能为0的参数进行区分,建议这里尽量不要设置为0
gettimeofday定义如下
定义函数 int gettimeofday (struct timeval tv, struct timezone tz);
函数说明 gettimeofday()会把目前的时间有tv所指的结构返回,当地时区的信息则放到tz所指的结构中。
类型信息
typedef long __kernel_long_t;
typedef __kernel_long_t __kernel_time_t;
typedef __kernel_long_t __kernel_suseconds_t;
struct timeval {
__kernel_time_t tv_sec;
__kernel_suseconds_t tv_usec;
};
根据上述信息,那么只需要在gettimeofday返回时向参数一写入两个long类型的数即可,分别代表秒数、微秒数
验证
另外对于图中马赛克的参数,经过测试,确定改变它们不影响最终结果,后面使用特定固定值
于是编写如下完整的稳定主动调用脚本
function freeze_funcs(){
let lrand48_addr = Module.findExportByName("libc.so", "lrand48");
Interceptor.attach(lrand48_addr, {onLeave: function(retval){retval.replace(7)}});
let tm_s = 1626403551;
let tm_us = 5151606;
let gettimeofday_addr = Module.findExportByName("libc.so", "gettimeofday");
Interceptor.attach(gettimeofday_addr, {
onEnter: function(args) {
this.tm_ptr = args[0];
},
onLeave:function(retval){
this.tm_ptr.writeLong(tm_s);
this.tm_ptr.add(0x4).writeLong(tm_us);
}
});
}
function call_getByte(){
Java.perform(function(){
let LongCls = Java.use("java.lang.Long");
let StringCls = Java.use("java.lang.String");
let ReflectArrayCls = Java.use('java.lang.reflect.Array')
let ByteDataCls = Java.use("com.tencent.starprotocol.ByteData");
let ctx = Java.use('android.app.ActivityThread').currentApplication().getApplicationContext();
let num_1 = LongCls.$new(1).longValue();
let num_2 = LongCls.$new(0).longValue();
let num_3 = LongCls.$new(0).longValue();
let num_4 = LongCls.$new(0).longValue();
let obj1 = ReflectArrayCls.newInstance(StringCls.class, 9);
ReflectArrayCls.set(obj1, 0, "dl_10303");
ReflectArrayCls.set(obj1, 1, "1");
ReflectArrayCls.set(obj1, 2, "66666666666666666666666666666666");
ReflectArrayCls.set(obj1, 3, "getCKey");
ReflectArrayCls.set(obj1, 4, "888888888888888888888888888888888888");
ReflectArrayCls.set(obj1, 5, "1626403551515");
ReflectArrayCls.set(obj1, 6, "");
ReflectArrayCls.set(obj1, 7, "8.3.95.26016");
ReflectArrayCls.set(obj1, 8, "com.tencent.qqlive");
let obj2 = StringCls.$new("");
let obj3 = StringCls.$new("66666666666666666666666666666666");
let obj4 = Java.array('B', [49,54,50,54,52,48,51,53,53,49,44,110,48,48,51,57,101,121,49,109,109,100,44,110,117,108,108]);
let ByteDataIns = ByteDataCls.getInstance()
let byte = ByteDataIns.getByte(ctx, num_1, num_2, num_3, num_4, obj1, obj2, obj3, obj4);
jhexdump(byte);
Interceptor.detachAll();
})
}
freeze_funcs();
call_getByte();
现在得到固定的返回了
---------jhexdump start---------
b6abddd8 68 65 68 61 00 00 00 01 01 00 00 00 00 00 00 00 heha............
b6abdde8 00 00 00 03 08 00 00 00 00 44 a7 2c da 00 00 00 .........D.,....
b6abddf8 01 00 00 00 96 00 01 00 08 00 00 01 7a ad 34 cb ............z.4.
b6abde08 37 00 02 00 0a 68 68 68 68 68 68 69 69 69 68 00 7....hhhhhhiiih.
b6abde18 03 00 04 01 00 00 01 00 05 00 04 01 00 00 01 00 ................
b6abde28 04 00 04 00 00 00 00 00 06 00 04 01 00 00 04 00 ................
b6abde38 07 00 04 01 00 00 02 00 08 00 04 01 00 00 03 00 ................
b6abde48 09 00 20 45 4f 2a db 77 40 90 33 9f e2 58 8c 2f .. EO*[email protected]./
b6abde58 c6 4a 3d ce 7a c7 30 7d ce ac 0b aa b6 18 b3 6f .J=.z.0}.......o
b6abde68 41 74 d3 00 0a 00 10 cd b5 df 89 f5 2d 25 05 4b At..........-%.K
b6abde78 85 81 f6 cf af 1e fb 00 0b 00 10 fb 08 ad 5f af .............._.
b6abde88 a8 93 35 2d 13 cb 93 27 e8 f7 fd ..5-...'...
---------jhexdump end---------
so分析准备
通过反编译apk,可以知道getByte
是一个native函数,熟练打开IDA、拖入so、搜索导出函数
不好意思,没有
再看看字符串
动态注册跑不了了
[RegisterNatives] java_class: com.tencent.starprotocol.ByteData name: getByte sig: (Landroid/content/Context;JJJJLjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)[B fnPtr: 0x90c969ad module_name: libpoxy_star.so module_base: 0x90c89000 offset: 0xd9ad
[RegisterNatives] java_class: com.tencent.starprotocol.ByteData name: putByte sig: (Landroid/content/Context;JJJJLjava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;Ljava/lang/Object;)I fnPtr: 0x90caa995 module_name: libpoxy_star.so module_base: 0x90c89000 offset: 0x21995
好了,让我们看看sub_D9AC
似乎有点麻烦,很多分支......
此刻是不是头都大了,再看看so大小,828KB,发现了什么没有
就动态注册了两个方法,并且没有其他静态注册的方法,so文件却如此之大
显然so有混淆
好在反编译代码中有迹可循,可以看到反编译结果有很多下面这样的判断
((dword_C2C04 ^ dword_C2C08 ^ 0x100011AA) & 0x5120FBAA) == -1964041611
如果你经验丰富,一眼就能看出来这是BCF(Bogus Control Flow),即虚假控制流
吐槽:我是算法还原完了才知道这个点,哎,心累...
OLLVM-BCF反混淆
这烦人的BCF自然是有方案处理的,具体请参考下面这篇文章
简单来说就是将data段数据的Write
属性去掉,然后重新F5即可,IDA会自动完成BCF的优化
这是优化后的结果,是不是非常非常清晰了
知道这个操作后,简直事半功倍,基本上不用去手动hook确定走向了
看雪还有一些关于BCF反混淆的文章,经过测试还是IDA来的快,效果相对更好...
cutter搭配使用
即使进行了BCF反混淆操作
IDA反编译出来的代码阅读有些问题,比如看不懂变量传递关系
表现为函数传入参数在确定要走的那个分支里面没有被直接或者间接调用
好在cutter的反编译结果至少是能看出来的
以sub_AA924
为例
大致可以推测出来数据处理在最后一个case进行
但是*(_BYTE *)(*v10 + *v8)
和a1什么关系实在是看不懂,v5和a1又有什么关系?
这也许要直接阅读汇编代码才能看出其中关系了,或者这是某种特定的形式
实在不行也可以hook来确定,毕竟动态的是准确的
或者这是一个我还没学习到的知识点
好在cutter可以帮上忙
根据下图的标注,可以知道a1就是piStack44
了
而对于后面的puStack52
,也能根据下面的标注推测出每一轮+1,等于是挨个取a1处的内容
如果只看IDA,可能就比较懵了...
所以搭配使用效果更佳
返回结果追踪定位
按照常规思路,一般是看native函数的返回,然后追踪结果的生成过程
先打印一波so的结果
function jbhexdump(array) {
console.log("---------jbhexdump start---------");
let env = Java.vm.getEnv();
let size = env.getArrayLength(array);
let data = env.getByteArrayElements(array);
console.log(hexdump(data, {offset: 0, length: size, header: false, ansi: false}));
env.releaseByteArrayElements(array, data, 0);
console.log("---------jbhexdump end---------");
}
function inline_hook(){
let base_addr = Module.getBaseAddress("libpoxy_star.so");
Interceptor.attach(base_addr.add(0xD9AC).add(1), {
onLeave: function (retval) {
console.log(`onLeave sub_D9AC`);
jbhexdump(retval);
}
});
}
freeze_funcs();
inline_hook();
call_getByte();
日志如下
一 模 一 样
是否反向查看函数,挨个跟踪定位算法呢,答案是否
在已经有BCF反混淆的加持下,依然可以看到函数嵌套函数,如果没有足够的精力,可能是难以定位算法的
既然so结尾返回的是jbyteArray,那么可以通过hook SetByteArrayRegion实现快速定位
jnitrace定位SetByteArrayRegion调用
jnitrace如果以spawn模式可能难以追踪,这里采用attach模式
具体操作是打开APP后一小会儿执行命令,同时注意脚本中主动调用时机
jnitrace -l libpoxy_star.so -m attach -a poxy_star.js com.tencent.qqlive
可以看到有一个SetByteArrayRegion
内容和最终结果一样,地址是0x13d79
另外可以通过-o
选项设置追踪结果
这就是调用的位置了,位于sub_13C7C
更优雅的jni追踪
前面提到可以直接用jnitrace进行jni函数调用追踪,但是不太方便
只想在主动调用某个函数之间进行追踪怎么办呢
参考JNI-Frida-Hook
这个项目,可以发现实现原理很简单,拿到jni函数的地址然后hook就行了
下面是一个简单的案例,这样会灵活很多
let jni_struct_array = [
"reserved0", "reserved1", "reserved2", "reserved3", "GetVersion", "DefineClass", "FindClass", "FromReflectedMethod", "FromReflectedField", "ToReflectedMethod", "GetSuperclass", "IsAssignableFrom", "ToReflectedField", "Throw", "ThrowNew",
"ExceptionOccurred", "ExceptionDescribe", "ExceptionClear", "FatalError", "PushLocalFrame", "PopLocalFrame", "NewGlobalRef", "DeleteGlobalRef", "DeleteLocalRef", "IsSameObject", "NewLocalRef", "EnsureLocalCapacity", "AllocObject", "NewObject",
"NewObjectV", "NewObjectA", "GetObjectClass", "IsInstanceOf", "GetMethodID", "CallObjectMethod", "CallObjectMethodV", "CallObjectMethodA", "CallBooleanMethod", "CallBooleanMethodV", "CallBooleanMethodA", "CallByteMethod", "CallByteMethodV",
"CallByteMethodA", "CallCharMethod", "CallCharMethodV", "CallCharMethodA", "CallShortMethod", "CallShortMethodV", "CallShortMethodA", "CallIntMethod", "CallIntMethodV", "CallIntMethodA", "CallLongMethod", "CallLongMethodV", "CallLongMethodA",
"CallFloatMethod", "CallFloatMethodV", "CallFloatMethodA", "CallDoubleMethod", "CallDoubleMethodV", "CallDoubleMethodA", "CallVoidMethod", "CallVoidMethodV", "CallVoidMethodA", "CallNonvirtualObjectMethod", "CallNonvirtualObjectMethodV",
"CallNonvirtualObjectMethodA", "CallNonvirtualBooleanMethod", "CallNonvirtualBooleanMethodV", "CallNonvirtualBooleanMethodA", "CallNonvirtualByteMethod", "CallNonvirtualByteMethodV", "CallNonvirtualByteMethodA", "CallNonvirtualCharMethod",
"CallNonvirtualCharMethodV", "CallNonvirtualCharMethodA", "CallNonvirtualShortMethod", "CallNonvirtualShortMethodV", "CallNonvirtualShortMethodA", "CallNonvirtualIntMethod", "CallNonvirtualIntMethodV", "CallNonvirtualIntMethodA",
"CallNonvirtualLongMethod", "CallNonvirtualLongMethodV", "CallNonvirtualLongMethodA", "CallNonvirtualFloatMethod", "CallNonvirtualFloatMethodV", "CallNonvirtualFloatMethodA", "CallNonvirtualDoubleMethod", "CallNonvirtualDoubleMethodV",
"CallNonvirtualDoubleMethodA", "CallNonvirtualVoidMethod", "CallNonvirtualVoidMethodV", "CallNonvirtualVoidMethodA", "GetFieldID", "GetObjectField", "GetBooleanField", "GetByteField", "GetCharField", "GetShortField", "GetIntField",
"GetLongField", "GetFloatField", "GetDoubleField", "SetObjectField", "SetBooleanField", "SetByteField", "SetCharField", "SetShortField", "SetIntField", "SetLongField", "SetFloatField", "SetDoubleField", "GetStaticMethodID",
"CallStaticObjectMethod", "CallStaticObjectMethodV", "CallStaticObjectMethodA", "CallStaticBooleanMethod", "CallStaticBooleanMethodV", "CallStaticBooleanMethodA", "CallStaticByteMethod", "CallStaticByteMethodV", "CallStaticByteMethodA",
"CallStaticCharMethod", "CallStaticCharMethodV", "CallStaticCharMethodA", "CallStaticShortMethod", "CallStaticShortMethodV", "CallStaticShortMethodA", "CallStaticIntMethod", "CallStaticIntMethodV", "CallStaticIntMethodA", "CallStaticLongMethod",
"CallStaticLongMethodV", "CallStaticLongMethodA", "CallStaticFloatMethod", "CallStaticFloatMethodV", "CallStaticFloatMethodA", "CallStaticDoubleMethod", "CallStaticDoubleMethodV", "CallStaticDoubleMethodA", "CallStaticVoidMethod",
"CallStaticVoidMethodV", "CallStaticVoidMethodA", "GetStaticFieldID", "GetStaticObjectField", "GetStaticBooleanField", "GetStaticByteField", "GetStaticCharField", "GetStaticShortField", "GetStaticIntField", "GetStaticLongField",
"GetStaticFloatField", "GetStaticDoubleField", "SetStaticObjectField", "SetStaticBooleanField", "SetStaticByteField", "SetStaticCharField", "SetStaticShortField", "SetStaticIntField", "SetStaticLongField", "SetStaticFloatField",
"SetStaticDoubleField", "NewString", "GetStringLength", "GetStringChars", "ReleaseStringChars", "NewStringUTF", "GetStringUTFLength", "GetStringUTFChars", "ReleaseStringUTFChars", "GetArrayLength", "NewObjectArray", "GetObjectArrayElement",
"SetObjectArrayElement", "NewBooleanArray", "NewByteArray", "NewCharArray", "NewShortArray", "NewIntArray", "NewLongArray", "NewFloatArray", "NewDoubleArray", "GetBooleanArrayElements", "GetByteArrayElements", "GetCharArrayElements",
"GetShortArrayElements", "GetIntArrayElements", "GetLongArrayElements", "GetFloatArrayElements", "GetDoubleArrayElements", "ReleaseBooleanArrayElements", "ReleaseByteArrayElements", "ReleaseCharArrayElements", "ReleaseShortArrayElements",
"ReleaseIntArrayElements", "ReleaseLongArrayElements", "ReleaseFloatArrayElements", "ReleaseDoubleArrayElements", "GetBooleanArrayRegion", "GetByteArrayRegion", "GetCharArrayRegion", "GetShortArrayRegion", "GetIntArrayRegion",
"GetLongArrayRegion", "GetFloatArrayRegion", "GetDoubleArrayRegion", "SetBooleanArrayRegion", "SetByteArrayRegion", "SetCharArrayRegion", "SetShortArrayRegion", "SetIntArrayRegion", "SetLongArrayRegion", "SetFloatArrayRegion",
"SetDoubleArrayRegion", "RegisterNatives", "UnregisterNatives", "MonitorEnter", "MonitorExit", "GetJavaVM", "GetStringRegion", "GetStringUTFRegion", "GetPrimitiveArrayCritical", "ReleasePrimitiveArrayCritical", "GetStringCritical",
"ReleaseStringCritical", "NewWeakGlobalRef", "DeleteWeakGlobalRef", "ExceptionCheck", "NewDirectByteBuffer", "GetDirectBufferAddress", "GetDirectBufferCapacity", "GetObjectRefType"
]
function getJNIFunctionAdress(func_name){
// 通过函数名获取到对应的jni函数地址
let jnienv_addr = Java.vm.getEnv().handle.readPointer()
let offset = jni_struct_array.indexOf(func_name) * Process.pointerSize;
return Memory.readPointer(jnienv_addr.add(offset))
}
function hook_jni(func_name){
let listener = null;
switch (func_name){
case "SetByteArrayRegion":
listener = Interceptor.attach(getJNIFunctionAdress(func_name), {
onEnter: function(args){
console.log(`env->${func_name} called from ${Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join("\n")}`);
this.arg_array = args[1];
},
onLeave: function(retval){
jbhexdump(this.arg_array);
console.log("SetByteArrayRegion onLeave");
}
})
default:
listener = Interceptor.attach(getJNIFunctionAdress(func_name), {
onEnter: function(args){
console.log(`env->${func_name} called from ${Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join("\n")}`);
}
})
}
return listener;
}
function inline_hook(){
let base_addr = Module.getBaseAddress("libpoxy_star.so");
Interceptor.attach(base_addr.add(0xD9AC).add(1), {
onEnter: function(args){
this.hook_jni_interceptor = hook_jni("SetByteArrayRegion");
},
onLeave: function (retval) {
this.hook_jni_interceptor.detach();
console.log(`onLeave sub_D9AC`);
jbhexdump(retval);
}
});
}
freeze_funcs();
inline_hook();
call_getByte();
这样追踪更方便,符合需求,输出结果如下
返回结果反向推导与追踪
那就这个时候开始反向追踪了吗
不,让我们把它的调用流程图也弄出来
祭出trace_natives,导入IDA插件,拿到要trace的函数列表
frida-trace一把梭,注意,为了得到调用层次分明的日志,这里直接重定向到文件
frida-trace -UF -O libpoxy_star_1626418239.txt > star_trace.log
建议先执行一次上面的命令,让它自动生成js,停掉
然后去目标js加一个返回时的日志打印,再重新运行
第一次执行命令的时候比较慢,因为要生成js,后面就很快了
然后执行主动调用脚本
得到记录后,找到添加的返回日志,把进入sub_D9AD
到这里之间的内容单独拿出来
为什么这里是sub_D9AD
而不是sub_D9AC
请查阅trace_natives的README
去除不相关的记录后依然有1w+行
当然还有办法精简,前面已经知道在进入sub_13C7C
的时候,就已经产生最终结果了
那么只要sub_13C7C
之前的记录即可
很好,现在只剩1000+的记录了
根据调用记录,可以大致确定sub_13C7C
由sub_11AC0
调用
另外发现sub_11AC0
所在位置非常靠前,说明它应该是运算的核心位置
现在可以反向追踪了,先看下最后这几个函数
并没有什么有用的信息,再往前是一个比较长的调用
sub_ABD9C
中只有两个函数调用
sub_ABDBC
sub_AAE88
这个时候,先看看参数内容,因为很有可能sub_AAE88
之前就已经出现最终结果了
如果是这样就可以不分析sub_AAE88
了
hook代码如下
function inline_hook(){
let hook_flag = false;
let base_addr = Module.getBaseAddress("libpoxy_star.so");
Interceptor.attach(base_addr.add(0xD9AC).add(1), {
onEnter: function(args){
hook_flag = true;
this.hook_jni_interceptor = hook_jni("SetByteArrayRegion");
},
onLeave: function (retval) {
hook_flag = false;
this.hook_jni_interceptor.detach();
console.log(`onLeave sub_D9AC`);
jbhexdump(retval);
}
});
Interceptor.attach(base_addr.add(0xAAE88).add(1), {
onEnter: function (args) {
if(hook_flag){
console.log(`call sub_AAE88`);
this.arg_0 = args[0];
console.log("sub_AAE88 arg_0", hexdump(args[0].readPointer()));
}
},
onLeave: function (retval) {
console.log("sub_AAE88 onLeave arg_0", hexdump(this.arg_0.readPointer()));
}
});
Interceptor.attach(base_addr.add(0xABDBC).add(1), {
onEnter: function (args) {
if(hook_flag){
console.log(`call sub_ABDBC`);
this.arg_0 = args[0];
console.log("sub_ABDBC arg_0", hexdump(args[0].readPointer()));
}
},
onLeave: function (retval) {
console.log("sub_ABDBC onLeave arg_0", hexdump(this.arg_0.readPointer()));
}
});
}
通过对比可以发现进入sub_AAE88
时数据头部还不完整,但是后面的部分是一致的
仔细观察头部数据发现三个可疑点
首先是前四个字节,这会不会是什么校验呢
答案是否,可以通过变化传入参数确定这个位置是固定值
其次是0x96,为何怀疑它,因为根据经验,一字节或两字节不为0前面为0的数据,很可能是后面数据的长度
0x96 = 150 = 11 + 8 * 16 + 11
96
起到f7 d0
这里,长度刚好是150,那没跑了,就是长度值
剩下一个44 a7 2c da
,这有可能是什么呢
逆向常常需要靠经验和猜测,既然确定0x96是长度,那它后面应该就是一个完整的数据块
那么猜测它是后面数据的校验,提到数据校验,相信大家都会想到CRC32
用CyberChef验证一下
猜测正确
那么头部前面还有一些01 02 03 00
可能是什么呢
首先在最开始已经将随机数固定为7
了,那么基本可以排除它们和随机数无关
可能是时间吗,显然可能性不大,而且可以通过变化传入参数,可以发现除了CRC32部分,其他内容不变
这样一来可以推测是一些固定或者变化比较简单的运算,那么先把它放一边吧
现在分析sub_ABDBC
函数
可以看到里面有好几个重复函数,不多说,直接hook看参数内容
先看sub_AC214
,参数内容都比较简单
然后是sub_AD1D0
,参数内容也是最终结果里面的
根据以上信息,除去头部,对照内容后整理如下
tag | length | payload | func |
---|---|---|---|
00 01 | 00 08 | 00 00 01 7a ad 34 cb 37 | sub_AC214 |
00 02 | 00 0a | 68 68 68 68 68 68 69 69 69 68 | sub_AD1D0 |
00 03 | 00 04 | 01 00 00 01 | sub_AC214 |
00 05 | 00 04 | 01 00 00 01 | sub_AC214 |
00 04 | 00 04 | 00 00 00 00 | sub_AC214 |
00 06 | 00 04 | 01 00 00 04 | sub_AC214 |
00 07 | 00 04 | 01 00 00 02 | sub_AC214 |
00 08 | 00 04 | 01 00 00 03 | sub_AC214 |
00 09 | 00 20 | 45 4f 2a db 77 40 90 33 9f e2 58 8c 2f c6 4a 3d ce 7a c7 30 7d ce ac 0b aa b6 18 b3 6f 41 74 d3 | sub_AD1D0 |
00 0a | 00 10 | cd b5 df 89 f5 2d 25 05 4b 85 81 f6 cf af 1e fb | sub_AD1D0 |
00 0b | 00 10 | fb 08 ad 5f af a8 93 35 2d 13 cb 93 27 e8 f7 fd | sub_AD1D0 |
第一个参数是什么呢,通过测试可以发现当gettimeofday
返回固定,这个值就固定
是不是和时间有关系呢,答案是yes
0x0000017aad34cb37 = 1626403556151
固定的时间代码片段如下
let tm_s = 1626403551;
let tm_us = 5151606;
Q: 似乎有些对不上,这是什么问题呢
A: tm_us是微秒,显然这里是5.151606s,超过1s了
将tm_us
改为151606
,重新整理
tag | length | payload | func |
---|---|---|---|
00 01 | 00 08 | 00 00 01 7a ad 34 b7 af | sub_AC214 |
00 02 | 00 0a | 68 68 68 68 68 68 69 69 69 68 | sub_AD1D0 |
00 03 | 00 04 | 01 00 00 01 | sub_AC214 |
00 05 | 00 04 | 01 00 00 01 | sub_AC214 |
00 04 | 00 04 | 00 00 00 00 | sub_AC214 |
00 06 | 00 04 | 01 00 00 04 | sub_AC214 |
00 07 | 00 04 | 01 00 00 02 | sub_AC214 |
00 08 | 00 04 | 01 00 00 03 | sub_AC214 |
00 09 | 00 20 | ab a3 29 3c be 3f 85 56 42 c6 91 8c 0c e8 e2 a6 06 16 ff 83 47 44 ca c3 26 6d 6f 0c e4 3e c6 64 | sub_AD1D0 |
00 0a | 00 10 | ea 36 73 53 c0 ac 2f 60 c7 96 68 ee f1 36 2d 3b | sub_AD1D0 |
00 0b | 00 10 | 23 9e 8e 64 9b 2d 17 c7 56 af 13 57 d9 46 5d 41 | sub_AD1D0 |
第二个参数是什么呢,通过变化传入参数等,观察结果可以知道是lrand48相关的
即原始数据应该是aaaaaabbba
,它们的ascii码加上随机数就是第二个参数了
第三到八个参数是什么呢,通过变化传入参数,可以确定任何参数都与它们无关,它们都是固定值
第九个参数是什么呢,观察长度,发现它是64位的,并且变化随机数、时间都会引起改变
- 修改时间的末尾三位不影响第九个参数
- 修改getByte除
obj4
外的参数不影响第九个参数
那么说明这个参数和随机数、时间、obj4相关
obj4长度27,随机数看第二个参数应该是有10个,时间长度是8,加起来长度45
结果是64位,那么是AES吗,答案是否,因为AES结果必然是16整数倍,而
45 + 16 < 64
所以基本排除掉AES,那么还有什么是32位呢
如果你经验丰富,那么肯定会想到SHA256在输入参数小于64的时候结果必然是64位
当然并不排除其他可能性,但目前看SHA256可能性最高
第十和十一个参数又是什么呢,对剩下的函数一一查看,结果如下
可以看出基本上和最后三个参数没有什么关系,32位的话那么可能的算法就很多了,最值得怀疑的当然是最常见的md5了
虽然暂时不知道第九、十、十一参数算法,但是到这里sub_ABDBC
完成分析了
那么现在应该看sub_ABD9C
之前的函数了,即sub_139A4
Interceptor.attach(base_addr.add(0x139A4).add(1), {
onLeave: function (retval) {
console.log("sub_139A4 retval", retval.readPointer());
}
});
通过测试发现它的返回结果是sub_ABDBC
的参数一,也就是放最终结果的地址
反编译代码中也没有其他特殊处理,那么可以跳过这个函数了
然后看sub_AFAC4
也和结果没有什么关系
再往前看,可以发现是一个比较长的调用,这个函数就是sub_5BF0
该函数主要调用了三个函数,sub_6794
和sub_68DC
和sub_691C
先简单看下对应的反编译代码,发现前两个是jni相关的调用,最后一个sub_691C
比较复杂
不管那么多,先把sub_691C
的参数hook一下看看
然后并没有发现什么特别的东西...
进入sub_691C
很快就会进入一个非常长的调用,结合之前猜测的SHA256和md5,这里极有可能是两者之一
是时候祭出findhash了,运行插件,最终结果如下
***************************在二进制文件中检索hash算法常量************************************
0xc9d3c:padding used in hashing algorithms (0x80 0 ... 0)
0xbd8bc:SHA256 / SHA224 K tabke
0x33ff9:函数sub_33FF8疑似哈希函数运算部分。
0x68d09:函数sub_68D08疑似哈希函数,包含初始化魔数的代码。
0x82485:函数sub_82484疑似哈希函数,包含初始化魔数的代码。
0x99a35:函数sub_99A34疑似哈希函数,包含初始化魔数的代码。
0xaae89:函数sub_AAE88疑似哈希函数,包含初始化魔数的代码。
***************************存在以下可疑的字符串************************************
0xbd1a0:/proc/self/cmdline
0xbd56c:/proc/self/cmdline
0xbda25:/proc/self/cmdline
***********************************************************************************
花费 41.270039081573486 秒,因为会对全部函数反编译,所以比较耗时间哈
对比调用日志,发现sub_68D08
和sub_82484
就在其中
另外sub_AAE88
已经分析过了,这里检测到的应该是CRC32
先看看大量被调用的sub_BB1A0
原来和算法无关...那把它去除掉
sub_82484
离sub_691C
不是很远,sub_68D08
则稍微后面一点
那现在优先看看它们,sub_82484
这显然是SHA256的魔数
为什么这么说呢,对比标准的SHA256算法就知道了嘛...
经过对比可以知道init这里的魔数有些不一样
插件还提示了0xbd8bc:SHA256 / SHA224 K tabke
,看一下
经过对比发现K表没有改变,另外发现K表就在init之后被使用
基本上可以推测是只改了魔数的SHA256算法,现在看看sub_862B4
对比下一般的调用,是不是一模一样呢
把数据和返回hook一下
Interceptor.attach(base_addr.add(0x82648).add(1), {
onEnter: function (args) {
console.log(`call sub_82648`);
console.log("arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())))
console.log("arg_2", args[2].toUInt32())
}
});
Interceptor.attach(base_addr.add(0x84890).add(1), {
onEnter: function (args) {
this.arg_1 = args[1];
},
onLeave: function (retval) {
console.log(`sub_84890 onLeave`);
console.log("arg_1", hexdump(this.arg_1))
}
});
确认无误
算法还原参考SHA256算法还原小节,确认是仅修改了魔数的SHA256算法
但是传入数据显然还不是明文,来看看sub_862B4
的调用函数sub_864F0
可以看到只有两个memcpy动作,应该不涉及数据的修改
再看sub_864F0
的调用函数sub_85A40
没错,连续调用了两次sub_864F0
,而sub_864F0
内会调用sub_862B4
等做SHA256运算
再看下hook结果,可以看出第一轮的SHA256结果被放到了第二轮SHA256的明文末尾
另外第一轮明文和第二轮明文中有大量重复的字符,是sub_85A40
里面的明文字符串
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\6666666666666666666666666666666666666666666666666666666666666666
这两个字符的ASCII分别是0x5C和0x36
另外还能观察到第二轮明文里面的重复字符前面是0x14长度,刚好是10位
综合上述信息,可以推测实际上这是一个HMAC-SHA256
算法,搜一个标准实现
这个可能看起来不太清晰,看这个
另外还有部分异或操作,另外可以确定没有走这里的sub_862B4
现在看看传入参数,可以看到sub_85A40
的a2应该是有内容的
并且后面和v20相与,根据这一点来看,属于稍稍修改过的HMAC-SHA256
看看sub_85A40
的a2是什么吧
刚好它是0x14长度,这和前面提到的那个内容匹配上了
再看看其他参数
Interceptor.attach(base_addr.add(0x85A40).add(1), {
onEnter: function (args) {
console.log(`call sub_85A40`);
console.log("sub_85A40 arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())));
console.log("sub_85A40 arg_2", args[2]);
console.log("sub_85A40 arg_3", hexdump(args[3].readByteArray(args[4].toUInt32())));
console.log("sub_85A40 arg_4", args[4]);
}
});
出现了熟悉的内容,a2应该是key,a4应该是明文
综合上述信息,可以进行还原了,具体参见HMAC-SHA256算法还原小节
至此参数九算法还原完成
上面都是基于sub_82484
函数所推导的内容,现在可以看sub_68D08
了,毕竟它也是被怀疑的哈希函数
查看反编译代码,显然这应该是一个md5函数,并且没有更改魔数
根据调用记录和已有信息,有理由推断就是这两个位置计算了出md5作为最终结果的
查看sub_9DB88
,又是熟悉的样式
Interceptor.attach(base_addr.add(0x68E44).add(1), {
onEnter: function (args) {
console.log(`call sub_68E44`);
console.log("sub_68E44 arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())));
console.log("sub_68E44 arg_2", args[2]);
}
});
Interceptor.attach(base_addr.add(0x6D628).add(1), {
onEnter: function (args) {
this.arg_0 = args[0];
},
onLeave: function (retval) {
console.log("sub_6D628 retval", hexdump(this.arg_0));
}
});
看到了md5明文和返回结果
经过验证确定是标准的md5
那么现在的问题就剩md5明文怎么来的了,接着看sub_9DB88
调用
先看sub_A70F4
,反编译代码如下,那么接着看sub_A605C
对它的参数进行hook
Interceptor.attach(base_addr.add(0xA605C).add(1), {
onEnter: function (args) {
console.log(`call sub_A605C`);
this.arg_6 = args[6];
console.log("sub_A605C arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())));
console.log("sub_A605C arg_2", args[2]);
console.log("sub_A605C arg_3", args[3]);
console.log("sub_A605C arg_4", args[4]);
console.log("sub_A605C arg_5", args[5]);
console.log("sub_A605C arg_6", hexdump(args[6]));
console.log("sub_A605C arg_7", hexdump(args[7].readByteArray(args[8].toUInt32())));
console.log("sub_A605C arg_8", args[8]);
},
onLeave: function (retval) {
console.log("sub_A605C retval", hexdump(this.arg_6));
}
});
结果如下,可以看到有一个0x14长度的key,即HMAC-SHA256用到的key
0x23长度的明文内容,返回结果就是后续md5的内容
那么现在参数十一的运算逻辑如下
结果 = md5(转换函数(明文 + key))
sub_A605C
涉及两个运算,一个是sub_A4550
,另外是一些位运算
sub_A4550
反编译结果如下,看起来想某种算法,经过检索,最终可以确定是Salsa20算法
魔数如下,不过对比标准算法后发现有很大差异
这个代码看着可能吃力,可以看这个版本的
差异表现为以下几点
- state数组的顺序不一样
_round运算内的逻辑不一样,表现为
- 原算法两个数先相加,结果循环左移,左移结果与另外的数异或
魔改算法分两种情况
- 两个数相加 -> 结果与新的数相异或 -> 结果循环左移 -> 最终结果
- 两个数相加 -> 结果与新的数做
(a & ~b) | (b & ~a)
运算 -> 结果循环左移 -> 最终结果
- _round运算结束后,要交换state内数据,魔改算法没有这个过程
- 10轮_round运算
基于上述结果重写了魔改后的Salsa20算法,其余信息参见Salsa20算法还原小节
至此参数十一算是还原完成了,可能会有疑问,key怎么来的,不急后面有
现在开始看sub_9CD48
,也就是对应参数十的计算过程
根据已有信息,可以知道sub_9DB88
是md5,那么看sub_9C1F0
Interceptor.attach(base_addr.add(0x9C1F0).add(1), {
onEnter: function (args) {
console.log(`call sub_9C1F0`);
this.arg_3 = args[3];
console.log("sub_9C1F0 arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())));
console.log("sub_9C1F0 arg_2", args[2]);
console.log("sub_9C1F0 arg_3", hexdump(args[3]));
},
onLeave: function (retval) {
console.log("sub_9C1F0 retval", hexdump(this.arg_3));
}
});
似乎是明文传入,然后直接拿到了后续待md5的内容
顺便hook下sub_9B980
Interceptor.attach(base_addr.add(0x9B980).add(1), {
onEnter: function (args) {
console.log(`call sub_9B980`);
this.arg_0 = args[0];
console.log("sub_9B980 arg_0", hexdump(args[0]));
console.log("sub_9B980 arg_1", hexdump(args[1].readByteArray(args[2].toUInt32())));
console.log("sub_9B980 arg_2", args[2]);
},
onLeave: function (retval) {
console.log("sub_9B980 retval", hexdump(this.arg_0));
}
});
又一个熟悉的数据出现了,同时可以看到sub_9B980
的参数一在函数结束后有了内容
先看看sub_9C1F0
的转换
可以看到主要运算就是一系列位运算,简单分析如下
这里比较简单,现在看sub_9B980
这里和前面类似
综合以上信息编写还原算法如下
def init_state(key: list):
index = 0
state = [_ for _ in range(256)]
key = (key * ((256 // len(key)) + 1))[:256]
for i in range(256):
index = (index + key[i] + state[i]) % 256
state[i], state[index] = state[index], state[i]
return state
def enccypt_data(ori_data: bytes, key: list):
state = init_state(key)
index_1 = 0
index_2 = 0
_ori_data = [0] * len(ori_data)
for offset, num in enumerate(list(ori_data)):
index_1 = (index_1 + 1) % 256
index_2 = (index_2 + state[index_1]) % 256
tmp = state[index_1]
state[index_1] = state[index_2]
state[index_2] = tmp
key_index = (state[index_2] + state[index_1]) & 0xff
_ori_data[offset] = (num & ~state[key_index]) | (state[key_index] & ~num)
return bytes(_ori_data)
if __name__ == '__main__':
import binascii
import hashlib
key = binascii.a2b_hex('4f274c3f286b54372a40612428095143565e3140')
data = binascii.a2b_hex('313632363430333535312c6e303033396579316d6d642c6e756c6c0000017aad34b7af')
enc_data = enccypt_data(data, key)
assert 'ea367353c0ac2f60c79668eef1362d3b' == hashlib.new('md5', enc_data).hexdigest(), "测试失败"
print('测试成功')
至此参数十也完成还原,现在就剩一个问题,4f274c3f286b54372a40612428095143565e3140
从何处来
已知进行HMAC-SHA256转换时就出现了这个key了,key是sub_85A40
的传入参数,那么接着它往前看
结果它的调用函数sub_86CD4
再向前似乎不太一样了
这是怎么回事呢
先看看调用日志,sub_86CD4
同级前一个函数是sub_8D970
果然这里暗藏玄机
sub_8DB6C
内部则是另一个跳转,显然这里没有跳,而是跳转到sub_86CD4
了
看sub_8D970
的调用处,结合上面的分析,这里v16经过sub_8D970
后应该是拿到了sub_86CD4
地址,但是实际没有产生调用
根据下面的v7定位到调用处
如图
通过hooksub_86CD4
参数可以知道其a2就是key
那么应该定位ptr来源
然后能找到一个相关的地方
对sub_87DD8
参数打印,结果如下
好起来了!参数二是明文,参数三在函数结束后就是key
sub_87DD8
内两处关键位置,注意到sub_89BF4
两次调用,且有一个参数是10
通过hooksub_89BF4
的参数,可以发现该函数的功能是将传参数二末尾两位数据移动到头部
然后搜索hex发现它是一个硬编码的key
是的,它在这里
IDA看半天也看不出来参数怎么来的
用cutter看看
很好!
结合sub_87DD8
末尾的位运算,现在可以编写key转换代码了
def gen_key(rand_data: bytes = b'hhhhhhiiih'):
data = list(binascii.a2b_hex('286b54372a4061244c3f'))
init_data_1 = data[-2:] + data[:-2]
data = list(binascii.a2b_hex('392b3e365829264f4061'))
init_data_2 = data[-2:] + data[:-2]
_rand_data = [num_1 ^ num_2 for num_1, num_2 in zip(list(rand_data), list(init_data_2))]
init_data = _rand_data[-2:] + init_data_1 + _rand_data[:-2]
print('key', bytes(init_data).hex())
return init_data
SHA256算法还原
将这个标准SHA256算法中的魔数修改,测试一下
修改部分伪代码如下
# ......
def generate_hash(message: bytearray) -> bytearray:
# ......
h0 = 0x6A09E669
h1 = 0xBB67AE87
h2 = 0x3C6BF372
h3 = 0xA54FF53A
h5 = 0x9B25688C
h4 = 0x511E527F
h6 = 0x1F73D9AB
h7 = 0x5BD0CD19
# ......
# ......
if __name__ == '__main__':
import binascii
data = binascii.a2b_hex(b'137b10637437086b761c3d7874550d1f0a026d1c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5caac3b82136d0d55907e5607db20a7da7e996d5d083c1edaa1c27252212c954ff')
assert 'aba3293cbe3f855642c6918c0ce8e2a60616ff834744cac3266d6f0ce43ec664' == generate_hash(data).hex(), '测试失败'
print('测试成功')
测试通过
HMAC-SHA256算法还原
def hmac_generate_hash(init_data: bytes, ori_data: bytes):
init_data_1 = [0x36] * 64
init_data_2 = [0x5C] * 64
for index, (num_1, num_2) in enumerate(zip(init_data, init_data_1)):
init_data_1[index] = num_1 ^ num_2
for index, (num_1, num_2) in enumerate(zip(init_data, init_data_2)):
init_data_2[index] = ((num_1 & 0xE5) + (~num_1 & 0x1A)) ^ ((num_2 & 0xE5) + (~num_2 & 0x1A))
tmp_result = generate_hash(bytes(init_data_1) + ori_data)
result = generate_hash(bytes(init_data_2) + tmp_result)
return result
if __name__ == '__main__':
import binascii
key = binascii.a2b_hex('4f274c3f286b54372a40612428095143565e3140')
data = binascii.a2b_hex('313632363430333535312c6e303033396579316d6d642c6e756c6c0000017aad34b7af')
assert 'aba3293cbe3f855642c6918c0ce8e2a60616ff834744cac3266d6f0ce43ec664' == hmac_generate_hash(key, data).hex(), '测试失败'
print('测试成功')
测试通过
Salsa20算法还原
还原不易,相信看到这里的人应该都会了,就贴个关键部分截图吧
返回结果构成总结
表中的明文由getByte传入参数obj4
和gettimeofday返回结果
构成
hex如下
313632363430333535312c6e303033396579316d6d642c6e756c6c0000017aad34b7af
tag | length | payload | 说明 |
---|---|---|---|
- | - | 68 65 68 61 | 固定值 |
- | - | 00 00 00 01 01 00 00 00 | 固定值 后半部分由前半部分翻转得到 |
- | - | 00 00 00 00 00 | 未修改 |
- | - | 00 00 03 08 | 固定值 由sub_175B4产生 |
- | - | 00 00 00 00 | 固定值 |
- | - | 00 00 00 00 | 未修改 |
- | - | 4b 46 ba a2 | 后续数据CRC32 |
- | - | 00 00 00 01 | 固定值 |
- | - | 00 00 00 96 | 后续数据长度 |
00 01 | 00 08 | 00 00 01 7a ad 34 b7 af | gettimeofday |
00 02 | 00 0a | 68 68 68 68 68 68 69 69 69 68 | 10位(不完全)随机种子 |
00 03 | 00 04 | 01 00 00 01 | 固定值 |
00 05 | 00 04 | 01 00 00 01 | 固定值 |
00 04 | 00 04 | 00 00 00 00 | 固定值 |
00 06 | 00 04 | 01 00 00 04 | 固定值 |
00 07 | 00 04 | 01 00 00 02 | 固定值 |
00 08 | 00 04 | 01 00 00 03 | 固定值 |
00 09 | 00 20 | ab a3 29 3c be 3f 85 56 42 c6 91 8c 0c e8 e2 a6 06 16 ff 83 47 44 ca c3 26 6d 6f 0c e4 3e c6 64 | HMAC-SHA256(随机种子key, 明文) |
00 0a | 00 10 | ea 36 73 53 c0 ac 2f 60 c7 96 68 ee f1 36 2d 3b | md5(数据交换运算(随机种子key, 明文)) |
00 0b | 00 10 | 23 9e 8e 64 9b 2d 17 c7 56 af 13 57 d9 46 5d 41 | md5(明文 ^ (Salsa20(随机种子key, 固定数据))) |
补充
其他部分固定值在sub_AAE88
产生,就不展开了
hook脚本
最后
感谢龙哥!
已有 4 条评论
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
牛逼啊!!!
牛逼