APK 二次打包检测实战:重签名与完整性校验

APK 二次打包检测之所以值得单独做一套流程,是因为重打包的门槛低到离谱:apktool 反编译、改几行 smali、塞一个广告 SDK、用自己的证书重签,全程不到十分钟。用户装上的仍然是你的图标和名称,出问题背锅的却是你的品牌。本文给出 APK 二次打包检测的完整落地方案,覆盖证书指纹基线、CI 构建期拦截、运行时校验与服务端比对四个环节。

适用场景

  • 自研 App 在第三方应用市场出现同名版本,安装后多出弹窗广告、后台自启或诱导下载行为。
  • 渠道包由代理商或分发平台二次签名,无法确认最终到用户手里的包是否被改动。
  • 应用市场上架审核要求提供签名一致性说明,或企业内部要求 App 具备防篡改能力。
  • 接入加固与热修复后,原有的完整性校验逻辑失效,需要重新设计检测点。

前置条件

  • Android SDK build-tools(提供 apksigner)与 JDK(提供 keytool),版本需高于 30.0.0 才支持 v2/v3 签名校验。
  • 至少一个官方发布包作为基线样本,以及对应的签名密钥库或证书文件。
  • CI 构建机(Jenkins、GitLab CI 均可)具备执行 shell 脚本的权限。
  • 一个用于复现验证的测试证书,不得使用正式签名密钥。

原理说明

APK 本质是一个 ZIP 包,签名方案有三代并存。v1(JAR 签名)在 META-INF 下生成 MANIFEST.MF 与 CERT.SF,对每个文件单独做摘要,问题在于它允许「只签名部分文件」,攻击者可以往包里塞入未被列入清单的新文件而不破坏签名。v2 在 ZIP 中央目录之前插入 APK Signing Block,对整个包(除签名块本身)做摘要,任何字节改动都会导致校验失败。v3 在 v2 基础上增加了密钥轮换能力,v4 用于增量安装,依赖外部 .idsig 文件。

重打包会改变 DEX、资源文件和 AndroidManifest,因此原签名必然失效,攻击者只能用自己的证书重新签一遍。这就留下两个稳定的检测点:签名证书的 SHA-256 指纹变了,以及 v2 签名块携带的整包摘要与官方基线不一致。前者实现简单、误报低,是首选;后者能进一步覆盖「证书被窃取后重签」的极端情况。

需要清楚检测的边界:客户端校验可以被 hook 绕过,因此它只能提高成本,不能作为唯一防线。真正的闭环是「客户端发现异常 → 上报服务端 → 服务端按包指纹与证书指纹做黑名单与告警」。另外,加固与热修复会改写 DEX,若校验逻辑基于 DEX 摘要就会自伤,这一点在方案设计时必须提前排除。

操作步骤

第一步:提取官方包的签名基线

先在构建产物上提取证书指纹与文件摘要,这份数据就是后续所有比对的基准,必须纳入版本管理。

# 查看签名方案、证书主体与指纹
apksigner verify --verbose --print-certs app-release.apk
# 仅取证书 SHA-256(用于写进白名单)
apksigner verify --print-certs app-release.apk | grep "SHA-256"
# 记录整包摘要,作为发布归档
sha256sum app-release.apk | tee app-release.sha256
# 确认 v1/v2/v3 各方案是否都存在
unzip -l app-release.apk | grep -i "META-INF"

输出里 Verified using v2 scheme: true 是必须项。如果只有 v1 为 true,说明签名配置还停留在旧方案,重打包者可以轻易追加未签名文件,应先在构建脚本里开启 v2/v3。

第二步:建立指纹白名单并接入 CI

把官方证书指纹写成 JSON,构建流水线每次出包都做一次比对。下面的脚本同时校验「证书指纹命中白名单」与「v2 签名有效」,任一失败即中断构建。

#!/usr/bin/env bash
set -euo pipefail
APK="$1"
EXPECT_SHA256="3f2a9c1d8b7e6f5a4c3d2b1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a"

if ! apksigner verify --verbose "$APK" >/dev/null 2>&1; then
  echo "[FAIL] 签名校验未通过,包可能被重签"; exit 1
fi

ACTUAL=$(apksigner verify --print-certs "$APK" \
  | grep -m1 "SHA-256" | awk '{print $NF}' | tr -d ':' | tr 'A-Z' 'a-z')

if [ "$ACTUAL" != "$EXPECT_SHA256" ]; then
  echo "[FAIL] 证书指纹不符:$ACTUAL"; exit 2
fi

if ! apksigner verify --verbose "$APK" | grep -q "Verified using v2 scheme: true"; then
  echo "[FAIL] 缺少 v2 签名,存在追加文件绕过风险"; exit 3
fi
echo "[PASS] 签名基线与官方一致"

这个脚本放在打包后、上传前执行,能把「渠道商私自重签后回传」这类问题拦在分发之前。指纹比较前统一去掉冒号并转小写,否则不同 SDK 版本的输出格式差异会造成误报。

第三步:运行时校验签名指纹

客户端在启动早期校验当前包的签名,与代码内置的指纹常量比对。Android 9 及以上使用 GET_SIGNING_CERTIFICATES,低版本回退到旧接口。

