前言
这篇文章脱了好久才写完,眼看假期快结束了才快马加鞭的写完,有些地方可能写的也不是很清楚,等以后了再慢慢改进写作的技巧。该篇文章主要对一个小说软件的验证码请求协议进行了分析并实现了脱机模拟,详情见下文。
环境
pixel 6 android 12
frida 15.2.2
七猫免费小说 v7.2.3
frida检测
首先需要过掉frida的检测,这里可以参考这篇文章:手动编译Hluda Frida Server,使用去掉特征之后的frida-server可以过掉大部分检测,如果编译的过程遇到问题,也可以参考我对编译过程问题的记录:frida编译 hluda编译 问题记录,编译完之后直接以attach模式附加就可以过掉检测。
脱壳
通过MT管理器可以查看该应用是有壳的,如图:
我使用frida-dexdump进行脱壳,项目地址:frida-dexdump,运行效果如下:
sign算法分析
软件对代理有检测,使用justTrustMe可以过掉检测,项目地址:justTrustMe,过掉检测后使用HttpCanary进行抓包,抓到两个包,结果如下:
POST /api/v1/init/is-open-sm-code h2
Host: xiaoshuo.wtzw.com
net-env: 1
channel: qm-tengxun_lf
is-white: 0
platform: android
app-version: 70203
reg:
application-id: com.kmxs.reader
authorization:
qm-params: cLGUuq2-HTZ5gI9wgI9wgI9QNI0rp5U5pI4Mth9wgI9QgI9wgI9wgI9wgI9wH5w5pyRlmqN2tq2-HTZ5gT9LgT9EgTHnNhfEgIfn4THLgTKnNh4w4hKnpho2Aq0r4hNxpy0npqgMphOrNI9ngIoxgqkTpz0nNlFrphK5taGQ4qg5A5GIgTZekTZYNTZUNe1sgeZwN3HjHSNDuCGTpCR1paHWH-kRgSuwFq27mCo-3TkkO_FQqfrUmSoQtC2MFEsoufkRFljMgRGyRC2-gMGa4ROUOyNCf-QAk-pEp0gnq2kVRSoTResMpRx3kyNoRTo3k2p04C1kcygLmI05taGecCgQuzRLHTZ5ghH5taGMOSReuyR-tq2-HTZ5kofLuEssmqY1OqkiNoowuaUphTRVOqMQcCkIO0RUkoRImeFnf-pRcqFeF-GxReRw4Uu33MYykSu-FeomRy1qOqNCg_k2qoG04MRqgRGyR-kxc2or4eGZg3HjHz2Qpq-5A5H5taGQBlk2BaHWH2s1cyRjHI45taGEByHQmqU2m3HWH5HjHSuj45UUmqF5A5G0RhGEO0o1Bz2np0ZMfCsMtR2ANq1nB3UYu0NwkCR0RfNvNIo3k2RYpINaFzoCNCsTRUGth-pyulkIgR1fm2pn4UOwuyR4f-kTkR4nf-pqkyoWfCxTgzKnH5w54ln1pqYMtq2-HTZ5NhHENhFYAyFrNhG-pI-Lp5HjHzGL4qY-HTZ5plJDpln2H5w5Blo1paHWH5GJ
sign: c42882c83550414161a30aa63dbcdbce
qm-it: 1658476625
qm-ii: 1780595992
no-permiss: 3
user-agent: webviewversion/0
content-type: application/x-www-form-urlencoded
content-length: 84
accept-encoding: gzip
cancell_check=1&encrypt_phone=ghKrgeKUNh-rgI9=&sign=17f86fc9135531b6d3d8117a52914799
POST /api/v1/login/send-code h2
Host: xiaoshuo.wtzw.com
net-env: 1
channel: qm-tengxun_lf
is-white: 0
platform: android
app-version: 70203
reg:
application-id: com.kmxs.reader
authorization:
qm-params: cLGUuq2-HTZ5gI9wgI9wgI9QNI0rp5U5pI4Mth9wgI9QgI9wgI9wgI9wgI9wH5w5pyRlmqN2tq2-HTZ5gT9LgT9EgTHnNhfEgIfn4THLgTKnNh4w4hKnpho2Aq0r4hNxpy0npqgMphOrNI9ngIoxgqkTpz0nNlFrphK5taGQ4qg5A5GIgTZekTZYNTZUNe1sgeZwN3HjHSNDuCGTpCR1paHWH-kRgSuwFq27mCo-3TkkO_FQqfrUmSoQtC2MFEsoufkRFljMgRGyRC2-gMGa4ROUOyNCf-QAk-pEp0gnq2kVRSoTResMpRx3kyNoRTo3k2p04C1kcygLmI05taGecCgQuzRLHTZ5ghH5taGMOSReuyR-tq2-HTZ5kofLuEssmqY1OqkiNoowuaUphTRVOqMQcCkIO0RUkoRImeFnf-pRcqFeF-GxReRw4Uu33MYykSu-FeomRy1qOqNCg_k2qoG04MRqgRGyR-kxc2or4eGZg3HjHz2Qpq-5A5H5taGQBlk2BaHWH2s1cyRjHI45taGEByHQmqU2m3HWH5HjHSuj45UUmqF5A5G0RhGEO0o1Bz2np0ZMfCsMtR2ANq1nB3UYu0NwkCR0RfNvNIo3k2RYpINaFzoCNCsTRUGth-pyulkIgR1fm2pn4UOwuyR4f-kTkR4nf-pqkyoWfCxTgzKnH5w54ln1pqYMtq2-HTZ5NhHENhFYAyFrNhG-pI-Lp5HjHzGL4qY-HTZ5plJDpln2H5w5Blo1paHWH5GJ
sign: c42882c83550414161a30aa63dbcdbce
qm-it: 1658476625
qm-ii: 1780595992
no-permiss: 3
user-agent: webviewversion/0
content-type: application/x-www-form-urlencoded
content-length: 112
accept-encoding: gzip
encrypt_phone=ghKrgeKUNh-rgI9=&rid=20180817160958967672365a4t3bf655&type=0&sign=50b41c8edd846e92a4bdd0fdd6cabc6f
首先分析第一个包,根据url进行定位 /api/v1/init/is-open-sm-code
由注解这个特征可以判断使用了retrofit2框架,进入到@Body注明的类中查找,找到了"sign"
写frida脚本进行验证
function hook_2() {
var is_sign = false
let Buffer = Java.use("okio.Buffer");
Buffer["writeUtf8"].overload('java.lang.String').implementation = function (str) {
if (str == "sign") {
is_sign = true
}
let ret = this.writeUtf8(str);
return ret;
};
let Encryption = Java.use("com.qimao.qmsdk.tools.encryption.Encryption");
Encryption["sign"].implementation = function (str) {
let ret = this.sign(str);
if (is_sign) {
console.log("Encryption.sign arg-->" + str)
console.log("sign=" + ret)
is_sign = false
}
return ret;
};
}
function main() {
Java.perform(function () {
hook_2()
})
}
setImmediate(main)
脚本运行结果如下:
经过对比,Encryption.sign的返回值就是两个包的body里面sign的值,下面分析Encryption.sign
是native方法,首先我们需要定位so文件,我先判断是不是静态注册,使用了frida-trace,这里要注意静态方法的命名规则(以字符串“Java”为前缀,并且用“_”下划线将包名、类名以及native方法名连接起来)
看来是静态注册,并定位到了libcommon-encryption.so,用IDA打开之后搜索,之后发现了一个很诡异的问题,在方法里面搜不到Java_com_km_encryption_api_Security_sign,如下图:
虽然没有这个方法名,但我们也可以通过偏移进行定位,frida脚本如下:
var module = Process.findModuleByName("libcommon-encryption.so")
var funcs = module.enumerateExports()
funcs.forEach(function(func){
if(func.name.indexOf("Java_com_km_encryption_api_Security_sign")>=0){
console.log("sign offset-->0x" + (func.address-module.base).toString(16))
}
})
拿到了偏移0x15620,跳转过去看一下
我不知道为什么,这里显示的是token,难道两个函数能注册到同一个地址?但不论发生了什么,我们可以验证这是不是sign的逻辑
这个函数最终返回了jstring,因此构造frida代码如下:
var env = Java.vm.tryGetEnv()
Interceptor.attach(module.base.add(0x15620), {
onLeave:function(retval){
var env = Java.vm.tryGetEnv()
console.log(env.getStringUtfChars(retval,0).readCString())
}
})
同java层的sign函数和token函数一起hook,结果如下:
发现虽然这个函数名是Java_com_km_encryption_api_Security_token,但是却也是Java_com_km_encryption_api_Security_sign的逻辑,下面直接分析逻辑
这个函数的逻辑非常简单,就是先生成KeyData作为MD5的盐,然后把它拼接到字符串的后面,最后进行md5运算,用frida脚本进行验证,如下:
function hook_2() {
var is_sign = false
let Buffer = Java.use("okio.Buffer");
Buffer["writeUtf8"].overload('java.lang.String').implementation = function (str) {
if (str == "sign") {
is_sign = true
}
let ret = this.writeUtf8(str);
return ret;
};
let Encryption = Java.use("com.qimao.qmsdk.tools.encryption.Encryption");
Encryption["sign"].implementation = function (str) {
let ret = this.sign(str);
if (is_sign) {
console.log("Encryption.sign arg-->" + str)
console.log("sign=" + ret)
is_sign = false
}
return ret;
};
}
function hook_so() {
var libcommon = Process.findModuleByName("libcommon-encryption.so")
Interceptor.attach(libcommon.base.add(0x1574C), {
onEnter: function (args) {
console.log("---keydata---");
console.log(hexdump(this.context.x1, {
offset: 0,
length: 128,
header: true,
ansi: true
}));
console.log("---keydataSize---");
console.log("size-->" + this.context.x2);
}
})
var data = null
Interceptor.attach(libcommon.base.add(0x15730), {
onEnter: function (args) {
data = this.context.x0
}
})
Interceptor.attach(libcommon.base.add(0x1575C), {
onEnter: function (args) {
if (data != null) {
console.log("---data---");
console.log(hexdump(data, {
offset: 0,
length: 128,
header: true,
ansi: true
}));
}
}
})
}
function main() {
Java.perform(function () {
hook_2()
})
hook_so()
}
setImmediate(main)
运行结果如下:
在上图中—keydata—是md5的盐,其值为d3dGiJc651gSQ8w1,—data—下的内容就是字符串拼接后的结果,sign就是该函数的返回值,用CyberChef验证一下
encrypt_phone=ghfYAIOMNhKYNTf=rid=20180817160958967672365a4t3bf655type=0d3dGiJc651gSQ8w1
与函数返回值一致,sign算法分析结束
encrypt_phone加密分析
现在让我们缕一缕分析的情况,如今sign加密函数已经分析出来了,sign这个字段在请求头与请求体中都有出现,我们不知道的是这两个sign是否是经过同一个加密函数加密的,也不知道sign函数传入的明文是如何生成的。hook sign代码如下:
var security = Java.use("com.km.encryption.api.Security")
security.sign.implementation = function(){
console.log("++++++++++++++++++++++");
var str = Java.use("java.lang.String").$new(arguments[0])
console.log("arguments -->",str);
var ret = this.sign.apply(this,arguments)
console.log("result -->",ret)
console.log("++++++++++++++++++++++");
return ret
}
运行结果分析:
请求头:
请求体(包1):
请求体(包2):
经过以上的整理我们可以得到两点:1. 请求头与请求体中的sign都由同一个加密函数加密 2. 获得了传入加密函数的字符串
经过多次抓包发现请求头里的sign以及qm-params是不会发生变化的,因此跳过对其的分析,直接开始分析请求体中的sign加密时传入的参数
其中只有encrypt_phone发生了变化,看着像base64,尝试用CyberChef进行base64解码
并没有解码成电话号码,那只能逆向分析了,在刚开始我们就已经定位到了请求体中sign的拼接位置,encrypt_phone是挨着的,代码如下:
为了确保encrypt_phone的位置没有找错,需要看一下this.f14735a的值,脚本及运行结果如下:
var is_sign = false
let Buffer = Java.use("okio.Buffer");
Buffer["writeUtf8"].overload('java.lang.String').implementation = function (str) {
if (str == "sign") {
is_sign = true
Java.choose("yh0", {
onMatch: function (instance) {
console.log(instance._a.value);
},
onComplete: function () {
}
})
}
let ret = this.writeUtf8(str);
return ret;
};
let Encryption = Java.use("com.qimao.qmsdk.tools.encryption.Encryption");
Encryption["sign"].implementation = function (str) {
let ret = this.sign(str);
if (is_sign) {
console.log("Encryption.sign arg-->" + str)
console.log("sign=" + ret)
is_sign = false
}
return ret;
};
结果中有encrypt_phone,说明我们找对位置了,那么我们就要寻找this.f14735a的赋值位置
红框中即为this.f14735a的赋值位置,也是encrypt_phone的赋值位置,上面代码的逻辑是把传入a方法的t转换成json,然后取出每一个元素进行赋值,那么我们需要知道t是什么,脚本如下:
let can_hook = false
let yh0 = Java.use("yh0");
yh0["a"].implementation = function (t) {
can_hook = true
let ret = this.a(t);
return ret;
};
let NBSGsonInstrumentation = Java.use("com.networkbench.agent.impl.instrumentation.NBSGsonInstrumentation");
NBSGsonInstrumentation["toJson"].overload('com.google.gson.Gson', 'java.lang.Object').implementation = function (gson, obj) {
let ret = this.toJson(gson, obj);
if (can_hook) {
console.log("t value is " + obj);
console.log('toJson ret value is ' + ret);
}
can_hook = false
return ret;
};
t是一个类的对象,我们先看看com.qimao.qmuser.model.entity.CaptchaEntity这个类
里面有encrypt_phone,继续寻找其赋值位置
直接打印调用栈,脚本如下:
经过一系列的调用定位到ag0.onNext,代码如下:
这很明显是一个回调方法,大概率的使用了框架,来看一下该类继承的类
谷歌了一下,这个类使用了RxJava框架,参考文章:http://www.jianshu.com/p/a406b94f3188,该框架的核心代码如下:
Observable.create(new ObservableOnSubscribe<Integer>() {
// 1. 创建被观察者 & 生产事件
@Override
public void subscribe(ObservableEmitter<Integer> emitter) throws Exception {
emitter.onNext(1);
emitter.onNext(2);
emitter.onNext(3);
emitter.onComplete();
}
}).subscribe(new Observer<Integer>() {
// 2. 通过通过订阅(subscribe)连接观察者和被观察者
// 3. 创建观察者 & 定义响应事件的行为
@Override
public void onSubscribe(Disposable d) {
Log.d(TAG, "开始采用subscribe连接");
}
// 默认最先调用复写的 onSubscribe()
@Override
public void onNext(Integer value) {
Log.d(TAG, "对Next事件"+ value +"作出响应" );
}
@Override
public void onError(Throwable e) {
Log.d(TAG, "对Error事件作出响应");
}
@Override
public void onComplete() {
Log.d(TAG, "对Complete事件作出响应");
}
});
为了能够定位到关键代码,我选择hook Observable.subscribe(Observer)这个方法,脚本如下:
let Observable = Java.use("io.reactivex.Observable")
Observable.subscribe.overload('io.reactivex.Observer').implementation = function(){
console.log("+++++++++++++++++");
console.log(Java.use("android.util.Log").getStackTraceString(Java.use("java.lang.Throwable").$new()));
let ret = this.subscribe.apply(this,arguments)
return ret
}
vo0.a的代码如下:
关键是new b(strArr),具体原因可以学习一下RxJava,点击去看一下
该类继承自Callable,当Callable创建时自动调用call()方法,而call()方法里面的逻辑就是取出this.fq4329a中的值进行加密,进入到encrypt(str)方法中看一下
很简单,就是先进行base64编码,然后对编码后的结果进行字符替换,replaceChar©的代码如下:
脱机
那么到这里所有需要分析的字段已经分析完了,接下来使用python进行模拟调用,具体代码不贴了,只展示最后的结果,如图:
哇,您们居然能看到这里,真棒呀 (´∀`)