一、背景
一直以来,签名校验都是防Apk被反编译的重要措施之一,但是随着反编译技术的日渐发展,普通的签名校验方式已经可以被轻易的攻破了。这里对目前常用的签名校验方式及其破解法进行了梳理:
1,Java层通过PackageManager获取签名信息进行对比×(hook掉与PMS交互的IPackageManager即可完美破解)
2,Java层通过解压Apk包获取签名信息进行对比×(写在Java层容易被找到逻辑代码然后被修改或被Xpose等hook工具破解)
3,NDK层通过反射PackageManager获取签名信息进行对比×(同1)
4,NDK层通过解压Apk包获取签名信息进行对比√(不通过PMS获取,并且写在NDK层代码较难被反编译,即使反编译了也难看懂,目前较完美的一种签名校验方式,本文采用此方式进行签名校验)
可以看到,当前签名校验方式存在两个问题:
①,容易被hook,通过钩子(hook)技术可以破解掉基本上所有的签名校验,使的签名校验这种防护措施如同虚设;
②,Java层容易被反编译,即使代码混淆的情况下,也可以找到相应的逻辑代码。
基于这两个问题,有了本文NDK实现防钩子签名校验
二、实现
大体流程如下:
②,解压Apk获取CERT.RSA,java层解压很好办,有相应的api,但NDK层解压Apk就得依赖第三方压缩库了,这里采用zip库;
③,把上一步获取CERT.RSA提取出公钥,这里通过openssl提取出公钥即可;
④,把上一步获取的公钥进行MD5后对比已知值,这里同样采用openssl的api,验证不通过的话可采用exit(0)退出应用。
//对公钥MD5后进行比对验证 void MD5_Check(char *src) { char buff[3] = {'\0'}; char hex[33] = {'\0'}; unsigned char digest[MD5_DIGEST_LENGTH]; MD5_CTX ctx; MD5_Init(&ctx); MD5_Update(&ctx, src, strlen(src)); MD5_Final(digest, &ctx); strcpy(hex, ""); for (int i = 0; i != sizeof(digest); i++) { sprintf(buff, "%02x", digest[i]); strcat(hex, buff); } if (strcmp(hex, "c8b5cf87aea796a187828b706504ca4b") == 0) { LOGI("SigCheckLog:MD5->验证通过 %s", hex); } else LOGI("SigCheckLog:MD5->验证失败 %s", hex); } //提取签名公钥 int Openssl_Verify(const unsigned char *signature_msg, unsigned int length) { //DER编码转换为PKCS7结构体 PKCS7 *p7 = d2i_PKCS7(NULL, &signature_msg, length); if (p7 == NULL) { printf("error.\n"); return 0; } //获得签名者信息stack STACK_OF(PKCS7_SIGNER_INFO) *sk = PKCS7_get_signer_info(p7); //获得签名者个数,可以有多个签名者 int signCount = sk_PKCS7_SIGNER_INFO_num(sk); for (int i = 0; i < signCount; i++) { //获得签名者信息 PKCS7_SIGNER_INFO *signInfo = sk_PKCS7_SIGNER_INFO_value(sk, i); //获得签名者证书 X509 *cert = PKCS7_cert_from_signer_info(p7, signInfo); char *pubKey = (char *) cert->cert_info->key->public_key->data; // LOGI("SigCheckLog: %s\n",pubKey); MD5_Check(pubKey); X509_free(cert); } return 1; } //解压apk void uncompress_apk(const char *mpath, const char *fname) { int i = 0; struct zip *apkArchive = zip_open(mpath, 0, NULL); struct zip_stat fstat; zip_stat_init(&fstat); struct zip_file *file = zip_fopen(apkArchive, fname, 0); if (!file) { return; } zip_stat(apkArchive, fname, 0, &fstat); LOGI("File %i:%s Size1: %d Size2: %d", i, fstat.name, fstat.size, fstat.comp_size); unsigned char *buffer = (unsigned char *) malloc(fstat.size); zip_fread(file, buffer, fstat.size); Openssl_Verify(buffer, fstat.size); free(buffer); zip_fclose(file); zip_close(apkArchive); } //获取apk路径 JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { JNIEnv *env = NULL; if (vm->GetEnv((void **) &env, JNI_VERSION_1_4) != JNI_OK) { return -1; } assert(env != NULL); jclass localClass = env->FindClass("android/app/ActivityThread"); if (localClass != NULL) { jmethodID getapplication = env->GetStaticMethodID(localClass, "currentApplication", "()Landroid/app/Application;"); if (getapplication != NULL) { jobject application = env->CallStaticObjectMethod(localClass, getapplication); jclass context = env->GetObjectClass(application); jmethodID methodID_func = env->GetMethodID(context, "getPackageCodePath", "()Ljava/lang/String;"); jstring path = static_cast<jstring>(env->CallObjectMethod(application, methodID_func)); const char *ch = env->GetStringUTFChars(path, 0);; LOGI("%s", ch); uncompress_apk(ch, "META-INF/CERT.RSA");//.SF env->ReleaseStringUTFChars(path, ch); } } return JNI_VERSION_1_4; }
三、验证
在同一签名的情况下,做修改代码,增加或删除资源操作,依然可以通过签名校验;
同一Apk,更换签名后,不能通过签名校验。
四、结语
此签名校验的方式最好是放在主程序逻辑代码里,因为如果把校验独立开来,破解者直接去掉loadSo的代码即可破解,所以应当把签名校验放在主程序逻辑代码里,比如请求数据的加密算法等,这样不加载此So,将直接导致Apk无法正常运行。当然,还有这里的代码只是测试代码,线上代码应该做一些去掉一些敏感的字符串,以及通过算法来生成签名信息,反调试等安全措施。