这篇文章上次修改于 509 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

致谢

龙哥往往一语中的,给我带来了莫大的帮助,非常感谢

前言

本文旨在对getByte算法进行分析与还原,仅供学习交流

本文不会提供完整算法脚本

本文将涉及以下内容:

  • OLLVM虚假控制流(OLLVM-BCF)反混淆
  • cutter反编译
  • md5算法识别
  • SHA256算法还原——魔数修改
  • Salsa20算法还原——逻辑魔改
  • trace_natives与frida-trace搭配使用
  • findhash使用
  • gettimeofday和lrand48
  • unidbg模拟调用(下一篇文章)

!!!为了节省版面,文章中重复的hook代码会省略掉,复现时请记得自行补充

环境和工具

名称物料补充
目标方法getByte-
目标类com.tencent.starprotocol.ByteData-
目标solibpoxy_star.somd5: 889415fb8e886dfdc3fdd405c105d262
目标apkcom.tencent.qqlive_V8.3.95.26016.apkmd5: 6d6cd9c0b36c49f17d0f204cf917774e
frida-serverfrida-server-14.2.18-android-arm64-
python3.8.5由miniconda创建
IDAIDA Pro 7.5爱盘地址
CyberChefCyberChef在线地址
findhashfindhashGithub地址
trace_nativestrace_nativesGithub地址
jnitracejnitraceGithub地址
JNI-Frida-HookJNI-Frida-HookGithub地址
hook_RegisterNativeshook_RegisterNativesGithub地址
cuttercutter官方地址
测试ROMQQ1B.200205.002能跑frida就行

过程


算法稳定主动调用

此处稳定意为固定输入、固定输出

获取输入输出参数

已知目标方法是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([email protected], 1, 0, 0, 0, [Ljava.lang.String;@6e859e6, , ce****************************b2, [[email protected])
(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稳定主动调用

经过测试,发现传入参数固定,返回结果并不固定,这是因为原算法用到了lrand48gettimeofday

这是怎么发现的呢

当然是根据经验测出来的

一般遇到传入参数固定但是结果变化,统统往时间随机数上靠

这里先认为是假设,下面进行验证

固定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、搜索导出函数

不好意思,没有

再看看字符串

动态注册跑不了了

直接就是hook_RegisterNatives

[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_13C7Csub_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,参数内容也是最终结果里面的

根据以上信息,除去头部,对照内容后整理如下

taglengthpayloadfunc
00 0100 0800 00 01 7a ad 34 cb 37sub_AC214
00 0200 0a68 68 68 68 68 68 69 69 69 68sub_AD1D0
00 0300 0401 00 00 01sub_AC214
00 0500 0401 00 00 01sub_AC214
00 0400 0400 00 00 00sub_AC214
00 0600 0401 00 00 04sub_AC214
00 0700 0401 00 00 02sub_AC214
00 0800 0401 00 00 03sub_AC214
00 0900 2045 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 d3sub_AD1D0
00 0a00 10cd b5 df 89 f5 2d 25 05 4b 85 81 f6 cf af 1e fbsub_AD1D0
00 0b00 10fb 08 ad 5f af a8 93 35 2d 13 cb 93 27 e8 f7 fdsub_AD1D0

第一个参数是什么呢,通过测试可以发现当gettimeofday返回固定,这个值就固定

是不是和时间有关系呢,答案是yes

0x0000017aad34cb37 = 1626403556151

固定的时间代码片段如下

let tm_s = 1626403551;
let tm_us = 5151606;

Q: 似乎有些对不上,这是什么问题呢
A: tm_us是微秒,显然这里是5.151606s,超过1s了

tm_us改为151606,重新整理

taglengthpayloadfunc
00 0100 0800 00 01 7a ad 34 b7 afsub_AC214
00 0200 0a68 68 68 68 68 68 69 69 69 68sub_AD1D0
00 0300 0401 00 00 01sub_AC214
00 0500 0401 00 00 01sub_AC214
00 0400 0400 00 00 00sub_AC214
00 0600 0401 00 00 04sub_AC214
00 0700 0401 00 00 02sub_AC214
00 0800 0401 00 00 03sub_AC214
00 0900 20ab 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 64sub_AD1D0
00 0a00 10ea 36 73 53 c0 ac 2f 60 c7 96 68 ee f1 36 2d 3bsub_AD1D0
00 0b00 1023 9e 8e 64 9b 2d 17 c7 56 af 13 57 d9 46 5d 41sub_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_6794sub_68DCsub_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_68D08sub_82484就在其中

另外sub_AAE88已经分析过了,这里检测到的应该是CRC32

先看看大量被调用的sub_BB1A0

原来和算法无关...那把它去除掉

sub_82484sub_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传入参数obj4gettimeofday返回结果构成

hex如下

313632363430333535312c6e303033396579316d6d642c6e756c6c0000017aad34b7af
taglengthpayload说明
--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 0100 0800 00 01 7a ad 34 b7 afgettimeofday
00 0200 0a68 68 68 68 68 68 69 69 69 6810位(不完全)随机种子
00 0300 0401 00 00 01固定值
00 0500 0401 00 00 01固定值
00 0400 0400 00 00 00固定值
00 0600 0401 00 00 04固定值
00 0700 0401 00 00 02固定值
00 0800 0401 00 00 03固定值
00 0900 20ab 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 64HMAC-SHA256(随机种子key, 明文)
00 0a00 10ea 36 73 53 c0 ac 2f 60 c7 96 68 ee f1 36 2d 3bmd5(数据交换运算(随机种子key, 明文))
00 0b00 1023 9e 8e 64 9b 2d 17 c7 56 af 13 57 d9 46 5d 41md5(明文 ^ (Salsa20(随机种子key, 固定数据)))

补充

其他部分固定值在sub_AAE88产生,就不展开了

hook脚本

最后

感谢龙哥!