Android 防二次打包第一道锁:签名校验与完整性校验

签名指纹能认出「这不是我发的包」,却认不出会改校验逻辑的人。

拿到一个 APK,反编译、改几行、重新打个包、重新签个名,装到手机上照样能跑------这件事在多数没做防护的应用上,成本低到只需要一个下午。更要命的是,改完之后它还能换上你的图标、顶着你的名字分发出去,用户装的根本分不清来源。

很多人把希望寄托在混淆上。混淆确实让反编译结果难读了一些,但它从头到尾没改过一件事:被改过的程序依然是一个合法可安装的包。名字换成 a、b、c,逻辑还是那段逻辑;改一行代码,重签名,系统照收不误。混淆抬高的是「看懂的成本」,不是「能不能装」。

真正能卡住攻击者的地方不在「难读」,而在「认人」。系统从安装到运行都要验签,而一次重打包必然导致签名发生改变。只要应用在启动时能确认「当前这个包的身份,是不是我自己」,二次打包就从暗处被拽到了明面上。这篇要做的,就是围绕这个身份锚点建起第一道锁,并且把这道锁到底能挡到哪一步说清楚------它不是万能墙,但它是投入产出比最高的一层。

一、把锚点钉死:为什么签名指纹能当身份标识

上一篇把二次打包的链路走了一遍,落点是一个绕不开的结论:重打包必然触发重签名。原包内容一旦被修改,原来的签名就失效了,攻击者必须用自己的密钥重新签一次名,系统才肯让它安装。

把这个结论反过来用,就得到了一条闭合的推理链:

  1. 有人改了函数,就一定改了 dex;
  2. dex 改了,原来对内容计算的签名就失效了;
  3. 签名失效,攻击者必须重新签名;
  4. 重签名用的是攻击者自己的密钥,签名证书随之改变;
  5. 证书一变,我们内置的「身份指纹」就对不上了。

这条链的关键在于「闭合」:只要攻击者动了包,就一定会走到最后一步。除非他能拿到你的私钥、用原密钥去签(那已经是另一个量级的攻击,不在本文讨论范围),否则签名指纹的改变无法避免。于是运行时只要问一句「当前签名指纹和我的官方指纹是否一致」,就能识别出被动过的包。

不过这里藏着一个必须先厘清的细节,很多人第一次写校验都会踩:我们要比对的是签名证书的指纹,不是整个签名文件、也不是签名值。 一个签名结构里同时躺着两类东西------一类是「证书」,也就是 X.509 里描述身份信息的那段,它是稳定的;另一类是「对内容计算的签名值」,它会随着被签内容的变化而变化。如果你拿后者、或者拿整个签名文件的哈希去比对,那么只要内容合法地发生一点变化(比如正常发了个新版),这个值就变了,你根本没法把它当成一个长期稳定的身份标识。证书是身份,签名值是按内容算的凭据,两者不能混用,这是后面所有实现的地基。

二、签名方案三代演进:v1、v2、v3 到底签了什么

要理解「为什么光有签名校验还不够、还得补一道完整性校验」,就必须先搞清楚 Android 的签名方案一共迭代了三代,而每一代「签的东西」范围完全不同。很多人只知道要「开 v2」,却说不出 v1 到底漏在哪------而那道漏洞,恰恰是完整性校验存在的理由。

v1,也就是 JAR 签名 ,是 Android 最早的签名方式,它沿用 Java 世界的归档签名套路。它的做法是「逐条目签名」:压缩包里每一个文件条目,都会在 META-INF/MANIFEST.MF 里登记一条它的内容摘要;再把这些摘要汇总成 CERT.SF;最后用私钥对 CERT.SF 签名,产出 .RSA 或 .DSA 文件。你可以粗略理解成一张「收货清单」:清单逐行列出了每个条目的指纹,签名保证的是「这张清单没被改」。

text 复制代码
META-INF/MANIFEST.MF   # 逐条目内容摘要,一张清单
META-INF/CERT.SF       # 对清单自身再算摘要
META-INF/CERT.RSA      # 用私钥对 CERT.SF 的签名,内含证书

