这段时间开始接触安卓逆向,说一下我的大致的学习步骤:
模拟器推荐逍遥模拟器,因为雷电4无法抓包,夜神无法安装xposed,只有逍遥啥毛病没有,我用的去广告绿色版。下载链接:https://www.52pojie.cn/forum.php?mod=viewthread&tid=1353837&highlight=%E5%D0%D2%A3
JEB和Android Killer等:https://down.52pojie.cn/Tools/Android_Tools/
抓包工具我比较喜欢charles:https://www.52pojie.cn/forum.php?mod=viewthread&tid=1350618&highlight=charles
样本APP:https://wwx.lanzoui.com/iEatQmeg8je
这个APP属于比较简单的逆向,没有混淆没有加壳,虽然加密调用了so,so里面也是用的java加密库。
打开模拟器,安装APP。在模拟器上安装charles证书,因为Android7.0不在信任用户安装的证书,所以https可能无法正常抓到包或者无法正常显示数据包。我的解决办法:先正常安装证书,然后将安装好的证书直接移动到系统证书目录去。
adb devices
查看adb设备adb connect 127.0.0.1:21503
连接逍遥模拟器adb push 电脑证书目录 /sdcard
将证书复制到模拟器sd卡根目录*:*
(host和port都填*就行)温馨提示:在抓APP的时候可以关闭charles抓Windows的包,点击proxy->Windows proxy就可以停止抓Windows的包,当然你在Windows设置里取消全局代理一样的效果。
只看首页一些栏目列表的数据包,随便搜索一下首页出现的文章的标题就能找到是哪条请求了,
URL:https://v6-gw.m.163.com/nc/api/v1/feed/dynamic/normal-list
参数有很多,可以多刷新几个请求看看哪些参数是变化的,测试发现就ts和sign是变化的,不过devId、devIdOD、lat和lon明显都有可能被加密,我们先处理sign,ts明显是时间戳。另外请求头也有很多加密值。
将apk拖入到jeb的窗口,然后一直点是就行。接着直接在反编译的smali代码里搜索链接中的一部分比如normal-list。Ctrl+f是搜索快捷键,搜索的时候勾选环绕搜索和区分大小写。
接着你就会发现只搜索到了一个地方,你再次点击寻找的时候还是在原来的地方。点击normal-list所在的那行代码右键->解析,就会反编译出java代码。如图:
g.d很有可能就是链接的前一部分了,那么com.netease.newsreader.common.constant.g.l.c
就是完整的链接了,点击c,c的背景色会变成黄色,说明被选中了,然后右键->交叉引用,就能知道这个变量在什么地方被调用了。
可以看到有两个地方引用了这个变量,点进去看会发现第二个其实就是上面的代码,所以第一个应该就是发送请求的位置了。点进去看看代码,
代码很长,就看后面那一部分,前面的应该只是判断参数是否正常。正常就执行后面那个,能发出请求说明肯定正常。
a.a(b.b(l.c, new NGRequestVar().setSize(Integer.valueOf(arg4)).setFn(Integer.valueOf(arg5)).setOffset(Integer.valueOf(arg3)).addExtraParam(new c("from", arg2))), arg6);
把代码分开来看
t1 = new NGRequestVar().setSize(Integer.valueOf(arg4)).setFn(Integer.valueOf(arg5)).setOffset(Integer.valueOf(arg3)).addExtraParam(new c("from", arg2)));
t2 = b.b(l.c, t1);
a.a(t2, arg6)
t1应该就是一个请求参数的容器对象,可以点进去看看,addExtraParam是添加额外参数的方法,其他几个方法有啥用就不分析了。
看到这些参数就知道这就是拼接参数的地方,而我们要找的ts和sign都在这里生成的。ts确实是10位的时间戳,虽然他还调用了一个com.netease.newsreader.common.utils.a.a.a
,但是抓包的结果和System.currentTimeMillis() / 1000L;
是一样的,就不用关心他里面的逻辑了,估计就是整型转字符串的方法。
String v0 = d.a();
long v1_4 = System.currentTimeMillis() / 1000L;
String v0_1 = v0 + v1_4;
sign = b.a(com.netease.newsreader.framework.e.a.c.b(v0_1))
而点进去d.a()可以看到v0,就是下面的代码生成的也就是v0_1
public static String a() {
Object v0 = d.v.get("nrcommon_sys_1");
if(v0 != null && ((v0 instanceof String))) {
return (String)v0;
}
String v0_1 = ((IGalaxyApi)b.a(IGalaxyApi.class)).a(Core.context());
if(!TextUtils.isEmpty(v0_1) && (TextUtils.isEmpty(a.a()))) {
a.a(v0_1);
}
d.a("nrcommon_sys_1", v0_1);
return v0_1;
}
IGalaxyApi是自定义的一个类,点进去看b.a和b.a().a这两个方法都挺麻烦的,就不看了下去了。等下可以直接调试一下看看这个值会不会变,上面的代码将他放到一个容器里去获取,如果容器没有才生成,虽然不知道具体算法,但这说明这个值在APP启动之后就是个固定值了。
先把他当成固定值来看,也就是说v0_1是一个固定的字符串+时间戳。我们在看c.b这个方法
public static String b(String arg2) {
if(TextUtils.isEmpty(arg2)) {
return arg2;
}
try {
return c.a(MessageDigest.getInstance("MD5").digest(c.a(arg2, Charset.forName("UTF-8"))), false);
}
catch(NoSuchAlgorithmException v2) {
throw new AssertionError(v2);
}
}
应该只是做个简单的md5,至于到底是不是可以调试一下看看输入和输出是不是md5的结果。最后就是b.a这个方法了。
private static String a(String arg0) {
return com.netease.newsreader.support.utils.k.b.a(Encrypt.getEncryptedParams(arg0));
}
com.netease.newsreader.support.utils.k.b.a
这个方法只是做个简单的url编码,可以不用看,关键就是Encrypt.getEncryptedParams(arg0)
点进去看,关键代码就是这个:
Encrypt.encrypt(arg0, arg1, arg2);
arg0是Core.context(),不清楚是什么,arg1就是上面传入的参数,arg2是0.而你在追encrypt方法时发现:
private static synchronized native byte[] encrypt(Context arg0, String arg1, int arg2) {
}
怎么什么都没有,百度了一下发现这是个native方法,方法逻辑在so里面,在前面有段System.loadLibrary("random");
就是加载so文件。文件名叫librandom.so。可以在jeb左侧的工程管理器看到
Libraries目录下的就有(arm64-v8a和armeabi这两个里面的so基本逻辑是一样的,选择哪个都行),可以选中这个so右键导出,然后用IDA查看,左侧红框对应的就是java的函数。
这样反编译的c代码基本看不懂,不过可以看到一些关键的字符串。当然也可以导入jni.h头文件,可以让代码更像代码。具体参考:https://www.52pojie.cn/thread-732955-1-1.html。
AES/ECB/PKCS7Padding,javax/crypto/Cipher,doFinal这些字符足以说明他是调用了java的加密库,用的AES的加密。既然是调用的java加密库,我们可以用frida hook一下java的库,来获取key,然后验证一下结果对不对。后面测试发现确实就是这样一个加密。
准备工作
上面只是静态分析了代码,虽然知道他用的什么算法,但不确定他有没有魔改算法,或者做些手脚。所以我们需要获取参数的中间值来验证一下。这里介绍一下jeb的调试。
要想APP能被调试,需要APP AndroidManifest文件中包含 android:debuggable=“true”,而一般的APP肯定不会有这个,需要修改后重打包,很麻烦一般不会去这么做。这篇文章说明了所有开启调试的方法,写的很好:https://blog.csdn.net/qq_38851536/article/details/100026480。
我采用的是mprop来修改ro.debuggable这个值,不过模拟器关闭之后需要重新修改。
mprop x86版本:https://github.com/jedy/mprop
adb push mprop /data/local/tmp
将mprop复制到模拟器的这个目录
adb shell
su
cd /data/local/tmp
chmod 755 mprop
./mprop ro.debuggable 1
就可以了
为了方便我直接使用es文件浏览器mprop移动到了/system/bin下了,这样就可以全局使用了,不用cd到目录。
下断点
String v0_1 = v0 + v1_4;
if(!TextUtils.isEmpty(v0_1)) {
arg6.add(new com.netease.newsreader.framework.d.a.c("sign", b.a(com.netease.newsreader.framework.e.a.c.b(v0_1))));
}
我们主要是需要v0_1的值,和c.b计算之后的值,来验证一下c.b是不是普通的md5,还有v0这个值是不是固定的。右键String v0_1 = v0 + v1_4;
这行代码点击解析就会跳到smali代码,
对照一下java代码很容易知道v0_1和c.b所在的位置。所以我们在图上框中的两行下面一行下两个断点,断点快捷键为Ctrl+b。左侧有红点表示断点下成功了。
这个浅蓝色背景是程序断在这一行的时候才会有的。图中我还没附加进程,应该是上次打断点留下的bug,不用管。
确认一下adb devices
下有模拟器设备,没有的话adb connect 127.0.0.1:21503
重新连接一下。然后打开APP,接着点击下图红框的那个按钮。
会出现这样一个界面
双击红框的那行就是附加了这个进程,这个是APP的包名,其他三个应该是这个APP的一些服务。
附加之后在模拟器里操作APP向下滑动加载新的文章就会触发断点。可能会多次出现下图这种情况,点击等待即可。
程序断下之后,我们关注的是v0的值,又知道它的数据类型是string,可以直接把int改成string,复制他的值备用,复制完记得改回int
点击第二个按钮运行,跳到下一个断点。同样改成string复制值之后改回int。
这样我们就得到两个值,计算一下上面的值md5确实就是下面的值,这也就验证了c.b只是做了md5的计算:
string@13512:“CQlkYTE0MDdjZmM5NTYyZjUzCTYwNTkxOTMw1614743514”
string@13513:“683c1311ef69f993fe29df30d7508fcf”
去抓包工具看一下sign,不知道哪条请求可以对比一下后面的时间戳。也可以直接在smali里面下断点获取。如何知道683c1311ef69f993fe29df30d7508fcf怎么得到sign(s1DyoPiJ5EPZK8gVtknxrQbP0ITFkr7O102aFhxwfIN48ErR02zJ6/KXOnxX046I)呢?
首先我们已经知道了他是AES/ECB/PKCS7Padding的加密模式,可以百度到这个加密模式只需要秘钥key,不需要iv。所以只要获取一下key,验证结果对不对了。
frida的环境就不说了,百度一下很简单的,pip装个包,在下载个x86版本的frida-server复制到模拟器改个权限运行就行。
直接拷贝官方文章中的一段代码,做些修改就可以了:https://frida.re/docs/examples/android/
修改之后,因为key是个java的对象,需要转成js可以输出的形式,一般是16进制或者base64:
import frida, sys
def on_message(message, data):
if message['type'] == 'send':
print("[*] {0}".format(message['payload']))
else:
print(message)
jscode = """
function bytesToHex(arr) {
var str = '';
var k, j;
for (var i = 0; i < arr.length; i++) {
k = arr[i];
j = k;
if (k < 0) {
j = k + 256;
}
if (j < 16) {
str += "0";
}
str += j.toString(16);
}
return str;
}
Java.perform(function () {
var Cipher = Java.use('javax.crypto.Cipher');
var Exception = Java.use('java.lang.Exception');
var Log = Java.use('android.util.Log');
var init = Cipher.init.overload('int', 'java.security.Key');
init.implementation = function (opmode, key) {
var result = init.call(this, opmode, key);
var bytes_key = key.getEncoded();
console.log('Cipher.init() opmode:', opmode, 'key:', bytesToHex(bytes_key));//
//console.log(stackTraceHere());
return result;
};
function stackTraceHere() {
return Log.getStackTraceString(Exception.$new());
}
});
"""
process = frida.get_usb_device().attach('com.netease.newsreader.activity')
script = process.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()
将上面的代码命名为hookaes.py,然后命令行运行python hookaes.py
,在APP里面翻页查看输出。
有多个key,但是只有最后两个是我翻页的时候才出现的,其他的应该是另外的接口使用的。
我们找个在线AES加密的网站验证一下这个key是不是对的。注意: key是16进制(hex)格式的,不是字符串
https://the-x.cn/cryptography/Aes.aspx
结果和上面的是一样的,也就是说,到现在sign的加密已经分析完了。
上面获取v0_1是使用jeb进行调试来获取的,其实用frida获取更简单。
arg6.add(new com.netease.newsreader.framework.d.a.c("sign", b.a(com.netease.newsreader.framework.e.a.c.b(v0_1))));
想要获取v0_1和c.b的执行结果,直接hook c.b。代码如下:
import frida, sys
def on_message(message, data):
if message['type'] == 'send':
print(message['payload'])
else:
print(message)
jscode = """
Java.perform(function () {
send("注入成功!");
var c = Java.use('com.netease.newsreader.framework.e.a.c');
var b = c.b;
b.overload('java.lang.String').implementation = function (v) {
send(v);
var result = b.call(this, v);
send(result);
return result;
};
});
"""
process = frida.get_usb_device().attach('com.netease.newsreader.activity')
script = process.create_script(jscode)
script.on('message', on_message)
script.load()
sys.stdin.read()
打开APP之后,运行这个Python脚本,然后在APP滑动加载即可显示参数和返回值,也就是我们要的。
按道理来说我也可以hook b.a来获取参数和返回值,但是试了一下没有效果,也不知道什么原因。如果有知道的,希望能指教下。