diff --git a/.github/release-notes/versions/1.8.6.md b/.github/release-notes/versions/1.8.6.md new file mode 100644 index 0000000..5b3d053 --- /dev/null +++ b/.github/release-notes/versions/1.8.6.md @@ -0,0 +1,13 @@ +### 离线加载与兼容修复 + +- 新产物统一单层压缩,减少加载复制并优化 Android 运行时编译设置;仍兼容旧的 1~3 层载荷,不改变签名与认证校验。 +- 使用标准 DEX 数据边界定位载荷,保留其他布局的兼容扫描,并修复密文中同名标记导致入口误选的问题。 +- 发布版加载器使用固定错误代码,减少内部算法和解析信息暴露;保留已挂起的 Java 异常。首次与缓存加载的认证失败均已验证拒绝启动。 +- 本地逐方法报告增加指令、寄存器、异常区、桥接及协程准入事实;成功与零转换失败报告均可排查,未放宽保护准入或提高覆盖率统计。 +- 修复还原验收中的重复 DEX 扫描,补齐真实 APK 的分层验证。测试范围通过不代表全部业务已覆盖;本次不新增 Native VMP,也不宣称阻止仅凭 APK 的静态还原。 + +### 升级说明 + +- 使用配套打包器与运行时重新加固后生效;PVM2 需要 JDK 17 或更高版本。逐方法报告包含业务标识,分享前请脱敏。 +- 保持通用离线加固、手机安装即用;不增加服务器或激活依赖。`balanced` 与 `high` 当前采用相同保护决策,保留选项用于旧配置兼容。 +- 单台 Android 15 真机完成真实 APK 的 620 组输入、62,620 次调用及本轮每包 30 次有效冷启动采样。性能随项目和设备变化;本样本缓存启动额外开销仍为 420.5 ms,不能表述为全部性能目标已达成。 diff --git a/Cargo.lock b/Cargo.lock index 2b87896..895abd9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "mocika-shield" -version = "1.8.5" +version = "1.8.6" dependencies = [ "dunce", "reqwest 0.12.28", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "mocikashield" -version = "1.8.5" +version = "1.8.6" dependencies = [ "cc", "chacha20poly1305", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "shield-cli" -version = "1.8.5" +version = "1.8.6" dependencies = [ "anyhow", "clap", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "shield-core" -version = "1.8.5" +version = "1.8.6" dependencies = [ "anyhow", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index e425047..f911526 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,18 @@ lto = true # 链接时优化 strip = "symbols" # 移除符号表(比 strip = true 更明确、更激进) panic = "abort" # 减少 unwind 代码体积 codegen-units = 1 # 更激进的跨单元优化 + +# 手机上每次启动都要认证载荷并初始化解释器,运行时优先优化执行速度。 +# 桌面程序仍沿用体积优先设置;不改变算法、认证或解释器准入规则。 +[profile.release.package.mocikashield] +opt-level = 2 + +# 载荷每次启动都需要完整认证;共享密码库也优先优化吞吐,不改变密码协议。 +[profile.release.package.chacha20poly1305] +opt-level = 3 + +[profile.release.package.chacha20] +opt-level = 3 + +[profile.release.package.poly1305] +opt-level = 3 diff --git a/apps/shield-cli/Cargo.toml b/apps/shield-cli/Cargo.toml index 395267d..fc5452e 100644 --- a/apps/shield-cli/Cargo.toml +++ b/apps/shield-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-cli" -version = "1.8.5" +version = "1.8.6" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/apps/shield-cli/src/args.rs b/apps/shield-cli/src/args.rs index 7dffb02..f879318 100644 --- a/apps/shield-cli/src/args.rs +++ b/apps/shield-cli/src/args.rs @@ -364,7 +364,7 @@ pub(crate) struct ProtectArgs { /// 保护级别:compat、balanced 或 strict。 #[arg(long, value_enum)] pub profile: Option, - /// AI 静态分析抵抗等级;实际映射为 DEXB v6 的 1/2/3 层压缩。 + /// 额外保护策略等级;新包统一单层压缩,效果以逐方法覆盖和产物验证为准。 #[arg(long, value_enum)] pub ai_resistance: Option, /// Xop transform-only Packer JAR;仅嵌入 PVM2,不引入第二个 Application/JNI 壳。 diff --git a/apps/shield-gui/package-lock.json b/apps/shield-gui/package-lock.json index 41514ea..72e9963 100644 --- a/apps/shield-gui/package-lock.json +++ b/apps/shield-gui/package-lock.json @@ -1,12 +1,12 @@ { "name": "mocika-shield-gui", - "version": "1.8.5", + "version": "1.8.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mocika-shield-gui", - "version": "1.8.5", + "version": "1.8.6", "license": "MIT OR Apache-2.0", "dependencies": { "@radix-ui/react-dialog": "^1.1.19", diff --git a/apps/shield-gui/package.json b/apps/shield-gui/package.json index 0cb70a8..878367d 100644 --- a/apps/shield-gui/package.json +++ b/apps/shield-gui/package.json @@ -1,6 +1,6 @@ { "name": "mocika-shield-gui", - "version": "1.8.5", + "version": "1.8.6", "license": "MIT OR Apache-2.0", "private": true, "type": "module", diff --git a/apps/shield-gui/src-tauri/Cargo.toml b/apps/shield-gui/src-tauri/Cargo.toml index 2d9eba3..5a32a6a 100644 --- a/apps/shield-gui/src-tauri/Cargo.toml +++ b/apps/shield-gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocika-shield" -version = "1.8.5" +version = "1.8.6" edition = "2021" license = "MIT OR Apache-2.0" publish = false diff --git a/apps/shield-gui/src-tauri/tauri.conf.json b/apps/shield-gui/src-tauri/tauri.conf.json index b6b93ee..aa44c5d 100644 --- a/apps/shield-gui/src-tauri/tauri.conf.json +++ b/apps/shield-gui/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Shellsmith", - "version": "1.8.5", + "version": "1.8.6", "identifier": "dev.mocika.shield-gui", "build": { "beforeDevCommand": "npm run dev -- --host 127.0.0.1", diff --git a/apps/shield-gui/src/hooks/use-about-page.ts b/apps/shield-gui/src/hooks/use-about-page.ts index 58c4502..50a4fdd 100644 --- a/apps/shield-gui/src/hooks/use-about-page.ts +++ b/apps/shield-gui/src/hooks/use-about-page.ts @@ -9,7 +9,7 @@ type AppInfo = { }; const defaultAppInfo: AppInfo = { - version: "1.8.5", + version: "1.8.6", git_hash: "dev", build_date: "unknown", }; diff --git a/apps/shield-gui/src/lib/i18n.ts b/apps/shield-gui/src/lib/i18n.ts index b0d171c..ce93827 100644 --- a/apps/shield-gui/src/lib/i18n.ts +++ b/apps/shield-gui/src/lib/i18n.ts @@ -160,7 +160,7 @@ const dict = { protectionBalanced: "平衡保护(推荐)", protectionStrict: "严格代码保护(需 PVM2)", aiResistance: "AI 分析抵抗", - aiResistanceHint: "该选项映射为 DEXB 多层认证压缩;PVM2 默认使用内置 Packer,仍需填写自有业务类前缀。Hollow/SO 融合仍未开放。", + aiResistanceHint: "保留旧配置选项;balanced 与 high 当前采用相同保护决策,不再叠加压缩。效果以逐方法覆盖和产物验证为准。PVM2 使用内置打包器,仍需填写自有业务类前缀。", aiResistanceOff: "关闭", aiResistanceBalanced: "平衡", aiResistanceHigh: "高强度", @@ -614,7 +614,7 @@ const dict = { protectionBalanced: "Balanced protection (recommended)", protectionStrict: "Strict code protection (PVM2 required)", aiResistance: "AI analysis resistance", - aiResistanceHint: "This controls authenticated DEXB compression layers. PVM2 uses the bundled Packer by default and still requires owned business-class prefixes; fused Hollow/SO remain unavailable.", + aiResistanceHint: "These options remain for configuration compatibility. Balanced and high currently use the same protection decisions without extra compression layers. Assess protection using method coverage and artifact verification. PVM2 uses the bundled packer and requires owned business-class prefixes.", aiResistanceOff: "Off", aiResistanceBalanced: "Balanced", aiResistanceHigh: "High", diff --git a/crates/shield-core/Cargo.toml b/crates/shield-core/Cargo.toml index a37d1df..4db9727 100644 --- a/crates/shield-core/Cargo.toml +++ b/crates/shield-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-core" -version = "1.8.5" +version = "1.8.6" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/crates/shield-core/src/protection_policy.rs b/crates/shield-core/src/protection_policy.rs index e2560e3..556adda 100644 --- a/crates/shield-core/src/protection_policy.rs +++ b/crates/shield-core/src/protection_policy.rs @@ -55,13 +55,10 @@ impl AiResistance { } } - /// DEXB v6 的实际压缩层数,范围固定为 1..=3。 + /// 新包只压缩一次;重复压缩不提供秘密,不随 AI 保护级别叠加启动成本。 + /// 运行时继续兼容已有的 1~3 层载荷。 pub fn compression_layers(self) -> u8 { - match self { - Self::Off => 1, - Self::Balanced => 2, - Self::High => 3, - } + 1 } } @@ -199,9 +196,11 @@ mod tests { } #[test] - fn ai_resistance_selects_bounded_compression_layers() { + fn 压缩不随保护级别重复且严格保护能力保持开启() { assert_eq!(AiResistance::Off.compression_layers(), 1); - assert_eq!(AiResistance::Balanced.compression_layers(), 2); - assert_eq!(AiResistance::High.compression_layers(), 3); + assert_eq!(AiResistance::Balanced.compression_layers(), 1); + assert_eq!(AiResistance::High.compression_layers(), 1); + let policy = ProtectionPolicy::for_profile(ProtectionProfile::Strict, AiResistance::High); + assert!(policy.pvm2 && policy.string_jit && policy.native_safe && policy.memory_preferred); } } diff --git a/crates/shield-core/src/protection_report.rs b/crates/shield-core/src/protection_report.rs index b5766a2..e304651 100644 --- a/crates/shield-core/src/protection_report.rs +++ b/crates/shield-core/src/protection_report.rs @@ -20,6 +20,9 @@ pub(crate) struct MethodRecord { plaintext_policy: Option, final_dex_index: Option, final_method_index: Option, + /// 本地准入事实只用于排查,不参与转换成功或覆盖率判定。 + #[serde(default, skip_serializing_if = "Option::is_none")] + admission: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -792,7 +795,8 @@ mod tests { detail.methods.push(serde_json::from_value(serde_json::json!({ "module": "base", "descriptor": format!("Lprivate/Secret;->method{i}()V"), "required": false, "input_instruction_bytes": 0, "input_dex": "classes.dex", "transform_status": "skipped", - "reason": reason, "artifact_verified": false, "plaintext_policy": null + "reason": reason, "artifact_verified": false, "plaintext_policy": null, + "admission": {"policy_exclusion": reason, "opcodes": {"invoke-virtual": 1}, "private_fact": "private"} })).unwrap()); } for image in [7, 8, 9, 10] { @@ -828,6 +832,10 @@ mod tests { assert_eq!(stored["build"]["status"], "failed"); assert_eq!(stored["build"]["failure_code"], "PVM2_ZERO_METHODS"); assert_eq!(stored["methods"].as_array().unwrap().len(), cases.len()); + assert_eq!( + stored["methods"][0]["admission"]["opcodes"]["invoke-virtual"], + 1 + ); assert!(!output.path().join("final.aab").exists()); assert!(!report_path(&output.path().join("final.aab")).exists()); #[cfg(unix)] diff --git a/docs/README.md b/docs/README.md index 407aaaf..9d474a8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -41,6 +41,7 @@ Shellsmith 是 Android 与 iOS 双平台应用加固工具。Android 流程加 | 了解 Native 库名去品牌化与按任务别名方案 | [design/native-library-alias.md](design/native-library-alias.md) | | 了解 Stub DEX 最小化、能力变体与二阶段加载实验 | [design/stub-dex-minimization.md](design/stub-dex-minimization.md) | | 了解运行时安全、缓存认证与 Root 策略规划 | [design/runtime-security.md](design/runtime-security.md) | +| 验证 REA 分析抵抗与 APK 离线恢复边界 | [design/rea-resilience.md](design/rea-resilience.md) | | 了解 API 31 以上内存 DEX 正式接入边界 | [design/memory-dex-production.md](design/memory-dex-production.md) | | 了解 DEX 结构与方法代码分离研究 | [design/dex-code-separation.md](design/dex-code-separation.md) | | 了解工具链轻量化、CI 与 Gradle 集成规划 | [design/toolchain-modernization.md](design/toolchain-modernization.md) | diff --git a/docs/design/rea-resilience.md b/docs/design/rea-resilience.md new file mode 100644 index 0000000..755a1b4 --- /dev/null +++ b/docs/design/rea-resilience.md @@ -0,0 +1,177 @@ +# REA 分析抵抗:实施与验证 + +2026-10-19 修订。本文替代旧方案中未经通用兼容验证的全资源加密、业务 JNI 改名和随机魔数方案。 + +## 威胁模型 + +目标项目为 [morluto/rea](https://github.com/morluto/rea),本轮固定 `rea-agents@6.7.0`。它编排 JADX、Native 分析引擎、资源工具和 adb,并已具备 [Frida 会话与脚本能力](https://github.com/morluto/rea/blob/rea-agents-6.7.0/docs/frida-instrumentation.md)。旧文档“不用 Frida、只能静态分析”的判断已失效。 + +保持通用离线加固、手机安装即用。软件自动管理本地项目材料,但手机执行所需的引导材料仍随 APK 交付。因此: + +- 证书摘要是公开身份信息;更换密码算法、随机文件名或增加加密层数不能产生包外秘密。 +- DEXB v6 的 APK 公开材料足以恢复载荷密钥。默认模式不承诺 APK 机密性。 +- 解释器与应用同时交付,虚拟化只能提高分析成本。镜像解密、规范化字节码、等价方法重建、恢复原始源码是不同成果。 +- 客户端完整性检查可以发现未经适配的修改,但不能在代码可任意修改时构成独立信任根。Android 系统验签与应用内部验签不是同一边界。 + +不针对工具名称拉黑、不向 AI 植入提示、不制造畸形 DEX,也不把分析器崩溃、工具缺失或超时计为防护通过。 + +## 已实施 + +### 收敛 Native 内部诊断 + +在所有调用共用的日志入口处理发布版 `PLOGE`:保留固定故障码 `P1`,移除方法编号、虚拟操作码、程序计数器、池与解析字段。解释器自己的 RuntimeException 和 SecurityException 分别使用 `P1` / `P2` 通用消息。 + +开发构建保留详细诊断。业务抛出的异常对象、异常类型、捕获顺序及 finally 行为按原语义传播。加固桌面端的逐方法失败报告保持可用。 + +同步将 `common/log.h` 纳入 Rust 构建的变更依赖,避免增量构建分发旧日志实现。Native 回归分别编译开发和发布实现,发布实现仍启用测试断言,并检查内部消息不进入二进制。 + +这项变更减少静态字符串与设备错误日志给分析者提供的线索,不改变密钥保密边界,也不代表阻止 Frida 注入或 Native 反编译。 + +### 修复离线恢复验收 + +`ApkRecoveryCheck` 原来只在完整 APK 路径接受镜像 v7/v8,而当前生产为 v9;自检走另一入口,未覆盖该问题。现在两个入口共用版本与结构检查,支持 v7~v9,未知版本必须明确失败。 + +检查覆盖报告中的全部已验证跳板与镜像,保留三种 ISA、随机映射、非法映射和未知版本反例。它使用仓库已有的公开格式知识,是白盒恢复检查,不能冒称 REA 自身已自动完成同样恢复。 + +### 建立实际 REA 回归 + +新增 `tests/scripts/check-rea-resilience.py`,读取授权 APK 与逐方法报告,校验输入/输出哈希后: + +1. 使用 REA 搜索加固包内的业务类,要求引擎完整返回。 +2. 仅用 APK 内材料执行白盒恢复,核对全部受保护方法的跳板及镜像。 +3. 从加固 APK 与恢复 DEX 生成仅供静态分析的未签名包,不借用原包代码,不安装。 +4. 使用 REA 对指定抽样方法分别读取原始方法体、恢复后的跳板,核对签名与报告中的保护范围。 +5. 统计各 ABI 的已知内部诊断;可要求发布产物没有这些诊断和性能采样标记。 + +记录工具版本、JADX 哈希、APK 哈希、逐次原始 Evidence 和错误日志。工具失败、部分输出及未知版本返回非零,不能成为“抗还原通过”。`same_decompiled_text=false` 只表示文本不同;语义重建与动态注入默认明确标记未验证。 + +报告目录权限为仅当前用户可访问,包含业务标识和恢复代码,默认放忽略的 `target/` 下,不自动上传。 + +## 本轮验收结果 + +自有 Java/D8 样本,min API 26;对照为 1.8.5 正式产物。结果不代表所有客户 APK 或任意 Native SO。 + +| 项目 | 1.8.5 | 本轮修改后 | +|---|---|---| +| 所选/已验证 PVM2 方法 | 52 / 46 | 52 / 46 | +| REA 直接搜索业务类 | 0 命中,搜索完整 | 0 命中,搜索完整 | +| 白盒恢复镜像及核对跳板 | 46 / 46 | 46 / 46 | +| 四 ABI 壳库的本轮诊断特征命中 | 每 ABI 12 处 | 每 ABI 0 处 | +| REA 读取恢复 DEX 的三个抽样方法 | VM 调用跳板 | VM 调用跳板 | + +两个构建的 46 个规范化指令摘要多重集一致,未观察到 VM 语义多样化增强。三个抽样方法覆盖字符串、嵌套异常和业务调用;不能据此宣称全部方法无法重建。 + +Native 开发/发布模式均通过 ASan/UBSan、JNI 执行、认证失败、解析失败、异常传播、缓存清理及并发回归。标准四 ABI 和独立 API 19 资源构建通过;本轮设备执行仅在 HONOR Android 15/API 35、arm64、4 KB 页大小上完成:5 个场景各 525 项检查,共 2,625 项,测试包已移除。未将其他平台的编译成功当作设备验证。 + +完整语义还原、真实 Frida 注入、Native 反编译成本和正式性能分布仍未在本轮验证;本轮没有“防住 REA”或保护代际提升的结论。 + +### 授权真实 APK 的追加验证 + +同日使用用户提供的真实 APK,在上述分支构建上加固并使用原匹配证书签名。业务标识、APK、逐方法报告及恢复输出仅保存在本机忽略目录,不随仓库分发。 + +| 项目 | 本产物结果 | +|---|---| +| 输入范围 | 8 份 DEX;`lib/` 中 39 个 SO,assets 归档另含 7 个 SO | +| 所选/已验证 PVM2 方法 | 54,249/11,613;总方法覆盖 21.41%,指令字节覆盖 30.52% | +| 指定必保测试范围 | 19/19;不代表关键业务完整清单 | +| 真机方法对照 | 原始与加固的独立测试包各 620 组输入、62,620 次调用通过 | +| 仅凭 APK 的白盒恢复 | 11,613 个镜像与跳板全部核对;27 个加密 SO 恢复为原包字节 | +| REA 直接搜索及恢复后抽样 | 直接搜索仅命中资源类;恢复后读取的三个抽样方法为 VM 跳板 | +| 条件方法语义重建 | 两个方法已从规范化指令重建,在边界、布尔组合及追加随机输入上与 Android 执行一致 | +| 三次独立构建 | 镜像字节各不相同,11,613 个方法的规范化指令摘要逐一相同 | +| 本 APK 的 Native VMP | 没有源码转换证据,不把 SO/归档加密计为函数虚拟化 | + +完整恢复入口原先对每条方法记录重新读取并扫描 DEX,在真实应用的上万条记录上形成重复工作;现在按 DEX 建立一次方法索引,再按完整签名核验原有跳板和镜像条件。报告中的未恢复 DEX、越界路径、缺失方法及错误索引仍被拒绝。打包器自检、完整实包核对及错误报告反例均通过。 + +三次白盒恢复执行约 4.3~5.2 秒,只是已有恢复程序的执行时间,不含研发、完整语义分析或攻击成本。真实 APK 的结果仍不支持“仅凭 APK 无法还原”:同一恢复器能够跨构建复用,两个条件方法已经形成重建反例。REA 自身的原始 Evidence 与白盒恢复证据分开保存,不把后者冒称 REA 原生全自动能力。 + +设备仍仅为上述 HONOR Android 15。为保护正式应用及其数据,使用同证书的独立测试包名,保留原始 DEX、SO 与业务签名检查;未做正式包升级安装、登录、录音和完整转写测试。REA 的 pwntools ELF 入口在本机 macOS 返回平台不支持,不计为 Native 防护通过;真实 Frida 注入仍未验证。 + +真实 APK 的交替六轮性能采样已完成,每包 30 个样本,另有每块三次预热,无异常启动。测量缓存建立后的进程冷启动到首屏:原始对照中位数 1,270 ms、P95 1,286 ms,加固包中位数 1,835 ms、P95 1,886 ms;启动中位数增加 44.49%。启动一秒后的主进程 PSS 中位数增加约 11.91 MiB(5.05%)。样本电池温度 35~38°C,热状态 0~1。未预设性能 SLA,不把这些数值表述为性能达标,也不外推到完整业务可交互时间或峰值内存。 + +独立测试包的载荷认证标签被修改一位、修复 DEX 校验和并重新使用匹配证书签名后,系统 APK 验签通过,运行时在加载业务前因载荷认证失败退出。它仅证明这一负例被拒绝,不证明客户端校验不可绕过。该异常仍暴露解密算法和加载栈,因此前述七类 PVM 诊断特征清理不能解释为全部运行时信息已隐藏。 + +## 复现 + +需要 JDK 17、兼容的 Node 22.19 以上版本、本地 zstd、已构建的配套打包器及壳。研究工具安装到隔离目录,不运行 `rea setup`,不修改个人代理配置: + +```bash +npm install --prefix target/rea-resilience/tooling --ignore-scripts --omit=optional \ + --no-audit --no-fund --registry=https://registry.npmjs.org rea-agents@6.7.0 +``` + +按 [REA 的 Android 指南](https://github.com/morluto/rea/blob/rea-agents-6.7.0/docs/android-analysis.md) 下载 `jadx-headless-mcp-0.7.1-all.jar`。脚本强制验证 SHA-256 为 `6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75`。 + +```bash +make build-stub +make build-cli +python3 tests/scripts/check-pvm2-art.py --min-api 26 --output target/rea-art +python3 tests/scripts/check-rea-resilience.py \ + --rea target/rea-resilience/tooling/node_modules/rea-agents/scripts/rea.mjs \ + --jadx-jar target/rea-resilience/jadx-headless-mcp-0.7.1-all.jar \ + --input-apk target/rea-art/input.apk \ + --protected-apk target/rea-art/protected-signed.apk \ + --report target/rea-art/protected-signed.apk.protection.json \ + --output target/rea-check \ + --class-query CompatibilityActivity \ + --method 'dev.mocika.shield.pvm2regression.CompatibilityActivity.RulesView#text' \ + --method 'dev.mocika.shield.pvm2regression.CompatibilityActivity.RulesView#nested' \ + --method 'dev.mocika.shield.pvm2regression.CompatibilityActivity.RulesView#calls' \ + --require-private-diagnostics +``` + +`--output` 必须不存在,以防覆盖历史证据。检查其他授权项目时显式指定对应输入、产物、报告与无重载的抽样方法;不能把本样本覆盖率套用到其他 APK。 + +## 启动、诊断与准入分析 + +### 启动与诊断优化 + +新产物的 DEXB 压缩固定为一层,与 AI 保护级别解耦;`high` 的方法保护、字符串和其他策略不因此降低。解压首层直接读取原切片,省去一次完整压缩块复制。标准 Android Native 运行时单独使用速度优先的 `opt-level=2`,共享 ChaCha20/Poly1305 依赖使用 `opt-level=3`;桌面应用本身及 API 19 独立构建继续沿用原设置。没有新增包格式、服务依赖或首次激活步骤,也没有跳过签名、AEAD 和缓存根摘要验证;旧的 1~3 层载荷仍可读取。 + +载荷入口先尝试标准小端 DEX 035~040 的 `data_off + data_size`,避免每次在整块密文中逐字节搜索。此字段只作为定位提示,仍检查 MSHD 长度边界并执行后续认证;其他格式或提示无效时保留扫描,且扫描会跳过长度不合法的同名标记。格式依据为 [Android DEX 头部定义](https://source.android.com/docs/core/runtime/dex-format);041 容器头不使用该快路径。测试覆盖全部支持版本、损坏提示、密文内同名标记和截断输入。 + +真实 APK 的修改前诊断构建确认走缓存路径,四次缓存启动的载荷读取中位数为 39.5 ms、PVM2 初始化中位数为 334.5 ms。这是阶段定位证据,不是对所有 APK 的瓶颈判断。标准 arm64 壳库由 753,240 增至 1,085,096 字节,速度收益必须与该体积成本一起评价。 + +Native 加载边界发布版只输出固定代码,Rust 调试构建保留原始细节: + +| 代码 | 本地排查范围 | +|---|---| +| L10 | 首次或内存路径的载荷认证、解压及运行时初始化 | +| L11 | 缓存命中后的 PVM2 初始化 | +| L12 / L13 | Java 字节数组 / 直接缓冲区输出 | +| L14 | Native DEX 注入失败,交给原有 Java 反射路径 | +| A10 | 受保护资源块读取 | +| N51 / N52 | SO 密钥表 / SO 还原 | +| S01 | 环境或执行策略拒绝 | + +已挂起的 Java 异常保持原样;业务方法解释器的异常传播未改写。此收敛针对加载器内部诊断,不承诺移除系统栈、应用日志或所有 Java 文件系统错误。 + +### 本轮优化产物的追加验收 + +新包与原始对照各完成 30 个有效进程冷启动样本,六轮交替、每块三次预热、停止后间隔 12 秒,未出现异常启动。缓存建立后,原始对照中位数 / P95 为 1258 / 1276 ms,新包为 1678.5 / 1724 ms;额外开销 420.5 ms,尚未达到本样本缓存启动额外开销不超过 300 ms 的工程目标。启动一秒后的主进程 PSS 中位数增加 13.11 MiB;不能外推为完整可交互时间、峰值内存或跨设备 SLA。 + +每包另有六次清缓存首启观察,原始对照中位数 1297 ms、新包 2045.5 ms;不混入上述缓存启动统计。此前同批 90 样本对照中的旧加固、中间优化版中位数为 1,827 / 1,706.5 ms,减少 120.5 ms;该中间版不含后加的载荷定位修复,与最终新包的跨批差值不能用于单项优化归因。前一批另有一次被桌面提前拉起的 WARM 预热,已排除并保留现场记录。 + +最终独立测试包的 620 组输入、62,620 次调用通过;所选 54,249 个方法中仍有 11,613 个获得产物核验,指定测试范围 19/19。翻转载荷认证标签、重算 DEX 校验和并以匹配证书重签后,缓存路径以 L11、首次加载以 L10 拒绝启动,日志未出现原有算法和内部解析错误文本。两个独立测试包已清理,正式应用未被覆盖。 + +标准四 ABI、独立 API 19 构建及相应通用回归通过;设备范围仍只有上述 HONOR Android 15。REA 与白盒回归仍恢复全部 11,613 个镜像/跳板和 27 个 SO,本轮不构成 Native VMP 或抗静态还原通过。 + +### 方法准入事实 + +成功与失败逐方法报告继续使用原有覆盖率分母。被排除或编译失败的普通方法增加可选 `admission` 对象:原准入原因、桥接/合成标志、类名规则是否命中、是否直接继承已知协程基类、寄存器数、异常区数和逐指令计数。已成功转换、初始化器及无代码方法不增加该对象。旧报告仍可读取;新增事实只保存在本地逐方法报告,不进入可分享失败摘要,不改变保护准入。 + +本次样本 31,052 个普通方法具有该记录。19,796 个桥接分类方法中,5,365 个有桥接标志、10,661 个有合成方法标志、13,577 个命中生成类名规则、5,431 个位于直接继承协程基类的类中;这些数量有重叠。不能将整个分类视作无业务意义的转发代码,也不能仅凭该事实判定语义安全。 + +10,620 个数据/资源范围非叶子方法中,5,615 个包含对象字段读取、3,733 个包含静态调用、3,336 个包含虚调用,411 个含异常区。下一步应先为状态读写、调用和异常路径建立对应框架对照,再讨论放行;协程暂停/恢复、同步和特殊对象方法应分别验证。77 个寄存器限制方法也保留实测寄存器和指令信息,不能以截断寄存器或忽略临时寄存器需求的方式放开。 + +## 后续优化的准入条件 + +| 方向 | 复用现有能力与验收要求 | +|---|---| +| PVM2 方法与 Native 覆盖 | 按每个产物报告分母、成功数和跳过原因;二进制 SO 加密不能计为源码 Native VMP。扩展语义必须有原始执行与保护后执行对照。 | +| VM 语义多样化 | 用同一恢复器对多个构建比较规范化结果、恢复步骤与人工成本。现有条件反转试验仍能被复用规范化,不作为增强默认启用。 | +| 动态明文窗口 | 复用已有按需解密、必保方法退出清理、LRU 和实际加载库完整性检查。真实 Frida 测试需单独记录目标、版本、权限与观察结果;普通启动通过不能替代。 | +| assets 与业务 SO | 复用已有精确覆盖与跳过原因报告。模型、数据库、媒体、动态路径和 JNI 导出涉及 SDK/加载契约,不默认改写所有文件。 | +| 包外秘密 | 只作为用户自管服务或离线设备激活的可选模式;需要明确改变首次使用流程。当前默认安装即用模式不能承诺这一性质。 | + +验收中保留“仍可静态恢复”的反例;不能因原脚本暂时失效、字节差异变大或特征串减少,就提高保护代际或宣称已防住 REA。 diff --git a/docs/process/release-acceptance-fusion.md b/docs/process/release-acceptance-fusion.md index d64e033..5898f88 100644 --- a/docs/process/release-acceptance-fusion.md +++ b/docs/process/release-acceptance-fusion.md @@ -25,7 +25,7 @@ ## 安全和 AI 成本评测 - DEXB v6 头部不直接保存 IKM。 -- `ai-resistance=off|balanced|high` 分别写入 DEXB v6 的 1/2/3 层压缩标志,运行时逐层认证解压;旧 v6 `flags=0` 必须保持单层兼容。 +- `ai-resistance=off|balanced|high` 的新产物统一写入单层压缩标志,不通过重复压缩区分保护级别;运行时仍须通过旧 v6 的 1/2/3 层读取回归,`flags=0` 保持单层兼容。压缩调整不得跳过载荷认证。 - 新包默认不使用旧 DEXB v5。 - 关键字符串不集中以明文出现在 DEX 中。 - PVM2/Hollow 的跳过和回退原因可诊断。 diff --git a/docs/process/test-checklist.md b/docs/process/test-checklist.md index c954531..48afcde 100644 --- a/docs/process/test-checklist.md +++ b/docs/process/test-checklist.md @@ -29,7 +29,9 @@ ANDROID_SDK_ROOT=/本机/Android/sdk \ python3 tests/scripts/check-pvm2-art.py --serial 目标设备序列号 ``` -该脚本自建无资源 APK 和测试证书,核验 26 个转换方法及 10 个保留方法,覆盖继承/接口分派、D8 桥接、私有方法反射、引用身份、整数边界、捕获顺序、重抛与 `finally`。每次启动以独立标记确认 482 项断言;执行未加固基线、加固首次启动、再次启动、覆盖安装及基线恢复。省略 `--serial` 仅构建并检查产物;指定设备时只安装测试包,不卸载应用或清除设备日志。默认保留合法空资源表,`--without-resource-table` 补测完全无资源表的 APK。结果写入 `target/pvm2-art-checks/result.json`,模拟器通过不能代替厂商真机验收。 +该脚本自建测试 APK 和证书,核验 46 个转换方法;min API 23 时总计 53 个方法,min API 26 时为 52 个。覆盖继承/接口分派、D8 桥接、私有方法反射、引用身份、整数边界、捕获顺序、重抛与 `finally`。每次启动以独立标记确认 525 项断言;执行未加固基线、加固首次启动、再次启动、覆盖安装及基线恢复。省略 `--serial` 仅构建并检查产物;指定设备时只安装测试包,不卸载应用或清除设备日志。默认保留合法空资源表,`--without-resource-table` 补测完全无资源表的 APK。结果写入 `target/pvm2-art-checks/result.json`,模拟器通过不能代替厂商真机验收。 + +修改发布版 Native 日志、PVM2 格式或抗分析策略后,还需执行 [REA 分层回归](../design/rea-resilience.md)。发布产物使用 `--require-private-diagnostics` 检查四 ABI,完整保留“包内材料仍可恢复”的检查结果;不得将工具错误计为防护有效。`make test-pvm2-runtime` 同时验证开发/发布模式下的 Native 语义和诊断收敛,生产 `NDEBUG` 不关闭测试断言。 AAB 候选版必须验证:`bundletool validate` 通过、APKS 包含预期 base/ABI/功能或 Asset Pack split、安装后进程保持运行,并在日志中观察到 PVM2 功能代码、PAS2 资源和业务 `.so` 探针。当前本机证据覆盖 API 35/Android 15 与 API 36/Android 16 的 arm64 install-time 组合;Google Play 内测和 Play App Signing 仍是独立门禁。 diff --git a/docs/usage.md b/docs/usage.md index 6780c56..0fe2207 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -189,7 +189,7 @@ shield protect-ios \ shield protect -i input.apk -o protected.apk ``` -保护级别和载荷压缩选项可以独立选择;后者当前沿用 `ai-resistance` 参数名: +保护级别与 `ai-resistance` 参数可以分别指定;新包压缩层数固定为一层: ```bash shield protect -i input.apk -o protected.apk \ @@ -200,7 +200,7 @@ shield protect -i input.apk -o protected.apk \ - `compat`:优先兼容 API 19–36,关闭高风险高级变换。 - `balanced`:默认策略。未提供 Xop 参数时显示 `xop-contract-only`;显式启用嵌入式 PVM2 时显示 `xop-pvm2-embedded`。 - `strict`:要求真实嵌入式 Xop PVM2。GUI 默认使用安装包内置的 Packer,只需提供至少一个自有业务类前缀;CLI 仍需显式传入 Packer 路径。严格模式同时启用资源路径缩短、同类型资源 ID 重排、PAS2 分块认证加密和符合安全策略的业务 ELF 函数区域选择性加密。资源 ID 若出现在 Native/opaque 数据或 packed-switch 中会自动固定;PAS2 会改写可识别的 `AssetManager.open/openFd/getAssets`,以 DEX/ELF 中的路径或目录常量覆盖常见动态拼接,并通过应用私有 overlay 支持常见 Native `AAssetManager` 读取。缺少必要条件会在变换前失败关闭。该状态为 `xop-pvm2-embedded`,不代表 Hollow、LLVM Native VMP 或完整 Xop RASP 已融合。 -- `ai-resistance` 支持 `off`、`balanced`、`high`,映射为 DEXB v6 认证载荷中的 1/2/3 层 Zstandard 压缩。层数不改变包内密钥可离线恢复的边界,也不能作为已验证的抗 AI 语义恢复能力。可选的 PVM2 方法虚拟化让成功转换的方法由 Native 解释器执行,需要单独核验覆盖范围。两者都不能替代服务端授权和密钥保护。 +- `ai-resistance` 继续接受 `off`、`balanced`、`high`,保持已有配置兼容;当前 `balanced` 与 `high` 采用相同保护决策,不再通过重复压缩区分层级,也不把 `high` 标签作为抗 AI 语义恢复能力的证明。实际能力取决于保护策略、运行时及逐方法覆盖。PVM2 让成功转换的方法由 Native 解释器执行,但不改变包内密钥可离线恢复的边界。 将 Xop PVM2 嵌入 Shellsmith 单 Stub(不引入第二个 Application/JNI 壳): @@ -447,7 +447,7 @@ unzip -l protected.apk | grep -E 'lib/|classes.*\.dex' 条目存在只能验证结构,不能代替签名验证与设备安装、启动测试。 ```bash -# 对比体积(off/balanced/high 的压缩层数会影响最终大小和首次加载成本) +# 对比体积(压缩、方法保护和附加运行时均会影响最终大小) ls -lh input.apk protected.apk ``` @@ -498,7 +498,7 @@ GUI 只维护一份全局证书状态。证书页保存、删除或切换默认 ### 加固后为什么体积反而变小? -DEX 文件经 Zstd 压缩后体积通常会明显减小;`high` 会再增加压缩层,实际体积和启动耗时应以目标 APK/设备实测为准。 +DEX 文件经 Zstd 压缩后体积通常会明显减小。新产物统一使用单层压缩,`high` 的其他保护策略保持有效;运行时仍兼容已有的多层载荷。实际体积和启动耗时应以目标 APK/设备实测为准。 --- @@ -512,8 +512,8 @@ DEX 文件经 Zstd 压缩后体积通常会明显减小;`high` 会再增加压 | classes2.dex | 12 MB | 3.7 MB | 30% | | classes3.dex | 6.7 MB | 1.9 MB | 29% | -- 首次启动额外耗时:取决于 AI 抵抗等级(单层/双层/三层解压、JNI 解密和缓存策略),必须用目标设备实测 -- Runtime 内存占用:约 1–2 MB +- 首次启动额外耗时:取决于载荷大小、解密解压、方法保护与缓存策略,必须用目标设备实测 +- 运行时内存开销:随载荷与解释器方法数量变化,需对照测量 PSS 和峰值内存 ## 更多界面预览 @@ -543,6 +543,8 @@ shield protect --input input.apk --output protected.apk \ 启用 PVM2 的成功输出旁会生成 `protected.apk.protection.json` 或 `protected.aab.protection.json`。统计分别展示转换成功率、选中方法覆盖率、原始指令字节覆盖率和必保方法覆盖率;未声明必保方法时该指标为 `null`,不记为 100%。这些百分比仅针对选择范围,不代表整个应用。报告包含逐方法原因、最终索引与产物摘要,不包含密钥或明文镜像,也不进入安装包。 +未转换的普通方法还会记录 `admission`:原兼容排除原因、桥接/合成标志、生成类名规则、直接协程父类、寄存器数、异常区数和指令计数。成功报告与 `pvm2-failure-*.json` 都保留这些本地事实,用于区分对象状态、调用、异常、协程及寄存器限制;它们不代表方法已获支持,也不进入可分享诊断摘要。初始化器和无代码方法仍直接查看原有原因分类。 + 选择范围在兼容性过滤**之前**统计。UI、导航和 Android 组件中的普通方法不再因包含字段、方法调用、数组元素或同步块而被叶子方法白名单整批排除;它们与普通业务类共用编译器,逐指令校验后再决定是否转换。跳板保留原声明、访问标志、参数与接收者,虚方法分派仍由 ART 完成。父类调用保留声明类的直接父类信息,接口默认实现单独按原接口调用,不能由接收者的动态类型推断。 这一路径支持经回归验证的字段读写、对象创建、静态/虚拟/接口/直接/父类调用、数组元素、空值调用、异常捕获与 finally、同步块。空值本身不再作为静态拒绝条件,运行时在原调用位置抛出并派发 `NullPointerException`。方法仍受支持指令、32 个寄存器(含编译临时寄存器)、4096 个 DEX 代码单元、编译后代码 65535 字节、分支偏移和异常表映射等边界约束;不是任意方法都能转换。标准 UTF-8 字符串保留内嵌空字符、补充平面字符及驻留身份;孤立 UTF-16 代理项明确拒绝,不能静默替换。 diff --git a/shield-stub/compat/api19-rust/Cargo.lock b/shield-stub/compat/api19-rust/Cargo.lock index d23f88d..993f279 100644 --- a/shield-stub/compat/api19-rust/Cargo.lock +++ b/shield-stub/compat/api19-rust/Cargo.lock @@ -242,7 +242,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mocikashield-api19" -version = "1.8.5" +version = "1.8.6" dependencies = [ "cc", "chacha20poly1305", diff --git a/shield-stub/compat/api19-rust/Cargo.toml b/shield-stub/compat/api19-rust/Cargo.toml index adf04c9..604396b 100644 --- a/shield-stub/compat/api19-rust/Cargo.toml +++ b/shield-stub/compat/api19-rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocikashield-api19" -version = "1.8.5" +version = "1.8.6" edition = "2021" license = "MIT OR Apache-2.0" build = "../../src/main/rust/build.rs" diff --git a/shield-stub/src/main/rust/Cargo.toml b/shield-stub/src/main/rust/Cargo.toml index e6bf3c2..5724658 100644 --- a/shield-stub/src/main/rust/Cargo.toml +++ b/shield-stub/src/main/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mocikashield" -version = "1.8.5" +version = "1.8.6" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/shield-stub/src/main/rust/build.rs b/shield-stub/src/main/rust/build.rs index 8668005..d43fb31 100644 --- a/shield-stub/src/main/rust/build.rs +++ b/shield-stub/src/main/rust/build.rs @@ -87,6 +87,7 @@ fn main() { rerun(&cpp.join(source)); } rerun(&cpp.join("common/runtime_state.h")); + rerun(&cpp.join("common/log.h")); rerun(&cpp.join("common/secure_memory.h")); rerun(&cpp.join("codeitem/multi_dex_code.h")); rerun(&cpp.join("crypto/aes.h")); diff --git a/shield-stub/src/main/rust/src/bin_loader.rs b/shield-stub/src/main/rust/src/bin_loader.rs index f47440b..9da2719 100644 --- a/shield-stub/src/main/rust/src/bin_loader.rs +++ b/shield-stub/src/main/rust/src/bin_loader.rs @@ -22,10 +22,31 @@ pub fn extract_from_dex_tail(dex_bytes: &[u8]) -> Result<&[u8]> { return extract_at(dex_bytes, trailer_start); } + // 打包只更新 file_size 与校验和,标准 DEX 的 data_off + data_size 仍指向原末尾。 + // 头部仅作为定位提示;必须验证完整边界,后续仍对整个载荷认证。 + // ponytail: 仅加速 035~040 的标准小端头;容器 DEX 和旧布局继续扫描,不猜测新格式。 + if dex_bytes.len() >= 112 + && matches!( + &dex_bytes[..8], + b"dex\n035\0" | b"dex\n037\0" | b"dex\n038\0" | b"dex\n039\0" | b"dex\n040\0" + ) + && dex_bytes[36..40] == 112u32.to_le_bytes() + && dex_bytes[40..44] == 0x12345678u32.to_le_bytes() + { + let size = u32::from_le_bytes(dex_bytes[104..108].try_into().unwrap()) as usize; + let offset = u32::from_le_bytes(dex_bytes[108..112].try_into().unwrap()) as usize; + if let Some(end) = offset.checked_add(size).filter(|end| *end >= 112) { + if validate_trailer(dex_bytes, end).is_ok() { + return extract_at(dex_bytes, end); + } + } + } + let found = dex_bytes .windows(4) - .rposition(|w| w == MSHD_MAGIC) - .and_then(|pos| validate_trailer(dex_bytes, pos).ok().map(|_| pos)); + .enumerate() + .rfind(|(pos, window)| *window == MSHD_MAGIC && validate_trailer(dex_bytes, *pos).is_ok()) + .map(|(pos, _)| pos); let mshd_pos = found.ok_or_else(|| "classes.dex 末尾未找到有效 MSHD trailer".to_string())?; extract_at(dex_bytes, mshd_pos) @@ -455,8 +476,8 @@ fn parse_entries( } fn decompress_entry(data: &[u8], compression_layers: u8, original_size: usize) -> Result> { - let mut decompressed = data.to_vec(); - for _ in 0..compression_layers { + let mut decompressed = zstd::decode_all(data).map_err(|e| format!("Zstd 解压失败: {e}"))?; + for _ in 1..compression_layers { decompressed = zstd::decode_all(&decompressed[..]).map_err(|e| format!("Zstd 解压失败: {e}"))?; } @@ -629,6 +650,9 @@ mod tests { native_so_key, crypto::derive_native_so_key(ikm, signature.as_bytes()) ); + *bin.last_mut().unwrap() ^= 1; + assert!(parse_and_decompress_payload(&bin, signature.as_bytes()).is_err()); + assert!(parse_xop_pvm2_payload(&bin, signature.as_bytes()).is_err()); } #[test] @@ -706,39 +730,44 @@ mod tests { #[test] fn v6_multi_layer_compression_roundtrip() { - let ikm = b"random-ikm-32-bytes-for-testing!"; - let build_id = [17u8; 16]; - let wrap_nonce = [18u8; DEXB_NONCE_LEN]; - let payload_nonce = [19u8; DEXB_NONCE_LEN]; - let signature = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"; - let dex_data = b"dex-v6-three-layer-content"; - let plaintext = build_plaintext_dex_with_layers("classes.dex", dex_data, 3); - let ciphertext = - build_encrypted_payload(&plaintext, ikm, payload_nonce, signature.as_bytes()); - let envelope_key = crypto::derive_envelope_key(signature.as_bytes(), &build_id); - let wrapped_ikm = { - let cipher = ChaCha20Poly1305::new((&envelope_key).into()); - cipher - .encrypt(Nonce::from_slice(&wrap_nonce), ikm.as_ref()) - .unwrap() - }; - - let mut bin = Vec::new(); - bin.extend_from_slice(b"DEXB"); - bin.extend_from_slice(&6u32.to_le_bytes()); - bin.extend_from_slice(&1u32.to_le_bytes()); - bin.extend_from_slice(&3u32.to_le_bytes()); - bin.push(signature.len() as u8); - bin.extend_from_slice(signature.as_bytes()); - bin.extend_from_slice(&build_id); - bin.extend_from_slice(&wrap_nonce); - bin.extend_from_slice(&(wrapped_ikm.len() as u16).to_le_bytes()); - bin.extend_from_slice(&wrapped_ikm); - bin.extend_from_slice(&payload_nonce); - bin.extend_from_slice(&ciphertext); + for layers in 1..=3 { + let ikm = b"random-ikm-32-bytes-for-testing!"; + let build_id = [17u8; 16]; + let wrap_nonce = [18u8; DEXB_NONCE_LEN]; + let payload_nonce = [19u8; DEXB_NONCE_LEN]; + let signature = "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899"; + let dex_data = b"dex-v6-three-layer-content"; + let plaintext = build_plaintext_dex_with_layers("classes.dex", dex_data, layers); + let ciphertext = + build_encrypted_payload(&plaintext, ikm, payload_nonce, signature.as_bytes()); + let envelope_key = crypto::derive_envelope_key(signature.as_bytes(), &build_id); + let wrapped_ikm = { + let cipher = ChaCha20Poly1305::new((&envelope_key).into()); + cipher + .encrypt(Nonce::from_slice(&wrap_nonce), ikm.as_ref()) + .unwrap() + }; - let (_, dex_files) = parse_and_decompress_all(&bin, signature.as_bytes()).unwrap(); - assert_eq!(dex_files, vec![dex_data.to_vec()]); + let mut bin = Vec::new(); + bin.extend_from_slice(b"DEXB"); + bin.extend_from_slice(&6u32.to_le_bytes()); + bin.extend_from_slice(&1u32.to_le_bytes()); + bin.extend_from_slice(&(layers as u32).to_le_bytes()); + bin.push(signature.len() as u8); + bin.extend_from_slice(signature.as_bytes()); + bin.extend_from_slice(&build_id); + bin.extend_from_slice(&wrap_nonce); + bin.extend_from_slice(&(wrapped_ikm.len() as u16).to_le_bytes()); + bin.extend_from_slice(&wrapped_ikm); + bin.extend_from_slice(&payload_nonce); + bin.extend_from_slice(&ciphertext); + + let (_, dex_files) = parse_and_decompress_all(&bin, signature.as_bytes()).unwrap(); + assert_eq!(dex_files, vec![dex_data.to_vec()]); + *bin.last_mut().unwrap() ^= 1; + assert!(parse_and_decompress_payload(&bin, signature.as_bytes()).is_err()); + assert!(parse_xop_pvm2_payload(&bin, signature.as_bytes()).is_err()); + } } #[test] @@ -754,6 +783,42 @@ mod tests { assert_eq!(extracted, payload); } + #[test] + fn 标准头定位不受载荷内标记干扰且旧布局跳过无效标记() { + let payload = b"DEXB-data-MSHD\x08\x00\x00\x00decoy!!!"; + let mut dex = vec![0u8; 128]; + dex[36..40].copy_from_slice(&112u32.to_le_bytes()); + dex[40..44].copy_from_slice(&0x12345678u32.to_le_bytes()); + dex[104..108].copy_from_slice(&16u32.to_le_bytes()); + dex[108..112].copy_from_slice(&112u32.to_le_bytes()); + dex.extend_from_slice(b"MSHD"); + dex.extend_from_slice(&(payload.len() as u32).to_le_bytes()); + dex.extend_from_slice(payload); + for magic in [ + b"dex\n035\0", + b"dex\n037\0", + b"dex\n038\0", + b"dex\n039\0", + b"dex\n040\0", + ] { + dex[..8].copy_from_slice(magic); + // 后面的诱饵也满足长度,必须优先使用已验证的 DEX 数据边界。 + assert_eq!(extract_from_dex_tail(&dex).unwrap(), payload); + } + // 让诱饵长度无效;旧布局不能仅检查最后一个同名标记就失败。 + let decoy_length = 136 + payload.windows(4).position(|w| w == MSHD_MAGIC).unwrap() + 4; + dex[decoy_length..decoy_length + 4].fill(0xff); + let expected = dex[136..].to_vec(); + for field in [36, 40, 104, 108] { + let mut broken = dex.clone(); + broken[field..field + 4].fill(0xff); + assert_eq!(extract_from_dex_tail(&broken).unwrap(), expected); + } + for end in 0..dex.len() { + assert!(extract_from_dex_tail(&dex[..end]).is_err()); + } + } + #[test] fn extract_from_dex_tail_no_magic_returns_error() { let dex = b"DEX_CONTENT_WITHOUT_MAGIC_AT_ALL____"; diff --git a/shield-stub/src/main/rust/src/lib.rs b/shield-stub/src/main/rust/src/lib.rs index 9b7638d..030e544 100644 --- a/shield-stub/src/main/rust/src/lib.rs +++ b/shield-stub/src/main/rust/src/lib.rs @@ -13,6 +13,37 @@ use jni::sys::{jboolean, jint, jobject, jobjectArray, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; use jni::NativeMethod; +/// 只收敛加载器内部错误;已挂起的 Java 异常(包括业务异常和 OOM)保持原样。 +fn throw_loader_error(env: &mut JNIEnv, class: &str, code: &str, detail: impl std::fmt::Display) { + if matches!(env.exception_check(), Ok(false)) { + let _ = env.throw_new(class, loader_error_message(code, detail)); + } +} + +fn loader_error_message(code: &str, detail: impl std::fmt::Display) -> String { + if cfg!(debug_assertions) { + format!("{code}: {detail}") + } else { + code.to_owned() + } +} + +#[cfg(test)] +mod loader_error_tests { + #[test] + fn 加载错误仅在调试构建保留细节() { + let text = super::loader_error_message("L10", "算法、偏移与本机路径"); + assert_eq!( + text, + if cfg!(debug_assertions) { + "L10: 算法、偏移与本机路径" + } else { + "L10" + } + ); + } +} + /// 动态注册 native 方法,使 Rust 函数名不出现在 .dynsym,切断符号可读性。 /// /// # Safety @@ -117,7 +148,7 @@ extern "C" fn f8(mut env: JNIEnv, _class: JClass, blob: JByteArray) -> jobjectAr match result { Ok(array) => array, Err(error) => { - let _ = env.throw_new("java/io/IOException", error); + throw_loader_error(&mut env, "java/io/IOException", "N51", error); std::ptr::null_mut() } } @@ -140,7 +171,7 @@ extern "C" fn f9(mut env: JNIEnv, _class: JClass, name: JString, encrypted: JByt match result { Ok(array) => array, Err(error) => { - let _ = env.throw_new("java/io/IOException", error); + throw_loader_error(&mut env, "java/io/IOException", "N52", error); std::ptr::null_mut() } } @@ -173,7 +204,7 @@ extern "C" fn f7(mut env: JNIEnv, _class: JClass, chunk: JByteArray) -> jobject match result { Ok(array) => array, Err(error) => { - let _ = env.throw_new("java/io/IOException", error); + throw_loader_error(&mut env, "java/io/IOException", "A10", error); std::ptr::null_mut() } } @@ -218,7 +249,7 @@ extern "C" fn f1( match f1_impl(&mut env, class_loader, dex_paths, opt_dir_path) { Ok(()) => JNI_TRUE, Err(e) => { - android_log_warn(&mut env, &format!("注入失败,降级到 Java 反射:{}", e)); + android_log_warn(&mut env, &loader_error_message("L14", e)); JNI_FALSE } } @@ -432,14 +463,13 @@ extern "C" fn f2( ) -> jobjectArray { // 反调试检测:先于所有解密动作,检测到调试器或 Frida 注入则立即拒绝。 if anti_debug::check() { - android_log_warn(&mut env, "dbg"); - let _ = env.throw_new("java/lang/RuntimeException", "dbg"); + throw_loader_error(&mut env, "java/lang/RuntimeException", "S01", "dbg"); return std::ptr::null_mut(); } let decoded = match decrypt_dex_files(&mut env, &ctx, &dex_data) { Ok(files) => files, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L10", e); return std::ptr::null_mut(); } }; @@ -447,10 +477,7 @@ extern "C" fn f2( let byte_array_class = match env.find_class("[B") { Ok(cls) => cls, Err(e) => { - let _ = env.throw_new( - "java/lang/RuntimeException", - format!("查找 byte[] 类失败: {}", e), - ); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L12", e); return std::ptr::null_mut(); } }; @@ -458,14 +485,19 @@ extern "C" fn f2( let dex_count = match i32::try_from(decoded.dex_files.len()) { Ok(n) => n, Err(_) => { - let _ = env.throw_new("java/lang/RuntimeException", "DEX 数量超过 i32 上限"); + throw_loader_error( + &mut env, + "java/lang/RuntimeException", + "L12", + "DEX 数量超过 i32 上限", + ); return std::ptr::null_mut(); } }; let result_array = match env.new_object_array(dex_count, &byte_array_class, JObject::null()) { Ok(arr) => arr, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("创建数组失败: {}", e)); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L12", e); return std::ptr::null_mut(); } }; @@ -474,18 +506,12 @@ extern "C" fn f2( let byte_array = match env.byte_array_from_slice(dex_data) { Ok(arr) => arr, Err(e) => { - let _ = env.throw_new( - "java/lang/RuntimeException", - format!("创建 byte[] 失败: {}", e), - ); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L12", e); return std::ptr::null_mut(); } }; if let Err(e) = env.set_object_array_element(&result_array, i as i32, byte_array) { - let _ = env.throw_new( - "java/lang/RuntimeException", - format!("设置数组元素失败: {}", e), - ); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L12", e); return std::ptr::null_mut(); } } @@ -524,7 +550,7 @@ extern "C" fn f6(mut env: JNIEnv, _class: JClass, ctx: JObject, dex_data: JByteA match result { Ok(()) => JNI_TRUE, Err(error) => { - let _ = env.throw_new("java/lang/RuntimeException", error); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L11", error); JNI_FALSE } } @@ -538,21 +564,20 @@ extern "C" fn f4( dex_data: JByteArray, ) -> jobjectArray { if anti_debug::check() { - android_log_warn(&mut env, "dbg"); - let _ = env.throw_new("java/lang/RuntimeException", "dbg"); + throw_loader_error(&mut env, "java/lang/RuntimeException", "S01", "dbg"); return std::ptr::null_mut(); } let decoded = match decrypt_dex_files(&mut env, &ctx, &dex_data) { Ok(files) => files, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", e); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L10", e); return std::ptr::null_mut(); } }; let buffer_class = match env.find_class("java/nio/ByteBuffer") { Ok(class) => class, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("b1: {e}")); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L13", e); return std::ptr::null_mut(); } }; @@ -563,7 +588,7 @@ extern "C" fn f4( ) { Ok(array) => array, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("b2: {e}")); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L13", e); return std::ptr::null_mut(); } }; @@ -579,7 +604,7 @@ extern "C" fn f4( { Ok(object) => object, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("b3: {e}")); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L13", e); return std::ptr::null_mut(); } }; @@ -587,13 +612,13 @@ extern "C" fn f4( let address = match env.get_direct_buffer_address(&buffer) { Ok(address) => address, Err(e) => { - let _ = env.throw_new("java/lang/RuntimeException", format!("b4: {e}")); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L13", e); return std::ptr::null_mut(); } }; unsafe { std::ptr::copy_nonoverlapping(dex.as_ptr(), address, dex.len()) }; if let Err(e) = env.set_object_array_element(&result, index as i32, &buffer) { - let _ = env.throw_new("java/lang/RuntimeException", format!("b5: {e}")); + throw_loader_error(&mut env, "java/lang/RuntimeException", "L13", e); return std::ptr::null_mut(); } } diff --git a/tests/java/com/yqsh/protector/packer/ApkRecoveryCheck.java b/tests/java/com/yqsh/protector/packer/ApkRecoveryCheck.java index b8d29ff..33317b4 100644 --- a/tests/java/com/yqsh/protector/packer/ApkRecoveryCheck.java +++ b/tests/java/com/yqsh/protector/packer/ApkRecoveryCheck.java @@ -50,6 +50,9 @@ private static byte[] decrypt(byte[] key, byte[] nonce, byte[] data, boolean aes /** 白盒成本检查:复用已公开的指令长度/寄存器布局,反解每包的置换表。 */ private static byte[] canonicalCode(byte[] image) throws Exception { ByteBuffer b = buffer(image); + if (image.length < 19 || b.getInt() != 0x324d5650) throw new IllegalArgumentException("VM 镜像无效"); + int version = u16(b); + if (version < 7 || version > 9) throw new IllegalArgumentException("恢复检查不支持该 VM 版本;不能视为防护通过"); int registers = u16((ByteBuffer) b.position(6)); b.position(10); int handlers = u16(b), size = u16(b); b.position(16); int strings = u16(b); b.get(); @@ -71,7 +74,7 @@ private static byte[] canonicalCode(byte[] image) throws Exception { if (slot >= registers || reverse[slot] != -1) throw new IllegalArgumentException("寄存器映射无效"); reverse[slot] = (byte) i; } - if (buffer(image).getShort(4) >= 8) take(b, Byte.toUnsignedInt(b.get())); + if (version >= 8) take(b, Byte.toUnsignedInt(b.get())); take(b, handlers * 10); byte[] code = take(b, size); if (b.hasRemaining()) throw new IllegalArgumentException("VM 镜像尾部残留"); @@ -124,6 +127,9 @@ private static void selfTest() throws Exception { byte[] image = Pvm2Compiler.tryCompile(null, code, "I", true, Pvm2Morph.fromIsa(isa, new java.security.SecureRandom()), true).image; if (!Arrays.equals(canonicalCode(image), expected)) throw new AssertionError("随机映射未还原为预期指令"); + byte[] unsupported = image.clone(); unsupported[4] = 10; + try { canonicalCode(unsupported); throw new AssertionError("未拒绝未知镜像版本"); } + catch (IllegalArgumentException expectedFailure) { } // 此无常量池样本的前两个操作码映射位于 26、27 字节。 image[27] = image[26]; try { canonicalCode(image); throw new AssertionError("未拒绝重复映射"); } @@ -262,12 +268,11 @@ && buffer(dex).getInt(i + 4) == dex.length - i - 8 int method = code.getInt(), original = code.getInt(), size = code.getInt(), flags = code.getInt(); if (size < 28 || flags != 2) throw new IllegalArgumentException("VM 记录无效"); byte[] image = decrypt(key, take(code, 12), take(code, size - 12), true); - if (image.length != original || image.length < 19 || !Arrays.equals(Arrays.copyOf(image, 4), new byte[]{'P','V','M','2'}) - || (buffer(image).getShort(4) != 7 && buffer(image).getShort(4) != 8)) throw new IllegalStateException("VM 镜像无效"); + if (image.length != original) throw new IllegalStateException("VM 镜像长度不一致"); + byte[] canonical = canonicalCode(image); String name = "image-" + dexIndex + "-" + method + ".bin"; if (recovered.put(dexIndex + ":" + method, (int) image[18]) != null) throw new IllegalStateException("重复 VM 记录"); Files.write(root.resolve(name), image); - byte[] canonical = canonicalCode(image); Files.write(root.resolve(name + ".canonical"), canonical); images.put(new JSONObject().put("file", name).put("sha256", hash(image)).put("policy", image[18]) .put("canonical_code_sha256", hash(canonical))); @@ -280,20 +285,27 @@ && buffer(dex).getInt(i + 4) == dex.length - i - 8 JSONObject report = new JSONObject(Files.readString(Path.of(args[3]))); if (!hash(Files.readAllBytes(apk)).equals(report.getString("output_sha256"))) throw new IllegalStateException("报告与 APK 不匹配"); JSONObject selection = report.getJSONObject("build").getJSONObject("selection"); + Map> dexMethods = new HashMap<>(); for (Object entry : report.getJSONArray("methods")) { JSONObject row = (JSONObject) entry; if (!row.getBoolean("artifact_verified")) continue; String name = row.getString("input_dex"); - DexBackedDexFile file = new DexBackedDexFile(Opcodes.getDefault(), Files.readAllBytes(root.resolve(name))); - boolean found = false; - for (var cls : file.getClasses()) for (var method : cls.getMethods()) { - if (!Pvm2Report.descriptor(method).equals(row.getString("descriptor"))) continue; - int index = row.getInt("final_method_index"), dexIndex = row.getInt("final_dex_index"); - if (!TrueVmpTrampoline.matches(method, dexIndex, index, selection.getString("bridge"), selection.getString("bridge_method")) - || !recovered.containsKey(dexIndex + ":" + index)) throw new IllegalStateException("跳板与镜像不一致"); - found = true; trampolines++; + if (!name.matches("classes[0-9]*\\.dex") || !hashes.has(name)) throw new IllegalArgumentException("报告引用了未恢复的 DEX"); + if (!dexMethods.containsKey(name)) { + // 每个 DEX 只清点一次;真实应用的上万条报告不能反复扫描整份 DEX。 + DexBackedDexFile file = new DexBackedDexFile(Opcodes.getDefault(), Files.readAllBytes(root.resolve(name))); + Map methods = new HashMap<>(); + for (var cls : file.getClasses()) for (var method : cls.getMethods()) { + if (methods.put(Pvm2Report.descriptor(method), method) != null) throw new IllegalStateException("DEX 方法签名重复"); + } + dexMethods.put(name, methods); } - if (!found) throw new IllegalStateException("恢复 DEX 中缺少方法"); + var method = dexMethods.get(name).get(row.getString("descriptor")); + if (method == null) throw new IllegalStateException("恢复 DEX 中缺少方法"); + int index = row.getInt("final_method_index"), dexIndex = row.getInt("final_dex_index"); + if (!TrueVmpTrampoline.matches(method, dexIndex, index, selection.getString("bridge"), selection.getString("bridge_method")) + || !recovered.containsKey(dexIndex + ":" + index)) throw new IllegalStateException("跳板与镜像不一致"); + trampolines++; } } JSONObject nativeFiles = recoverNative(apk, root, cert, ikm); diff --git a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java index e34f9d6..6a8d813 100644 --- a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java +++ b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java @@ -50,6 +50,8 @@ public static void main(String[] args) throws Exception { assert row.isNull("plaintext_policy"); skipped++; if (row.getString("descriptor").startsWith("Lfixture/MainActivity;")) { assert row.getString("reason").equals("ui_runtime_glue:virtual_contract"); excluded++; + assert row.getJSONObject("admission").getString("policy_exclusion").equals(row.getString("reason")); + assert row.getJSONObject("admission").getJSONObject("opcodes").getInt("return") == 1; } else assert row.getString("reason").equals("no_code"); } } @@ -391,6 +393,14 @@ private static void checkMethodCompatibility(Path root) throws Exception { new ImmutableInstruction11n(Opcode.CONST_4, 0, 0), new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(new ImmutableTryBlock(0, 1, List.of(new ImmutableExceptionHandler(null, 2)))), List.of())); assert AutoVmpPolicy.exclusionReason(plain, caught) == null; + var facts = Pvm2Report.admissionFacts(plain, caught, "仅用于事实检查"); + assert facts.getInt("try_blocks") == 1 && facts.getInt("register_count") == 1; + assert facts.getJSONObject("opcodes").getInt("return") == 2; + assert !facts.getBoolean("direct_coroutine_base") && !facts.getBoolean("generated_name_rule"); + var coroutine = calculationOwner("Lfixture/Rules$1;", "Lkotlin/coroutines/jvm/internal/SuspendLambda;", List.of(), List.of()); + facts = Pvm2Report.admissionFacts(coroutine, calculation(type, "invokeSuspend", 0x1041), "compiler_generated_bridge"); + assert facts.getBoolean("direct_coroutine_base") && facts.getBoolean("generated_name_rule"); + assert facts.getBoolean("method_bridge_flag") && facts.getBoolean("method_synthetic_flag"); for (String generated : List.of("Lfixture/Rules$$ExternalSyntheticLambda0;", "Lfixture/Rules$1;")) assert AutoVmpPolicy.exclusionReason(calculationOwner(generated, "Ljava/lang/Object;", List.of(), List.of()), calculation(generated, "calculate", 9)).equals("compiler_generated_bridge"); diff --git a/tests/native/pvm2_runtime_check.cpp b/tests/native/pvm2_runtime_check.cpp index e8e68ab..94ab9ae 100644 --- a/tests/native/pvm2_runtime_check.cpp +++ b/tests/native/pvm2_runtime_check.cpp @@ -5,6 +5,13 @@ #define PROTECTOR_ENCRYPT #include "vm/pvm2_interp.cpp" #include "codeitem/multi_dex_code.h" +#ifdef NDEBUG +static constexpr bool release_diagnostics = true; +// 生产实现使用 NDEBUG,测试断言始终执行。 +#undef NDEBUG +#else +static constexpr bool release_diagnostics = false; +#endif #include #include #include @@ -197,6 +204,22 @@ int main(int argc, char** argv) { assert(JNI_CreateJavaVM(&jvm, reinterpret_cast(&env), &args) == JNI_OK); jobject result = interpret(env, 2, 0, nullptr); assert(result && !env->ExceptionCheck()); + auto check_error = [&](const char* type, const char* message) { + assert(env->ExceptionCheck()); + jthrowable error = env->ExceptionOccurred(); env->ExceptionClear(); + jclass cls = env->FindClass(type); + assert(env->IsInstanceOf(error, cls)); + auto text = static_cast(env->CallObjectMethod(error, + env->GetMethodID(cls, "getMessage", "()Ljava/lang/String;"))); + const char* chars = env->GetStringUTFChars(text, nullptr); + assert(!strcmp(chars, message)); + env->ReleaseStringUTFChars(text, chars); + env->DeleteLocalRef(text); env->DeleteLocalRef(cls); env->DeleteLocalRef(error); + }; + assert(!interpret(env, -1, 0, nullptr)); + check_error("java/lang/RuntimeException", release_diagnostics ? "Protection runtime error (P1)" : "VMP bad dex"); + throw_security(env, "VMP refused by RASP"); + check_error("java/lang/SecurityException", release_diagnostics ? "Protection policy rejected (P2)" : "VMP refused by RASP"); #if defined(MOCIKA_PVM2_PROFILE) assert(g_pvm2_profile == nullptr); const auto& profiled = g_pvm2_profile_sums.at(uint64_t(2) << 32); diff --git a/tests/scripts/check-pvm2-runtime.py b/tests/scripts/check-pvm2-runtime.py index d64da79..de717c7 100644 --- a/tests/scripts/check-pvm2-runtime.py +++ b/tests/scripts/check-pvm2-runtime.py @@ -41,8 +41,14 @@ str(ROOT / 'shield-stub/src/main/rust/src/xop_pvm2_bridge.cpp')] cmd += [str(CPP / s) for s in ['common/runtime_state.cpp', 'codeitem/multi_dex_code.cpp', 'crypto/aes.cpp', 'vm/pvm2_format.cpp']] cmd += ['-L' + str(java / 'lib/server'), '-Wl,-rpath,' + str(java / 'lib/server'), '-ljvm', '-o', str(exe)] - subprocess.run(cmd, check=True) env = dict(os.environ, ASAN_OPTIONS='detect_leaks=0', UBSAN_OPTIONS='halt_on_error=1') # JVM 自有分配及进程级 JNI 类缓存不用于解释器泄漏判定;对象生命周期由断言检查。 - subprocess.run([str(exe), 'block-before-init'], check=True, env=env) - subprocess.run([str(exe), str(temp)], check=True, env=env) + for release in (False, True): + subprocess.run(cmd + (['-DNDEBUG'] if release else []), check=True) + if release: + binary = exe.read_bytes() + for marker in (b'PVM2 bad ', b'PVM2 unknown op', b'unsupported code.bin version', + b'VMP bad dex', b'VMP argument count mismatch', b'VMP refused by RASP'): + assert marker not in binary, '发布版仍携带内部诊断:' + marker.decode() + subprocess.run([str(exe), 'block-before-init'], check=True, env=env) + subprocess.run([str(exe), str(temp)], check=True, env=env) diff --git a/tests/scripts/check-rea-resilience.py b/tests/scripts/check-rea-resilience.py new file mode 100644 index 0000000..cfec1f6 --- /dev/null +++ b/tests/scripts/check-rea-resilience.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""用固定版本 REA 与仓库白盒恢复检查分别验证授权 APK;不安装工具、不连接设备。""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +JADX_SHA256 = '6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75' +DIAGNOSTICS = (b'PVM2 bad ', b'PVM2 unknown op', b'unsupported code.bin version', + b'VMP bad dex', b'VMP argument count mismatch', b'VMP refused by RASP', + b'MOCIKA_PVM2_PROFILE') + + +def digest(path): + value = hashlib.sha256() + with path.open('rb') as stream: + for block in iter(lambda: stream.read(1024 * 1024), b''): + value.update(block) + return value.hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--rea', type=Path, required=True, help='rea-agents 6.7.0 的 scripts/rea.mjs') + parser.add_argument('--jadx-jar', type=Path, required=True) + parser.add_argument('--input-apk', type=Path, required=True) + parser.add_argument('--protected-apk', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True, help='与加固 APK 哈希匹配的逐方法报告') + parser.add_argument('--output', type=Path, required=True, help='尚不存在的本地证据目录,包含业务标识') + parser.add_argument('--class-query', required=True) + parser.add_argument('--method', action='append', required=True, help='REA 类名#方法名;只接受无重载的抽样方法,可重复') + parser.add_argument('--require-private-diagnostics', action='store_true') + args = parser.parse_args() + for name in ('rea', 'jadx_jar', 'input_apk', 'protected_apk', 'report'): + path = getattr(args, name).resolve(strict=True) + if not path.is_file(): + raise ValueError('输入必须是文件:' + name) + setattr(args, name, path) + if digest(args.jadx_jar) != JADX_SHA256: + raise ValueError('JADX 与 REA 固定元数据不匹配') + rea = ['node', str(args.rea)] + version = subprocess.check_output(rea + ['--version'], text=True, timeout=30).strip() + if version != '6.7.0': + raise ValueError('本回归固定 REA 6.7.0;升级后需重新核验结果契约') + report = json.loads(args.report.read_text(encoding='utf-8')) + if (report['output_sha256'] != digest(args.protected_apk) + or report['build']['input_sha256'] != digest(args.input_apk)): + raise ValueError('报告与输入/输出 APK 不匹配') + work = args.output.resolve() + work.mkdir(mode=0o700, parents=True, exist_ok=False) + summary = {'rea_version': version, 'jadx_sha256': JADX_SHA256, + 'input_sha256': digest(args.input_apk), 'output_sha256': digest(args.protected_apk), + 'status': 'incomplete', 'semantic_reconstruction': 'not_evaluated', + 'dynamic_instrumentation': 'not_evaluated'} + env = dict(os.environ, REA_JADX_MCP_JAR=str(args.jadx_jar)) + + def inspect(label, apk, command, *selection): + with (work / (label + '.json')).open('w') as out, (work / (label + '.log')).open('w') as err: + subprocess.run(rea + [command, str(apk), *selection, '--json'], + stdout=out, stderr=err, env=env, check=True, timeout=180) + evidence = json.loads((work / (label + '.json')).read_text(encoding='utf-8')) + if (evidence['subject']['digest']['sha256'] != digest(apk) + or evidence['normalized_result']['engine']['artifact_sha256'] != JADX_SHA256): + raise ValueError('REA 证据未绑定本次产物或指定引擎') + return evidence['normalized_result'] + + try: + direct = inspect('direct-classes', args.protected_apk, 'search-android-classes', args.class_query) + if direct['coverage'] != 'complete': + raise ValueError('REA 类搜索未完成,不能判定保护效果') + summary['direct_class_matches'] = len(direct['matches']) + # 白盒恢复与 REA 分开计时/记证据,不把仓库私有知识冒充 REA 自动还原能力。 + classes = work / 'checker' + classes.mkdir() + jar = ROOT / 'tools/xop-pvm2-packer.jar' + sources = ROOT / 'tests/java/com/yqsh/protector/packer' + with (work / 'recovery.log').open('w') as log: + subprocess.run(['javac', '--release', '17', '-cp', str(jar), '-d', str(classes), + *[str(sources / (name + '.java')) for name in + ('Pvm2ProtectionCheck', 'Pvm2RuntimeImages', 'ApkRecoveryCheck')]], + stdout=log, stderr=log, check=True, timeout=120) + zstd = shutil.which('zstd') + if not zstd: + raise ValueError('缺少本地 zstd,不能把工具缺失视为抗还原通过') + subprocess.run(['java', '-ea', '-cp', str(classes) + os.pathsep + str(jar), + 'com.yqsh.protector.packer.ApkRecoveryCheck', str(args.protected_apk), + str(work / 'recovered'), zstd, str(args.report)], + stdout=log, stderr=log, check=True, timeout=180) + recovery = json.loads((work / 'recovered/recovery.json').read_text(encoding='utf-8')) + verified = sum(row['artifact_verified'] for row in report['methods']) + if verified == 0 or verified != recovery['verified_trampolines']: + raise ValueError('逐方法报告与恢复跳板数量不一致') + summary.update(selected_methods=len(report['methods']), verified_methods=verified, + recovered_images=len(recovery['recovered_images']), + verified_trampolines=recovery['verified_trampolines'], + apk_only_payload_recovery='recovered', recovery_ms=recovery['elapsed_ms']) + # 只从加固 APK 和恢复出的 DEX 重建静态分析输入,不借用原包代码,不安装此未签名包。 + recovered_apk = work / 'recovered-for-analysis.apk' + with zipfile.ZipFile(args.protected_apk) as original, zipfile.ZipFile(recovered_apk, 'w') as rebuilt: + for item in original.infolist(): + if re.fullmatch(r'classes\d*\.dex', item.filename) or item.filename.startswith('META-INF/'): + continue + rebuilt.writestr(item, original.read(item)) + for name in recovery['entries']: + if re.fullmatch(r'classes\d*\.dex', name): + rebuilt.write(work / 'recovered' / name, name) + samples = [] + protected_methods = {row['descriptor'].split(';->', 1)[0][1:].replace('/', '.').replace('$', '.') + + '#' + row['descriptor'].split(';->', 1)[1] + for row in report['methods'] if row['artifact_verified']} + for index, selection in enumerate(args.method): + owner, method = selection.rsplit('#', 1) + before = inspect('method-%d-before' % index, args.input_apk, 'inspect-android-method', owner, method) + after = inspect('method-%d-recovered' % index, recovered_apk, 'inspect-android-method', owner, method) + for evidence in (before, after): + if evidence['body_status'] != 'available' or evidence['source']['status'] != 'complete': + raise ValueError('REA 方法体读取不完整,不能判定语义是否隐藏') + if (selection + before['method']['dex_descriptor'] not in protected_methods + or before['method']['dex_descriptor'] != after['method']['dex_descriptor']): + raise ValueError('抽样方法没有对应到同签名的已验证保护方法') + samples.append({'class': owner, 'method': method, + 'same_decompiled_text': before['source']['text'] == after['source']['text'], + 'before_representation': before['representation'], 'after_representation': after['representation']}) + summary['method_samples'] = samples + with zipfile.ZipFile(args.protected_apk) as apk: + summary['native_diagnostic_matches'] = {} + for name in apk.namelist(): + if name.startswith('lib/') and name.endswith('.so'): + binary = apk.read(name) + summary['native_diagnostic_matches'][name] = sum(binary.count(marker) for marker in DIAGNOSTICS) + if args.require_private_diagnostics: + if not summary['native_diagnostic_matches']: + raise ValueError('未检查到 Native 库,不能判定发布诊断检查通过') + if any(summary['native_diagnostic_matches'].values()): + raise ValueError('产物仍携带内部诊断或性能采样文本') + summary['status'] = 'complete' + print('REA 回归完成;白盒已恢复 %d 个镜像,不能宣称 APK 载荷保密。' % summary['recovered_images']) + except Exception: + summary['status'] = 'error' + raise + finally: + (work / 'summary.json').write_text(json.dumps(summary, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + + +if __name__ == '__main__': + main() diff --git a/third_party/xopprotector/native/src/main/cpp/common/log.h b/third_party/xopprotector/native/src/main/cpp/common/log.h index 480c0f7..53fe72b 100644 --- a/third_party/xopprotector/native/src/main/cpp/common/log.h +++ b/third_party/xopprotector/native/src/main/cpp/common/log.h @@ -5,12 +5,11 @@ #define PROTECTOR_LOG_TAG "protector" #ifdef NDEBUG -// Release build: strip all diagnostic logs — they leak protection internals. +// 发布版保留故障信号,但不携带方法编号、指令或解析结构。 #define PLOGD(...) ((void)0) #define PLOGI(...) ((void)0) #define PLOGW(...) ((void)0) -// Keep PLOGE for crash forensics (only fires right before a crash). -#define PLOGE(...) __android_log_print(ANDROID_LOG_ERROR, PROTECTOR_LOG_TAG, __VA_ARGS__) +#define PLOGE(...) __android_log_print(ANDROID_LOG_ERROR, "runtime", "%s", "Protection runtime error (P1)") #else #define PLOGD(...) __android_log_print(ANDROID_LOG_DEBUG, PROTECTOR_LOG_TAG, __VA_ARGS__) #define PLOGI(...) __android_log_print(ANDROID_LOG_INFO, PROTECTOR_LOG_TAG, __VA_ARGS__) diff --git a/third_party/xopprotector/native/src/main/cpp/vm/pvm2_interp.cpp b/third_party/xopprotector/native/src/main/cpp/vm/pvm2_interp.cpp index 34fe47d..667926f 100644 --- a/third_party/xopprotector/native/src/main/cpp/vm/pvm2_interp.cpp +++ b/third_party/xopprotector/native/src/main/cpp/vm/pvm2_interp.cpp @@ -948,6 +948,10 @@ static void throw_arith(JNIEnv* env, const char* msg) { } static void throw_runtime(JNIEnv* env, const char* msg) { +#ifdef NDEBUG + // 只收敛解释器内部错误;业务抛出的异常仍按原对象传播。 + msg = "Protection runtime error (P1)"; +#endif throw_cached(env, jni_cache().runtime_ex, msg); } @@ -960,6 +964,9 @@ static void throw_cce(JNIEnv* env, const char* msg) { } static void throw_security(JNIEnv* env, const char* msg) { +#ifdef NDEBUG + msg = "Protection policy rejected (P2)"; +#endif throw_cached(env, jni_cache().security_ex, msg); } diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java index 2e5bbf9..afa4976 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Report.java @@ -3,6 +3,7 @@ import com.android.tools.smali.dexlib2.Opcodes; import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile; import com.android.tools.smali.dexlib2.iface.Method; +import com.android.tools.smali.dexlib2.iface.ClassDef; import com.android.tools.smali.dexlib2.iface.reference.MethodReference; import org.json.JSONArray; import org.json.JSONObject; @@ -75,12 +76,36 @@ void inventory(List files, Predicate selectedType) throws Exceptio .put("input_dex", file.getName()).put("transform_status", "skipped") .put("reason", reason).put("artifact_verified", false) .put("plaintext_policy", JSONObject.NULL); + if (impl != null && !method.getName().startsWith("<")) + row.put("admission", admissionFacts(cls, method, exclusion)); if (methods.putIfAbsent(id, row) != null) throw new IllegalArgumentException("模块中存在重复方法:" + id); } } for (String id : required) if (!methods.containsKey(id)) throw new IllegalArgumentException("关键方法未匹配输入 DEX:" + id); } + /** 记录准入事实,不将命名启发式解释为业务分类或语义已获支持。 */ + static JSONObject admissionFacts(ClassDef owner, Method method, String exclusion) { + var code = method.getImplementation(); + Map opcodes = new TreeMap<>(); + for (var instruction : code.getInstructions()) + opcodes.merge(instruction.getOpcode().name, 1, Integer::sum); + String parent = owner.getSuperclass(); + boolean coroutine = Set.of("Lkotlin/coroutines/jvm/internal/SuspendLambda;", + "Lkotlin/coroutines/jvm/internal/RestrictedSuspendLambda;", + "Lkotlin/coroutines/jvm/internal/ContinuationImpl;", + "Lkotlin/coroutines/jvm/internal/RestrictedContinuationImpl;").contains(parent == null ? "" : parent); + // ponytail: 仅记录直接父类;不从方法名推断协程,也不宣称已解析完整继承链。 + return new JSONObject().put("policy_exclusion", exclusion == null ? JSONObject.NULL : exclusion) + .put("method_bridge_flag", (method.getAccessFlags() & 0x40) != 0) + .put("method_synthetic_flag", (method.getAccessFlags() & 0x1000) != 0) + .put("class_synthetic_flag", (owner.getAccessFlags() & 0x1000) != 0) + .put("generated_name_rule", AutoVmpPolicy.isCompilerGeneratedBridge(owner.getType())) + .put("direct_coroutine_base", coroutine) + .put("register_count", code.getRegisterCount()).put("try_blocks", code.getTryBlocks().size()) + .put("opcodes", new JSONObject(opcodes)); + } + void begin(File dex) { currentModule = modules.getOrDefault(dex.getName(), "base"); } boolean requiresType(String type) { @@ -104,6 +129,7 @@ void compiled(String key, byte[] image) throws Exception { verifyPolicy(image, row.getBoolean("required")); row.put("transform_status", "transformed").put("reason", JSONObject.NULL) .put("plaintext_policy", row.getBoolean("required") ? "clear_after_use" : "bounded_cache"); + row.remove("admission"); images.put(id(key), sha256(image)); } diff --git a/tools/xop-pvm2-packer.jar b/tools/xop-pvm2-packer.jar index fc1dfb6..d658864 100644 Binary files a/tools/xop-pvm2-packer.jar and b/tools/xop-pvm2-packer.jar differ