一、加密算法介绍
- RSA、AES、DES 一种方式做解析,不传明文,只传密文
- MD5 一种方式做解析,即传递明文(参数),也传递密文(md5),后台先去校验比对成功返回数据,不成功返回错误
RSA(非对称加密)
- 密钥:一对公钥(public key)和私钥(private key)
- 常用于:登录认证、公钥加密敏感信息、服务器通信中传输对称密钥等
- 优点:公私钥分离,安全性高
- 缺点:运算速度慢,适合加密小数据量
AES(对称加密-高级加密标准)
- 多用于:对称加密敏感数据,如缓存、Token、本地数据库等
- 优点:加密速度快,安全性强
- 缺点:密钥管理复杂(必须保证密钥安全)
DES(对称加密-数据加密标准,已过时)
- 曾用于早期数据加密,但现在已被 AES 取代
- 历史久,曾广泛使用
- 缺点:安全性差,容易被暴力破解
MD5(消息摘要算法)
- 类型: 哈希算法(不可逆)
- 输出长度: 128 位(16 字节,通常表示为 32 个十六进制字符)
- 安卓中常用于:文件校验、快速对比数据是否一致、加密缓存 key 等
- 优点:速度快,实现简单
- 缺点 :存在碰撞漏洞(可构造两个不同输入产生相同 MD5 值)、不适合做密码或安全验证用途
尽管我们平时用到最多的加密就是 MD5,但其缺点我们也要正视,因为 MD5 使用的是哈希运算,所以不可避免的有概率会产生哈希碰撞,出现异常情况,虽然这个概率可能极低,而且考虑到不是特别敏感的情况下及考虑兼容性情况仍在使用,但其问题仍不可忽视!
那么有没有更完美的算法来代替 MD5 ?有的,拿安卓举例,安卓使用的签名方案之一是v2签名方案 (自Android 7.0 Nougat引入)和v3签名方案 (自Android 9 Pie开始支持),它们都支持使用SHA-256 和SHA-512等更强的哈希算法。
SHA256 代码示例:
java
public static String getSHA256Hash(String data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if(hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
SHA-256主要用来生成文件或整个APK内容的摘要,确保数据未被篡改。由于其高安全性和抗碰撞特性,SHA-256有效地增强了安卓应用的安全性。
但!没有 一中算法可以被认为是最完美的,没有最好只有更好,SHA-256 加密只是整个签名和校验流程的一部分,最终还要结合对称和非对称多种算法混合加密,进一步确保数据完整性。
二、实战
1. 网络请求参数使用MD5加密
不能直接拿原始数据进行MD5加密,不然没有任何意义,可以在so里对数据动点手脚,比如前面加上几个固定字符,后面去掉几个字符,然后生成MD5值,是不是就有点加密那种感觉了。
cpp
static char *EXTRA_SIGNATURE = "XAYE";
extern "C"
JNIEXPORT jstring JNICALL
Java_com_xaye_myjni_jni_SignatureUtils_signatureParams(JNIEnv *env, jclass clazz, jstring params) {
if (is_verify == 0) {
return env->NewStringUTF("error_signature");
}
const char *paramsStr = env->GetStringUTFChars(params, nullptr);
// MD5 签名规则,加点料
// 1.字符串前面加点料
string signature_str(paramsStr);
signature_str.insert(0, EXTRA_SIGNATURE);
// 2.后面去掉两位
signature_str = signature_str.substr(0, signature_str.length() - 2);
// 3.进行MD5签名, C++ 和 Java 是一样的,唯一不同的是C++需要自己回收内存
MD5_CTX *ctx = new MD5_CTX();
MD5Init(ctx);
MD5Update(ctx, (unsigned char *) signature_str.c_str(), signature_str.length());
unsigned char digest[16];
MD5Final(digest, ctx);
// 生产32位的字符串, 转换为十六进制字符串
char md5_str[33]; // 32字符 + '\0' ,存放终止符 \0
for (int i = 0; i < 16; i++) {
// 不足的情况下补0, f:0f ,ab:ab
// snprintf 是标准 C 库函数,用于安全地格式化字符串,相比 sprintf 多了一个缓冲区大小参数,防止缓冲区溢出。
// str:目标缓冲区地址
// size:缓冲区剩余可用大小,限制写入长度(3)
// format:格式化字符串
// ...:可变参数(要格式化的数据)
// 每次 snprintf 会写入终止符,但下一次循环会覆盖它。最终通过 md5_str[32] = '\0' 显式添加终止符,确保字符串完整。
snprintf(md5_str + i*2, 3, "%02x", digest[i]);
}
md5_str[32] = '\0';
// 释放资源
env->ReleaseStringUTFChars(params, paramsStr);
return env->NewStringUTF(md5_str);
}
这里使用的是使用C++生成MD5值,我们知道在Java中字符串生成MD5非常简单,几行代码搞定,但在C++中,需要一堆代码... 当然你也可以在C++中调用Java代码,去生成MD5值,这样就简单了些,但是如果你想写的so 也放在ios中可以直接使用,那么这种方式就不那么合适了。
下面就简单介绍下,在C++中如何使用MD5算法👇
使用 JNI 在 C++ 实现 MD5 加密算法
C++ 中实现 MD5 的完整流程,重点分为如下几步:
一、填充数据
MD5算法要求输入消息的长度必须是512位(64字节)的整数倍减去64位(因为最后64位要存储原始长度)
- 首先计算原始消息的位长度(原始字节数×8)。
- 在消息末尾先添加一个
0x80
字节(二进制10000000
),这相当于在消息末尾添加了一个"1"比特,后面跟着七个"0"比特。 - 继续填充
0x00
字节,直到消息长度满足(长度 % 64 == 56)
(即448位模512, 56 字节是 448 位 )。 - 最后8字节(64位)以小端序存储原始消息的位长度。
填充完后,信息的长度就为N*512+448(bit);
示例 :原始消息为 "abc",即 3 字节,添加 1 字节 0x80
,再加 52 个 0x00
,再加 8 字节长度,共 64 字节。
二、追加长度(Length)
如上, MD5 需要在填充后的消息末尾追加 8 个字节(64 位):
表示原始消息的长度(以 bit 为单位);
存储方式是小端序,即低位在前,高位在后。
这64位加在第一步结果的后面,这样信息长度就变为N*512+448+64=(N+1)*512位。 这一步的作用是确保哈希值对不同长度的数据唯一性更强。
三、初始化缓冲区(四个幻数)
MD5 使用四个 32 位的整数作为初始化向量(IV),也被称为标准幻数:
kotlin
context->state[0] = 0x67452301; // A
context->state[1] = 0xefcdab89; // B
context->state[2] = 0x98badcfe; // C
context->state[3] = 0x10325476; // D
这四个初始值会在每一轮运算中不断被更新,最终组合为结果。
四、分组处理、 四轮主循环计算
将填充后的消息按512位(64字节)分组,对每个分组进行如下处理:
- 将当前分组划分为16个32位子块(小端序)
kotlin
uint32_t M[16]; // 16个 32 位小端整数
32 位 = 4 个字节,16 x 4 = 64 字节
- 定义四个核心函数
kotlin
F(X,Y,Z) = (X & Y) | (~X & Z)
G(X,Y,Z) = (X & Z) | (Y & ~Z)
H(X,Y,Z) = X ^ Y ^ Z
I(X,Y,Z) = Y ^ (X | ~Z)
这些函数用于各轮次的非线性变换。
- 每轮 16 次操作(共 4 轮,64 次)
Round 1 - 使用 F 函数 + FF 宏:
kotlin
/* ROTATE_LEFT rotates x left n bits.
*/
#define ROTATE_LEFT(x, n) (((x) << (n)) | ((x) >> (32-(n))))
#define F(x,y,z) (((x) & (y)) | ((~x) & (z)))
#define FF(a, b, c, d, x, s, ac) { \
(a) += F((b),(c),(d)) + (x) + (UINT4)(ac); \
(a) = ROTATE_LEFT((a), (s)); \
(a) += (b); \
}
- 每轮使用不同函数(F/G/H/I),分别执行 16 步;
- 每步涉及:
- 输入块
x[k]
; - 常量
ac = T[i] = 2^32 * abs(sin(i))
; - 左移位数
s = Sij
;
- 输入块
- 例如:
kotlin
FF (a, b, c, d, x[0], S11, 0xd76aa478); // 第1步
FF (d, a, b, c, x[1], S12, 0xe8c7b756); // 第2步
Round 2 - 使用 G 函数 + GG 宏
Round 3 - 使用 H 函数 + HH 宏
Round 4 - 使用 I 函数 + II 宏
每轮操作都是在不断改变 a,b,c,d
的值,最终加回到 state[]
中。
五、输出摘要
kotlin
Encode(digest, context->state, 16);
- 将
state[0]~state[3]
转为 16 字节小端字节串; - 将 A、B、C、D 拼接起来,构成 128 位(16 字节)结果 。
- 最终的
digest[16]
就是 MD5 的结果,输出唯一的128位哈希值。
看不懂?我也看不懂,毕竟有的算法是别人究其一生才研究出来的,难度还是有的,我们只需要了解即可。
这种方法不是很保险,因为我们无法防止别人反编译我们的 apk,因为 ndk 本身原因无法对方法进行混淆,别人只要反编译你的apk 拿到so库并且匹配好包名和方法名照样在任何地方都可以调用,怎么办?
2. 增加包名及签名校验
为了防止别人拿到你的.so后直接拿去使用,我们可以在应用启动时先进行包名和签名校验,校验通过可以正常使用,不通过则无法使用!
在Java中拿到包名和签名信息很简单,如下:
java
PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
return signatures[0].toCharsString();
而在JNI中要写辣么多代码,其实C++调用Java代码,和Java中反射很像,类比着去看其实也没那么复杂。
cpp
//校验签名
static int is_verify = 0;
static char *PACKAGE_NAME = "com.xaye.myjni";
// debug 包的签名,打正式包 需要将 APP_SIGNATURE 修改为 正式 包的签名!
static char *APP_SIGNATURE = "308202e4308201cc020101300d06092a864886f70d010105050030373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b30090603550406130255533020170d3234313033303135343530385a180f32303534313032333135343530385a30373116301406035504030c0d416e64726f69642044656275673110300e060355040a0c07416e64726f6964310b300906035504061302555330820122300d06092a864886f70d01010105000382010f003082010a0282010100a5bfb2191c8fc174b11907f7c1fb2d7809a9543f43a17c2d84b0c4f0c7b71361c0a33f98c0a6e45d54d5eb4229a93dcfb78fd8e62057cbf67e84b09b33a06ef10ddd3576ec77c5207601f0d47a4fd889360dbb1bfb60d19adb35d78cc53c39e4134211f2d2262f9cbc5372c38fb9efe778ddace3642201bab0f2da2b0e4b5cee1c88039c5554f7281cda90f1780967e07cc623c339135fcc7f020b5cda9fbab2829b2863f7f94b694140fa744a05af5700946f81f5fa3a3a8cef125367640946d6c73ad957050eff33a63bc3fd82f1fbc8ba3087a0ad31043d266368897208f6dbe350a7aba822a0e2b0ff1f6e94ea94c187dcc8328980a4bcdae7e30301a60d0203010001300d06092a864886f70d010105050003820101001614fc5f47f814ae0814eff343441e13d56f31fb2ea553de94cf90d37e9da0fe3ed5870fe57afb2bbadec1368258955b84ddf39a445379544286ec74d9c3251ca9f5417eb0b9c750af9b32534f05816440a0bb5a9eded76c507032b39ebb628789e5bdcfd13363b7cdb94a4f4ed2869d8a3604c3617676e101161ffe3ea6aec934b01b093db45fc264a3a29de59753513d74d3d94395cbbc0328c36d5086f414a62ddff736634f0d173141d80a5417ed3a14fc9085b2db223280d354caf661b123ae569d1f284e0eff2cf6972f5bd8b2e53b81256ba55d83df9309fb0c1dc1a8bade54587dfcaaed535768d6cd64f7983f9666ed5502929a298d6b42b19be9cc";
extern "C"
JNIEXPORT jstring JNICALL
Java_com_xaye_myjni_jni_SignatureUtils_signatureVerify(JNIEnv *env, jclass clazz, jobject context) {
// C 调用 Java 代码,分为以下四步
// 1. 获取包名
jclass j_clz = env->GetObjectClass(context);
jmethodID j_method_id = env->GetMethodID(j_clz, "getPackageName", "()Ljava/lang/String;");
jstring j_package_name = (jstring)env->CallObjectMethod(context, j_method_id);
// 2. 比对包名是否一样
const char *c_package_name = env->GetStringUTFChars(j_package_name, nullptr);
if (strcmp(PACKAGE_NAME, c_package_name) != 0) {
return env->NewStringUTF("error_package_name");
}
LOGI("包名一致:%s", c_package_name);
// 3. 获取签名
// 3.1 获取 PackageManager , 先获取方法id ,用这个id 去对应的对象获取,都是按这个步骤。
j_method_id = env->GetMethodID(j_clz, "getPackageManager", "()Landroid/content/pm/PackageManager;");
jobject j_package_manager = env->CallObjectMethod(context, j_method_id);
// 3.2 获取 PackageInfo
j_clz = env->GetObjectClass(j_package_manager);
j_method_id = env->GetMethodID(j_clz, "getPackageInfo", "(Ljava/lang/String;I)Landroid/content/pm/PackageInfo;");
jobject j_package_info = env->CallObjectMethod(j_package_manager, j_method_id, j_package_name, 0x00000040);
// 3.3 Signature[] 数组
j_clz = env->GetObjectClass(j_package_info);
jfieldID j_field_id = env->GetFieldID(j_clz, "signatures", "[Landroid/content/pm/Signature;");
jobjectArray j_signatures = (jobjectArray)env->GetObjectField(j_package_info, j_field_id);
// 3.4 获取 signatures[0]
jobject j_signature_first = env->GetObjectArrayElement(j_signatures, 0);
// 3.5 获取 signatures[0].toCharsString()
j_clz = env->GetObjectClass(j_signature_first); // 获取 Signature 类,因为 signatures[0] 是 Signature 类
j_method_id = env->GetMethodID(j_clz, "toCharsString", "()Ljava/lang/String;");
jstring j_signature_str = (jstring)env->CallObjectMethod(j_signature_first, j_method_id);
// 4. 签名比对
const char *c_signature_str = env->GetStringUTFChars(j_signature_str, nullptr);
if (strcmp(c_signature_str, APP_SIGNATURE) != 0) {
return env->NewStringUTF("error_signature");
}
is_verify = 1;
LOGI("签名校验成功√");
return env->NewStringUTF("success");
}
那么,做到这一步是不是就真正的安全了,当然,只要你的签名文件没被泄露,还是能够达到90%的安全的,比如你的对手很厉害,能够使用 Xpose 调试,Native 线程轮询 tracep_id ... 那么你就得进一步进行加密了,哈哈。
我学习JNI的代码,和本文源码及C++经md5算法,已经上传GitHub:github.com/wuxaye/JNIS...