article

Protect Secrets in your app - NDK Cert Fingerprinting - Part 3

Going beyond

As the above shows, using Android NDK will strengthen the security and making it a lot more tedious to retrieve key and secret, but regardless it stays pretty much possible with some time at your hands.

There are two issues with this approach now:

  1. Include libprotected.so in equally named class and call getter
  2. Extract strings from *.so file and use values looking most likely like a key or secret.

Concat parts

The potentially easiest solution is to split the key and secret into multiple pieces and concat it together.

char str[20];
strcpy(str, "super-");
strcat(str, "secure-");
strcat(str, "secret");
return (*env)->NewStringUTF(env, str);

Looking into it using strings again.

$ strings libprotected.so
...
super-
secure-
secret

This is better, with a real secret it will already get harder to identify. But well the order was kept.

Let's try to mix this apart.

char str[20];
const char *Z = "secret";
const char *X = "super-";
const char *Y = "secure-";

strcpy(str, X);
strcat(str, Y);
strcat(str, Z);
return (*env)->NewStringUTF(env, str);

Let's check again:

$ strings libprotected.so
...
secret
super-
secure-

Amazing that worked. Which will make the (2) issue less likely of a problem, as it will raise the bar of combining the strings into their original form.

Remember that even these steps are not too hard to reverse engineer for an experienced reverse engineer. Using the right tools the .so is reversed into machine code, allowing an attacker to reverse the different operations we executed to split the key, and put it back into its order.

This will already require significant amount of work and an experienced attacker or attacker with enough time at their hand.

We've seen how to make the constants less vulnerable, but why go the complicated approach of extracting the string constants or reverse engineer if you can just include the native library within a sample project with the exact same JNI methods and just call the functions?

Going even further

As we've found in the above sections native code is the hardest to reverse engineer. One way to prevent attacks as highlighted above and only allow trusted parties to retrieve key and secret, we can make use of the fact that Android apps are signed using a key to the play store, which makes it significantly harder to tamper an app.

Please be careful if using app bundles (aab) as these will be signed with alternative certificate.

Play App Signing

This article will not cover the potential new API offering of signing certificate rotation.

First let's identify the android APIs we will need to retrieve the Signature of the app. This can be done via the PackageInfo.

This approach will work for all versions of app signing (v1-v4). Extracting the META-INF/CERT.RSA from the apk and retrieving the fingerprint through this file, will only work for app signing v1 which you should not use anymore.

// retrieve packageInfo with signatures flag
val packageInfo = packageManager.getPackageInfo(packageName, if (SDK_INT >= P) GET_SIGNING_CERTIFICATES else GET_SIGNATURES)
// warning: this ignore certificate rotation, uses the oldest
val signature = if (SDK_INT >= P) packageInfo.signingInfo.apkContentsSigners[0] else packageInfo.signatures[0]
// get the md5 hash of the certificate
MessageDigest.getInstance("MD5").also { md ->
    md.update(signature.toByteArray())
    Log.e("TEST", md.digest().joinToString("") { "%02x".format(it) })
}

Running this will log something like

E/TEST: 66c60bdae163ecdb2a8871c0b53fff00

We can verify this same certification hash by using the apksigner on the .apk

$ java -jar ${SDK_HOME}/build-tools/30.0.2/lib/apksigner.jar verify  --print-certs -v app-release.apk 
...
Signer #1 certificate MD5 digest: 66c60bdae163ecdb2a8871c0b53fff00
...

This signature will usually differ for debug / release build as different certificates are used to sign. Please also note that App signing by Google Play will re-sign the apk. Check the Google Play console for the according certificate fingerprint md5 hash.

Now we have to convert the above code to native code. As these APIs are only available through the Java Android Framework we will be required to call these system APIs through JNI.