fun certSha256(context: Context, expected: String): Boolean {
    val pm = context.packageManager
    val pkg = context.packageName
    val sigs = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
            .signingInfo?.apkContentsSigners
    } else {
        @Suppress("DEPRECATION")
        pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES).signatures
    } ?: return false

    val md = MessageDigest.getInstance("SHA-256")
    return sigs.any { sig ->
        md.reset()
        md.digest(sig.toByteArray()).joinToString("") { "%02x".format(it) } == expected
    }
}

校验失败时不要直接闪退,静默降级并上报更有利于取证:记录包名、安装来源、证书指纹、设备型号,服务端据此判断是重打包分发还是内部测试包。直接崩溃会让攻击者立刻知道检测点位置,反而方便定位绕过。

第四步:复现重打包样本验证检测有效

没有验证过的检测等于没有。用一个自签名测试证书模拟攻击者的重签流程,确认 CI 与客户端都能拦住。

# 生成测试密钥(仅用于验证,禁止用于发布)
keytool -genkeypair -v -keystore test-repack.jks -alias repack \
  -keyalg RSA -keysize 2048 -validity 365 -storepass test123456 -keypass test123456 \
  -dname "CN=Repack Test, O=Test, C=CN"
# 模拟重打包:解包、改内容、重新对齐并签名
apktool d app-release.apk -o repack_src
apktool b repack_src -o repack-unsigned.apk
zipalign -p -f 4 repack-unsigned.apk repack-aligned.apk
apksigner sign --ks test-repack.jks --ks-pass pass:test123456 \
  --out repack-signed.apk repack-aligned.apk
# 用 CI 脚本检测,预期返回码 1 或 2
bash verify_apk.sh repack-signed.apk; echo "exit=$?"

预期退出码为 1 或 2,并且客户端安装该包后应触发上报。如果重打包包仍然通过校验,说明白名单里误把测试证书加了进去,或者 apksigner verify 的退出码判断写反了。

第五步:服务端比对与处置

客户端上报的数据要在服务端做聚合,形成包指纹黑名单,同时对同一证书指纹的下载量突增做告警。处置动作包括:向应用市场提交侵权投诉并附证书指纹与构建时间戳,向分发渠道发函要求下架,以及在自家下载页对已安装用户做升级提示。取证时保留重打包包的完整文件哈希、证书指纹、首次发现时间,这三项是投诉受理的必需材料。

配置验证

# 1. 官方包应通过全部校验
bash verify_apk.sh app-release.apk; echo "exit=$?"
# 2. 确认 v2/v3 同时存在
apksigner verify --verbose app-release.apk | grep -E "v2 scheme|v3 scheme"
# 3. 客户端日志中确认签名校验结果已上报
adb logcat -s SecurityCheck | grep -i "signature"
# 4. 抽查线上包与归档基线是否一致
sha256sum app-release.apk && cat app-release.sha256

第 1 步退出码必须为 0,第 2 步两项都应为 true,第 4 步两个哈希必须完全一致。哈希不一致通常意味着打包流程里有二次对齐或资源注入步骤,需要回溯构建日志。

常见问题

Q1:只校验 v2 签名够不够,还要不要看 v1?

不够。v2 校验的是整包摘要,能挡住绝大多数改动,但 v1 才是决定安装器接受哪些文件的依据。部分低版本系统与第三方安装器只校验 v1,若攻击者保留原 v1 签名、只追加未被 v1 清单覆盖的文件,v2 摘要其实已经变了——所以两者都要校验。实践口径是:v2 必须存在且有效,v1 若存在则其文件清单需要与官方基线一致。

Q2:接入加固或热修复后,运行时签名校验失效怎么处理?

加固方案通常会重打包并替换签名,热修复则改写 DEX,两者都会让基于 DEX 摘要的校验失败。正确做法是把校验对象从「DEX 内容」改成「签名证书指纹」,因为加固后证书一般不变;同时在加固流程中把指纹常量写进配置,由构建脚本在加固完成后注入,避免人工改代码遗漏。若加固厂商会更换证书,必须在合同里明确要求保留原始签名或提供指纹映射。

Q3:使用 Google Play App Signing 后,本地包与线上包证书不一致怎么办?

这是正常现象:上传密钥签本地包,Google 用应用签名密钥重签后分发。此时基线应取Play 控制台里展示的应用签名证书 SHA-256,而不是本地 keystore 的指纹;运行时校验的常量也要用线上证书。若同时在国内市场分发,需要维护两套指纹(自签证书 + Play 证书),按安装来源区分校验分支。

总结

APK 二次打包检测的落点很明确:把签名证书指纹当作身份凭证,把整包摘要当作内容凭证,两者在构建期、运行期、服务端各校验一次。构建期拦住渠道私自重签,运行期发现已分发到用户手里的异常包,服务端负责聚合与追责。不要指望单一环节解决问题,客户端校验天生可被 hook,重点是让绕过成本高于收益,并保证每一次异常都有日志可追。最后提醒一句:正式签名密钥务必离线保管,一旦泄漏,所有基于指纹的检测会同时失效,那才是真正的灾难场景。