Android so庫防客戶端破解的解決方案

JavaDog發表於2019-01-17

背景

隨著移動網際網路的發展,移動應用的安全問題越來越突顯,特別是涉及到錢相關的產品,前段一段時間,我們的Android客戶端產品被人破解了,修改了一些程式碼,重新打包簽名後,就可以免費獲得資源,對我們的收入造成了一些影響,移動產品安全性就不得不提重視,安全性這個話題很大,包括客戶端、服務端、資料儲存、協議等很多方面,這裡只是從客戶端的角度來討論一上如何保證客戶端產品的安全性,拋磚引玉,也希望大家多提意見和建議。

下面主要從以下幾個方面來展開討論:

C/S協議安全

在不使用https的前提下,要保證C/S協議的安全,一般都會進行引數的校驗,以及引數加密,客戶端和服務端會約定一個固定的字串作為key,對於客戶端來說,這個key應該放到哪裡?最早之前,我們是直接放到Java程式碼中,這樣可以說沒有什麼安全性,後來為了稍微更加安全一點,把這些key都統一放到so庫中實現,雖然也不能保證絕對安全,但起碼可以增加破解的難度。

你肯定要說,如果這樣做,就會有一個問題,如果別人把so拿出來,在直接呼叫這些native介面,也同樣可以獲得key,所以也同樣不安全,怎麼辦呢?

能不能讓 so 庫只能在我們自己的app執行,別人呼叫就是磚頭呢?

各位看官,接著往下看。

so庫校驗簽名

一般的情況下,都會在 Application.onCreate() 方法裡面檢查當前應用的簽名是否合法,如果不合法就直接退出,這種情況其實無法正在防止破解,因為破解的可以找到呼叫入口,把相應的程式碼刪除,所以這樣方法也就失效了,那有沒有更好的方案呢?

想到的一種思路就是,so庫本身就具體簽名校驗的機制,當so庫被載入時 (JNI_OnLoad()方法),如果簽名不合法,直接失敗,so庫根本載入不起來。

大概的思路如下程式碼所示:

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env;
    if (vm->GetEnv((void **) (&env), JNI_VERSION_1_6) != JNI_OK) {
        return -1;
    }

    LOGI("Library JNI_OnLoad begin =========");

  if (checkSignature(env) != JNI_TRUE) {
            LOGE("    The app signature is NOT correct, please check the apk signture. ");
            LOGI("Library JNI_OnLoad end ===========");
            return -1;
    } else {
            LOGI("    The app signature is correct.");
    }

    LOGI("Library JNI_OnLoad end ===========");

    return JNI_VERSION_1_6;
}
複製程式碼

說明:這裡有一個問題,需要注意,在開發過程中,我們不需要檢查簽名的合法性,只有在release版本才檢查,所以上述邏輯還再完善,需要新增上DEBUG和RELASE的判斷。

要怎麼判斷呢?我目前的思路是通過巨集來判斷,如果定義了巨集並且為JNI_TRUE的話,就認為是release版本。

以下是完整的實現:

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env;
    if (vm->GetEnv((void **) (&env), JNI_VERSION_1_6) != JNI_OK) {
        return -1;
    }

    LOGI("Library JNI_OnLoad begin =========");

    // RELEASE_MODE這個巨集是通過編譯指令碼設定的,如果是release模式,
    // 則RELEASE_MODE=1,否則為0或者未定義
#ifdef RELEASE_MODE
    if (RELEASE_MODE == 1) {
        // 檢查當前應用的簽名是否一致,如果不簽名不一致的話,則直接退出
        if (checkSignature(env) != JNI_TRUE) {
            LOGE("    The app signature is NOT correct, please check the apk signture. ");
            LOGI("Library JNI_OnLoad end ===========");
            return -1;
        } else {
            LOGI("    The app signature is correct.");
        }
    } else {
        // Do nothing
    }
#endif

    LOGI("Library JNI_OnLoad end ===========");

    return JNI_VERSION_1_6;
}
複製程式碼


RELEASE_MODE 在哪裡定義的?

