NDK 是个啥?为什么用它做哈希?
NDK(Native Development Kit,原生开发套件)其实就是一个工具集,允许我们在 Android 应用中直接使用 C 和 C++ 代码。
它的能力范围和核心优势:
- 极致的性能:比如游戏引擎、音视频处理(FFmpeg)、复杂的物理模拟等,C/C++ 的运行效率远超 Java。
- 更高的安全性 :Java 编译后的代码比较容易被反编译,而 C/C++ 编译出的
.so动态链接库(机器码),逆向破解难度要更大。
为什么要用 NDK 做哈希?
MD5 是一种哈希算法,可以将任意数据转换为固定长度的哈希值,常用于身份的快速匹配或数据完整性的校验。如果我们在 Java 层做接口参数的 MD5 签名处理,很容易就被别人反编译拿到签名逻辑和"盐值"。把这部分逻辑下沉到 C++ 层,就能增加接口被破解和伪造的难度。
接下来,我们来完成通过调用 Native 方法对接口参数哈希。
搭建 NDK 开发环境
首先打开 Tools-> SDK Manager,然后安装 SDK Tools 下的 NDK 和 CMake(C/C++外部构建工具)。
环境搭建好后,创建一个 Native C++ 模版项目即可,项目会自动生成 cpp 目录:

这里粘贴一下项目默认生成的代码,首先是 MainActivity,运行效果是界面会显示 "Hello from C++"。
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// 调用 Native 方法
binding.sampleText.text = stringFromJNI()
}
/**
* 声明一个外部 Native 方法,实现在底层的 demo 库中
*/
external fun stringFromJNI(): String
companion object {
// 在应用启动时加载 demo 底层库
init {
System.loadLibrary("demo")
}
}
}
还有存放原生 C/C++ 代码的 native-lib.cpp:
c++
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_example_ndk_demo_MainActivity_stringFromJNI(
JNIEnv* env, jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
最后是原生代码构建脚本 CMakeLists.txt,它指定了模块名是 demo:
python
cmake_minimum_required(VERSION 3.22.1)
# 模块名
project("demo")
# 声明一个共享库(SHARED),并指定源文件路径
add_library(${CMAKE_PROJECT_NAME} SHARED
native-lib.cpp)
# 链接 Android 系统的基础库和日志库
target_link_libraries(${CMAKE_PROJECT_NAME}
android
log)
通过 C++ 实现参数哈希
首先创建签名工具类单例对象 SignatureUtils:
kotlin
/**
* 签名工具类
*/
object SignatureUtils {
// 必须在初始化时加载对应的 so 库,否则调用方法时应用会直接崩溃
init {
System.loadLibrary("demo")
}
/**
* 对接口参数字符串生成数字签名
* @return 参数哈希值
*/
external fun signatureParams(params: String): String // 仿照默认生成的代码,来声明 Native 方法
}
接下来是 C++ 层的实现。我们先来了解一下 JNI(Java Native Interface),它是 Java 和 C/C++ 互相沟通的桥梁。在 C++ 里,我们没法直接使用 Java 中的 String,必须要通过 JNIEnv 这个工具来进行类型转换。
这里我直接把所有实现给出,目的是为了熟悉 NDK 开发流程:
c
#include <jni.h>
#include <string>
#include "md5.h"
#include <sstream>
#include <iomanip>
using namespace std;
// 额外的加盐数据,增加 MD5 被彩虹表暴力破解的难度
const string EXTRA_DATA = "Q3ZkYLfxmyn3F4m9jz9j6DJdNfr9sUw2";
extern "C"
JNIEXPORT jstring JNICALL
Java_com_example_ndk_demo_SignatureUtils_signatureParams(JNIEnv *env, jobject thiz, jstring raw_params) {
// 将 Java 的 jstring 转换成 C++ 的字符数组 (char*)
const char *raw_params_str = env->GetStringUTFChars(raw_params, nullptr);
// 拼接原始参数和"盐值"
string salted_params(raw_params_str);
salted_params.insert(0, EXTRA_DATA);
// 用完 Java 传过来的字符串后,必须通知 JVM 释放内存,防止内存泄漏
env->ReleaseStringUTFChars(raw_params, raw_params_str);
// --------- 下面是标准的 C/C++ MD5 哈希计算流程 ---------
MD5_CTX ctx;
MD5Init(&ctx);
MD5Update(&ctx, (const unsigned char *) salted_params.c_str(), salted_params.length());
unsigned char md5_bytes[16];
MD5Final(md5_bytes, &ctx);
// 将 16 字节的二进制结果格式化为 32 位的十六进制字符串
stringstream ss;
for (unsigned char md5_byte : md5_bytes) {
ss << hex << setw(2) << setfill('0') << (int) md5_byte;
}
string md5_hex = ss.str();
// -----------------------------------------------------
// 把 C++ 算好的字符串转换为 Java 的 String 并返回
return env->NewStringUTF(md5_hex.c_str());
}
(注:MD5 的具体算法代码有些长就不贴了,可以自行搜索 "MD5算法 C语言实现")
写完后,需要在 CMakeList.txt 中加上 md5.cpp 的相对路径,不然在链接阶段,链接器找不到方法对应的实现。
python
add_library(${CMAKE_PROJECT_NAME} SHARED
# List C/C++ source files with relative paths to this CMakeLists.txt.
native-lib.cpp md5.cpp)
现在运行,界面就会显示哈希后的结果,比如: "965f905eb3afa122d35e4b0b82165543"。
防止 so 库被盗用:添加签名校验
虽然现在 MD5 的算法逻辑和盐值不会轻易暴露,增加了破解难度,但无法防止别人直接反编译 Apk 获取 .so 文件,然后在他们自己的 App 里调用。
解决办法是进行包名和签名校验 。在 SignatureUtils 中定义一个校验方法:
kotlin
/**
* 签名校验
*/
external fun signatureVerify(context: Context) // 在 Application 初始化时调用
C++ 层获取应用签名的过程,就相当于在写 Java 的反射代码。具体实现如下:
c
static int is_verify = 0;
// 我们自己 App 的包名
const string PACKAGE_NAME = "com.example.ndk.demo";
// 我们自己 App 的签名哈希值
const string APP_SIGNATURE = "你的应用签名";
extern "C"
JNIEXPORT void JNICALL
Java_com_example_ndk_demo_SignatureUtils_signatureVerify(JNIEnv *env, jobject thiz, jobject context) {
// 1. 获取包名:相当于 Java 中的 context.getPackageName()
jclass j_clz = env->GetObjectClass(context);
jmethodID j_method_ID = env->GetMethodID(j_clz, "getPackageName", "()Ljava/lang/String;");
auto j_package_name = (jstring) env->CallObjectMethod(context, j_method_ID);
// 2. 比对包名:防止别人把 so 库拷走后,放在别的包名应用下运行
const char *c_package_name = env->GetStringUTFChars(j_package_name, nullptr);
if (strcmp(PACKAGE_NAME.c_str(), c_package_name) != 0) {
env->ReleaseStringUTFChars(j_package_name, c_package_name);
return; // 包名不一致,直接终止
}
env->ReleaseStringUTFChars(j_package_name, c_package_name);
// 3. 获取签名:相当于 context.getPackageManager().getPackageInfo(...).signatures[0]
jmethodID j_get_pm_id = env->GetMethodID(j_clz, "getPackageManager",
"()Landroid/content/pm/PackageManager;");
jobject j_pm = env->CallObjectMethod(context, j_get_pm_id);
jclass j_pm_clz = env->GetObjectClass(j_pm);
jmethodID j_get_pi_id = env->GetMethodID(j_pm_clz, "getPackageInfo",
"(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
jclass j_pm_class = env->FindClass("android/content/pm/PackageManager");
jfieldID j_get_signatures_id = env->GetStaticFieldID(j_pm_class, "GET_SIGNATURES", "I");
jint j_flags = env->GetStaticIntField(j_pm_class, j_get_signatures_id);
jobject j_pi = env->CallObjectMethod(j_pm, j_get_pi_id, j_package_name, j_flags);
jclass j_pi_clz = env->GetObjectClass(j_pi);
jfieldID j_signatures_id = env->GetFieldID(j_pi_clz, "signatures",
"[Landroid/content/pm/Signature;");
auto j_signatures = (jobjectArray) env->GetObjectField(j_pi, j_signatures_id);
jobject j_signature = env->GetObjectArrayElement(j_signatures, 0);
jclass j_signature_clz = env->GetObjectClass(j_signature);
jmethodID j_to_chars_id = env->GetMethodID(j_signature_clz, "toCharsString",
"()Ljava/lang/String;");
auto j_signature_str = (jstring) env->CallObjectMethod(j_signature, j_to_chars_id);
// 4. 比对签名:防止别人通过反编译二次打包
const char *c_signature_str = env->GetStringUTFChars(j_signature_str, nullptr);
if (strcmp(APP_SIGNATURE.c_str(), c_signature_str) != 0) {
env->ReleaseStringUTFChars(j_signature_str, c_signature_str);
return; // 签名不一致,终止
}
env->ReleaseStringUTFChars(j_signature_str, c_signature_str);
// 5. 签名验证成功,改变状态位
is_verify = 1;
}
最后,在前面的 signatureParams() 方法开头加上校验判断,这样就能避免一般人随意调用方法了:
c
if (is_verify == 0) {
return env->NewStringUTF("Signature verification not passed");
}
总结与进阶思考
现在,我们就完成了基础的参数哈希和签名校验逻辑,不过当前的方案还是有局限性:
-
因为 Native 方法名并不能像 Java 那样被混淆,所以反编译后方法名一览无余。并且,如果破解者使用了 IDA 等逆向分析工具,还是能看到我们写死在代码里的"盐值"。
-
即使我们加了
is_verify标志位,别人依然可以通过 Xposed 等工具对程序进行动态调试,强行把内存里的is_verify改成 1 从而绕过校验。面对这种情况,通常需要加入反调试机制(比如轮询检测 transport ID 是否异常,一旦检测到被调试注入就强行退程序)。
当然,现在的我们只需了解即可。