声明:本文所述均为技术讨论,切勿用于违法行为。
我们知道签名是Android软件的一种有效身份标识,因为签名所使用的秘钥文件是我们所独有的,而当我们app被重新打包后,app的签名信息势必会被篡改,所有我们就可以根据软件运行时签名与发布时签名的相同与否来决定是否需要将app中止运行。常用的Java层签名校验方法见下:
签名校验
Android SDK中提供了检测软件签名的方法,我们可以使用签名对象的 hashCode() 方法来获取一个Hash值,在代码中比较它的值即可,下面是获取当前运行时的签名信息代码:
public static int getSignature(Context context) {
PackageManager pm = context.getPackageManager();
PackageInfo pi;
StringBuilder sb = new StringBuilder();
// 获取签名信息
try {
pi = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signatures = pi.signatures;
for (Signature signature : signatures) {
sb.append(signature.toCharsString());
}
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
return sb.toString().hashCode();
}
复制代码
接下来我们需要跟我们发布时的签名信息比较,在这里已经把Hash值MD5加密了:
int signature = getSignature(getApplicationContext());
if(!MD5Util.getMD5(String.valueOf(signature)).equals("发布时签名值")){
// 可能被重编译了,需要退出
android.os.Process.killProcess(android.os.Process.myPid());
}
复制代码
classes.dex的crc32校验
通常重编译 apk 就是重编译 classes 文件,而代码重新编译后,生成的 classes.dex 文件的Hash值就会改变,所以我们可以检查程序安装后 classes.dex 文件的Hash值来判断软件是否被重新打包过。至于Hash算法MD5和CRC都可以,在这里就直接使用CRC算法获取当前运行的app的crc32值了:
public static long getApkCRC(Context context) {
ZipFile zf;
try {
zf = new ZipFile(context.getPackageCodePath());
// 获取apk安装后的路径
ZipEntry ze = zf.getEntry("classes.dex");
return ze.getCrc();
}catch (Exception e){
return 0;
}
}
复制代码
有了当前的crc32值了,那么我们只需要将其与我们app发布时的crc32原始值做比较了,这是我们的java逻辑,R.string.classes_txt 的值我们我们可以先随意赋予一个(不影响),随后AndroidStudio开始正式打包:
String srcStr = MD5Util.getMD5(String.valueOf(CommentUtils.getApkCRC(getApplicationContext())));
if(!srcStr.equals(getString(R.string.classes_txt))){
// 可能被重编译了,需要退出
android.os.Process.killProcess(android.os.Process.myPid());
}
复制代码
当打包成功后,我们获取apk的classes.dex的crc32值,随后将该crc32值赋予R.string.classes_txt,最后通过AndroidStudio再重新打包即可(因为更改资源文件并不会改变classe.dex的crc32值,改变代码才会)。获取classes.dex的crc32值的方法,可使用 Windows CRC32命令工具,使用方法如下:
Java层面的校验方法都是脆弱的,因为破解者可以直接更改我们的判断逻辑以达到绕开校验的目的,所以我们只能通过增加其破解工作量,来达到一点点防破解的夙愿。
建议将crc32值或签名的hash值进行MD5加密,在代码中使用加密后的值进行比较,防止反编译后的全局搜索;建议将签名校验与 classes.dex 校验结合起来使用,先进行签名校验,校验成功后将正确签名hash值作为参数去后台请求 classes.dex 的crc32值,再与当前运行crc32值进行比较;建议进行多处校验,每处使用变形判断语句,并与其他判断条件组合使用,以增加破解时的工作量。
反编译与二次打包
在讲述如何绕过Java代码签名校验的内容前,我先简单介绍下如何使用apktool来反编译apk,并进行二次打包。首先需要下载工具,这里使用的是:apktool.jar和apktool.bat :
使用apktool获取apk资源文件和smali文件
将我们下载的apktool.jar文件和apktool.bat文件放在一起,并将待编译apk文件拷贝过来,如下目录:
随后在相应文件目录下,执行命令:apktool d test.apk,执行完毕,我们会发现apktool所在目录下生成了一个与apk同名的文件夹,即apk反编译出来的资源文件和smali文件,smali文件是dex文件反编译的结果,但不同于dex2jar的反编译过程:
使用apktool对apk文件进行二次打包
在上述的反编译操作完成后,我们就能够发现smali文件夹内的.smali文件,其由smali语言编写,即Davlik的寄存器语言,smali有自己的语法并且可以修改,修改后可以被二次打包为apk,需要注意的是,apk经过二次打包后并不能直接安装,必须要经过签名后才能安装。
现在我们要将编译出来的test文件,重新打包成apk文件,刚才我就说了,smali是有自己的语法并且可以修改的,所以我们完全可以按照我们的要求,更改smali文件之后再进行打包,不过在这里我就仅仅简单的演示下打包操作了。
首先我们打开cmd命令,输入命令:apktool b test,执行命令完毕后,会在test文件夹中生成dist文件夹,该文件夹下就保存着我们二次打包后生成的apk文件,但是这个apk文件由于没有进行过签名,所以是不能够安装和运行的,签名的方法咱们接着往下看:
使用Auto-sign对二次打包后的apk文件进行签名
首先我们需要下载Auto-sign工具,并放在apktool所在目录下(推荐):
随后将我们待签名的apk文件复制到Auto-sign目录之下,并更改名称为update.zip :
至于为何要更改为update.zip文件,我们可以看下Sign.bat文件则一目了然:
最后我们双击Sign.bat文件,将同目录下生成的update_signed.zip文件更改为test.apk文件即可,这个test.apk文件就是我们最终所需要的签名后的二次打包文件,在这个例子中,如果用户app没有做签名校验,那么重新打包后的apk与原始apk功能完全一样:
通过上面的操作,我们能够发现,如果我们不进行签名校验,那么不法者仅仅只凭借apktool和Auto-sign工具就可以轻松破解我们的app并重新打包成新的apk。
绕过Java代码签名校验
在这里我就仅以一个简单demo为例,首先我们将待编译apk通过apktool生成我们所需要的smali文件,这些文件会根据程序包的层次结构生成相应的目录,程序中所有的类都会在相应的目录下生成独立的smali文件:
然后我们通过 dex2jar 和 jd-gui 得到反编译出的java代码(往往都是已混淆的),通过查看Java代码我们可以快速搜索出需要的Android API方法,再通过API方法的位置来定位到相应smali文件的大概位置:
一般java层面的签名校验都离不开signatures来获取签名信息,所以我们可以在 jd-gui 中全局搜索signatures关键字,找到获取签名的方法,当然如果app在校验失败前有着特殊的Toast提示或者Log信息那就更方便了:
随后打开我们查找到的signatures代码,一般情况下app都会进行多处校验:
随后我们顺藤摸瓜,找到f()方法被调用的地方,在这里就只拿 jd-gui 来试试水了,我们可以先通过AndroidManifest.xml文件找到Application和主Activity,一般在这里都会进行一些校验和身份状态的判断,改好一处之后,通过运行app,再根据app退出或者卡住的位置来定位下一处校验代码的位置,直到运行成功。
通过上面的语句我们可以知道,这只是一个简单的equals()比较,之后我们打开相应的smali文件,搜索"F010AF8CFE611E1CC74845F80266",定位签名校验的反编译代码位置:
invoke-static {v0}, Ljava/lang/String;->valueOf(I)Ljava/lang/String;
move-result-object v0
invoke-static {v0}, Lcom/lcmhy/c/a/d;->a(Ljava/lang/String;)Ljava/lang/String;
move-result-object v0
const-string v1, "F010AF8CFE611E1CC74845F80266"
invoke-virtual {v0, v1}, Ljava/lang/String;->equals(Ljava/lang/Object;)Z
move-result v0
if-nez v0, :cond_1
复制代码
在这里我们只需要将判断语句if-nezv0, :cond_1更改为if-eqz v0, :cond_1翻转逻辑即可,随后我们通过apkTool重新打包并签名,ok,运行成功。
hook绕过系统签名
上面的方式虽然正统,但在实际的操作过程中,如果目标代码很多,校验逻辑又分散在各处,就很难全部修改绕过了。所以目前都是尝试从最根本的接口入手,通过hook系统的接口来达到绕过签名校验的目的。通过上文我们知道获取系统签名的API如下:
public static int getSignature(Context context) {
PackageManager pm = context.getPackageManager();
...
// 获取签名信息
pi = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
...
}
复制代码
可以看到,获取签名最核心的方法:getPackageInfo,我们来查看下getPackageManager源码:
可以看到这里会接着调用ActivityThread的静态方法,我们进入ActivityThread源码看下:
竟然有个静态变量sPackageManager ,而且是个接口。到这里你想到了什么?第一想法当然是动态代理啊,不了解的可以去翻下我的上篇文章:Android修炼系列(一),写一篇易懂的动态代理讲解,思路就是通过反射将 sPackageManager 对象替换成我们的代理对象,并在代理对象中对于 getPackageInfo 方法进行重定义即可。
代码也不难,直接通过反射拿到sPackageManager,并注入到我们的代理对象内,hook代码如下:
public void hookGetPackageInfo(Application app) throws Exception {
Class clzActivityThread = Class.forName("android.app.ActivityThread");
Method methodGetPackageManager = clzActivityThread.getDeclaredMethod("getPackageManager");
methodGetPackageManager.setAccessible(true);
Object sPackageManager = methodGetPackageManager.invoke(null);
// 动态代理
Class clzIPackageManager = Class.forName("android.content.pm.IPackageManager");
Object proxy = Proxy.newProxyInstance(clzActivityThread.getClassLoader()
, new Class[]{clzIPackageManager}
, new PackageManagerProxy(app, sPackageManager));
// 替换原sPackageManager
Field filedIPackageManager = clzActivityThread.getDeclaredField("sPackageManager");
filedIPackageManager.setAccessible(true);
filedIPackageManager.set(null, proxy);
}
复制代码
这是我们代理对象代码,如果有不清楚的,可以翻一翻原来的文章:
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if (method.getName().equals("getPackageInfo")) {
String packageName = "";
if (null != args && args.length > 0 && args[0] instanceof String) {
packageName = (String) args[0];
}
final PackageInfo packageInfo;
if (mApplication.getPackageName().equals(packageName)
&& null != (packageInfo = (PackageInfo) method.invoke(mRealPackageManager, args))) {
final byte[] b = null; // 原APK签名
Signature signature = new Signature(b);
if (null == packageInfo.signatures) {
packageInfo.signatures = new Signature[1];
}
packageInfo.signatures[0] = signature;
return packageInfo;
}
}
return method.invoke(mRealPackageManager, args);
}
复制代码
好了,本文到这里,关于签名校验的介绍就结束了。如果本文对你有用,来点个赞吧,大家的肯定也是阿呆i坚持写作的动力。