问题正出在「逐条目」这三个字上。v1 保护的是清单里列出的那些条目的内容 ,但它不保护压缩包这个容器整体:压缩包的中央目录、条目的排列顺序、条目上附加的额外字段、以及整个归档的结构,全都不在签名的覆盖范围内。换句话说,v1 给每个抽屉里的东西贴了封条,却没给整个柜子拍照。于是就有了一个在归档格式与代码格式之间存在「双重解释空间」的经典问题------在部分低版本系统上,存在一种构造方式,能让「验签时系统读到的内容」和「运行时真正加载执行的内容」不是同一份。v1 只校验清单内条目,对这类归档层面的差异无能为力。这就是 v1 时代留下的缺口类型。

对照着看 v2。v2 是整包签名 :它不再逐个条目算摘要,而是对整个 APK 的字节流 签名。签名块被插在压缩包中央目录之前、条目数据之后(即 APK Signing Block),覆盖了包内所有条目的内容、条目的元数据以及中央目录结构。任何字节被改动,验证都会直接失败。它带来两个直接好处:一是「整个归档都在保护范围内」,上面那类双重解释的攻击面被堵死;二是验证时不必逐条目解压比对,速度更快。v1 是清单式保护,v2 是整体式保护,这是量级上的差别。

v3 在 v2 的基础上又加了一样东西:签名谱系。 它的签名块结构和 v2 一致、覆盖范围也和 v2 一样是整包,区别在于它允许「密钥轮换」------当你需要更换签名密钥时,新密钥可以携带一条由旧密钥背书的继承谱系,证明「新身份是旧身份的合法延续」。这样系统既能接受新签名的包,又能沿着谱系回溯确认真实来源。它从 Android 9(API 28)开始完整支持。三代的关系可以用一张覆盖范围图看明白。

flowchart TD A["APK 压缩包"] --> B["META-INF 签名文件"] A --> C["classes.dex 代码"] A --> D["resources.arsc 与资源"] A --> E["lib 与 assets"] B --> V1["v1 逐条目签名 只绑清单内条目"] C --> V1X["v1 不覆盖归档整体 留下缺口"] A --> V2["v2 整包签名 覆盖压缩包全部字节"] V2 --> V3["v3 整包签名 另加密钥轮换谱系"]

把三代的差异拉成一张表,覆盖范围的区别一眼可见:

方案 引入时机 签名对象 覆盖范围 是否覆盖 dex 整体 密钥轮换
v1(JAR 签名) 最早 压缩包内每个条目 仅清单里列出的条目内容 否 不支持
v2(整包签名) Android 7.0(API 24) 整个 APK 字节流 全部条目内容与中央目录结构 是 不支持
v3(整包签名 + 谱系) Android 9(API 28) 整个 APK 字节流 同 v2 是 支持

还有一条工程事实要记住:从 Android 11 起,targetSdkVersion 达到 30 及以上的应用,必须使用 v2 或更高的签名方案 ,只带 v1 的包在安装时会被直接拒绝。这意味着新应用基本都天然获得整包保护,v1 的缺口主要残留在一些仍在低版本系统上运行、或签名配置陈旧的包上。但恰恰是这些包最容易被忽略,所以才需要一层兜底。到这里可以得出结论:v2/v3 覆盖了 dex 整体,签名校验才足够硬;而 v1 覆盖不到,才需要完整性校验补位。 顺序不能反------先把整包签名打开,再谈补充手段。

三、读签名的 API 兼容:旧接口、新接口与多签名者

从系统里把「当前签名证书」读出来,看起来是一行调用的事,实际上有两个坑:接口随版本变化,以及一个包里可能不止一个签名者。

旧的接口是 PackageManager.GET_SIGNATURES,它返回一个 Signature[] 数组。它从 API 28 起被标记废弃,原因是它表达能力太弱:它表达不了签名谱系,也表达不清「多签名者」到底意味着什么,只给一个数组,让调用方自己猜该取哪个。

