NDK 初探:基于 C++ 实现参数哈希与签名校验

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 是否异常,一旦检测到被调试注入就强行退程序)。

当然,现在的我们只需了解即可。

相关推荐
程序员正茂2 小时前
Android studio中初步使用OpenCV库
android·opencv
紫_龙2 小时前
window 维护多版本Android studio
android·ide·android studio
杉氧3 小时前
KMP 自动化之路 (4):iOS 自动化构建与签名 —— 攻克最硬的骨头
android·架构·android jetpack
通玄8 小时前
Jetpack Compose 入门系列(十):Paging 3 分页加载
android
hunterandroid8 小时前
DataStore 工程化实践:迁移、并发更新与异常恢复
android·前端
程序员-珍10 小时前
报错下载android sdk失败
android·java
齊家治國平天下11 小时前
AAOS 电源管理深度解析:休眠/唤醒/功耗优化
android·车载系统·aaos·aosp·电源管理·休眠唤醒·carpowermanager
qq36219670511 小时前
安卓大版本升级后APK不兼容怎么办?2026跨版本更新指南
android
Xzaveir11 小时前
企业号码展示不可观测怎么办:状态机、拨测事件与异常回放
android·人工智能