那問題又來了? RELEASE_MODE 巨集要在哪裡定義?不能改程式碼吧?

很容易想到通過編譯中的buildTypes來控制,如果當前打release包,那麼就定義這個巨集。請看Android Stuido的build.gradle中的buildTypes

buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

            ndk {
                // release包定義RELEASE_MODE=1巨集,so庫中會使用
                cFlags "-DRELEASE_MODE=1"
            }
        }
        debug {
               // do nothing
        }
    }
複製程式碼

我們在buildTypes中新增了一個ndk塊,裡面定義了 RELEASE_MODE 巨集,這裡使用了 cFlags。

注意:-DRELEASE_MODE=1 中前面的 -D 不能省略。


checkSignature()如何實現?

最主要的就是解決如何得到當前app的context,我的做法是反射Java層的一個特定的方法,返回 getAppContext() 方法得到context。其中 setAppContext() 方法在 Application.onCreate()中呼叫。

public class NativeContext implements NoProGuard {
    /**
     * App context
     */
    private static Context sAppContext;

    /**
     * 得到 app context
     */
    public static Context getAppContext() {
        return sAppContext;
    }

    /**
     * Set the app context
     */
    static void setAppContext(Context appContext) {
        sAppContext = appContext;
    }
}
複製程式碼

Native這一層的實現如下:

/**
 * 檢查載入該so的應用的簽名,與預置的簽名是否一致
 */
static jboolean checkSignature(JNIEnv *env) {
    // 得到當前app的NativeContext類
    jclass classNativeContext = env->FindClass(CLASS_NAME_NATIVECONTEXT);
    // 得到getAppContext靜態方法
    jmethodID midGetAppContext = env->GetStaticMethodID(classNativeContext,
                                                        METHOD_NAME_GETAPPCONTEXT,
                                                        METHOD_SIGNATURE_GETAPPCONTEXT);
    // 呼叫getAppContext方法得到context物件
    jobject appContext = env->CallStaticObjectMethod(classNativeContext, midGetAppContext);

    if (appContext != NULL) {
        jboolean signatureValid = Java_com_xxxx_android_AppRuntime_checkSignature(env, NULL, appContext);
        if (signatureValid == JNI_TRUE) {
            LOGI("    checkSignature() return true");
        } else {
            LOGI("    checkSignature() return false");
        }
        return signatureValid;
    }

    return JNI_FALSE;
}
複製程式碼

這裡呼叫了 Java_com_xxxx_android_AppRuntime_checkSignature 方法,它的實現如下所示,核心的思路是將從 Context 裡面得到當前app的簽名MD5字串,然後再與預置的常量作比較,呼叫了 strcmp C函式。

extern "C" JNIEXPORT jboolean JNICALL
Java_com_xxxx_android_AppRuntime_checkSignature(
        JNIEnv *env, jclass clazz, jobject context) {

    jstring appSignature = loadSignature(env, context);
    jstring releaseSignature = env->NewStringUTF(APP_SIGNATURE);
    const char *charAppSignature = env->GetStringUTFChars(appSignature, NULL);
    const char *charReleaseSignature = env->GetStringUTFChars(releaseSignature, NULL);

    jboolean result = JNI_FALSE;
    if (charAppSignature != NULL && charReleaseSignature != NULL) {
        if (strcmp(charAppSignature, charReleaseSignature) == 0) {
            result = JNI_TRUE;
        }
    }

    env->ReleaseStringUTFChars(appSignature, charAppSignature);
    env->ReleaseStringUTFChars(releaseSignature, charReleaseSignature);

    return result;
}
複製程式碼

APP_SIGNATURE 是const char*的常量,是release版本的簽名字串。

完整的程式碼如下:

jstring ToMd5(JNIEnv *env, jbyteArray source) {
    // MessageDigest類
    jclass classMessageDigest = env->FindClass("java/security/MessageDigest");
    // MessageDigest.getInstance()靜態方法
    jmethodID midGetInstance = env->GetStaticMethodID(classMessageDigest, "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;");
    // MessageDigest object
    jobject objMessageDigest = env->CallStaticObjectMethod(classMessageDigest, midGetInstance, env->NewStringUTF("md5"));

    // update方法,這個函式的返回值是void,寫V
    jmethodID midUpdate = env->GetMethodID(classMessageDigest, "update", "([B)V");
    env->CallVoidMethod(objMessageDigest, midUpdate, source);

    // digest方法
    jmethodID midDigest = env->GetMethodID(classMessageDigest, "digest", "()[B");
    jbyteArray objArraySign = (jbyteArray) env->CallObjectMethod(objMessageDigest, midDigest);

    jsize intArrayLength = env->GetArrayLength(objArraySign);
    jbyte* byte_array_elements = env->GetByteArrayElements(objArraySign, NULL);
    size_t length = (size_t) intArrayLength * 2 + 1;
    char* char_result = (char*) malloc(length);
    memset(char_result, 0, length);

    // 將byte陣列轉換成16進位制字串,發現這裡不用強轉,jbyte和unsigned char應該位元組數是一樣的
    ByteToHexStr((const char*)byte_array_elements, char_result, intArrayLength);
    // 在末尾補\0
    *(char_result + intArrayLength * 2) = '\0';

    jstring stringResult = env->NewStringUTF(char_result);
    // release
    env->ReleaseByteArrayElements(objArraySign, byte_array_elements, JNI_ABORT);
    // 釋放指標使用free
    free(char_result);

    return stringResult;
}