新的接口是 PackageManager.GET_SIGNING_CERTIFICATES,它返回一个 SigningInfo,能同时表达两种语义:apkContentsSigners 是「实际对包内容签名的签名者集合」;signingCertificateHistory 是「历史证书链」,用于密钥轮换的场景。取哪个,取决于包处于哪种状态。正确的分支写法大致如下:

kotlin 复制代码
@Suppress("DEPRECATION")
fun readSigners(context: Context): Array<ByteArray> {
    val pm = context.packageManager
    val pkg = context.packageName
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
        val si = info.signingInfo
        when {
            si == null -> emptyArray()
            // 多签名者:多渠道重签名等场景会同时存在多个签名者
            si.hasMultipleSigners() -> si.apkContentsSigners.map { it.toByteArray() }.toTypedArray()
            // 单签名者:可能正处于密钥轮换期,历史链条一并纳入判定
            else -> si.signingCertificateHistory.map { it.toByteArray() }.toTypedArray()
        }
    } else {
        val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES)
        info.signatures.map { it.toByteArray() }.toTypedArray()
    }
}

旧版本的回退分支单独拎出来,其语义就是一次朴素取值,没有「多签名者」的概念:

kotlin 复制代码
// API 28 以下只能走旧接口,注意它无法区分「内容签名者」与「历史证书」
val legacyInfo = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES)
val firstOnly = legacyInfo.signatures.firstOrNull()?.toByteArray()

这里有两个必须点出来的问题。

第一,「取哪个签名者」不是随意挑一个就行 。如果你只取 apkContentsSigners.firstOrNull(),在存在多渠道重签名的包里就可能取错那个真正的签名者;而如果你的应用做过密钥轮换、改朝换代过签名,那么老用户机器上装的仍是旧证书签的包,此时历史链里既有旧证书也有新证书,你的白名单必须能同时接纳链条上的身份。判定逻辑应当从「取第一个」升级为「在白名单里做集合匹配」------只要当前实际签名者或历史链中任一身份命中内置白名单,就算通过。

第二,回退分支决定了你要准备两套判定 。低版本系统拿不到 SigningInfo,只有裸的 Signature[],因此校验逻辑要能同时吃下「新接口的结构化结果」和「旧接口的数组」,否则一旦在某类机型上走了回退分支而判定没写全,就会出现莫名其妙的放行或误杀。这类兼容问题在只测主流新机型时几乎发现不了。

四、比对的为什么是证书指纹,而不是签名文件

第一节埋下的那个细节,值得单独用一节讲透,因为它是最容易被写错、又最难排查的一处。

一个 .RSA 签名文件(或 v2/v3 的签名块)里,至少有两类字节:一类是证书 ,一段 X.509 结构的二进制,记录着公钥、颁发者、有效期这些身份信息;另一类是签名值,是「拿私钥对内容摘要加密」得到的凭据。前者是身份,相对稳定;后者是凭据,随内容变化。你的官方指纹如果在开发机上算的是「整个签名文件的哈希」,那么只要内容合法地变一次,值就变了,白名单立刻失效------你会发现自己每次发版都要重刷指纹,还搞不清为什么。

正确做法是:只对证书本身计算哈希。 证书只要私钥不变就不会变,因此它是天然稳定的身份锚点;而内容怎么变,都不影响证书指纹。这个区分不搞清楚,后面的自动化、多签名白名单全都建在沙子上。

再来看指纹算法和格式这块,也有一段演进史。早期常见两种做法,现在都不推荐:

kotlin 复制代码
// 不建议:toCharsString 返回的是内部编码,不是标准指纹,跨系统/跨工具对不上
val legacy = signature.toCharsString()

// 不建议:MD5 已不适合新的安全用途,且各家对「怎么格式化成字符串」理解不一
val md5 = MessageDigest.getInstance("MD5").digest(signature.toByteArray())

