程序安全一直是困扰开发者的大难题,自己的程序一旦出现漏洞,被人反编译破解,就可能造成重大损失。而安卓开发主力语言为Java,该语言自身的不安全性更是让安卓程序变成了破解的重灾区。今天笔者就写一篇新手向的攻防实例文章,带领大家初步了解安卓开发中的防守与进攻,我们今天的主题就是比较常见的程序签名验证机制,并演示如何攻破它。
安卓程序在打包时,必须加上一层数字签名,来代表该程序的独一无二性,而签名文件与密码只有开发者本人拥有,如果别人破解了你的程序重新打包,由于签名不一致,也无法覆盖安装你原本的程序。所以借着签名的独特性,很多应用都在代码里加入了签名验证机制,比如程序运行时检查签名信息是否为原版,如果不是则直接退出,以此来让被篡改的程序无法正常运行。那么我们现在就来简单探讨一下这种方式该如何实现与攻破。
常见的签名验证方式,按安全性递增,大概有三种:
一.在Java代码中保存原版签名信息,然后获取当前的签名信息,并让两者进行比对,程序的核心逻辑也在Java中。
二.在JNI中保存原版签名信息,并在其中获取当前签名再进行比对,比对结果返回到Java代码,程序的核心逻辑在Java中。
三.在JNI中保存原版签名信息,并在其中获取当前签名再进行比对,程序的核心逻辑在JNI中。
那么我们就按这个顺序来做实验,核心逻辑依旧模拟为摄氏度转华氏度的数学公式。本文用到的工具有:Android Studio3.2、AndroidKiller1.3.1、IDA6.5。
我们先创建一个自己的原版签名文件(test.jks),程序最后发布时用它来打包。再创建一个盗版的签名文件(test2.jks),反编译后重新打包时用它来签名。
一、
首先我们写一个获取签名信息并打印的方法:
private void getSignInfo() {
try {
String sign = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES).signatures[0].toCharsString();
Log.i("lbw", "sign:" + sign);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
}
然后用刚才创建的test.jks打包并运行,可以从日志看到该签名为:“3082032f30820217a003020102020427fa71bc300d06092a864886f.....”
把该字符串专门保存起来:
public class Constants {
public static final String sign = "3082032f30820217a003020102020427fa71bc300d060...";
}
现在就可以写一个获取当前签名并验证的方法了:
private boolean javaCheckSign() {
try {
return getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES).signatures[0].toCharsString().equals(Constants.sign);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return false;
}
}
如果当前签名和我们保存的原版签名信息一致,则返回true,不一致则返回false。
那么我们在调用核心逻辑前先加入此判断:
public int centigradeToFahrenheit1(int a) {
if (javaCheckSign()) {
return a * 9 / 5 + 32;
} else {
return -1;
}
}
如果验证通过,则进行正常的温度换算并返回结果,不一致则永远返回-1。
我们将参数a设为20,执行程序,将结果显示到一个textview中。
public void doCal1(View view) {
tv.setText(centigradeToFahrenheit1(20) + "");
}
可以看出现在的程序的计算是正确的,那么我们接下来使用Android Killer(以下简称AK)来更换签名重新打包该程序。
将test2.jks导入AK中
然后先不要修改任何代码,直接选择test2签名进行编译并运行
此时因为签名不一致,所以得到了-1,看似这样的验证算是通过了,但其实这样无异于裸奔。。。我们现在来试着攻破它。
相信很多朋友都有过把Apk还原出jar文件再查看Java代码的经历,但是如果要修改并重新编译一个程序,我们并不能直接修改它的Java代码,而是要修改它的Smali代码。Smali的语法本次不过多讨论,只介绍简单的几个知识点就足以完成这次的任务。
那么破解的思路也很简单,我们验证的步骤是获取签名信息并和代码里储存的签名对比,那么我们只需要替换代码中储存的签名信息为另外一个签名(test2.jks)即可,用刚才说的方法,我已提前获取了test2的签名"308202bf308201a7a00302010202047f..."。
选到工程管理标签,打开Smali文件夹,根据包名找到之前用于存储签名的Constants文件:
这里可以清晰的看到签名信息的字符串被存储在这里,但是改这里其实是没有作用的,因为MainActivity引用这个字符串时,实际上是拿走了原文去做比较,那现在打开MainActivity.smali,找到javaCheckSign方法:
果然,在验证这一步时,签名信息的字符串在这里,现在直接替换成test2的字符串,然后保存、编译、安装三连:
正确结果就计算出来了,至此第一种近似于裸奔的验证方式已经被终结。
二、
看完第一节,可能很多同学会说,9102年了,怎么可能有人这么写,验证的步骤肯定都是放在JNI里好么?是的,现在很多程序都是把验证部分用JNI完成,正确的签名信息也存储在C/C++代码中,但是后续的逻辑依旧使用Java处理,这样其实也是十分致命的。。。和刚才一样,先按这个思路完成防守方的代码,在JNI中验证签名信息,然后Java代码调用它,得到返回结果:
const char *sign = "3082032f30820......."
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_lbw_signaturecheck_MainActivity_jniCheckSign(
JNIEnv *env,
jobject thiz, jobject context_object) {
jclass context_class = env->GetObjectClass(context_object);
jmethodID methodId = env->GetMethodID(context_class, "getPackageManager",
"()Landroid/content/pm/PackageManager;");
jobject package_manager_object = env->CallObjectMethod(context_object, methodId);
if (package_manager_object == NULL) {
return false;
}
methodId = env->GetMethodID(context_class, "getPackageName", "()Ljava/lang/String;");
jstring package_name_string = (jstring) env->CallObjectMethod(context_object, methodId);
if (package_name_string == NULL) {
return false;
}
env->DeleteLocalRef(context_class);
jclass pack_manager_class = env->GetObjectClass(package_manager_object);
methodId = env->GetMethodID(pack_manager_class, "getPackageInfo",
"(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
env->DeleteLocalRef(pack_manager_class);
jobject package_info_object = env->CallObjectMethod(package_manager_object, methodId,
package_name_string, 64);
if (package_info_object == NULL) {
return false;
}
env->DeleteLocalRef(package_manager_object);
jclass package_info_class = env->GetObjectClass(package_info_object);
jfieldID fieldId = env->GetFieldID(package_info_class, "signatures",
"[Landroid/content/pm/Signature;");
env->DeleteLocalRef(package_info_class);
jobjectArray signature_object_array = (jobjectArray) env->GetObjectField(
package_info_object,
fieldId);
if (signature_object_array == NULL) {
return false;
}
jobject signature_object = env->GetObjectArrayElement(signature_object_array, 0);
env->DeleteLocalRef(package_info_object);
jclass signature_class = env->GetObjectClass(signature_object);
methodId = env->GetMethodID(signature_class, "toCharsString", "()Ljava/lang/String;");
env->DeleteLocalRef(signature_class);
jstring signature_string = (jstring) env->CallObjectMethod(signature_object, methodId);
const char *sign_string = env->GetStringUTFChars(signature_string, 0);
if (strcmp(sign_string, sign) == 0) {
return true;
} else {
return false;
}
}
static {
System.loadLibrary("native-lib");
}
public native boolean jniCheckSign(Context context);
public int centigradeToFahrenheit2(int a) {
if (jniCheckSign(getApplicationContext())) {
return a * 9 / 5 + 32;
} else {
return -1;
}
}
代码这里不做具体的解释,如果对NDK开发不熟的同学可以看我之前的文章。思路还是Java调用了jniCheckSign这个方法,如果为真返回计算结果,否则返回-1。
这样乍看好像比之前的方法稳妥很多,因为别人无法随意修改你正确的签名信息了,但问题还是出在Java代码中!jniCheckSign返回为真时,验证通过,如果我直接在这入手,修改为返回为假时验证通过呢?
依旧打开MainActivity.smali,找到centigradeToFahrenheit2方法:
可以清晰看到有if-eqz这个指令,看起来好像是在做什么判断,没错,就是这里。if-eqz的意思是如果条件成立则跳转,那么只要改成如果条件不成立跳转,问题就迎刃而解。而与之对应的指令就是if-nez,这里直接把if-eqz替换为if-nez:
if-nez v0, :cond_0
继续三连,点击CAL2按钮,查看运行情况:
又得到了正确的结果,看来之前用JNI做了那么多工作,还是竹篮打水一场空啊,毕竟你的核心逻辑在Java里,这就是硬伤呢。
三、
到这里,有过和友商斗智斗勇经验的同学可能会说了,我们直接把核心逻辑也放在JNI里,任你怎么改Java部分都没用!是啊,如果我们把换算的公式也放入JNI,那不就安全了吗?代码如下:
extern "C"
JNIEXPORT jint JNICALL
Java_com_lbw_signaturecheck_MainActivity_jniCentigradeToFahrenheit(
JNIEnv *env,
jobject thiz, jobject context_object, jint a) {
if (Java_com_lbw_signaturecheck_MainActivity_jniCheckSign(env, thiz, context_object)) {
return a * 9 / 5 + 32;
} else {
return -1;
}
}
public native int jniCentigradeToFahrenheit(Context context, int a);
public void doCal3(View view) {
tv.setText(jniCentigradeToFahrenheit(getApplicationContext(), 20) + "");
}
思路是先在JNI中调用刚才JNI验证签名的函数,然后继续执行JNI的计算函数,最终只返回计算结果给Java。
这样一来就感觉天衣无缝了,虽然Java层可以反编译,但是C++反编译可就太困难了。是的,但是不要忘了这世界上还有个东西叫做反汇编。
此时光有AK在手肯定是不够了,怎么也得再加个闪光弹才敢rush b吧(手动滑稽),那么我们闪光弹就是开头提到的IDA。
思路有2种,一是修改SO文件中的正确签名信息为假的签名,二是想办法改变判断的条件。
第一种其实是不可行的,虽然签名信息确实以十六进制保存在SO里,但是签名的长度是不固定的,我们只能按位修改,不能改变它的长度,否则地址全部错乱,程序无法执行。
第二种从理论上来说可行,在开始实战前,我们先复习(学习)一下几个ARM的汇编指令:
简单来说BEQ和BNE对应,CBZ和CBNZ对应,我们只需要在汇编代码里找到这些指令,改成对应的指令,就可以改变跳转的结果,现在IDA启动,导入apk中的libnative-lib.so。
此时很多同学可能都一脸懵逼了,不要怕,IDA并没有那么难,我们先从左侧函数列表里找到计算的函数Java_com_lbw_signaturecheck_MainActivity_jniCentigradeToFahrenhei
在1的地方先调用了验证签名的函数,紧接着在2的的地方进行了判断,没错,看到了一个很眼熟的指令CBZ!而这里只需要想办法把CBZ改成CBNZ就大功告成。
先把光标放在CBZ上,然后点View->Open subviews->Hex dump
现在来到了十六进制的视图,而光标停在了60 B1处,而B1正是CBZ,根据我开头给出的表格,将B1修改为B9:
应用修改后再返回汇编的视图,可以看到CBZ确实已经变成了CBNZ
.text:000009CA CBNZ R0, loc_9E6
然后点击Edit->Plugins->modifyfile,存储为新的SO文件。拿到了新的SO文件,再打开AK,替换掉原有的SO文件。
然后我们三连,点击CAL3按钮,查看运行结果:
果然,计算出了正确的结果。
尾声
至此,本文提出的三种签名验证方式全部告破。但是实际运用中破解一个程序并没有这么直接快速,虽然原理是一样,但是理清程序的逻辑是一件非常耗时的工作,甚至很多开发者代码乱的自带混淆。。。我们的例子比较简单而且是自己写出来的,所以修改时才可以快速定位核心部分。最后的最后再提醒所有同学,破解修改别人的程序来达到一些不可告人的目的是一件很不道德的事情,甚至会触犯法律,而本文仅做学习交流之用,请于阅读后的二十四小时内全部忘光!
示例代码下载:
链接:https://pan.baidu.com/s/1h3qPwjMeW2tmvqku51yBjg
提取码:9doj