const char *getSignature(JNIEnv *env, jobject context) {
    // Build.VERSION.SDK_INT
    jclass versionClass = (*env)->FindClass(env, "android/os/Build$VERSION");
    jfieldID sdkIntFieldID = (*env)->GetStaticFieldID(env, versionClass, "SDK_INT", "I");
    int sdkInt = (*env)->GetStaticIntField(env, versionClass, sdkIntFieldID);
    // Context
    jclass contextClass = (*env)->FindClass(env, "android/content/Context");
    // Context#getPackageManager()
    jmethodID pmMethod = (*env)->GetMethodID(env, contextClass, "getPackageManager", "()Landroid/content/pm/PackageManager;");
    jobject pm = (*env)->CallObjectMethod(env, context, pmMethod);
    jclass pmClass = (*env)->GetObjectClass(env, pm);
    // PackageManager#getPackageInfo()
    jmethodID piMethod = (*env)->GetMethodID(env, pmClass, "getPackageInfo", "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
    // Context#getPackageName()
    jmethodID pnMethod = (*env)->GetMethodID(env, contextClass, "getPackageName", "()Ljava/lang/String;");
    jstring packageName = (jstring) ((*env)->CallObjectMethod(env, context, pnMethod));
    int flags;
    if (sdkInt >= 28) {
        flags = 0x08000000; // PackageManager.GET_SIGNING_CERTIFICATES
    } else {
        flags = 0x00000040; // PackageManager.GET_SIGNATURES
    }
    jobject packageInfo = (*env)->CallObjectMethod(env, pm, piMethod, packageName, flags);
    jclass piClass = (*env)->GetObjectClass(env, packageInfo);
    // PackageInfo#signingInfo.apkContentsSigners | PackageInfo#signatures
    jobjectArray signatures;
    if (sdkInt >= 28) {
        // PackageInfo#signingInfo
        jfieldID signingInfoField = (*env)->GetFieldID(env, piClass, "signingInfo", "Landroid/content/pm/SigningInfo;");
        jobject signingInfoObject = (*env)->GetObjectField(env, packageInfo, signingInfoField);
        jclass signingInfoClass = (*env)->GetObjectClass(env, signingInfoObject);
        // SigningInfo#apkContentsSigners
        jmethodID signaturesMethod = (*env)->GetMethodID(env, signingInfoClass, "getApkContentsSigners", "()[Landroid/content/pm/Signature;");
        jobject signaturesObject = (*env)->CallObjectMethod(env, signingInfoObject, signaturesMethod);
        signatures = (jobjectArray) (signaturesObject);
    } else {
        // PackageInfo#signatures
        jfieldID signaturesField = (*env)->GetFieldID(env, piClass, "signatures", "[Landroid/content/pm/Signature;");
        jobject signaturesObject = (*env)->GetObjectField(env, packageInfo, signaturesField);
        if ((*env)->IsSameObject(env, signaturesObject, NULL)) {
            return ""; // in case signatures is null
        }
        signatures = (jobjectArray) (signaturesObject);
    }
    // Signature[0]
    jobject firstSignature = (*env)->GetObjectArrayElement(env, signatures, 0);
    jclass signatureClass = (*env)->GetObjectClass(env, firstSignature);
    // PackageInfo#signatures[0].toCharString()
    jmethodID signatureByteMethod = (*env)->GetMethodID(env, signatureClass, "toByteArray", "()[B");
    jobject signatureByteArray = (jobject) (*env)->CallObjectMethod(env, firstSignature, signatureByteMethod);
    // MessageDigest.getInstance("MD5")
    jclass mdClass = (*env)->FindClass(env, "java/security/MessageDigest");
    jmethodID mdMethod = (*env)->GetStaticMethodID(env, mdClass, "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;");
    jobject md5Object = (*env)->CallStaticObjectMethod(env, mdClass, mdMethod, (*env)->NewStringUTF(env, "MD5"));
    // MessageDigest#update
    jmethodID mdUpdateMethod = (*env)->GetMethodID(env, mdClass, "update", "([B)V");// The return value of this function is void, write V
    (*env)->CallVoidMethod(env, md5Object, mdUpdateMethod, signatureByteArray);
    // MessageDigest#digest
    jmethodID mdDigestMethod = (*env)->GetMethodID(env, mdClass, "digest", "()[B");
    jobject fingerPrintByteArray = (*env)->CallObjectMethod(env, md5Object, mdDigestMethod);
    // iterate over the bytes and convert to md5 array
    jsize byteArrayLength = (*env)->GetArrayLength(env, fingerPrintByteArray);
    jbyte *fingerPrintByteArrayElements = (*env)->GetByteArrayElements(env, fingerPrintByteArray, JNI_FALSE);
    char *charArray = (char *) fingerPrintByteArrayElements;
    char *md5 = (char *) calloc(2 * byteArrayLength + 1, sizeof(char));
    int k;
    for (k = 0; k < byteArrayLength; k++) {
        sprintf(&md5[2 * k], "%02X", charArray[k]); // Not sure if the cast is needed
    }
    return md5;
}

The c code sadly isn't as short and easy to follow as the kotlin code, but does the exact same thing.

Get the Signature from the app and then get its fingerprint as md5.

As the last part let's integrate this functionality into our code retrieving the secret. To retrieve the PackageManager a Context is required. That requires a modification of the JNI interface additionally.

JNIEXPORT jstring JNICALL
Java_com_mikepenz_credsshowcase_CustomApplication_getSdkSecret(JNIEnv *env, jobject thiz, jobject context) {
    // modify fingerprint to your certificates fingerprint here
    if (strcmp("66C60BDAE163ECDB2A8871C0B53FFF00", getSignature(env, context)) == 0) {
        // define the logic to retrieve or compute the secret 
        return (*env)->NewStringUTF(env, secret);
    } else {
        return (*env)->NewStringUTF(env, "not-verified");
    }
}

Doing so will now require the signature retrieved from the app to match with the one specified, making it significant amount of work to reproduce the same fake environment to simply retireve the secret.

But is this now secure? Well it depends...

Nothing shipped within an app is ever fully protected. If it is in the app it can be reverse engineered. You can do the best to make it harder and more time consuming to retrieve and extract secrets.

To answer it short.

No.

But, it introduced multiple barriers to make it even harder to retrieve key and secret. After all the main purpose of such actions is to increase the time a potential attacker has to spend to reverse engineer the code and extract the key and secret. The goal is to potentially make the effort as tedious so an attacker will not bother retrieving the secrets.
And absolutely make it extremely unlikely if not impossible for any automated bot to extract those from your app (Which is simple if stored as resource).

Sample code for this part

What's left

With all the above measures an attacker still has different (time consuming) options to reverse engineer the app and extract the secrets:

  • reverse engineer the bytecode to identify how the secret/key gets constructed from its pieces
  • brute force all combinations of parts which look like being related to the secret
  • reconstruct the whole java environment including all system classes used to retrieve the Signature and fake the digest method to retrieve a ByteArray representing the md5 we compare against.

There are more measures which can be done to make reverse engineering harder, brute forcing more unlikely like:

  • split secret into more pieces
  • encrypt secret with app signature hash as key
  • compute secret via a custom algorithm
  • combine all these approaches

Conclusion

Ultimately the whole topic is a cat and mouse game, just shifting the attack vectors and making different approaches easier again.

The harder it is to reverse engineer, the more likely an attack with a reconstructed environment becomes likely, and visa versa.

Feedback

Got thoughts, feedback, improvements, suggestions, or comments?

Let me know @mike_penz

Attribution

Link preview photo by charles Lebegue / Unsplash