// 建议:对证书的 DER 字节求 SHA-256,统一输出十六进制小写
fun fingerprint(cert: ByteArray): String =
    MessageDigest.getInstance("SHA-256")
        .digest(cert)
        .joinToString("") { "%02x".format(it) }

toCharsString() 拿到的是平台内部的字符串编码,带着长度前缀之类的私有信息,它既不是标准指纹,也无法和构建脚本、后端、第三方工具产出的值对齐;MD5 则因为碰撞问题,早已退出安全用途,而且「要不要加冒号、大小写怎么定、字节序怎么排」这些格式细节,往往是线上误判的真正源头。统一到 SHA-256 + 十六进制小写,是让「构建期回填的指纹」和「运行时算出的指纹」能对上号的前提。

最后一条容易被忽略的实践:官方指纹的来源应该是 keystore 里的证书,而不是从一个已经签好名的 APK 里读出来的值。 前者是「源头」,与打包产物无关;后者是「结果」,你等于拿自己证明自己,一旦读取逻辑或当前包被动过,你根本发现不了。这也为第 4 篇的「构建期自动注入」埋下伏笔------指纹从密钥库来,才能干净地自动化。

五、启动时校验的完整时序

指纹怎么算清楚了,接下来是「什么时候校验」。位置选得不对,要么留出绕过窗口,要么拖垮冷启动。

理想的位置是越早越好 。Application.onCreate() 已经算早了,但它之前还有一个更早的入口:一个自定义的 ContentProvider,它的 onCreate() 会在 Application.onCreate() 之前被系统调用。把校验的触发点挂在这里,可以在业务代码跑起来之前就完成判定。但要克制------这个阶段尽量只做「触发与判定」,把耗时的上报、网络请求丢到异步里,别在启动首帧里做重活。

kotlin 复制代码
// 用 ContentProvider 抢一个比 Application 更早的初始化时机
class SecurityCheck : ContentProvider() {
    override fun onCreate(): Boolean {
        val ctx = context ?: return true
        SecurityGate.initAsync(ctx)   // 只触发校验,重活丢异步,不阻塞启动
        return true
    }
    override fun query(/* ... */): Cursor? = null
    override fun insert(/* ... */): Uri? = null
    override fun update(/* ... */): Int = 0
    override fun delete(/* ... */): Int = 0
    override fun getType(/* ... */): String? = null
}

真正做判定的门面,把签名校验与完整性校验的结果汇总,交给统一的失败策略:

kotlin 复制代码
object SecurityGate {
    fun initAsync(context: Context) {
        // 判定与处置分离:这里只产出「可信 / 完整」两个信号
        val trusted = SignatureGuard.isTrusted(context)
        val intact = DexIntegrityGuard.check(context)
        if (!trusted || !intact) {
            FailureHandler.onTampered(context, trusted, intact)
        }
    }
}

用一张时序图把整个过程串起来,各角色各司其职:

sequenceDiagram participant App as &#34;应用启动&#34; participant Gate as &#34;SecurityGate&#34; participant PM as &#34;PackageManager&#34; participant Guard as &#34;SignatureGuard&#34; App->>Gate: &#34;启动时触发校验&#34; Gate->>Guard: &#34;请求签名指纹&#34; Guard->>PM: &#34;读取签名信息&#34; PM-->>Guard: &#34;返回签名证书&#34; Guard->>Guard: &#34;计算 SHA-256 指纹&#34; Guard-->>Gate: &#34;返回比对结果&#34; Gate->>Gate: &#34;结合完整性校验判定&#34; Gate-->>App: &#34;放行或交给失败策略&#34;

下面是签名校验主体的实现,也是这一层的核心:读到当前证书、算出指纹、和内置值比对。

kotlin 复制代码
object SignatureGuard {

    // 官方签名指纹(构建期回填,十六进制小写)
    private const val OFFICIAL_FINGERPRINT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"

    fun isTrusted(context: Context): Boolean {
        val current = readFingerprint(context)
        // 读不到时不能当作通过,交由失败策略统一处置
        if (current.isEmpty()) return false
        return OFFICIAL_FINGERPRINT.equals(current, ignoreCase = true)
    }

