把判断写进机器码,再把指纹维护交给构建流程,让 patch 无利可图。前言签名校验落地后,很多团队会松一口气:重打包必然重签名,重签名必然改指纹,运行时比对一下就能发现。逻辑没错,问题出在校验逻辑放在哪里。只要判断还写在应用层,它就和业务代码一样可反编译、可改:让它永远返回通过,再重新打包,一气呵成。它拦得住「拿来就改」的人,拦不住「会改校验逻辑」的人。我在实际项目里被卡过一次之后,才把思路换成「把判断换到更难下手的地方放」:下沉原生层 → 补防 patch 手段 → 指纹维护自动化,三步走完才算收口。一、为什么必须往下沉1.1 两层的差距是量级的层次反编译产物patch 难度应用层(字节码)几乎等价源码的中间码改一行,重打包原生层(机器码)汇编级逆向改二进制、修复格式、处理重定位应用层被改相当于改文本,原生层被改相当于在二进制上做外科手术,门槛不在一个层级。代价是多一套包与证书解析代码、多一个构建环节,所以边界要划清:只沉校验逻辑,业务逻辑留在应用层。二、分工:应用层门禁,原生层校验2.1 一句话设计应用层只留一个「门禁」,原生层做真正的校验:原生层返回通过与否,应用层据此决定是否继续运行。objectNativeGuard{privatevalavailable=try{System.loadLibrary("secguard")true}catch(e:Throwable){false}// 原生校验:0 失败,1 通过externalfunverify(apkPath:String):Intfuncheck(context:Context):Boolean{// 动态库不可用时降级为应用层校验兜底,避免误杀if(!available)returntruevalpath=context.applicationInfo?.sourceDir?:returnfalsereturntry{verify(path)==1}catch(e:Throwable){false}}}降级策略必须写:动态库加载失败时交回应用层兜底,否则动态库一旦没打进包,所有正常用户都会被误杀。loadLibrary("secguard")里是不带前后缀的库名,实际找的是libsecguard.so:先搜java.library.path(应用安装目录的lib/<abi>/在其中),再回退系统库目录;库名写错、该 ABI 缺文件、依赖 so 缺失都会抛UnsatisfiedLinkError。2.2 绑定发生在什么时候:JNI_OnLoadexternal fun verify和 C 函数靠两种方式对上:按符号名自动绑定:原生层按「包路径 + 类名 + 方法名」拼出符号名找函数,对不上就崩,混淆规则里必须保留原生方法名;JNI_OnLoad手动注册:库加载时回调,用RegisterNatives显式绑定,不受符号名规则约束,还带来「库一加载就自检」的时机。// secguard.c 片段:库加载时注册原生方法#include<jni.h>staticjintverify(JNIEnv*env,jobject thiz,jstring apkPath);staticconstJNINativeMethod kMethods[]={{"verify","(Ljava/lang/String;)I",(void*)verify}};JNIEXPORT jintJNI_OnLoad(JavaVM*vm,void*reserved){JNIEnv*env=NULL;if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!=JNI_OK){returnJNI_ERR;// 取不到环境,加载失败}jclass clazz=(*env)->FindClass(env,"com/example/yourapp/security/NativeGuard");if(clazz==NULL)returnJNI_ERR;if((*env)->RegisterNatives(env,clazz,kMethods,1)!=JNI_OK){returnJNI_ERR;}returnJNI_VERSION_1_6;// 必须返回有效版本,否则加载失败}JNI_OnLoad的返回值就是接下来要用的 JNI 版本,返回非法值等于加载失败。它按字符串找类,所以混淆时要保住类名:-keep class com.example.yourapp.security.NativeGuard { *; };走符号名绑定则加-keepclasseswithmembernames class * { native <methods>; }。三、原生层怎么读到签名证书(核心难点)在应用层读签名只需调一个系统接口;原生层这条路走不通——回调应用层接口等于把逻辑交回可 hook 的地方,只能纯原生地解析包本身:定位签名文件 → 解压出签名数据 → 按 ASN.1 逐层剥离出 X.509 证书 → 算指纹与白名单比对。// 原生层读取签名证书的示意流程funreadSignatureFingerprint(apkPath:String):ByteArray?{// 1. 解析压缩包目录,定位签名文件valsignatureEntry=findSignatureEntry(apkPath)?:returnnull// 2. 解压签名文件,得到结构化签名数据valsignedData=readZipEntry(apkPath,signatureEntry)?:returnnull// 3. 从签名数据中提取签名者证书valcert=extractFirstCertificate(signedData)?:returnnull// 4. 计算证书指纹returnsha256(cert)}两个坑:压缩包解析不能「整个读进内存再找」——签名文件位置靠中央目录定位,被压缩过还要按对应算法还原;证书解析要按「标签 + 长度 + 内容」逐层剥离 ASN.1 才能定位 X.509 证书。四、让攻击者下不去手4.1 指纹怎么存明文写进动态库,用十六进制编辑器就能搜出来改掉;常见做法是「拆散 + 异或混淆」存储:// 原生层比对示意:常量以异或混淆存储,运行时还原后比对valKEY=0x5AvalHASH_LIST=arrayOf(byteArrayOf(0xE1,0x9A,0x2C/* ... 共 32 字节,混淆后的指纹 */),byteArrayOf(0x4F,0xB7,0x0D/* ... 第二套签名的指纹 */))funcheckSignature(actual:ByteArray):Boolean{for(expectedinHASH_LIST){vardiff=0for(iinactual.indices){diff=diffor(actual[i].toInt()xor(expected[i].toInt()xorKEY))}if(diff==0)returntrue}returnfalse}「逐字节累加差异再判断」而非「一发现不同就返回」,是为了避免时序攻击——比较耗时不能随匹配位置变化。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再加一层反调试、反注入检测(读进程状态里的跟踪者标识,或扫描内存映射里的注入特征):// 原生层反调试示意:读进程状态中的跟踪者标识funisDebugged():Boolean{valstatus=readFile("/proc/self/status")valline=status.lineSequence().firstOrNull{it.startsWith("TracerPid:")}returnline?.substringAfter(":").orEmpty().trim()!="0"}再叠自校验与多点分散校验,避免「改掉一个函数就全盘绕过」。4.4 互锁:dex 与 so 的相互校验关系只校验自己有个漏洞:入口函数被整体改写,校验代码根本没被执行。更稳的是让两侧互相牵着:dex 侧校验 so:从 APK 里读lib/<abi>/libsecguard.so的摘要,与构建期内置值比对;so 侧校验 dex:从 APK 里读classes*.dex的摘要,与编译进 so 的预期值比对。// 原生层自校验示意:从 APK 内读条目摘要,与构建期注入的预期值比对// sha256ApkEntry 复用第三节那套轻量解析intcheckEntryDigest(constchar*apkPath,constchar*entryName,constunsignedcharexpected[32]){unsignedcharactual[32];if(sha256ApkEntry(apkPath,entryName,actual)!=0){return0;// 条目缺失或读取失败,一律不通过}unsignedchardiff=0;for(inti=0;i<32;++i){diff|=(unsignedchar)(actual[i]^expected[i]);// 恒定时间比较}returndiff==0;}有个约束常被写错:两端预期值都必须是构建期锚定的常量,不能互相从对方现取,否则一端被改、另一端照着改后的值去比,两边一起通过。五、把指纹维护交给构建流程手动维护内置指纹有三处麻烦:每次换签名都要重算;多套签名时一个动态库只内置一个指纹、覆盖不全;手工填错一个字节就误杀正常用户。解法是交给构建流程:遍历签名配置,读出证书、算指纹、混淆后生成头文件供原生层引用。// 构建期自动注入示意:遍历签名配置,生成原生层引用的指纹白名单fungenerateSecurityConfig(signingConfigs:List<SigningConfig>){valfingerprints=signingConfigs.mapNotNull{cfg->runCatching{valcert=loadCertificate(cfg.keystore,cfg.alias)sha256(cert.encoded).map{(it.toInt()and0xFF)xor0x5A}}.getOrNull()}writeHeader(fingerprints)}换签名会自动带上新指纹,多套签名生成白名单数组、原生层「任一匹配即通过」,「填错」这一环也不存在。5.1 一个能直接抄的 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 配置任务是按依赖图执行的,「生成头文件」和「编译原生代码」默认没有先后关系,必须显式建立依赖,否则原生编译可能先跑、头文件还不存在,直接失败。// 让原生编译相关任务依赖配置生成任务,保证顺序tasks.configureEach{task->valname=task.nameif(name.startsWith("externalNativeBuild")||name.startsWith("buildCMake")||name.startsWith("configureCMake")){task.dependsOn("generateSecurityConfig")}}任务名随插件版本变化,所以要匹配多个前缀;并且要能优雅降级——依赖没挂上时宁可退回「空配置」(跳过校验),也不能让构建失败。注册任务本身只有几行: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 密钥库路径的解析陷阱多模块工程里很隐蔽的坑:签名配置常通过共享脚本被多模块引用,而脚本里的相对路径是相对「引用它的模块目录」解析的,不是相对脚本文件自身——同一个../../某密钥库在不同模块解析结果不同,不参与签名的库模块尤其容易拿到错路径却不报错。所以按文件名在项目根目录重新定位:// 按文件名在项目根目录重新定位密钥库,规避相对路径解析偏差valkeystore=rootProject.file(signingConfig.storeFile.name)valkeyStore=KeyStore.getInstance(KeyStore.getDefaultType()).apply{load(FileInputStream(keystore),signingConfig.storePassword.toCharArray())}valcert=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构建#应用加固
把判断写进机器码,再把指纹维护交给构建流程,让 patch 无利可图。前言签名校验落地后,很多团队会松一口气:重打包必然重签名,重签名必然改指纹,运行时比对一下就能发现。逻辑没错,问题出在校验逻辑放在哪里。只要判断还写在应用层,它就和业务代码一样可反编译、可改:让它永远返回通过,再重新打包,一气呵成。它拦得住「拿来就改」的人,拦不住「会改校验逻辑」的人。我在实际项目里被卡过一次之后,才把思路换成「把判断换到更难下手的地方放」:下沉原生层 → 补防 patch 手段 → 指纹维护自动化,三步走完才算收口。一、为什么必须往下沉1.1 两层的差距是量级的层次反编译产物patch 难度应用层(字节码)几乎等价源码的中间码改一行,重打包原生层(机器码)汇编级逆向改二进制、修复格式、处理重定位应用层被改相当于改文本,原生层被改相当于在二进制上做外科手术,门槛不在一个层级。代价是多一套包与证书解析代码、多一个构建环节,所以边界要划清:只沉校验逻辑,业务逻辑留在应用层。二、分工:应用层门禁,原生层校验2.1 一句话设计应用层只留一个「门禁」,原生层做真正的校验:原生层返回通过与否,应用层据此决定是否继续运行。objectNativeGuard{privatevalavailable=try{System.loadLibrary("secguard")true}catch(e:Throwable){false}// 原生校验:0 失败,1 通过externalfunverify(apkPath:String):Intfuncheck(context:Context):Boolean{// 动态库不可用时降级为应用层校验兜底,避免误杀if(!available)returntruevalpath=context.applicationInfo?.sourceDir?:returnfalsereturntry{verify(path)==1}catch(e:Throwable){false}}}降级策略必须写:动态库加载失败时交回应用层兜底,否则动态库一旦没打进包,所有正常用户都会被误杀。loadLibrary("secguard")里是不带前后缀的库名,实际找的是libsecguard.so:先搜java.library.path(应用安装目录的lib/<abi>/在其中),再回退系统库目录;库名写错、该 ABI 缺文件、依赖 so 缺失都会抛UnsatisfiedLinkError。2.2 绑定发生在什么时候:JNI_OnLoadexternal fun verify和 C 函数靠两种方式对上:按符号名自动绑定:原生层按「包路径 + 类名 + 方法名」拼出符号名找函数,对不上就崩,混淆规则里必须保留原生方法名;JNI_OnLoad手动注册:库加载时回调,用RegisterNatives显式绑定,不受符号名规则约束,还带来「库一加载就自检」的时机。// secguard.c 片段:库加载时注册原生方法#include<jni.h>staticjintverify(JNIEnv*env,jobject thiz,jstring apkPath);staticconstJNINativeMethod kMethods[]={{"verify","(Ljava/lang/String;)I",(void*)verify}};JNIEXPORT jintJNI_OnLoad(JavaVM*vm,void*reserved){JNIEnv*env=NULL;if((*vm)->GetEnv(vm,(void**)&env,JNI_VERSION_1_6)!=JNI_OK){returnJNI_ERR;// 取不到环境,加载失败}jclass clazz=(*env)->FindClass(env,"com/example/yourapp/security/NativeGuard");if(clazz==NULL)returnJNI_ERR;if((*env)->RegisterNatives(env,clazz,kMethods,1)!=JNI_OK){returnJNI_ERR;}returnJNI_VERSION_1_6;// 必须返回有效版本,否则加载失败}JNI_OnLoad的返回值就是接下来要用的 JNI 版本,返回非法值等于加载失败。它按字符串找类,所以混淆时要保住类名:-keep class com.example.yourapp.security.NativeGuard { *; };走符号名绑定则加-keepclasseswithmembernames class * { native <methods>; }。三、原生层怎么读到签名证书(核心难点)在应用层读签名只需调一个系统接口;原生层这条路走不通——回调应用层接口等于把逻辑交回可 hook 的地方,只能纯原生地解析包本身:定位签名文件 → 解压出签名数据 → 按 ASN.1 逐层剥离出 X.509 证书 → 算指纹与白名单比对。// 原生层读取签名证书的示意流程funreadSignatureFingerprint(apkPath:String):ByteArray?{// 1. 解析压缩包目录,定位签名文件valsignatureEntry=findSignatureEntry(apkPath)?:returnnull// 2. 解压签名文件,得到结构化签名数据valsignedData=readZipEntry(apkPath,signatureEntry)?:returnnull// 3. 从签名数据中提取签名者证书valcert=extractFirstCertificate(signedData)?:returnnull// 4. 计算证书指纹returnsha256(cert)}两个坑:压缩包解析不能「整个读进内存再找」——签名文件位置靠中央目录定位,被压缩过还要按对应算法还原;证书解析要按「标签 + 长度 + 内容」逐层剥离 ASN.1 才能定位 X.509 证书。四、让攻击者下不去手4.1 指纹怎么存明文写进动态库,用十六进制编辑器就能搜出来改掉;常见做法是「拆散 + 异或混淆」存储:// 原生层比对示意:常量以异或混淆存储,运行时还原后比对valKEY=0x5AvalHASH_LIST=arrayOf(byteArrayOf(0xE1,0x9A,0x2C/* ... 共 32 字节,混淆后的指纹 */),byteArrayOf(0x4F,0xB7,0x0D/* ... 第二套签名的指纹 */))funcheckSignature(actual:ByteArray):Boolean{for(expectedinHASH_LIST){vardiff=0for(iinactual.indices){diff=diffor(actual[i].toInt()xor(expected[i].toInt()xorKEY))}if(diff==0)returntrue}returnfalse}「逐字节累加差异再判断」而非「一发现不同就返回」,是为了避免时序攻击——比较耗时不能随匹配位置变化。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再加一层反调试、反注入检测(读进程状态里的跟踪者标识,或扫描内存映射里的注入特征):// 原生层反调试示意:读进程状态中的跟踪者标识funisDebugged():Boolean{valstatus=readFile("/proc/self/status")valline=status.lineSequence().firstOrNull{it.startsWith("TracerPid:")}returnline?.substringAfter(":").orEmpty().trim()!="0"}再叠自校验与多点分散校验,避免「改掉一个函数就全盘绕过」。4.4 互锁:dex 与 so 的相互校验关系只校验自己有个漏洞:入口函数被整体改写,校验代码根本没被执行。更稳的是让两侧互相牵着:dex 侧校验 so:从 APK 里读lib/<abi>/libsecguard.so的摘要,与构建期内置值比对;so 侧校验 dex:从 APK 里读classes*.dex的摘要,与编译进 so 的预期值比对。// 原生层自校验示意:从 APK 内读条目摘要,与构建期注入的预期值比对// sha256ApkEntry 复用第三节那套轻量解析intcheckEntryDigest(constchar*apkPath,constchar*entryName,constunsignedcharexpected[32]){unsignedcharactual[32];if(sha256ApkEntry(apkPath,entryName,actual)!=0){return0;// 条目缺失或读取失败,一律不通过}unsignedchardiff=0;for(inti=0;i<32;++i){diff|=(unsignedchar)(actual[i]^expected[i]);// 恒定时间比较}returndiff==0;}有个约束常被写错:两端预期值都必须是构建期锚定的常量,不能互相从对方现取,否则一端被改、另一端照着改后的值去比,两边一起通过。五、把指纹维护交给构建流程手动维护内置指纹有三处麻烦:每次换签名都要重算;多套签名时一个动态库只内置一个指纹、覆盖不全;手工填错一个字节就误杀正常用户。解法是交给构建流程:遍历签名配置,读出证书、算指纹、混淆后生成头文件供原生层引用。// 构建期自动注入示意:遍历签名配置,生成原生层引用的指纹白名单fungenerateSecurityConfig(signingConfigs:List<SigningConfig>){valfingerprints=signingConfigs.mapNotNull{cfg->runCatching{valcert=loadCertificate(cfg.keystore,cfg.alias)sha256(cert.encoded).map{(it.toInt()and0xFF)xor0x5A}}.getOrNull()}writeHeader(fingerprints)}换签名会自动带上新指纹,多套签名生成白名单数组、原生层「任一匹配即通过」,「填错」这一环也不存在。5.1 一个能直接抄的 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 配置任务是按依赖图执行的,「生成头文件」和「编译原生代码」默认没有先后关系,必须显式建立依赖,否则原生编译可能先跑、头文件还不存在,直接失败。// 让原生编译相关任务依赖配置生成任务,保证顺序tasks.configureEach{task->valname=task.nameif(name.startsWith("externalNativeBuild")||name.startsWith("buildCMake")||name.startsWith("configureCMake")){task.dependsOn("generateSecurityConfig")}}任务名随插件版本变化,所以要匹配多个前缀;并且要能优雅降级——依赖没挂上时宁可退回「空配置」(跳过校验),也不能让构建失败。注册任务本身只有几行: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 密钥库路径的解析陷阱多模块工程里很隐蔽的坑:签名配置常通过共享脚本被多模块引用,而脚本里的相对路径是相对「引用它的模块目录」解析的,不是相对脚本文件自身——同一个../../某密钥库在不同模块解析结果不同,不参与签名的库模块尤其容易拿到错路径却不报错。所以按文件名在项目根目录重新定位:// 按文件名在项目根目录重新定位密钥库,规避相对路径解析偏差valkeystore=rootProject.file(signingConfig.storeFile.name)valkeyStore=KeyStore.getInstance(KeyStore.getDefaultType()).apply{load(FileInputStream(keystore),signingConfig.storePassword.toCharArray())}valcert=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构建#应用加固