jstring loadSignature(JNIEnv *env, jobject context) {
    // 獲得Context類
    jclass cls = env->GetObjectClass(context);
    // 得到getPackageManager方法的ID
    jmethodID mid = env->GetMethodID(cls, "getPackageManager", "()Landroid/content/pm/PackageManager;");

    // 獲得應用包的管理器
    jobject pm = env->CallObjectMethod(context, mid);

    // 得到getPackageName方法的ID
    mid = env->GetMethodID(cls, "getPackageName", "()Ljava/lang/String;");
    // 獲得當前應用包名
    jstring packageName = (jstring) env->CallObjectMethod(context, mid);

    // 獲得PackageManager類
    cls = env->GetObjectClass(pm);
    // 得到getPackageInfo方法的ID
    mid = env->GetMethodID(cls, "getPackageInfo", "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
    // 獲得應用包的資訊
    jobject packageInfo = env->CallObjectMethod(pm, mid, packageName, 0x40); //GET_SIGNATURES = 64;
    // 獲得PackageInfo 類
    cls = env->GetObjectClass(packageInfo);
    // 獲得簽名陣列屬性的ID
    jfieldID fid = env->GetFieldID(cls, "signatures", "[Landroid/content/pm/Signature;");
    // 得到簽名陣列
    jobjectArray signatures = (jobjectArray) env->GetObjectField(packageInfo, fid);
    // 得到簽名
    jobject signature = env->GetObjectArrayElement(signatures, 0);

    // 獲得Signature類
    cls = env->GetObjectClass(signature);
    // 得到toCharsString方法的ID
    mid = env->GetMethodID(cls, "toByteArray", "()[B");
    // 返回當前應用簽名資訊
    jbyteArray signatureByteArray = (jbyteArray) env->CallObjectMethod(signature, mid);

    return ToMd5(env, signatureByteArray);
}

void ByteToHexStr(const char *source, char *dest, int sourceLen) {
    short i;
    char highByte, lowByte;

    for (i = 0; i < sourceLen; i++) {
        highByte = source[i] >> 4;
        lowByte = source[i] & 0x0f;
        highByte += 0x30;

        if (highByte > 0x39) {
            dest[i * 2] = highByte + 0x07;
        } else {
            dest[i * 2] = highByte;
        }

        lowByte += 0x30;
        if (lowByte > 0x39) {
            dest[i * 2 + 1] = lowByte + 0x07;
        } else {
            dest[i * 2 + 1] = lowByte;
        }
    }
}
複製程式碼

以下就是在native層實現簽名檢驗的邏輯,下面接下來說一下如何編譯。

如何編譯,mk vs gradle

建立jni的過程,這裡就不多說,網上有很多相關的文章,簡單地說,就是 src/main/ 路徑下,建立jni資料夾,然後把native的程式碼放在這個目錄下,在根目錄下在,建立對應的Android.mk和Application.mk檔案,關於 Android.mkApplication.mk 的說明,請參考Android開發的官方文件:

如果是使用gradle構建的話,需要作一點配置,新增一個 ndk block,gradle裡面的配置會覆蓋 mk 中設定的。

defaultConfig {
        ...

        ndk {
            // so庫的名字
            moduleName 'libAppRuntime_V1_0'
            // 支援armeabi和armeabi-v7a
            abiFilters("armeabi", "armeabi-v7a")
            // 依賴的類庫
            ldLibs("log")
        }
    }
複製程式碼


  • moduleName:so庫的名字
  • abiFilters:so庫的平臺
  • ldLibs:依賴的類庫,這裡需要輸出Log到logcat中,所以依賴log這個Android提供的庫


Android Gradle外掛如何編譯出library module的debug包

為什麼需要debug模式的library呢?因為我是想主工程打debug模式的包,library也是走debug的配置,如果主工程是release配置模式,那麼library也是release的配置,這樣做的目的是為了定義 RELEASE_MODE=1 這樣的巨集,是為了控制在不同的版本的so庫執行不同的業務邏輯。

由於上述的功能邏輯是放到一個library module中的,那麼就面臨一個問題,library module如何打出debug包,對於library Android預設是打出release模式的,這一點可以從編譯出來的BuildConfog.DEBUG 恆為false可以看出。

那麼到底要如何才能打出debug的aar呢?為了實現這個功能,再真費了點勁。直接上結論!

參考文件:Gradle外掛不能編譯出library模組的DEBUG模式

文中也有人說到了不能打debug模式的包:

Well, Gradle Android plugin simply can't build the debug version of dependent library modules. This is a well-known, old issue and this is not resolved yet.
You can try to use some workarounds from the discussion I mentioned, specifically take a look at posts #35 and #38.

解決方案也大概如文中所說的,也進行了多次嘗試:

主工程依賴方式需要改:

通常是這樣引用libraray module

compile project(':AppLibrary')

需要改成這樣:

debugCompile project(path: ':AppLibrary', configuration: 'debug')
releaseCompile project(path: ':AppLibrary', configuration: 'release')

再看看library modlue的gradle配置:

  • 首先 buildTypes 增加 debugrelease 配置塊
  • android 塊裡面增加 publishNonDefault true

這樣改後,我們編譯後就可以發現生成的檔案中會有debug和release兩個資料夾了,如下圖所示:

library_module_build

JNI開發的一些tips

官方的tips請點選這裡:JNI Tips

在開發過程中,遇到了一些比較蛋疼的問題,給大家說說,避免踩坑。

1、生成 .h 標頭檔案

如果native方法中引用了android的類,例如Context之類的,需要顯示指定--classpath
參考連結:android - javah doesn't find my class

If you are on Linux or MAC-OS, use ":" to separate the directories for classpath rather than ";" character: Example:

javah -cp /Users/Android/android-sdk/platforms/android-xy/android.jar:. com.test.JniTest

2、JNI so庫未找到方法實現

如果實現是C++(後續是cpp),沒有標頭檔案(.h)的話,需要在介面實現處新增上 extern "C",簡單地說,C++的實現需要向前相容C的實現,關於 extern "C"的作用,這裡不多講,
可以參考:extern "C"用法解析

extern "C" JNIEXPORT jboolean JNICALL
Java_com_xxxx_android_AppRuntime_checkSignature(
複製程式碼


總結

1、上述的東西,可以再進一步封裝,獨立成為一個libaray module,提供一個sdk,輸出的就是aar,Java層面上就是一個NativeContext類,這個類的setAppContext()介面必須由業務方來呼叫。

2、上述提到的 so 最大的一個好處是自己具備識別簽名的能力,只能在我們自己的app裡面使用,別人是無法用的

Android so庫防客戶端破解的解決方案


相關文章