    private fun readFingerprint(context: Context): String {
        val pm = context.packageManager
        val info = pm.getPackageInfo(
            context.packageName,
            PackageManager.GET_SIGNING_CERTIFICATES
        )
        val cert = info.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray()
        return cert?.let { sha256Hex(it) }.orEmpty()
    }
}

六、补充锁:dex 完整性校验(CRC 从哪来)

签名校验看起来已经是主力了,为什么还要补一道完整性校验?答案就在第二节的结论里:v1 覆盖不到归档整体,在残留的低版本场景下,仅靠签名校验存在盲区。 给 dex 加一道校验和(CRC)比对,就是覆盖这个盲区的手段。

flowchart LR A[&#34;v1 只保护清单内条目&#34;] --> B[&#34;归档整体缺乏密码学保护&#34;] B --> C[&#34;低版本系统上存在双重解释空间&#34;] C --> D[&#34;签名校验出现盲区&#34;] D --> E[&#34;用 dex 的 CRC 校验补充&#34;]

先把最关键的一个问题讲清楚:这个 CRC 值从哪来? 答案是不用自己算------APK 本质是一个 zip 压缩包,zip 的每个条目在中央目录里都带着一个 CRC32 字段,这是压缩规范的一部分,打包工具在写入时就已经算好并存进了中央目录。我们只需要解析中央目录、把每个 dex 条目对应的 CRC 读出来即可,既不必解压也不必重新计算。

kotlin 复制代码
private fun readDexCrcs(context: Context): Map<String, Long> {
    val apk = context.applicationInfo.sourceDir ?: return emptyMap()
    val result = mutableMapOf<String, Long>()
    // APK 就是 zip,用 ZipFile 遍历中央目录,CRC 字段直接可得
    ZipFile(apk).use { zip ->
        val entries = zip.entries()
        while (entries.hasMoreElements()) {
            val entry = entries.nextElement()
            if (entry.name.endsWith(".dex")) {
                result[entry.name] = entry.crc   // 中央目录自带的 CRC32,无需解压
            }
        }
    }
    return result
}

校验的范围就是包里所有 dex 条目,classes.dex、classes2.dex 直到 classesN.dex,逐个和内置白名单比对:

kotlin 复制代码
object DexIntegrityGuard {

    // 各 dex 的校验值白名单,key 为 dex 文件名,value 为 CRC 值
    private val expected: Map<String, Long> = mapOf(
        "classes.dex" to 1234567890L
    )

    fun check(context: Context): Boolean {
        if (expected.isEmpty()) return true
        val current = readDexCrcs(context)
        return expected.all { (name, crc) -> current[name] == crc }
    }
}

但必须把它的定位说白:签名校验才是主力,dex 校验值只是补充,而不是并列的第二道主力。 三个理由。其一,它只有 32 位,抗碰撞能力弱,它不是密码学哈希 ,攻击者如果专门针对它,碰撞成本并不高,所以它挡不住有备而来的人。其二,在 v2/v3 整包签名已经开启的包上,dex 的任何改动都会被整包签名直接覆盖发现,CRC 的意义进一步被压缩------它真正有额外价值的场景,是私钥泄露、或系统只认 v1 的低版本环境。其三,它有一个绕不开的工程代价:每次打包,dex 都会变,CRC 就会变,白名单必须重新回填。 这个「因」与「果」纠缠在同一个打包产物上的循环依赖,让它很难像签名指纹那样干净地自动化,这一点第 4 篇会专门展开。结论是务实的一句话:签名指纹全自动,dex 校验值按需手动。

七、失败了怎么办:静默上报 + 延迟退出

校验跑起来之后,真正难的不是「判定是否通过」,而是**「判定不通过时该做什么」**。这一节里的每个决定,都直接关系到会不会误伤正常用户。

核心原则有两条,看起来矛盾,其实必须同时满足:读不到指纹不能当作通过(安全上要 fail-closed),但也别立刻崩溃(可用性上不能 fail-loud)。 如果你 fail-open------读不到就当通过,那攻击者只要想办法让你的读取失败,防线就整个作废。如果你 fail-loud------一发现不对就闪退,那第一次上线的误判就会让所有正常用户打不开 App,舆情比被破解还严重。

误杀的来源比想象中多。渠道重签名 会让一个包里出现多个签名者;灰度发布 期间新签名的包正在放量、而白名单还没更新;不同 ROM 的 PackageManager 实现差异可能导致某类机型上读取异常。任何一个环节没兜住,都会把「正常用户」判成「被篡改」。

所以处理思路要拆成四步。第一,判定与处置分离 ------判定只负责产出「是否可信、是否完整、是否读到了」这几个信号,怎么处理交给一个统一的策略模块,不要让判定逻辑里到处散落 finish()。第二,静默上报 ------把异常先上报到自有通道,带上包名、版本、指纹前后缀这类脱敏后的现场信息,先拿到真实分布再动手,别拍脑袋处置。第三,延迟退出 ------不立刻杀进程,让应用先进入受限模式或延迟到下一次冷启动再退出,留出取证和灰度观察的窗口。第四,分级处置------单信号可疑和多重信号一致,处置力度必须不同。

场景 判定信号 处置等级 动作
指纹不匹配且 CRC 也不符 多信号一致 重度 静默上报后延迟退出
仅指纹不匹配 单信号 中 上报并限制核心功能
仅 CRC 不符 单信号且弱信号 轻 上报并观察
读不到指纹或签名信息 异常 中 上报并限制,不直接放行

把这张策略表翻译成代码,就是「判定已由上游完成,这里只做分级处置」:

kotlin 复制代码
object FailureHandler {

    fun onTampered(context: Context, trusted: Boolean, intact: Boolean) {
        // 第一步:先静默上报,保留现场,不打扰用户
        SecurityReporter.report(
            context,
            trusted = trusted,
            intact = intact,
            fingerprint = SignatureGuard.currentPrefix(context) // 仅上报前后缀,注意脱敏
        )
        // 第二步:分级处置,不直接崩溃,交由统一策略决定
        when {
            !trusted && !intact -> degrade(context, Level.BLOCK)     // 多信号一致,明确被改
            !trusted -> degrade(context, Level.RESTRICT)             // 单信号,先限制
            else -> degrade(context, Level.MONITOR)                  // 仅 CRC 异常,先观察
        }
    }

    private fun degrade(context: Context, level: Level) {
        // 延迟退出:不在当前帧直接杀进程,避免误杀时表现为闪退,留出灰度与取证窗口
        SecurityGate.applyPolicy(context, level)
    }
}

再配一张分支流程图,把「读到了没有」和「一致不一致」两条判断线的走向画清楚:

flowchart TD S[&#34;启动读取签名指纹&#34;] --> Q1{&#34;读到了指纹吗&#34;} Q1 -->|&#34;否&#34;| R[&#34;记录异常 静默上报&#34;] Q1 -->|&#34;是&#34;| Q2{&#34;与内置指纹一致吗&#34;} Q2 -->|&#34;是&#34;| P[&#34;放行并继续启动&#34;] Q2 -->|&#34;否&#34;| D[&#34;触发统一失败策略&#34;] R --> D D --> L1[&#34;仅单信号 限制功能&#34;] D --> L2[&#34;多信号一致 延迟退出&#34;]

八、这道防线的边界

到此为止,第一道锁已经建好:读签名、算指纹、比对,再加一道 CRC 兜底,失败时静默上报、分级处置、延迟退出。它拦得住什么、拦不住什么,必须一开始就摆在台面上说清楚,否则很容易产生「做完就安全了」的错觉。

一张对照表把边界框出来:

攻击者类型 典型手段 能否拦住 原因
拿来就改的人 反编译后改代码、重打包、重签名 拦得住 重签名必然改变证书指纹,启动即被识别
会改校验逻辑的人 反编译定位校验函数并改写其返回值 拦不住 校验逻辑就在应用层,可读、可改
直接在运行时动手的人 运行时注入或调试篡改 拦不住 应用层没有反调试、反注入能力

这张表里第二行就是这层防线的天花板。校验逻辑本身是用应用层可读的形式写成的 ------攻击者反编译之后,完全可以把那个「签名校验」方法改成永远返回通过,你的指纹比对再严谨也白搭,因为判定被执行的结果已经被人换掉了。这也是为什么混淆只能提高门槛却当不了防线:它让函数名难认,但改一个 return 关键字并不需要看懂它。

所以它的作用边界其实非常清晰:挡住「拿来就改」的大多数人,挡不住「会改逻辑」的少数人。 而对绝大多数应用来说,第一类人占了攻击者的绝大多数------把门槛抬到「需要反编译定位校验、再手工 patch」这一层,就已经劝退了大部分低成本的盗版和二开。用最低的实现成本换掉最大比例的攻击,这正是它被称为「第一道锁」的原因。

要突破第二行这个天花板,只有一条路:把真正的判断从应用层挪走,沉到更难下手的地方去。 应用层留下一个空壳门禁,真正的校验编译成机器码放进动态库,攻击者面对的就不再是可读的字节码,而是汇编级的二进制。这是下一篇的主题。

九、小结

  • 重签名是二次打包绕不开的环节,因此「运行时校验签名指纹」是成本最低、收益最高的第一道防线,应用层即可实现。
  • 比对的对象是签名证书的指纹 ,不是整个签名文件,也不是内容签名值------证书稳定,才能当身份标识;指纹统一用 SHA-256 + 十六进制小写,来源应是 keystore 而非已签名的包。
  • v1 逐条目保护、v2 整包保护、v3 加密钥轮换;v1 覆盖不到归档整体,是完整性校验存在的理由,v2/v3 才是真正覆盖 dex 整体的一代。
  • 内容完整性校验是补充而非主力:dex 的 CRC 直接取自 zip 中央目录条目,无需自算,但它只有 32 位、且每次打包都要重新回填,只在私钥泄露等极端场景才有额外价值。
  • 失败处理要同时满足「读不到不算通过」和「不直接崩溃」------判定与处置分离,静默上报、分级处置、延迟退出,把误杀风险压到最低。
  • 应用层校验可被 patch:它挡得住「拿来就改」,挡不住「会改逻辑」;要更进一步,必须把校验下沉到原生层。

系列导航:Android 安全加固系列第 2 篇(共 4 篇)。上一篇《Android 混淆不等于安全:二次打包链路完整走一遍》,下一篇《把校验沉到 Android 原生层:让 patch 下不去手》。

你们现在的包,签名指纹是手填在代码里,还是构建期自动注入的?如果同时跑多渠道重签名、又做过密钥轮换,白名单是怎么维护的------欢迎在评论区聊聊你们踩过的坑。

相关推荐
mmsx1 小时前
Android 混淆不等于安全:二次打包链路完整走一遍
android·kotlin
小宋10213 小时前
Agent轨迹级评测实战:工具选择、预算超限与回归门禁
android·网络·人工智能·回归
墨天梦5 小时前
D05_ViewModel与单向数据流
android·kotlin
蒸鱼Yuzheng6 小时前
Android 构建可复现性:APK 指纹、文件级差异与供应链审计
android·apk·devops·软件供应链·可复现构建
墨天梦8 小时前
D03_Compose列表与稳定身份
android·gitee·kotlin
萌新杰少9 小时前
Kuikly股票查看软件开发体验——SaiRen
android·kotlin·客户端
vilya9 小时前
把 Python 塞进 APK:Chaquopy 打包实践
android·python
用户92817267390169 小时前
Android Compose版本的AI组件库来了。
android·kotlin
知昂七昂9 小时前
00-02:AOSP 源码阅读环境与检索方法论源码剖析(Android 16 / aosp-main)
android