把判断写进机器码,再把指纹维护交给构建流程,让 patch 无利可图。
前言
签名校验落地后,很多团队会松一口气:重打包必然重签名,重签名必然改指纹,运行时比对一下就能发现。逻辑没错,问题出在校验逻辑放在哪里。
只要判断还写在应用层,它就和业务代码一样可反编译、可改:让它永远返回通过,再重新打包,一气呵成。它拦得住「拿来就改」的人,拦不住「会改校验逻辑」的人。
我在实际项目里被卡过一次之后,才把思路换成「把判断换到更难下手的地方放」:下沉原生层 → 补防 patch 手段 → 指纹维护自动化,三步走完才算收口。
一、为什么必须往下沉
1.1 两层的差距是量级的
| 层次 | 反编译产物 | patch 难度 |
|---|---|---|
| 应用层(字节码) | 几乎等价源码的中间码 | 改一行,重打包 |
| 原生层(机器码) | 汇编级逆向 | 改二进制、修复格式、处理重定位 |
应用层被改相当于改文本,原生层被改相当于在二进制上做外科手术,门槛不在一个层级 。代价是多一套包与证书解析代码、多一个构建环节,所以边界要划清:只沉校验逻辑,业务逻辑留在应用层。
二、分工:应用层门禁,原生层校验
2.1 一句话设计
应用层只留一个「门禁」,原生层做真正的校验:原生层返回通过与否,应用层据此决定是否继续运行。
kotlin
object NativeGuard {
private val available = try {
System.loadLibrary("secguard")
true
} catch (e: Throwable) {
false
}
// 原生校验:0 失败,1 通过
external fun verify(apkPath: String): Int
fun check(context: Context): Boolean {
// 动态库不可用时降级为应用层校验兜底,避免误杀
if (!available) return true
val path = context.applicationInfo?.sourceDir ?: return false
return try {
verify(path) == 1
} catch (e: Throwable) {
false
}
}
}
降级策略必须写 :动态库加载失败时交回应用层兜底,否则动态库一旦没打进包,所有正常用户都会被误杀。
loadLibrary("secguard") 里是不带前后缀的库名 ,实际找的是 libsecguard.so:先搜 java.library.path(应用安装目录的 lib/<abi>/ 在其中),再回退系统库目录;库名写错、该 ABI 缺文件、依赖 so 缺失都会抛 UnsatisfiedLinkError。
2.2 绑定发生在什么时候:JNI_OnLoad
external fun verify 和 C 函数靠两种方式对上:
- 按符号名自动绑定:原生层按「包路径 + 类名 + 方法名」拼出符号名找函数,对不上就崩,混淆规则里必须保留原生方法名;
JNI_OnLoad手动注册 :库加载时回调,用RegisterNatives显式绑定,不受符号名规则约束,还带来「库一加载就自检」的时机。
c
// secguard.c 片段:库加载时注册原生方法
#include <jni.h>
static jint verify(JNIEnv *env, jobject thiz, jstring apkPath);
static const JNINativeMethod kMethods[] = {
{"verify", "(Ljava/lang/String;)I", (void *) verify}
};
JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
JNIEnv *env = NULL;
if ((*vm)->GetEnv(vm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR; // 取不到环境,加载失败
}
jclass clazz = (*env)->FindClass(env, "com/example/yourapp/security/NativeGuard");
if (clazz == NULL) return JNI_ERR;
if ((*env)->RegisterNatives(env, clazz, kMethods, 1) != JNI_OK) {
return JNI_ERR;
}
return JNI_VERSION_1_6; // 必须返回有效版本,否则加载失败
}
JNI_OnLoad 的返回值就是接下来要用的 JNI 版本,返回非法值等于加载失败 。它按字符串找类,所以混淆时要保住类名:-keep class com.example.yourapp.security.NativeGuard { *; };走符号名绑定则加 -keepclasseswithmembernames class * { native <methods>; }。
三、原生层怎么读到签名证书(核心难点)
在应用层读签名只需调一个系统接口;原生层这条路走不通------回调应用层接口等于把逻辑交回可 hook 的地方,只能纯原生地解析包本身:定位签名文件 → 解压出签名数据 → 按 ASN.1 逐层剥离出 X.509 证书 → 算指纹与白名单比对。
kotlin
// 原生层读取签名证书的示意流程
fun readSignatureFingerprint(apkPath: String): ByteArray? {
// 1. 解析压缩包目录,定位签名文件
val signatureEntry = findSignatureEntry(apkPath) ?: return null
// 2. 解压签名文件,得到结构化签名数据
val signedData = readZipEntry(apkPath, signatureEntry) ?: return null
// 3. 从签名数据中提取签名者证书
val cert = extractFirstCertificate(signedData) ?: return null
// 4. 计算证书指纹
return sha256(cert)
}
两个坑:压缩包解析 不能「整个读进内存再找」------签名文件位置靠中央目录定位,被压缩过还要按对应算法还原;证书解析要按「标签 + 长度 + 内容」逐层剥离 ASN.1 才能定位 X.509 证书。
四、让攻击者下不去手
4.1 指纹怎么存
明文写进动态库,用十六进制编辑器就能搜出来改掉;常见做法是「拆散 + 异或混淆」存储:
kotlin
// 原生层比对示意:常量以异或混淆存储,运行时还原后比对
val KEY = 0x5A
val HASH_LIST = arrayOf(
byteArrayOf(0xE1, 0x9A, 0x2C /* ... 共 32 字节,混淆后的指纹 */),
byteArrayOf(0x4F, 0xB7, 0x0D /* ... 第二套签名的指纹 */)
)
fun checkSignature(actual: ByteArray): Boolean {
for (expected in HASH_LIST) {
var diff = 0
for (i in actual.indices) {
diff = diff or (actual[i].toInt() xor (expected[i].toInt() xor KEY))
}
if (diff == 0) return true
}
return false
}
「逐字节累加差异再判断」而非「一发现不同就返回」,是为了避免时序攻击------比较耗时不能随匹配位置变化。
4.2 编译期防 patch
编译期让动态库里「能定位到的函数尽可能少」:打开 -fvisibility=hidden、-ffunction-sections -fdata-sections、-Wl,--gc-sections、-Wl,--exclude-libs,ALL,用 -Wl,--version-script=export.map 把导出收成清单。
版本脚本会把 JNI_OnLoad 一起藏掉,必须在 export.map 里显式放行------{ global: JNI_OnLoad; local: *; }; 就够,这是「隐藏符号」与「绑定对得上」之间最容易踩的冲突。
4.3 运行时防 patch
再加一层反调试、反注入检测(读进程状态里的跟踪者标识,或扫描内存映射里的注入特征):
kotlin
// 原生层反调试示意:读进程状态中的跟踪者标识
fun isDebugged(): Boolean {
val status = readFile("/proc/self/status")
val line = status.lineSequence()
.firstOrNull { it.startsWith("TracerPid:") }
return line?.substringAfter(":").orEmpty().trim() != "0"
}
再叠自校验 与多点分散校验,避免「改掉一个函数就全盘绕过」。
4.4 互锁:dex 与 so 的相互校验关系
只校验自己有个漏洞:入口函数被整体改写,校验代码根本没被执行。更稳的是让两侧互相牵着:
- dex 侧校验 so :从 APK 里读
lib/<abi>/libsecguard.so的摘要,与构建期内置值比对; - so 侧校验 dex :从 APK 里读
classes*.dex的摘要,与编译进 so 的预期值比对。
c
// 原生层自校验示意:从 APK 内读条目摘要,与构建期注入的预期值比对
// sha256ApkEntry 复用第三节那套轻量解析
int checkEntryDigest(const char *apkPath, const char *entryName,
const unsigned char expected[32]) {
unsigned char actual[32];
if (sha256ApkEntry(apkPath, entryName, actual) != 0) {
return 0; // 条目缺失或读取失败,一律不通过
}
unsigned char diff = 0;
for (int i = 0; i < 32; ++i) {
diff |= (unsigned char) (actual[i] ^ expected[i]); // 恒定时间比较
}
return diff == 0;
}
有个约束常被写错:两端预期值都必须是构建期锚定的常量,不能互相从对方现取,否则一端被改、另一端照着改后的值去比,两边一起通过。
五、把指纹维护交给构建流程
手动维护内置指纹有三处麻烦:每次换签名都要重算;多套签名时一个动态库只内置一个指纹、覆盖不全;手工填错一个字节就误杀正常用户。解法是交给构建流程:遍历签名配置,读出证书、算指纹、混淆后生成头文件供原生层引用。
kotlin
// 构建期自动注入示意:遍历签名配置,生成原生层引用的指纹白名单
fun generateSecurityConfig(signingConfigs: List<SigningConfig>) {
val fingerprints = signingConfigs.mapNotNull { cfg ->
runCatching {
val cert = loadCertificate(cfg.keystore, cfg.alias)
sha256(cert.encoded).map { (it.toInt() and 0xFF) xor 0x5A }
}.getOrNull()
}
writeHeader(fingerprints)
}
换签名会自动带上新指纹,多套签名生成白名单数组、原生层「任一匹配即通过」,「填错」这一环也不存在。
5.1 一个能直接抄的 Gradle 任务
gradle
// app/build.gradle.kts
import java.security.KeyStore
import java.security.MessageDigest
abstract class GenerateSecurityConfig : DefaultTask() {
@get:InputFiles abstract val keystores: ConfigurableFileCollection
@get:Input abstract val credentials: Property<String> // ks.storePassword=..,ks.keyAlias=..
@get:OutputFile abstract val header: RegularFileProperty
@TaskAction fun generate() {
val creds = credentials.get().split(",")
.associate { it.substringBefore("=") to it.substringAfter("=") }
val list = mutableListOf<String>()
keystores.files.sortedBy { it.name }.forEach { ks ->
val pass = creds["${ks.name}.storePassword"] ?: return@forEach
val alias = creds["${ks.name}.keyAlias"] ?: return@forEach
val cert = runCatching {
KeyStore.getInstance(KeyStore.getDefaultType()).apply {
ks.inputStream().use { load(it, pass.toCharArray()) }
}.getCertificate(alias)
}.getOrNull() ?: return@forEach
val hash = MessageDigest.getInstance("SHA-256").digest(cert.encoded)
list += hash.joinToString(", ", " { ", " },") {
"0x%02X".format((it.toInt() and 0xFF) xor 0x5A)
}
}
header.get().asFile.apply { parentFile.mkdirs() }.writeText(
"#pragma once\nstatic const unsigned char kFingerprints[][32] = {\n" +
list.joinToString("\n") + "\n};\n")
}
}
@InputFiles / @Input / @OutputFile 让 Gradle 能比对快照:一致就跳过任务,密钥库或期望值一变就重跑;密钥库读不到时 return@forEach 跳过,不让构建挂掉。别用 outputs.upToDateWhen { false } 硬关增量。
5.2 任务依赖顺序与 ABI 配置
任务是按依赖图执行的,「生成头文件」和「编译原生代码」默认没有先后关系,必须显式建立依赖,否则原生编译可能先跑、头文件还不存在,直接失败。
kotlin
// 让原生编译相关任务依赖配置生成任务,保证顺序
tasks.configureEach { task ->
val name = task.name
if (name.startsWith("externalNativeBuild") ||
name.startsWith("buildCMake") ||
name.startsWith("configureCMake")
) {
task.dependsOn("generateSecurityConfig")
}
}
任务名随插件版本变化 ,所以要匹配多个前缀;并且要能优雅降级------依赖没挂上时宁可退回「空配置」(跳过校验),也不能让构建失败。注册任务本身只有几行:
gradle
tasks.register<GenerateSecurityConfig>("generateSecurityConfig") {
keystores.from(fileTree(rootProject.file("keystore")) { include("*.jks", "*.keystore") })
credentials.set(providers.gradleProperty("secguard.credentials"))
header.set(layout.buildDirectory.file("generated/secguard/secguard_fingerprints.h"))
}
再把生成头文件的目录通过 target_include_directories(secguard PRIVATE <生成目录>) 引进 CMake。另外:abiFilters 决定哪些 ABI 会产出并打进 APK,裁掉一个 ABI 却仍在代码里无脑 load 库,省了体积、直接崩 ;android:extractNativeLibs(新版插件对应 packaging { jniLibs { useLegacyPackaging } })决定 so 是否解压落盘------不落盘时 so 直接从 APK 内映射、磁盘上没有可单独替换的副本,默认值随插件版本与 minSdkVersion 而变(经验值),读 so 摘要要统一走 APK 内条目解析。
5.3 密钥库路径的解析陷阱
多模块工程里很隐蔽的坑:签名配置常通过共享脚本被多模块引用,而脚本里的相对路径是相对「引用它的模块目录」解析的,不是相对脚本文件自身 ------同一个 ../../某密钥库 在不同模块解析结果不同,不参与签名的库模块 尤其容易拿到错路径却不报错。所以按文件名在项目根目录重新定位:
kotlin
// 按文件名在项目根目录重新定位密钥库,规避相对路径解析偏差
val keystore = rootProject.file(signingConfig.storeFile.name)
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(FileInputStream(keystore), signingConfig.storePassword.toCharArray())
}
val cert = keyStore.getCertificate(signingConfig.keyAlias)
5.4 绕不开的循环依赖:dex 校验值
签名指纹能全自动,但内容校验值(dex 的摘要或 CRC)不行,因为存在一个循环依赖:
- dex 校验值是打包产物,要等 dex 合并完成后才确定;
- 这个值要编进动态库,作为原生校验的预期值;
- 而动态库本身又要打进同一个 APK,反过来影响打包产物的布局。
校验值的「因」和「果」纠缠在同一个产物上,单次构建内无法自举。更要命的是增量构建:原生编译任务一旦被判为「无需重跑」,产物里就会残留上一版本的 dex 摘要,校验静默失效------比报错更危险 。签名指纹来自密钥库、与产物无关,能干净地自动注入;内容校验值只有两条出路:两遍构建 (第二遍必须让原生任务真正重跑)或手动回填 。所以务实的选择是签名指纹全自动、内容校验值按需手动 ;第 02 篇已证明签名校验足够拦截二次打包,内容校验值只在私钥泄露时才有用。
六、三篇一条线:立靶 → 筑墙 → 收口
- 第 01 篇《Android 混淆挡不住二次打包:一条完整的攻击链与突破口》------立靶:摊开二次打包链路,指出混淆与映射文件都不是安全边界;
- 第 02 篇《Android 签名校验为什么能拦住二次打包:指纹与完整性校验》------筑墙:在这个突破口上立起第一道防线;
- 本篇------收口:把判断挪进机器码、补齐防 patch 手段、把指纹维护自动化。
结论
- 应用层校验可被改,原生层(机器码)才是抬高门槛的战场:应用层做门禁、原生层做判断。
- 降级策略不能省:动态库不可用时交回应用层兜底,避免误杀。
- 纯原生地读签名证书:压缩包 → 签名文件 → ASN.1 取 X.509 → 算指纹,不依赖第三方库。
- 绑定方式决定混淆规则 :
RegisterNatives保类名,符号名绑定保 native 方法名;JNI_OnLoad返回非法版本直接失败。 - 指纹异或混淆存储、比较恒定时间;编译期隐藏符号裁剪无用段、运行时反调试反注入,再叠自校验与分散校验。
- 互锁要「两条锚点 + 交叉检查」:两端预期值都来自构建期常量,绝不能互相现取。
- 指纹维护交给构建流程 :
@InputFiles/@OutputFile保住增量,显式dependsOn原生编译,密钥库按根目录定位并容错。 - 内容校验值受循环依赖制约:dex 摘要要等合并完成才能算、又要编进同一个包,按需手动即可。
如果你们也在做类似改造,欢迎在评论区说说踩到的坑------尤其是增量构建里「校验静默失效」这类不报错的问题。
关键词标签 :#Android #APK安全 #原生层校验 #防二次打包 #JNI #Gradle构建 #应用加固