同事发现有个游戏有签名校验,我挺久没手动处理签名校验了。都是用脚本 hook 签名处理了,详情可以看我之前写的博客。因为入职的职位后面会接触比较多的逆向相关的知识,所以还是从头分析一遍,用 nop 掉签名检测的方式来过签名
目标游戏包
https://share.weiyun.com/mnUM2n5A
这是一个很老的单机游戏了,不过我体验了一下画风还可以,战斗模式也很爽,对比普通的回合制有特殊的玩法
分析
先把包下载下来,用 apktool 解包之后,不做任何改动,直接回编译。发现弹出弹窗如下:
因此判断这个包有签名校验,下一步直接查看应用的入口 activity,因为这个界面很明显已经到了 activity,所以签名校验行为应该不是在 application 中执行的(也有可能 application 执行检测,到了 activity 再执行弹窗)。
所以接下来简单判断一下,第一步先要找到这个弹窗的代码在哪里。
执行 adb 命令 查看当前 activity 是哪个
adb shell dumpsys activity | grep mResum
得到结果是 com.game.hytc.dthtd.AppActivity,把 apk 丢进 jeb 里面,去看这个 activity 的逻辑
public class AppActivity extends Cocos2dxActivity {
private static AppActivity gameActivity;
private static String gameSmsPayGoodsName;
private static int gameSmsPayIdx;
private static int gameSmsPayRmb;
public static Handler handler;
private static String porder;
private static String qdStr;
private static int waitResumeToPayCallBackWhat;
static {
AppActivity.qdStr = "AH_UC";
AppActivity.gameActivity = null;
AppActivity.handler = null;
AppActivity.waitResumeToPayCallBackWhat = -1;
}
public static String getAppId() {
return AppActivity.getContext().getPackageName();
}
public static String getAppName() {
CharSequence v1;
try {
v1 = AppActivity.getContext().getPackageManager().getApplicationLabel(AppActivity.getContext().getPackageManager().getApplicationInfo(AppActivity.getContext().getPackageName(), 0));
}
catch(PackageManager$NameNotFoundException v0) {
String v1_1 = "UNKNOWN";
}
return ((String)v1);
}
public static String getAppVersion() {
String v1;
try {
v1 = AppActivity.getContext().getPackageManager().getPackageInfo(AppActivity.getContext().getPackageName(), 0).versionName;
}
catch(PackageManager$NameNotFoundException v0) {
v1 = "UNKNOWN";
}
return v1;
}
public static String getDeviceCountry() {
return Locale.getDefault().getCountry();
}
public static String getDeviceLanguage() {
return Locale.getDefault().getLanguage();
}
public static String getDeviceModel() {
return Build.MODEL;
}
public static String getDeviceOSName() {
return "Android";
}
public static String getDeviceOSVersion() {
return Build$VERSION.RELEASE;
}
public String getSignature() {
PackageManager v2 = this.getPackageManager();
String v5 = "";
try {
Signature[] v6 = v2.getPackageInfo(this.getPackageName(), 0x40).signatures;
StringBuilder v0 = new StringBuilder();
int v8 = v6.length;
int v7;
for(v7 = 0; v7 < v8; ++v7) {
v0.append(v6[v7].toCharsString());
}
return v0.toString();
}
catch(PackageManager$NameNotFoundException v1) {
v1.printStackTrace();
return v5;
}
}
public void onCreate(Bundle arg3) {
AppActivity.gameActivity = this;
super.onCreate(arg3);
this.getWindow().setFlags(0x80, 0x80);
TalkingDataGA.sPlatformType = 1;
TalkingDataGA.init(((Context)this), "26C0868AFF5F53D6AA926FD4B29A80AF", AppActivity.qdStr);
AppActivity.handler = new Handler() {
public void handleMessage(Message arg3) {
switch(arg3.what) {
case 0: {
AppActivity.payCallBack(false);
break;
}
case 1: {
AppActivity.payCallBack(true);
break;
}
default: {
AppActivity.payCallBack(false);
break;
}
}
super.handleMessage(arg3);
}
};
PayAPI.initAPIFromActivity(this);
}
public void tsGameIsDB() {
AppActivity.handler.post(new Runnable() {
static AppActivity access$0(com.game.hytc.dthtd.AppActivity$5 arg1) {
return arg1.this$0;
}
public void run() {
new AlertDialog$Builder(AppActivity.gameActivity).setTitle("提示").setMessage("该应用程序为盗版").setPositiveButton("确定", new DialogInterface$OnClickListener() {
public void onClick(DialogInterface arg2, int arg3) {
this.this$1.this$0.finish();
System.exit(0);
}
}).show();
}
});
}
}
可以看到里面有俩个嫌疑比较大的函数,一个是 tsGameIsDB ,这个函数展示了前面我们看到的弹窗,还有一个函数 getSignature 这个函数
下一步直接看看这俩个函数的调用栈,先看 tGameIsDB ,看看这个函数被谁调用的。
android hooking watch class_method com.game.hytc.dthtd.AppActivity.tsGameIsDB --dump-args --dump-backtrace --dump-return
objection 框架中可以打印这个函数的调用栈
可以看到 在执行了 nativeInit 之后 调用了 tsGameIsDB ,那大概率是从 so 中调用的 这个 函数。
再看看另一个函数 getSignature
调用栈和 tsGameDB 是一致的,那就很简答了,这次的 签名校验也是在 java 层的,只是 比对逻辑是在 so 层的,因为只要获取原签名的逻辑在 java 层,那 都可以用 java 层签名爆破通杀。不过这里我们还是手动处理吧,把 so 中的签名校验逻辑给 nop 掉
爆破
根据分析的结果,目前最简单的签名爆破方案就是,直接 修改 getSignature 这个函数返回固定的原签名值就可以完成破解,哈哈。但是我们用复杂点的方案哈,去分析一下他的 so 是怎么写的签名比对逻辑。
把 lib 中的 libcocos2dcpp.so 丢进 ida, 为什么丢这个 so ,因为很明显 org.cocos2dx.lib 下面的 so 逻辑会在 libcocos2dcpp 中书写。(就是搞的多了就有这种第六感)
等待 ida 解析完 so 之后,ctrl+P ,全局查找函数 java_org
找到之后进去看看,这个 函数的逻辑
一开始以为是 这个 sub_6AFA54 里面的逻辑,因为他在 cocos 代码初始化之前 执行的,但是点进去研究了一下 发现不是这个逻辑,这里没有调用 java 里面的 checkisdb ,
emmm,那么这种一层层看代码的方式就有点慢了,直接搜索字符串吧,因为 so 调用 java 是要固定字符串的,类似反射的写法。
shift+f12 打开字符串页面 ,右键调用 quickfilter 过滤 ,关键词 tsGameIsDB ,找到之后跳转到对应 的 hex view
选中旁边的高亮地址,键盘 按 x 就可以查看这个地址在哪里被调用了。
整个调用逻辑到这里就定位到了,比较简单,转出来的伪代码比较清晰 最后的返回值是 一个 int,简单分析一下逻辑可以知道 v5的返回值为1 的时候代表签名校验通过,那我们先用 frida 来hook 这个函数 测试一下。
下面是 frida hook 脚本
Java.perform(function(){
var render = Java.use('org.cocos2dx.lib.Cocos2dxRenderer');
//因为这是启动就调用的,所有要用启动时 hook,先hook java 层的,判断加载完成了 so 之后,再 hook so 层
// .method public onSurfaceCreated(Ljavax/microedition/khronos/opengles/GL10;Ljavax/microedition/khronos/egl/EGLConfig;)V
render.onSurfaceCreated.implementation=function (obj1 ,obj2) {
console.log('do on onSurfaceCreated')
var n_addr_so = Module.findBaseAddress("libcocos2dcpp.so"); //加载到内存后 函数地址 = so地址 + 函数偏移
console.log("libcocos2dcpp base addr:"+n_addr_so)
console.log("start hook so")
// var funaddr = n_addr_so.add(0x2860E5) #因为 so 是 thump 状态所以要 +1,这里注释了是因为后面直接用 符号寻址
var funaddr = Module.findExportByName("libcocos2dcpp.so", "_ZN4Tool13checkGameIsDBEv")
console.log("fun addr:"+funaddr)
var fakeMethod = new NativeFunction(ptr(funaddr), 'int',['pointer']);
//函数附加
Interceptor.attach(funaddr,{
onEnter:function(args){
// // send(args[2]);
// // // send("open called! args[0]",Memory.readByteArray(args[0],256));
// // send("open called! args[1]",Memory.readByteArray(args[1],256));
// var temp = Memory.readByteArray(args[0],256)
// var temp = Memory.readByteArray(args[1],256)
// console.log(typeof temp);
// var x = Memory.readFloat(args[0])
// console.log(x)
},
onLeave:function(retval){
console.log("hook leave")
console.log(retval)
retval.replace(0x1)
return retval
}
})
// 函数替换
// Interceptor.replace(fakeMethod, new NativeCallback(function (a) {
// //h不论是什么参数都返回 1
// console.log('hook success')
// console.log(a)
// // // console.log(c)
// var result1 = fakeMethod(a)
// var result = 1
// console.log(typeof(result))
// console.log(result)
// return result1
// }, 'int',['pointer']));
this.onSurfaceCreated(obj1,obj2)
// // body...
}
});
});
执行命令在游戏启动的时候附加
frida -U -f com.game.hytc.dthtd.uc -l frida_test.js
然后在命令行 输入 %resume 通知主线程放行,游戏启动效果如下:
很明显游戏进程已经可以正常加载了,说明除了返回值校验,这个游戏的签名校验没有其他暗桩。frida 我们常用来在修改 so 之前做一下技术验证,现在验证完毕这样修改没问题,那我们的技术思路就很清晰了。
只需要修改 这个 so 的跳转逻辑就行,把 第 50行的 if 逻辑修改一下,就完美破解了。
修改 so 库
先在 ida 中找到第 50 行的, 查看对应的汇编指令
cmp 是比较两个数是否相等
LDR指令用于从存储器中将一个32位的字数据传送到目的寄存器中
BNE 指不相等的时候跳转(branch not equal)
BEQ 指相等的时候跳转(branch equal)
所以我们这里把 BEQ 修改为 BNE,那么就可以绕过签名校验
选中 BEQ ,快捷键 ctrl+alt+k ,进入 keypatch ,将 BEQ 修改为 BNE
效果如下:
不知道为神马,我在 ida 里面修改之后没有生效,我就用 010editor 来修改了。
也是一样的,把 so 丢进 010 editor ,然后快捷键 ctrl+g ,在地址栏中输入 地址 0028619c ,就可以找到对应的 hex ,进行修改就行
光标放在 D0 中间,然后输入 1 ,即可修改(我第一次用也觉得很怪),修改为 12 D1 后,保存文件即可。
到这里,整个游戏的 签名校验破解就完成了,我们用了最难的方向来完成这个任务。目的是为了锻炼自己。如果只是为了达成目的,那么我们java 层修改 getSignatrue 函数即可,或者我之前有写过通用签名爆破的代码。直接用就行。
测试效果
测试机是 google pixel4,没有插卡,签名校验完美绕过。
后话
后面我用了自己的手机测试,发现闪退,闪退log 是空指针:
2022-06-17 15:22:28.338 25114-25161/? A/libc: Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 25161 (GLThread 2521), pid 25114 (e.hytc.dthtd.uc)
2022-06-17 15:22:28.400 25363-25363/? A/DEBUG: *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
2022-06-17 15:22:28.400 25363-25363/? A/DEBUG: Build fingerprint: 'HUAWEI/LYA-AL00/HWLYA:10/HUAWEILYA-AL00/10.1.0.163C00:user/release-keys'
2022-06-17 15:22:28.400 25363-25363/? A/DEBUG: Revision: '0'
2022-06-17 15:22:28.400 25363-25363/? A/DEBUG: ABI: 'arm'
2022-06-17 15:22:28.403 25363-25363/? A/DEBUG: SYSVMTYPE: Maple
APPVMTYPE: Art
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: Timestamp: 2022-06-17 15:22:28+0800
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: pid: 25114, tid: 25161, name: GLThread 2521 >>> com.game.hytc.dthtd.uc <<<
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: uid: 13976
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: Cause: null pointer dereference
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: r0 00000000 r1 00000000 r2 c3cf26cc r3 00000000
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: r4 c2b1f2c0 r5 de0131c0 r6 c3558739 r7 d95879c0
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: r8 00000000 r9 d95f7000 r10 c3cf2868 r11 c3cf27ec
2022-06-17 15:22:28.408 25363-25363/? A/DEBUG: ip 80000000 sp c3cf26d4 lr c3423ebb pc c3484e3e
2022-06-17 15:22:28.411 25363-25363/? A/DEBUG: backtrace:
2022-06-17 15:22:28.411 25363-25363/? A/DEBUG: #00 pc 002e6e3e /data/app/com.game.hytc.dthtd.uc-kN5nzWD-fZtRG_q2G68XxQ==/lib/arm/libcocos2dcpp.so (TDCCAccount::setAccountType(TDCCAccount::TDCCAccountType)+2)
2022-06-17 15:22:28.411 25363-25363/? A/DEBUG: #01 pc 008e8e0c /data/app/com.game.hytc.dthtd.uc-kN5nzWD-fZtRG_q2G68XxQ==/lib/arm/libcocos2dcpp.so!libcocos2dcpp.so (offset 0x8a7000)
一看报错我就知道 啥原因,因为我的手机 插了手机卡,他这个 lib 会生成一个账号,但是这个第三方的库 TDCCAccount 也有签名校验,没有生成账号,所以就翻车了。
所以还要把第三库这个暗桩给处理了。或者单纯处理这个空指针问题,很好解决。
等有空了再搞,会写博客分享修改过程
期待。。。