diff --git a/.github/release-notes/versions/1.8.4.md b/.github/release-notes/versions/1.8.4.md new file mode 100644 index 0000000..5b6d8c9 --- /dev/null +++ b/.github/release-notes/versions/1.8.4.md @@ -0,0 +1,12 @@ +### PVM2 继承与异常处理兼容修复 + +- 符合条件的独立方法不再仅因所属类存在继承或接口契约而被排除;保留原方法声明、访问标志和接收者,由 ART 继续负责虚方法分派。 +- 放行通过指令、控制流及异常表校验的独立异常处理方法,修复 `throw null` 未产生可捕获 `NullPointerException` 的运行时问题,覆盖捕获顺序、重抛和 `finally` 路径。 +- 修复合法空资源表、完全无资源表 APK 在资源 ID 重排、Apktool 重打包及空路径映射校验中的失败。 +- 构造器、同步、编译器桥接、特殊对象契约及不支持的状态访问/调用仍按规则保留;零转换和必保方法未覆盖仍明确失败,不把跳过的方法计为已保护。 + +### 验证与使用说明 + +- 自建 Java/D8 样本的 36 个方法中,26 个完成转换与产物核验,10 个明确保留。在 Android 9、15、16 的 arm64 模拟器上完成 20 轮运行,每轮 482 项断言全部通过,包含 Android 16 的 16 KB 内存页和无资源表变体。 +- 模拟器回归不能替代厂商真机或客户原始 APK 验收;没有客户方法体时,不能保证其全部方法都能转换。完整覆盖范围以当前产物的逐方法报告为准。 +- 升级后需要重新加固,已有 APK 不会自动获得本次修复。PVM2 需要 JDK 17 或更高版本;分享逐方法报告前请手动脱敏。 diff --git a/Cargo.lock b/Cargo.lock index 1f00c7f..ee885cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "mocika-shield" -version = "1.8.3" +version = "1.8.4" dependencies = [ "dunce", "reqwest 0.12.28", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "mocikashield" -version = "1.8.3" +version = "1.8.4" dependencies = [ "cc", "chacha20poly1305", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "shield-cli" -version = "1.8.3" +version = "1.8.4" dependencies = [ "anyhow", "clap", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "shield-core" -version = "1.8.3" +version = "1.8.4" dependencies = [ "anyhow", "chacha20poly1305", diff --git a/apps/shield-cli/Cargo.toml b/apps/shield-cli/Cargo.toml index 665a553..30e9d9b 100644 --- a/apps/shield-cli/Cargo.toml +++ b/apps/shield-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-cli" -version = "1.8.3" +version = "1.8.4" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/apps/shield-gui/package-lock.json b/apps/shield-gui/package-lock.json index 156a019..cd45ed1 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.3", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mocika-shield-gui", - "version": "1.8.3", + "version": "1.8.4", "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 40b524f..2027415 100644 --- a/apps/shield-gui/package.json +++ b/apps/shield-gui/package.json @@ -1,6 +1,6 @@ { "name": "mocika-shield-gui", - "version": "1.8.3", + "version": "1.8.4", "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 66a7cd9..012ff08 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.3" +version = "1.8.4" 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 99bc4ba..82d7902 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.3", + "version": "1.8.4", "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 5fd114e..35e0beb 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.3", + version: "1.8.4", git_hash: "dev", build_date: "unknown", }; diff --git a/apps/shield-gui/src/lib/protect-diagnostic.ts b/apps/shield-gui/src/lib/protect-diagnostic.ts index 8ab5bbe..ed93f21 100644 --- a/apps/shield-gui/src/lib/protect-diagnostic.ts +++ b/apps/shield-gui/src/lib/protect-diagnostic.ts @@ -56,7 +56,7 @@ export function buildPvm2FailureDiagnostic(summary: Pvm2FailureSummary, locale: data_resource_runtime_glue: ["数据/资源框架兼容规则", "Data/resource compatibility"], compiler_generated_bridge: ["编译器生成桥接/合成方法", "Compiler-generated bridge/synthetic method"], playback_compatibility: ["媒体/辅助类兼容规则", "Media/helper compatibility"], - compatibility_virtual_contract: ["兼容范围:继承/接口虚方法契约未验证", "Compatibility: unverified inherited/interface contract"], + compatibility_virtual_contract: ["兼容范围:特殊对象方法或虚方法契约未验证", "Compatibility: unverified special object method or virtual contract"], compatibility_synchronization: ["兼容范围:同步方法", "Compatibility: synchronized method"], compatibility_exception_region: ["兼容范围:含异常处理区", "Compatibility: exception region"], compatibility_non_leaf_instruction: ["兼容范围:含叶子方法白名单之外的指令", "Compatibility: instruction outside the leaf allowlist"], diff --git a/apps/shield-gui/tests/protect-diagnostic.test.mjs b/apps/shield-gui/tests/protect-diagnostic.test.mjs index 5c9ec2b..2f72e5c 100644 --- a/apps/shield-gui/tests/protect-diagnostic.test.mjs +++ b/apps/shield-gui/tests/protect-diagnostic.test.mjs @@ -52,7 +52,7 @@ test("兼容限制按方法实际阻断条件统计,不再全部显示为生 reasons: { initializer: 3, compatibility_virtual_contract: 6, compatibility_synchronization: 2, compatibility_exception_region: 3, compatibility_non_leaf_instruction: 7 } }, "zh"); assert.match(text, /所选方法数: 21/); - assert.match(text, /继承\/接口虚方法契约未验证\): 6/); + assert.match(text, /特殊对象方法或虚方法契约未验证\): 6/); assert.match(text, /同步方法\): 2/); assert.match(text, /含异常处理区\): 3/); assert.match(text, /白名单之外的指令\): 7/); diff --git a/crates/shield-core/Cargo.toml b/crates/shield-core/Cargo.toml index 087eed4..cbe45ad 100644 --- a/crates/shield-core/Cargo.toml +++ b/crates/shield-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-core" -version = "1.8.3" +version = "1.8.4" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/crates/shield-core/src/protect/resource_format.rs b/crates/shield-core/src/protect/resource_format.rs index 2aca9e6..2b99eda 100644 --- a/crates/shield-core/src/protect/resource_format.rs +++ b/crates/shield-core/src/protect/resource_format.rs @@ -8,6 +8,33 @@ use zip::{ZipArchive, ZipWriter}; const JPEG_SIGNATURE: [u8; 3] = [0xFF, 0xD8, 0xFF]; +/// 修正 Apktool 3 对空资源表的包信息及无资源表时遗漏的 Android 框架引用。 +pub(crate) fn normalize_empty_resource_metadata(apk_dir: &Path) -> Result { + let path = apk_dir.join("apktool.yml"); + let metadata = fs::read_to_string(&path).context("读取 Apktool 资源元数据失败")?; + // ponytail: 只修正 Apktool 3 已验证的空表输出格式;其他格式保持原样,不猜测资源 ID。 + let mut normalized = metadata + .replace("resourcesInfo:\n packageId: 0\n packageName: \n", "") + .replace( + "resourcesInfo:\r\n packageId: 0\r\n packageName: \r\n", + "", + ); + // 完全无资源表时 Apktool 不记录框架,但重编 Manifest 的 android:* 属性仍需要它。 + if !apk_dir.join("res").exists() + && !apk_dir.join("resources.arsc").exists() + && !metadata + .lines() + .any(|line| line.starts_with("usesFramework:") || line.starts_with("resourcesInfo:")) + { + normalized.push_str("\nusesFramework:\n ids:\n - 1\n"); + } + if normalized == metadata { + return Ok(false); + } + fs::write(path, normalized).context("修正 Apktool 空资源元数据失败")?; + Ok(true) +} + /// Apktool 必须重编 Manifest,但不能因此丢掉输入 APK 已有的资源名混淆。 /// 用输入 APK 的原始 `resources.arsc` 和 `res/` 条目覆盖重编结果,其他受保护 /// 条目(Manifest、Stub DEX、Native Runtime)保持不变。 diff --git a/crates/shield-core/src/protect/resource_format_tests.rs b/crates/shield-core/src/protect/resource_format_tests.rs index bf94133..2985957 100644 --- a/crates/shield-core/src/protect/resource_format_tests.rs +++ b/crates/shield-core/src/protect/resource_format_tests.rs @@ -1,4 +1,7 @@ -use super::resource_format::{normalize_mislabeled_jpeg_resources, restore_original_resources}; +use super::resource_format::{ + normalize_empty_resource_metadata, normalize_mislabeled_jpeg_resources, + restore_original_resources, +}; use std::fs; use std::io::{Read, Write}; use std::path::Path; @@ -7,6 +10,58 @@ use zip::{CompressionMethod, ZipArchive, ZipWriter}; const JPEG_PREFIX: &[u8] = &[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10]; +#[test] +fn 空资源元数据修正不影响普通资源包或共享库() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("apktool.yml"); + for newline in ["\n", "\r\n"] { + for (id, name, empty) in [ + (0, "", true), + (0, "shared.library", false), + (127, "app", false), + ] { + let prefix = "version: 3.0.3\nusesFramework:\n ids:\n - 1\n"; + let suffix = "doNotCompress:\n- arsc\n"; + let source = format!( + "{prefix}resourcesInfo:\n packageId: {id}\n packageName: {name}\n{suffix}" + ) + .replace('\n', newline); + fs::write(&path, &source).unwrap(); + assert_eq!( + normalize_empty_resource_metadata(dir.path()).unwrap(), + empty + ); + let expected = if empty { + format!("{prefix}{suffix}").replace('\n', newline) + } else { + source + }; + assert_eq!(fs::read_to_string(&path).unwrap(), expected); + assert!(!normalize_empty_resource_metadata(dir.path()).unwrap()); + } + } + for source in [ + "version: 3.0.3\n", + "version: 3.0.3\nusesFramework:\n ids:\n - 2\n", + ] { + fs::write(&path, source).unwrap(); + let missing = !source.contains("usesFramework:"); + assert_eq!( + normalize_empty_resource_metadata(dir.path()).unwrap(), + missing + ); + assert_eq!( + fs::read_to_string(&path).unwrap(), + if missing { + format!("{source}\nusesFramework:\n ids:\n - 1\n") + } else { + source.to_string() + } + ); + assert!(!normalize_empty_resource_metadata(dir.path()).unwrap()); + } +} + #[test] fn jpeg_伪装_png_会无损改名为_jpg() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/shield-core/src/protect/xop_pvm2.rs b/crates/shield-core/src/protect/xop_pvm2.rs index 9dd67fa..9441b76 100644 --- a/crates/shield-core/src/protect/xop_pvm2.rs +++ b/crates/shield-core/src/protect/xop_pvm2.rs @@ -445,6 +445,10 @@ fn capture_resource_source( ) -> Result<(ResourceEntryFingerprints, u32)> { let file = fs::File::open(apk).context("打开待资源混淆 APK 失败")?; let mut archive = ZipArchive::new(file).context("解析待资源混淆 APK 失败")?; + // ponytail: 没有路径改写时校验器不读取资源表 CRC;无资源 APK 合法缺少该表。 + if mapping.is_empty() { + return Ok((BTreeMap::new(), 0)); + } let mut entries = BTreeMap::new(); for old in mapping.keys() { let entry = archive @@ -1378,4 +1382,28 @@ mod tests { let mapping = NamedTempFile::new().unwrap(); assert_eq!(validate_resource_id_mapping(mapping.path()).unwrap(), 0); } + + #[test] + fn 无资源表只允许空路径映射() { + let apk = NamedTempFile::new().unwrap(); + let mut archive = zip::ZipWriter::new(apk.reopen().unwrap()); + archive + .start_file( + "res/drawable/a.bin", + zip::write::SimpleFileOptions::default(), + ) + .unwrap(); + archive.finish().unwrap(); + assert_eq!( + capture_resource_source(apk.path(), &BTreeMap::new()).unwrap(), + (BTreeMap::new(), 0) + ); + let mapping = BTreeMap::from([("res/drawable/a.bin".into(), "r/a.bin".into())]); + assert!(capture_resource_source(apk.path(), &mapping) + .unwrap_err() + .to_string() + .contains("resources.arsc")); + fs::write(apk.path(), b"invalid zip").unwrap(); + assert!(capture_resource_source(apk.path(), &BTreeMap::new()).is_err()); + } } diff --git a/crates/shield-core/src/protect_api.rs b/crates/shield-core/src/protect_api.rs index 547a014..80c68e4 100644 --- a/crates/shield-core/src/protect_api.rs +++ b/crates/shield-core/src/protect_api.rs @@ -22,7 +22,8 @@ use crate::protect::{ }, native_alias::verify_in_apk, resource_format::{ - normalize_mislabeled_jpeg_resources, restore_original_resources, verify_original_resources, + normalize_empty_resource_metadata, normalize_mislabeled_jpeg_resources, + restore_original_resources, verify_original_resources, }, runtime::{inject_runtime, read_runtime_selection}, }; @@ -400,6 +401,9 @@ fn protect_apk_inner( ) .map_err(ShieldError::from)?; print_success("解包完成"); + if normalize_empty_resource_metadata(&apk_dir).map_err(ShieldError::from)? { + print_success("已修正无资源 APK 的资源编译元数据"); + } remove_excluded(&apk_dir, &opts.excluded_abis).map_err(ShieldError::from)?; if !opts.excluded_abis.is_empty() { let message = format!("已从输出排除 ABI:{}", opts.excluded_abis.join("、")); diff --git a/crates/shield-core/src/protection_report.rs b/crates/shield-core/src/protection_report.rs index 97a471f..4b7d437 100644 --- a/crates/shield-core/src/protection_report.rs +++ b/crates/shield-core/src/protection_report.rs @@ -124,7 +124,7 @@ impl Pvm2SkipReason { Self::DataResourceRuntimeGlue => "数据/资源框架兼容规则", Self::CompilerGeneratedBridge => "编译器生成桥接/合成方法", Self::PlaybackCompatibility => "媒体/辅助类兼容规则", - Self::CompatibilityVirtualContract => "兼容范围:继承/接口虚方法契约未验证", + Self::CompatibilityVirtualContract => "兼容范围:特殊对象方法或虚方法契约未验证", Self::CompatibilitySynchronization => "兼容范围:同步方法", Self::CompatibilityExceptionRegion => "兼容范围:含异常处理区", Self::CompatibilityNonLeafInstruction => "兼容范围:含叶子方法白名单之外的指令", @@ -291,7 +291,7 @@ impl std::fmt::Display for Pvm2Failure { | Pvm2SkipReason::CompatibilityNonLeafInstruction ) }) { - write!(f, "\n兼容分类表示当前准入限制,不代表这些方法都是生命周期回调。仅修改类前缀或降低保护级别不能证明这些方法已获 PVM2 保护;可将独立业务逻辑提取到普通类,并重新核验逐方法覆盖。")?; + write!(f, "\n兼容分类表示当前准入限制,不代表这些方法都是生命周期回调。仅修改类前缀或降低保护级别不能证明这些方法已获 PVM2 保护;请核对逐方法报告中的剩余语义限制,使用配套打包器重新加固并核验覆盖。")?; } if self.summary.reasons.keys().any(|r| { matches!( diff --git a/docs/process/test-checklist.md b/docs/process/test-checklist.md index ad47e1e..c954531 100644 --- a/docs/process/test-checklist.md +++ b/docs/process/test-checklist.md @@ -22,6 +22,15 @@ ANDROID_SERIAL=目标设备序列号 RUN_DEVICE_TEST=1 PVM2_STRICT_TEST=1 \ 存在多台在线设备时必须设置 `ANDROID_SERIAL`,脚本不会随机选择设备。候选版设备矩阵使用 `tests/scripts/run-device-matrix.sh`。脚本可盘点设备、以 `MATRIX_PVM2_STRICT_TEST=1` 逐设备执行 strict APK E2E,或安装 bundletool 生成的 APKS split,并可用 `MATRIX_REQUIRED_APIS/ABIS/VENDORS` 对缺失格子失败关闭;报告不保存 adb serial。dynamic-feature 必须执行功能代码探针,install-time Asset Pack 必须执行 PAS2 解密读取探针,不能只以 base 进程存活标记通过。 +PVM2 方法准入、跳板或异常语义调整后,还需运行 Java/D8 到 ART 的语义回归(JDK 17、SDK 35、Build Tools 35.0.0,以及已构建的 CLI、打包器和标准壳): + +```bash +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`,模拟器通过不能代替厂商真机验收。 + 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 ef5646f..d2031ea 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -543,11 +543,13 @@ shield protect --input input.apk --output protected.apk \ 启用 PVM2 的成功输出旁会生成 `protected.apk.protection.json` 或 `protected.aab.protection.json`。统计分别展示转换成功率、选中方法覆盖率、原始指令字节覆盖率和必保方法覆盖率;未声明必保方法时该指标为 `null`,不记为 100%。这些百分比仅针对选择范围,不代表整个应用。报告包含逐方法原因、最终索引与产物摘要,不包含密钥或明文镜像,也不进入安装包。 -选择范围在兼容性过滤**之前**统计。UI/导航、数据/资源辅助和媒体命名不再直接排除整类:没有对象状态访问、外部调用、同步或异常处理的独立方法,可以继续进入 PVM2 指令与产物检查。支持基本类型计算,以及对象/数组参数未使用、引用透传、空值分支和引用身份比较;引用参数不会再仅因其类型被排除,但数组元素和字段访问仍不在这一兼容放行范围内。私有和静态方法可在框架类中单独转换;其它实例方法仅在直接继承 `Object`、不实现接口且不覆盖 `hashCode/finalize` 契约时适用此放行规则。分支、循环和数值运算仍须通过现有编译器检查,不能只凭命名认定可转换。 +选择范围在兼容性过滤**之前**统计。UI/导航、数据/资源辅助和媒体命名不再直接排除整类:没有对象状态访问、外部调用或同步的独立方法,可以继续进入 PVM2 指令与产物检查。支持基本类型计算,以及对象/数组参数未使用、引用透传、空值分支和引用身份比较;引用参数不会再仅因其类型被排除,但数组元素和字段访问仍不在这一兼容放行范围内。私有、静态和实例方法适用相同的方法体检查;继承父类或实现接口本身不再排除普通实例方法。跳板保留原声明、访问标志、参数与接收者,虚方法分派仍由 ART 完成;`hashCode/finalize` 等特殊对象契约仍保守保留。分支、循环和数值运算仍须通过现有编译器检查,不能只凭命名认定可转换。 -构造器、静态初始化器、生成桥接/合成方法和仍涉及框架状态的候选保持原兼容边界;未解析的外部或跨 DEX 继承契约不据此扩大虚方法范围。跳过项仍计入覆盖率分母,并保留 `ui_runtime_glue`、`compiler_generated_bridge`、`data_resource_runtime_glue` 或 `playback_compatibility` 等实际分类。精确必保清单同样受方法兼容检查约束,必保项被排除时任务失败,不会自动降级。所有候选都不可转换时仍返回 `PVM2_ZERO_METHODS`,不会把零转换记为成功。这些规则不代表已识别应用全部关键业务;完整覆盖率需用新打包器重新生成。 +独立方法中的异常处理不再仅因存在异常表而被排除。捕获、异常透传、重新抛出和不访问外部状态的清理逻辑继续经过异常表映射、控制流及指令检查;`throw null` 产生可被捕获的 `NullPointerException`。这不意味着任意 `try/catch/finally` 都受支持:异常区中的外部调用、字段、数组元素或同步仍受上述限制。 -兼容排除的本地原因带有首个阻断条件,例如 `ui_runtime_glue:virtual_contract`;脱敏摘要分别统计继承/接口虚方法契约未验证、同步方法、异常处理区及叶子方法白名单之外的指令。它们表示准入限制,不能据此认定方法一定是生命周期回调。旧报告没有细分信息时仍按原分类展示,不能反推具体方法体。基础预检通过也不代表 PVM2 方法覆盖已经通过。 +构造器、静态初始化器、生成桥接/合成方法和仍涉及框架状态的候选保持原兼容边界;放行继承类的独立方法不等于放行包含 `invoke-super` 等外部调用的方法体。跳过项仍计入覆盖率分母,并保留 `ui_runtime_glue`、`compiler_generated_bridge`、`data_resource_runtime_glue` 或 `playback_compatibility` 等实际分类。精确必保清单同样受方法兼容检查约束,必保项被排除时任务失败,不会自动降级。所有候选都不可转换时仍返回 `PVM2_ZERO_METHODS`,不会把零转换记为成功。这些规则不代表已识别应用全部关键业务;完整覆盖率需用配套的新打包器和运行时重新生成。 + +兼容排除的本地原因带有首个阻断条件,例如 `ui_runtime_glue:virtual_contract`;脱敏摘要分别统计特殊对象方法或虚方法契约未验证、同步方法及叶子方法白名单之外的指令,并兼容旧打包器返回的异常区排除原因。它们表示准入限制,不能据此认定方法一定是生命周期回调。旧报告没有细分信息时仍按原分类展示,不能反推具体方法体。基础预检通过也不代表 PVM2 方法覆盖已经通过。 只有一条 `return` 的参数透传方法同样可转换,包括对象、数组及基本类型/宽值。PVM2 重建完整方法体,不再沿用旧式原地回填的最小字节数限制;最终跳板与加密镜像仍必须通过产物校验。 diff --git a/shield-stub/compat/api19-rust/Cargo.lock b/shield-stub/compat/api19-rust/Cargo.lock index c2cd11f..5247f87 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.3" +version = "1.8.4" dependencies = [ "cc", "chacha20poly1305", diff --git a/shield-stub/compat/api19-rust/Cargo.toml b/shield-stub/compat/api19-rust/Cargo.toml index 4e99383..bdf2769 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.3" +version = "1.8.4" 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 905dd88..d70838a 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.3" +version = "1.8.4" edition = "2021" license = "MIT OR Apache-2.0" diff --git a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java index e92f5a5..c333ac7 100644 --- a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java +++ b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java @@ -100,8 +100,10 @@ public static void main(String[] args) throws Exception { } assert Pvm2Safety.check(new short[]{0x0238, 4, 0x1020, 0, 0x0038, 5, 0x106e, 0, 1, 0x000e}, 3, 2, true, false) != null; Path root = Path.of(args[0]); + checkEmptyResourceTable(root); checkZeroMethodReports(root); checkMethodCompatibility(root); + checkInheritedExceptions(root); Path dex = Files.createDirectory(root.resolve("dex")); fixture(dex.resolve("classes.dex")); fixture(dex.resolve("classes3.dex")); @@ -211,7 +213,7 @@ private static void checkZeroMethodReports(Path root) throws Exception { methods.add(new ImmutableMethod(owner, "nativeCall", List.of(), "I", 0x109, Set.of(), Set.of(), null)); methods.add(new ImmutableMethod(owner, "", List.of(), "V", 0x10008, Set.of(), Set.of(), new ImmutableMethodImplementation(0, List.of(new ImmutableInstruction10x(Opcode.RETURN_VOID)), List.of(), List.of()))); - if (!kind.equals("native")) methods.add(new ImmutableMethod(owner, "value", List.of(), "I", kind.equals("ui") ? 1 : 9, Set.of(), Set.of(), + if (!kind.equals("native")) methods.add(new ImmutableMethod(owner, kind.equals("ui") ? "hashCode" : "value", List.of(), "I", kind.equals("ui") ? 1 : 9, Set.of(), Set.of(), new ImmutableMethodImplementation(kind.equals("registers") ? 512 : 1, List.of(new ImmutableInstruction31i(Opcode.CONST, 0, 42), new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of()))); DexPool pool = new DexPool(Opcodes.getDefault()); @@ -253,6 +255,102 @@ private static ImmutableClassDef calculationOwner(String type, String parent, Li return new ImmutableClassDef(type, 1, parent, interfaces, null, Set.of(), List.of(), methods); } + // 同一批异常方法同时用于真实打包检查和生产 Native 解释器执行,避免只验证准入结果。 + static List exceptionMethods(String owner) { + var parameter = List.of(new ImmutableMethodParameter("I", Set.of(), null)); + var division = List.of(new ImmutableInstruction21s(Opcode.CONST_16, 0, 12), + new ImmutableInstruction23x(Opcode.DIV_INT, 0, 0, 2), new ImmutableInstruction11x(Opcode.RETURN, 0), + new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 0), new ImmutableInstruction11n(Opcode.CONST_4, 0, -7), + new ImmutableInstruction11x(Opcode.RETURN, 0)); + var ordered = new ArrayList(division); + ordered.addAll(List.of(new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 0), + new ImmutableInstruction11n(Opcode.CONST_4, 0, -8), new ImmutableInstruction11x(Opcode.RETURN, 0), + new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 0), new ImmutableInstruction21s(Opcode.CONST_16, 0, -9), + new ImmutableInstruction11x(Opcode.RETURN, 0))); + return List.of( + new ImmutableMethod(owner, "ordered", parameter, "I", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(3, ordered, List.of(new ImmutableTryBlock(2, 2, List.of( + new ImmutableExceptionHandler("Ljava/lang/ArithmeticException;", 5), + new ImmutableExceptionHandler("Ljava/lang/RuntimeException;", 8), new ImmutableExceptionHandler(null, 11)))), List.of())), + new ImmutableMethod(owner, "mismatch", parameter, "I", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(3, division, List.of(new ImmutableTryBlock(2, 2, + List.of(new ImmutableExceptionHandler("Ljava/lang/NullPointerException;", 5)))), List.of())), + new ImmutableMethod(owner, "cleanup", parameter, "I", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(4, List.of( + new ImmutableInstruction21s(Opcode.CONST_16, 0, 40), + new ImmutableInstruction23x(Opcode.DIV_INT, 1, 0, 3), + new ImmutableInstruction22b(Opcode.ADD_INT_LIT8, 0, 1, 2), new ImmutableInstruction11x(Opcode.RETURN, 0), + new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 1), + new ImmutableInstruction22b(Opcode.ADD_INT_LIT8, 0, 0, 2), new ImmutableInstruction11x(Opcode.THROW, 1), + new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 1), new ImmutableInstruction11x(Opcode.RETURN, 0)), + List.of(new ImmutableTryBlock(2, 2, List.of(new ImmutableExceptionHandler(null, 7))), + new ImmutableTryBlock(7, 4, List.of(new ImmutableExceptionHandler("Ljava/lang/ArithmeticException;", 11)))), List.of())), + new ImmutableMethod(owner, "rethrow", List.of(new ImmutableMethodParameter("Ljava/lang/Throwable;", Set.of(), null)), + "V", 1, Set.of(), Set.of(), new ImmutableMethodImplementation(3, List.of( + new ImmutableInstruction11x(Opcode.THROW, 2), new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 0), + new ImmutableInstruction11x(Opcode.THROW, 0)), + List.of(new ImmutableTryBlock(0, 1, List.of(new ImmutableExceptionHandler("Ljava/lang/Throwable;", 1)))), List.of()))); + } + + private static void checkInheritedExceptions(Path root) throws Exception { + Path work = Files.createDirectory(root.resolve("inherited-exceptions")); + Path dex = Files.createDirectory(work.resolve("dex")); + String base = "Lfixture/contracts/Base;", contract = "Lfixture/contracts/Contract;"; + var declaration = new ImmutableMethod(contract, "calculate0", List.of(new ImmutableMethodParameter("I", Set.of(), null)), + "I", 0x401, Set.of(), Set.of(), null); + writeClasses(dex.resolve("classes2.dex"), List.of(calculationOwner(base, "Ljava/lang/Object;", List.of(), + List.of(new ImmutableMethod(base, "", List.of(), "V", 0x10001, Set.of(), Set.of(), + new ImmutableMethodImplementation(1, List.of(new ImmutableInstruction35c(Opcode.INVOKE_DIRECT, 1, 0, 0, 0, 0, 0, + new ImmutableMethodReference("Ljava/lang/Object;", "", List.of(), "V")), + new ImmutableInstruction10x(Opcode.RETURN_VOID)), List.of(), List.of())), + new ImmutableMethod(base, "calculate0", declaration.getParameters(), "I", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(2, List.of(new ImmutableInstruction11n(Opcode.CONST_4, 0, -1), + new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of())))), + new ImmutableClassDef(contract, 0x601, "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), List.of(declaration)))); + byte[] contracts = Files.readAllBytes(dex.resolve("classes2.dex")); + List selected = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + String type = "Lfixture/ui/Rules" + i + ";"; + // 覆盖跨 DEX 父类、接口,以及包外框架父类;不依赖外部 SDK 解析继承图。 + String parent = i == 2 ? "Landroid/app/Activity;" : base; + List methods = new ArrayList<>(); + methods.add(new ImmutableMethod(type, "", List.of(), "V", 0x10001, Set.of(), Set.of(), + new ImmutableMethodImplementation(1, List.of(new ImmutableInstruction35c(Opcode.INVOKE_DIRECT, 1, 0, 0, 0, 0, 0, + new ImmutableMethodReference(parent, "", List.of(), "V")), new ImmutableInstruction10x(Opcode.RETURN_VOID)), List.of(), List.of()))); + for (int j = 0; j < (i == 2 ? 2 : 6); j++) methods.add(calculation(type, "calculate" + j, j == 1 ? 4 : 1)); + if (i == 2) methods.addAll(exceptionMethods(type)); + selected.add(calculationOwner(type, parent, List.of(contract), methods)); + } + writeClasses(dex.resolve("classes.dex"), selected); + Path config = work.resolve("config.json"), report = work.resolve("report.json"); + Files.writeString(config, new JSONObject().put("schema_version", 2).put("modules", new JSONObject() + .put("classes.dex", "base").put("classes2.dex", "base")).put("required", new JSONArray()).toString()); + run(dex, config, work.resolve("code.bin"), report, "Lfixture/ui/"); + int verified = 0, initializers = 0; + for (Object item : new JSONObject(Files.readString(report)).getJSONArray("methods")) { + JSONObject row = (JSONObject)item; + if (row.getBoolean("artifact_verified")) verified++; + else { assert row.getString("reason").equals("initializer") : row; initializers++; } + } + assert verified == 18 && initializers == 3; + assert Arrays.equals(contracts, Files.readAllBytes(dex.resolve("classes2.dex"))); + var rewritten = new DexBackedDexFile(Opcodes.getDefault(), Files.readAllBytes(dex.resolve("classes.dex"))); + for (var cls : rewritten.getClasses()) { + var original = selected.stream().filter(c -> c.getType().equals(cls.getType())).findFirst().orElseThrow(); + assert cls.getSuperclass().equals(original.getSuperclass()) && cls.getInterfaces().equals(original.getInterfaces()); + for (var method : cls.getMethods()) { + var before = new ArrayList(); original.getMethods().forEach(before::add); + var declarationBefore = before.stream().filter(m -> Pvm2Report.descriptor(m).equals(Pvm2Report.descriptor(method))).findFirst().orElseThrow(); + assert method.getAccessFlags() == declarationBefore.getAccessFlags(); + assert method.getAnnotations().equals(declarationBefore.getAnnotations()); + assert method.getParameters().equals(declarationBefore.getParameters()); + if (!method.getName().startsWith("<")) assert TrueVmpTrampoline.matches(method, 0, + rewritten.getMethodSection().indexOf(method), BRIDGE, "interpret"); + } + } + System.out.println("继承与异常兼容:跨 DEX/包外父类/接口的 14 个虚方法、4 个异常方法全部完成转换及产物验证,3 个初始化器保留"); + } + private static void writeClasses(Path path, List classes) throws Exception { DexPool pool = new DexPool(Opcodes.getDefault()); for (var cls : classes) pool.internClass(cls); @@ -285,12 +383,12 @@ private static void checkMethodCompatibility(Path root) throws Exception { var staticMethod = calculation(type, "utility", 9); for (String parent : List.of("Landroid/app/Activity;", "Lfixture/BaseActivity;", "Lexternal/Unknown;")) { var cls = calculationOwner(type, parent, List.of(), List.of()); - assert AutoVmpPolicy.exclusionReason(cls, publicMethod).equals("ui_runtime_glue:virtual_contract"); + assert AutoVmpPolicy.exclusionReason(cls, publicMethod) == null; assert AutoVmpPolicy.exclusionReason(cls, privateMethod) == null; assert AutoVmpPolicy.exclusionReason(cls, staticMethod) == null; } var callback = calculationOwner(type, "Ljava/lang/Object;", List.of("Lexternal/Callback;"), List.of()); - assert AutoVmpPolicy.exclusionReason(callback, publicMethod).equals("ui_runtime_glue:virtual_contract"); + assert AutoVmpPolicy.exclusionReason(callback, publicMethod) == null; var plain = calculationOwner(type, "Ljava/lang/Object;", List.of(), List.of()); for (int flags : List.of(0x29, 0x20009, 0x49, 0x1009)) assert AutoVmpPolicy.exclusionReason(plain, calculation(type, "calculate", flags)) != null; @@ -301,14 +399,14 @@ private static void checkMethodCompatibility(Path root) throws Exception { new ImmutableInstruction11x(Opcode.RETURN, 0), new ImmutableInstruction11x(Opcode.MOVE_EXCEPTION, 0), 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).equals("ui_runtime_glue:exception_region"); + assert AutoVmpPolicy.exclusionReason(plain, caught) == null; 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"); - // 混淆类仍直接继承已知框架类型时,生命周期和接口回调保持原执行方式。 + // 混淆或框架继承不改变无状态方法体;入口契约由跳板保留。 var obfuscated = calculation("Lfixture/a;", "callback", 1); - assert AutoVmpPolicy.exclusionReason(calculationOwner("Lfixture/a;", "Landroid/app/Activity;", List.of(), List.of()), obfuscated).equals("ui_runtime_glue:virtual_contract"); - assert AutoVmpPolicy.exclusionReason(calculationOwner("Lfixture/a;", "Ljava/lang/Object;", List.of("Landroid/view/View$OnClickListener;"), List.of()), obfuscated).equals("ui_runtime_glue:virtual_contract"); + assert AutoVmpPolicy.exclusionReason(calculationOwner("Lfixture/a;", "Landroid/app/Activity;", List.of(), List.of()), obfuscated) == null; + assert AutoVmpPolicy.exclusionReason(calculationOwner("Lfixture/a;", "Ljava/lang/Object;", List.of("Landroid/view/View$OnClickListener;"), List.of()), obfuscated) == null; // 字段/数组元素访问、调用和锁仍不能借叶子方法规则放行。 var field = new ImmutableFieldReference(type, "state", "I"); var call = new ImmutableMethodReference(type, "external", List.of(), "I"); @@ -380,10 +478,13 @@ private static void checkMethodCompatibility(Path root) throws Exception { var references = new JSONObject(Files.readString(report)).getJSONArray("methods"); assert references.length() == 84; for (Object value : references) assert ((JSONObject)value).getBoolean("artifact_verified") : value; - // 精确必保清单也必须服从同一兼容判断;声明回调不会使其自动变得安全。 + // 精确必保清单不能绕过特殊对象方法的重入限制。 String activity = "Lfixture/ui/ScreenActivity;"; var sensitive = calculationOwner(activity, "Landroid/app/Activity;", List.of(), - List.of(calculation(activity, "callback", 1), calculation(activity, "local", 2))); + List.of(new ImmutableMethod(activity, "hashCode", List.of(), "I", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(1, List.of(new ImmutableInstruction11n(Opcode.CONST_4, 0, 1), + new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of())), + calculation(activity, "local", 2))); writeClasses(dex.resolve("classes.dex"), List.of(sensitive)); var captured = new java.io.ByteArrayOutputStream(); var previous = System.out; @@ -404,18 +505,48 @@ private static void checkMethodCompatibility(Path root) throws Exception { assert kept == 1 && protectedCount == 1 : "转换成功率不能代替完整范围覆盖率"; for (boolean safe : List.of(true, false)) { writeClasses(dex.resolve("classes.dex"), List.of(sensitive)); - selection.put("required", new JSONArray(List.of("base\t" + activity + "->" + (safe ? "local" : "callback") + "(I)I"))); + selection.put("required", new JSONArray(List.of("base\t" + activity + "->" + (safe ? "local(I)I" : "hashCode()I")))); Files.writeString(config, selection.toString()); try { run(dex, config, code, report, false); - assert safe : "回调被精确清单绕过兼容检查"; + assert safe : "特殊对象方法被精确清单绕过兼容检查"; } catch (InvocationTargetException failure) { assert !safe && failure.getCause().getMessage().contains("关键方法未通过验证"); JSONObject row = new JSONObject(Files.readString(report)).getJSONArray("methods").getJSONObject(0); assert !row.getBoolean("artifact_verified") && row.getString("reason").equals("ui_runtime_glue:virtual_contract"); } } - System.out.println("方法级兼容检查通过:通用命名、独立计算、回调保留、必保约束、18 个计算方法、60 个引用叶子方法及 24 个基本类型短方法转换"); + System.out.println("方法级兼容检查通过:通用命名、独立计算、特殊契约保留、必保约束、18 个计算方法、60 个引用叶子方法及 24 个基本类型短方法转换"); + } + + private static void checkEmptyResourceTable(Path root) throws Exception { + Path unpack = Files.createDirectory(root.resolve("resource-table")); + Path xml = Files.createDirectories(unpack.resolve("res/values")).resolve("public.xml"); + Path dex = unpack.resolve("classes.dex"); + fixture(dex); + byte[] before = Files.readAllBytes(dex); + for (String source : List.of("", "", + "")) { + Files.writeString(xml, source); + var result = ResourceIdRewriter.rewrite(unpack.toFile()); + assert result.mapping.isEmpty() && result.dexFiles == 1; + assert Files.readString(xml).equals(source) && Arrays.equals(before, Files.readAllBytes(dex)); + } + for (String source : List.of("", "", + "")) { + Files.writeString(xml, source); + try { ResourceIdRewriter.rewrite(unpack.toFile()); throw new AssertionError("未拒绝损坏资源表"); } + catch (java.io.IOException expected) { } + } + Files.delete(xml); + try { ResourceIdRewriter.rewrite(unpack.toFile()); throw new AssertionError("未拒绝缺失资源表"); } + catch (java.io.IOException expected) { } + Files.delete(xml.getParent()); Files.delete(unpack.resolve("res")); + assert ResourceIdRewriter.rewrite(unpack.toFile()).mapping.isEmpty(); + Files.write(unpack.resolve("resources.arsc"), new byte[]{0}); + try { ResourceIdRewriter.rewrite(unpack.toFile()); throw new AssertionError("未拒绝未解码资源表"); } + catch (java.io.IOException expected) { } + System.out.println("资源表检查通过:无资源、空表和单资源保持不变,损坏或未解码资源表仍拒绝"); } private static void fixture(Path path) throws Exception { @@ -428,7 +559,7 @@ private static void fixture(Path path) throws Exception { DexPool pool = new DexPool(Opcodes.getDefault()); pool.internClass(new ImmutableClassDef(OWNER, 1, "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), methods)); String excluded = "Lfixture/MainActivity;"; - var unchanged = new ImmutableMethod(excluded, "value", List.of(), "I", 1, Set.of(), Set.of(), + var unchanged = new ImmutableMethod(excluded, "hashCode", List.of(), "I", 1, Set.of(), Set.of(), new ImmutableMethodImplementation(1, List.of(new ImmutableInstruction31i(Opcode.CONST, 0, 42), new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of())); pool.internClass(new ImmutableClassDef(excluded, 1, "Landroid/app/Activity;", List.of(), null, Set.of(), List.of(), List.of(unchanged))); diff --git a/tests/java/com/yqsh/protector/packer/Pvm2RuntimeImages.java b/tests/java/com/yqsh/protector/packer/Pvm2RuntimeImages.java index 4e1aabe..681f7f8 100644 --- a/tests/java/com/yqsh/protector/packer/Pvm2RuntimeImages.java +++ b/tests/java/com/yqsh/protector/packer/Pvm2RuntimeImages.java @@ -110,5 +110,32 @@ public static void main(String[] args) throws Exception { Files.write(Path.of(args[0], "image" + i + ".gcm"), com.yqsh.protector.packer.util.CryptoUtils.aesGcmEncrypt(new byte[16], result.image)); } + String owner = "Lfixture/ui/VirtualRules;"; + List methods = new ArrayList<>(Pvm2ProtectionCheck.exceptionMethods(owner)); + methods.add(new ImmutableMethod(owner, "self", List.of(), "Ljava/lang/Object;", 1, Set.of(), Set.of(), + new ImmutableMethodImplementation(1, List.of(new ImmutableInstruction11x(Opcode.RETURN_OBJECT, 0)), List.of(), List.of()))); + methods.add(new ImmutableMethod(owner, "identity", List.of(new ImmutableMethodParameter("Ljava/lang/Object;", Set.of(), null)), + "Ljava/lang/Object;", 1, Set.of(), Set.of(), new ImmutableMethodImplementation(2, + List.of(new ImmutableInstruction11x(Opcode.RETURN_OBJECT, 1)), List.of(), List.of()))); + var cls = new ImmutableClassDef(owner, 1, "Lexternal/Base;", List.of("Lexternal/Contract;"), null, Set.of(), List.of(), methods); + DexPool pool = new DexPool(Opcodes.getDefault()); pool.internClass(cls); + Path path = Path.of(args[0], "compatibility.dex"); + FileDataStore store = new FileDataStore(path.toFile()); + try { pool.writeTo(store); } finally { store.close(); } + Dex compatibility = new Dex(path.toFile()); + Map bodies = new HashMap<>(); + for (var definition : compatibility.classDefs()) for (var method : compatibility.readClassData(definition).allMethods()) + bodies.put(compatibility.strings().get(compatibility.methodIds().get(method.getMethodIndex()).getNameIndex()), compatibility.readCode(method)); + for (int kind = 0; kind < methods.size(); kind++) for (int isa = 0; isa < 3; isa++) { + var method = methods.get(kind); + if (AutoVmpPolicy.exclusionReason(cls, method) != null) throw new AssertionError("兼容样本未准入"); + var result = Pvm2Compiler.tryCompile(compatibility, bodies.get(method.getName()), method.getReturnType(), false, + Pvm2Morph.fromIsa(isa, new SecureRandom()), true, method.getParameterTypes().stream().map(CharSequence::toString).toArray(String[]::new)); + if (!result.isOk()) throw new AssertionError(method.getName() + ": " + result.failReason); + int index = 72 + kind * 3 + isa; + Files.write(Path.of(args[0], "image" + index + ".bin"), result.image); + Files.write(Path.of(args[0], "image" + index + ".gcm"), + com.yqsh.protector.packer.util.CryptoUtils.aesGcmEncrypt(new byte[16], result.image)); + } } } diff --git a/tests/native/pvm2_runtime_check.cpp b/tests/native/pvm2_runtime_check.cpp index 0d1c35e..01bd454 100644 --- a/tests/native/pvm2_runtime_check.cpp +++ b/tests/native/pvm2_runtime_check.cpp @@ -92,8 +92,8 @@ int main(int argc, char** argv) { assert(written == 123 && std::all_of(std::begin(boundary), std::end(boundary), [](uint8_t c) { return c == 0xa5; })); const auto plain = image(); std::vector code; - u16(code, 4); u16(code, 1); u32(code, 8); u32(code, 2); u16(code, argc > 1 && std::string(argv[1]) != "block-before-init" ? 170 : 98); - for (int i = 0; i < (argc > 1 && std::string(argv[1]) != "block-before-init" ? 170 : 98); i++) { + u16(code, 4); u16(code, 1); u32(code, 8); u32(code, 2); u16(code, argc > 1 && std::string(argv[1]) != "block-before-init" ? 188 : 98); + for (int i = 0; i < (argc > 1 && std::string(argv[1]) != "block-before-init" ? 188 : 98); i++) { auto bytes = plain; if (i >= 98) { std::ifstream input(std::string(argv[1]) + "/image" + std::to_string(i - 98) + ".bin", std::ios::binary); @@ -346,6 +346,56 @@ int main(int argc, char** argv) { env->DeleteLocalRef(unary); env->DeleteLocalRef(array); env->DeleteLocalRef(text); env->DeleteLocalRef(equalText); env->DeleteLocalRef(alias); puts("引用叶子方法:三种 ISA 共 666 次透传、空值选择、身份比较、数组引用及清理验证通过"); + // 继承类/接口方法沿用相同跳板参数布局,this 不能和首个业务参数混淆。 + jclass arithmetic = env->FindClass("java/lang/ArithmeticException"); + jclass nullPointer = env->FindClass("java/lang/NullPointerException"); + jobject thrown = env->NewObject(arithmetic, env->GetMethodID(arithmetic, "", "()V")); + jobjectArray instanceArgs = env->NewObjectArray(2, objectClass, nullptr); + jobjectArray thisOnly = env->NewObjectArray(1, objectClass, nullptr); + int compatibilityChecks = 0; + for (int isa = 0; isa < 3; isa++) for (jobject receiver : {sentinel, wrappers[0]}) { + assert(receiver); + env->SetObjectArrayElement(instanceArgs, 0, receiver); + env->SetObjectArrayElement(thisOnly, 0, receiver); + result = interpret(env, 2, 182 + isa, thisOnly); + assert(!env->ExceptionCheck() && env->IsSameObject(result, receiver)); + env->DeleteLocalRef(result); compatibilityChecks++; + for (jobject argument : {receiver, thrown, static_cast(nullptr)}) { + env->SetObjectArrayElement(instanceArgs, 1, argument); + result = interpret(env, 2, 185 + isa, instanceArgs); + assert(!env->ExceptionCheck() && env->IsSameObject(result, argument)); + if (result) env->DeleteLocalRef(result); + compatibilityChecks++; + } + for (int kind = 0; kind < 3; kind++) for (int divisor : {INT32_MIN, -3, 0, 1, 2, INT32_MAX}) { + jobject argument = env->CallStaticObjectMethod(integer, box, divisor); + env->SetObjectArrayElement(instanceArgs, 1, argument); env->DeleteLocalRef(argument); + result = interpret(env, 2, 170 + kind * 3 + isa, instanceArgs); + if (kind == 1 && divisor == 0) { + assert(!result && env->ExceptionCheck()); + jthrowable error = env->ExceptionOccurred(); env->ExceptionClear(); + assert(env->IsInstanceOf(error, arithmetic)); env->DeleteLocalRef(error); + } else { + int expected = kind == 2 ? (divisor == 0 ? 42 : 40 / divisor + 2) : (divisor == 0 ? -7 : 12 / divisor); + assert(result && !env->ExceptionCheck() && env->CallIntMethod(result, unbox) == expected); + env->DeleteLocalRef(result); + } + compatibilityChecks++; + } + for (jobject argument : {thrown, static_cast(nullptr)}) { + env->SetObjectArrayElement(instanceArgs, 1, argument); + result = interpret(env, 2, 179 + isa, instanceArgs); + assert(!result && env->ExceptionCheck()); + jthrowable error = env->ExceptionOccurred(); env->ExceptionClear(); + assert(argument ? env->IsSameObject(error, argument) : env->IsInstanceOf(error, nullPointer)); + env->DeleteLocalRef(error); compatibilityChecks++; + } + } + assert(compatibilityChecks == 144); + for (int id = 170; id < 188; id++) assert(!items.at(id)->parsed_vm && items.at(id)->vm_image.capacity() == 0); + env->DeleteLocalRef(arithmetic); env->DeleteLocalRef(nullPointer); env->DeleteLocalRef(thrown); + env->DeleteLocalRef(instanceArgs); env->DeleteLocalRef(thisOnly); + puts("继承与异常方法:三种 ISA 共 144 次 this/参数身份、捕获优先级、不匹配异常传播、finally 清理及重抛身份检查通过"); for (jobject wrapper : wrappers) if (wrapper) env->DeleteLocalRef(wrapper); env->DeleteLocalRef(sentinel); env->DeleteLocalRef(tailArgs); env->DeleteLocalRef(mixedArgs); env->DeleteLocalRef(objectClass); puts("声明参数类型:5 种包装引用、null、混合宽参数、数量/类型错误拒绝通过"); diff --git a/tests/scripts/check-pvm2-art.py b/tests/scripts/check-pvm2-art.py new file mode 100644 index 0000000..baf16c8 --- /dev/null +++ b/tests/scripts/check-pvm2-art.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""构建自有 Java/D8 APK,对指定设备执行基线、首次、缓存及覆盖安装回归;不自动选择设备。""" +import argparse +from collections import Counter +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import time +import uuid +import zipfile + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = r''' +package dev.mocika.shield.pvm2regression; + +import android.app.Activity; +import android.os.Bundle; +import android.util.Log; +import java.lang.reflect.Method; + +/** Java/D8 真实调用样本;同时检查被转换方法和保持 ART 的兼容边界。 */ +public class CompatibilityActivity extends Activity { + public interface Contract { + int compute(int value); + default int defaultValue(int value) { return value * 3; } + } + public static class Base { public int compute(int value) { return -1; } } + public static class RulesView extends Base implements Contract { + @Override public int compute(int value) { return value + 7; } + public long compute(long value) { return value ^ 0x123456789abcdefL; } + public Object self() { return this; } + public Object identity(Object value) { return value; } + public int[] identity(int[] value) { return value; } + public boolean same(Object a, Object b) { return a == b; } + public Object choose(Object a, Object b) { return a != null ? a : b; } + protected int protectedValue(int value) { return value + 3; } + private int privateValue(int value) { return value ^ 0x5a; } + public static int staticValue(int value) { return value - 11; } + public int divide(int value, int divisor) { + try { return value / divisor; } catch (ArithmeticException error) { return -17; } + } + public long wideDivide(long value, long divisor) { + try { return value / divisor; } catch (ArithmeticException error) { return -17L; } + } + public long wideRemainder(long value, long divisor) { + try { return value % divisor; } catch (ArithmeticException error) { return -17L; } + } + public int nested(int divisor) { + int value = 40; + try { + try { value = 40 / divisor; } catch (ArithmeticException error) { value = -17; } + } finally { value += 3; } + return value; + } + public int ordered(Throwable error) { + try { throw error; } + catch (IllegalArgumentException caught) { return 1; } + catch (RuntimeException caught) { return 2; } + catch (Exception caught) { return 3; } + catch (Throwable caught) { return 4; } + } + public int multi(Throwable error) { + try { throw error; } + catch (IllegalArgumentException | NullPointerException caught) { return 1; } + catch (RuntimeException caught) { return 2; } + catch (Throwable caught) { return 3; } + } + public void rethrow(Throwable error) throws Throwable { + try { throw error; } catch (Throwable caught) { throw caught; } + } + public void replace(Throwable original, Throwable replacement) throws Throwable { + try { throw original; } finally { throw replacement; } + } + public int returnInFinally(int divisor) { + try { return 12 / divisor; } finally { return 77; } + } + public Throwable catchReturn(Throwable error) { + try { throw error; } catch (Throwable caught) { return caught; } + } + public Object finallyReference(Object input, Throwable error) throws Throwable { + try { if (error != null) throw error; return input; } finally { input = null; } + } + public int loopCatch(int count) { + int sum = 0; + for (int i = 0; i < count; i++) { + try { sum += 100 / (i - 2); } catch (ArithmeticException error) { sum ^= 5; } + } + return sum; + } + public int switchInCatch(int value, int divisor) { + try { return value / divisor; } + catch (ArithmeticException error) { + switch (value) { case -1: return 31; case 0: return 43; case 1: return 59; default: return 71; } + } + } + public double floating(double value) { return value * 1.5d + 2.0d; } + } + public static class ChildView extends RulesView { + @Override public int compute(int value) { return value + 17; } + @Override public Object self() { return this; } + } + public static class RejectedView extends Base { + private int counter; + public int stateful() { return ++counter; } + public int callOther(int value) { return Math.abs(value); } + public int superCompute(int value) { return super.compute(value); } + public synchronized int locked() { return 5; } + @Override public int hashCode() { return 13; } + public native int nativeCall(); + } + private int checks; + private void check(boolean passed, String label) { + if (!passed) throw new AssertionError(label); + checks++; + } + @Override public void onCreate(Bundle state) { + super.onCreate(state); + try { + RulesView parent = new RulesView(); ChildView child = new ChildView(); + Base base = child; Contract face = child; RulesView view = child; + check(base.compute(5) == 22 && face.compute(5) == 22 && view.compute(5) == 22, "虚分派"); + check(face.defaultValue(5) == 15 && new Base().compute(5) == -1, "默认接口和父类"); + check(parent.self() == parent && child.self() == child, "接收者"); + Object first = new Object(), other = new Object(); int[] array = {1, 2}; + for (Object input : new Object[]{null, first, other, array, Integer.valueOf(3)}) { + check(parent.identity(input) == input && parent.choose(null, input) == input, "引用与空值"); + check(parent.finallyReference(input, null) == input, "finally 引用存活"); + check(parent.same(input, input) && !parent.same(first, other), "引用身份"); + } + check(parent.identity(array) == array && parent.identity((int[])null) == null, "数组透传"); + Method privateMethod = RulesView.class.getDeclaredMethod("privateValue", int.class); + privateMethod.setAccessible(true); + int[] ints = {Integer.MIN_VALUE, -10000, -3, -1, 0, 1, 2, 7, 10000, Integer.MAX_VALUE}; + long[] longs = {Long.MIN_VALUE, -10000000001L, -3L, -1L, 0L, 1L, 2L, Long.MAX_VALUE}; + for (int value : ints) { + check(parent.compute(value) == value + 7 && parent.protectedValue(value) == value + 3, "整数与访问标志"); + check((Integer)privateMethod.invoke(parent, value) == (value ^ 0x5a) && RulesView.staticValue(value) == value - 11, "反射私有和静态"); + for (int divisor : ints) { + check(parent.divide(value, divisor) == (divisor == 0 ? -17 : value / divisor), "整数除法异常"); + int caught = value == -1 ? 31 : value == 0 ? 43 : value == 1 ? 59 : 71; + check(parent.switchInCatch(value, divisor) == (divisor == 0 ? caught : value / divisor), "catch switch"); + } + check(parent.nested(value) == (value == 0 ? -14 : 40 / value + 3), "嵌套清理"); + check(parent.returnInFinally(value) == 77, "finally 覆盖返回与异常"); + } + for (long value : longs) { + check(parent.compute(value) == (value ^ 0x123456789abcdefL), "宽值重载"); + for (long divisor : longs) { + check(parent.wideDivide(value, divisor) == (divisor == 0 ? -17L : value / divisor), "长整数除法"); + check(parent.wideRemainder(value, divisor) == (divisor == 0 ? -17L : value % divisor), "长整数余数"); + } + } + Throwable[] errors = {null, new IllegalArgumentException(), new IllegalStateException(), new Exception(), new AssertionError()}; + for (int i = 0; i < errors.length; i++) { + Throwable error = errors[i]; + check(parent.ordered(error) == (i == 1 ? 1 : i <= 2 ? 2 : i == 3 ? 3 : 4), "捕获顺序"); + check(parent.multi(error) == (i <= 1 ? 1 : i == 2 ? 2 : 3), "多类型捕获"); + Throwable returned = parent.catchReturn(error); + check(error == null ? returned instanceof NullPointerException : returned == error, "捕获对象"); + try { parent.rethrow(error); throw new AssertionError("异常丢失"); } + catch (Throwable caught) { check(error == null ? caught instanceof NullPointerException : caught == error, "重抛身份"); } + if (error != null) { + try { parent.finallyReference(first, error); throw new AssertionError("异常丢失"); } + catch (Throwable caught) { check(caught == error, "finally 异常身份"); } + } + for (Throwable replacement : errors) { + try { parent.replace(error, replacement); throw new AssertionError("异常丢失"); } + catch (Throwable caught) { check(replacement == null ? caught instanceof NullPointerException : caught == replacement, "finally 替换异常"); } + } + } + for (int count = 0; count < 30; count++) { + int expected = 0; + for (int i = 0; i < count; i++) expected = i == 2 ? expected ^ 5 : expected + 100 / (i - 2); + check(parent.loopCatch(count) == expected, "循环异常汇合"); + } + for (double value : new double[]{-Double.MAX_VALUE, -0.0d, 0.0d, Double.NaN, Double.POSITIVE_INFINITY, 3.25d}) + check(Double.doubleToLongBits(parent.floating(value)) == Double.doubleToLongBits(value * 1.5d + 2.0d), "浮点特殊值"); + RejectedView rejected = new RejectedView(); + check(rejected.stateful() == 1 && rejected.stateful() == 2 && rejected.callOther(-3) == 3, "状态和调用保留"); + check(rejected.superCompute(5) == -1 && rejected.locked() == 5 && rejected.hashCode() == 13, "super 同步与对象契约保留"); + Log.e("Pvm2Regression", "PVM2_REGRESSION_OK token=" + getIntent().getStringExtra("run_token") + + " checks=" + checks + " pages=" + android.system.Os.sysconf(android.system.OsConstants._SC_PAGESIZE)); + } catch (Throwable error) { throw new AssertionError("PVM2 回归失败 checks=" + checks, error); } + } +} +''' + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--serial', action='append', default=[], help='明确指定本轮有权测试的设备,可重复传入;省略只构建和检查覆盖') +parser.add_argument('--output', type=Path, default=ROOT / 'target/pvm2-art-checks') +parser.add_argument('--without-resource-table', action='store_true', help='生成完全不带 resources.arsc 的 APK,默认保留合法空表') +args = parser.parse_args() +work = args.output.resolve() +work.mkdir(parents=True, exist_ok=True) +sdk_value = os.environ.get('ANDROID_SDK_ROOT') or os.environ.get('ANDROID_HOME') +if not sdk_value: + raise SystemExit('请设置 ANDROID_SDK_ROOT') +sdk = Path(sdk_value) +bt = sdk / 'build-tools/35.0.0' +android = sdk / 'platforms/android-35/android.jar' +cli = ROOT / 'target/release/shield' +env = dict(os.environ, SHIELD_TEST_KS_PASS='shellsmith-art-regression') +log = (work / 'commands.log').open('w') + +def run(command): + result = subprocess.run([str(x) for x in command], cwd=ROOT, env=env, capture_output=True, text=True, timeout=180) + log.write(result.stdout + result.stderr); log.flush() + if result.returncode: + raise RuntimeError('命令失败,请查看 ' + str(work / 'commands.log')) + return result.stdout + +for name in ['classes', 'dex']: + (work / name).mkdir(exist_ok=True) +(work / 'CompatibilityActivity.java').write_text(SOURCE) +package = 'dev.mocika.shield.pvm2regression' +(work / 'AndroidManifest.xml').write_text('') +run([bt / 'aapt2', 'link', '-o', work / 'base.apk', '--manifest', work / 'AndroidManifest.xml', '-I', android]) +run(['javac', '--release', '8', '-cp', android, '-d', work / 'classes', work / 'CompatibilityActivity.java']) +run(['jar', 'cf', work / 'classes.jar', '-C', work / 'classes', '.']) +run([bt / 'd8', '--release', '--lib', android, '--min-api', '23', '--output', work / 'dex', work / 'classes.jar']) +with zipfile.ZipFile(work / 'base.apk') as base, zipfile.ZipFile(work / 'input-unsigned.apk', 'w') as archive: + for entry in base.infolist(): + if not args.without_resource_table or entry.filename != 'resources.arsc': + archive.writestr(entry, base.read(entry.filename)) + archive.write(work / 'dex/classes.dex', 'classes.dex') +keystore = work / 'test.p12' +if not keystore.exists(): + run(['keytool', '-genkeypair', '-noprompt', '-keystore', keystore, '-storetype', 'PKCS12', '-storepass:env', 'SHIELD_TEST_KS_PASS', '-keypass:env', 'SHIELD_TEST_KS_PASS', '-alias', 'probe', '-keyalg', 'RSA', '-validity', '365', '-dname', 'CN=Shellsmith ART Test']) +keystore.chmod(0o600) +env['MOCIKA_SHIELD_KS_PASS'] = env['SHIELD_TEST_KS_PASS'] + +def sign(source, destination): + run([cli, 'sign', '-i', work / source, '-o', work / destination, '--ks', keystore, '--key-alias', 'probe', '--ks-type', 'pkcs12', '--apksigner', ROOT / 'tools/apksigner.jar']) + +sign('input-unsigned.apk', 'input.apk') +protect = [cli, 'protect', '-i', work / 'input.apk', '-o', work / 'protected.apk', '--resources', ROOT / 'shield-stub/build/outputs/resources/resources.zip', '--apktool', ROOT / 'tools/apktool_3.0.3.jar', '--apksigner', ROOT / 'tools/apksigner.jar', '--profile', 'strict', '--ai-resistance', 'high', '--environment-policy', 'compatible', '--xop-pvm2-packer', ROOT / 'tools/xop-pvm2-packer.jar'] +for name in ['RulesView', 'ChildView', 'RejectedView']: + protect += ['--xop-true-vmp-prefix', 'L' + package.replace('.', '/') + '/CompatibilityActivity$' + name + ';'] +run(protect) +sign('protected.apk', 'protected-signed.apk') +report = json.loads((work / 'protected-signed.apk.protection.json').read_text()) +assert report['output_sha256'] == hashlib.sha256((work / 'protected-signed.apk').read_bytes()).hexdigest() +counts = Counter() +for row in report['methods']: + if '-><' in row['descriptor']: + assert not row['artifact_verified'] and row['reason'] == 'initializer', row + elif row['descriptor'].endswith('$RulesView;->defaultValue(I)I'): + # D8 为默认接口方法生成转发桥;它应留在 ART,运行时仍须验证接口调用结果。 + assert not row['artifact_verified'] and row['reason'] == 'compiler_generated_bridge', row + elif '$RejectedView;' in row['descriptor']: + assert not row['artifact_verified'], row + expected = {'stateful': 'ui_runtime_glue:non_leaf_instruction', 'callOther': 'ui_runtime_glue:non_leaf_instruction', + 'superCompute': 'ui_runtime_glue:non_leaf_instruction', 'locked': 'ui_runtime_glue:synchronization', + 'hashCode': 'ui_runtime_glue:virtual_contract', 'nativeCall': 'no_code'} + name = row['descriptor'].split('->')[1].split('(')[0] + assert row['reason'] == expected[name], row + else: + assert row['artifact_verified'], row + counts['verified' if row['artifact_verified'] else row['reason']] += 1 +assert counts['verified'] == 26 and len(report['methods']) == 36, counts +print('Java/D8 整包:26 个方法验证通过,3 个初始化方法、1 个 D8 桥接方法和 6 个明确不支持的方法保留。', flush=True) +results = {'coverage': dict(counts), 'apk_sha256': report['output_sha256'], + 'resource_table': 'absent' if args.without_resource_table else 'empty', + 'source_sha256': hashlib.sha256(SOURCE.encode()).hexdigest(), 'devices': []} +(work / 'result.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n') +for serial in args.serial: + adb = [sdk / 'platform-tools/adb', '-s', serial] + device = {'api': run(adb + ['shell', 'getprop', 'ro.build.version.sdk']).strip(), + 'abi': run(adb + ['shell', 'getprop', 'ro.product.cpu.abi']).strip(), 'scenarios': []} + for scenario, apk in [('baseline', 'input.apk'), ('protected-first', 'protected-signed.apk'), + ('protected-cached', None), ('protected-update', 'protected-signed.apk'), ('baseline-restore', 'input.apk')]: + if apk: + assert 'Success' in run(adb + ['install', '-r', work / apk]) + run(adb + ['shell', 'am', 'force-stop', package]) + # 只读取本次启动之后的日志;不清除设备日志或现有业务数据。 + stamp = run(adb + ['shell', 'date', "'+%m-%d %H:%M:%S.000'"]).strip() + token = uuid.uuid4().hex + marker = 'PVM2_REGRESSION_OK token=' + token + ' checks=' + run(adb + ['shell', 'am', 'start', '-W', '-n', package + '/.CompatibilityActivity', '--es', 'run_token', token]) + logs = '' + for _ in range(15): + logs = run(adb + ['logcat', '-d', '-T', stamp, '-s', 'Pvm2Regression:E', 'AndroidRuntime:E', '*:S']) + if marker in logs or 'FATAL EXCEPTION' in logs: + break + time.sleep(1) + path = work / ('api' + device['api'] + '-' + scenario + '.log') + path.write_text(logs) + match = re.search(re.escape(marker) + r'(\d+) pages=(\d+)', logs) + assert match and 'FATAL EXCEPTION' not in logs, str(path) + checks = int(match.group(1)) + assert checks == 482, checks + device['page_size'] = int(match.group(2)) + assert run(adb + ['shell', 'pidof', package]).strip(), str(path) + device['scenarios'].append({'name': scenario, 'checks': checks}) + print('Android API ' + device['api'] + ' ' + scenario + ':' + str(checks) + ' 项执行检查通过', flush=True) + results['devices'].append(device) + (work / 'result.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n') diff --git a/tests/scripts/check-pvm2-runtime.py b/tests/scripts/check-pvm2-runtime.py index a2fc758..d64da79 100644 --- a/tests/scripts/check-pvm2-runtime.py +++ b/tests/scripts/check-pvm2-runtime.py @@ -24,6 +24,7 @@ jar = ROOT / 'tools/xop-pvm2-packer.jar' subprocess.run([sys.executable, str(ROOT / 'scripts/build_pvm2_packer.py'), '--check'], check=True) subprocess.run(['javac', '--release', '17', '-cp', str(jar), '-d', str(temp), + str(ROOT / 'tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java'), str(ROOT / 'tests/java/com/yqsh/protector/packer/Pvm2RuntimeImages.java')], check=True) subprocess.run(['java', '-cp', str(temp) + os.pathsep + str(jar), 'com.yqsh.protector.packer.Pvm2RuntimeImages', str(temp)], check=True) 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 8537ec5..f90bb1c 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 @@ -3167,8 +3167,10 @@ PROTECTOR_ENCRYPT static jobject interpret_body(JNIEnv* env, int dex_index, uint case OP_THROW: { if (pc + 1 > code_size) goto fail; uint8_t src = code[pc++]; - if (!reg_bounds(src, regs.size()) || regs[src].o == nullptr) goto fail; - env->Throw(static_cast(regs[src].o)); + if (!reg_bounds(src, regs.size())) goto fail; + // throw null 与 ART 一致地产生 NPE,且必须经过当前方法的异常表。 + if (regs[src].o == nullptr) throw_npe(env, "throw null"); + else env->Throw(static_cast(regs[src].o)); if (dispatch_exception(env, img, fault_pc, &pc, &stashed_exception)) { break; } diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/AutoVmpPolicy.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/AutoVmpPolicy.java index c48f2cc..72067e7 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/AutoVmpPolicy.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/AutoVmpPolicy.java @@ -134,21 +134,20 @@ static String exclusionReason(ClassDef owner, Method method) { reason = "playback_compatibility"; if (reason == null) return null; if (method.getImplementation() == null || method.getName().startsWith("<")) return reason; - String restriction = independentMethodRestriction(owner, method); + String restriction = independentMethodRestriction(method); return restriction == null ? null : reason + ":" + restriction; } - private static String independentMethodRestriction(ClassDef owner, Method method) { + private static String independentMethodRestriction(Method method) { int flags = method.getAccessFlags(); var code = method.getImplementation(); if ((flags & (0x20 | 0x20000)) != 0) return "synchronization"; - if (!code.getTryBlocks().isEmpty()) return "exception_region"; - // 私有/静态方法不覆盖框架回调;其它实例方法仅放行无继承/接口契约的普通类。 - // ponytail: 未解析外部及跨 DEX 父类的回调契约,保守保留其虚方法;扩展需完整继承图验证。 - if ((flags & (0x2 | 0x8)) == 0 && (!"Ljava/lang/Object;".equals(owner.getSuperclass()) - || !owner.getInterfaces().isEmpty() || method.getName().equals("hashCode") + // 跳板保留原声明、访问标志和 this,虚分派仍由 ART 完成;继承/接口本身不改变叶子方法语义。 + // ponytail: hashCode/finalize 仍留在 ART,避免运行时重入;扩展需单独验证 GC 与内部对象操作。 + if ((flags & (0x2 | 0x8)) == 0 && (method.getName().equals("hashCode") || method.getName().equals("finalize"))) return "virtual_contract"; - // ponytail: 仅放行无状态访问、外部调用的叶子方法;引用参数、透传与比较复用已有类型化运行时。 + // 异常表交给 Pvm2Safety 和编译器校验;move-exception/throw 支持捕获、finally 和重新抛出。 + // ponytail: 仅放行无状态访问、外部调用的叶子方法;引用和异常复用已有类型化运行时。 // 字段、数组元素、调用等仍留在 ART;扩展需验证对象状态与框架语义,不能直接移除命名规则。 for (var instruction : code.getInstructions()) { Opcode opcode = instruction.getOpcode(); @@ -156,8 +155,8 @@ private static String independentMethodRestriction(ClassDef owner, Method method Short value = OPCODES.getOpcodeValue(opcode); if (value == null) return "non_leaf_instruction"; int op = Short.toUnsignedInt(value); - if (!(op <= 0x09 || (op >= 0x0e && op <= 0x11) || (op >= 0x12 && op <= 0x19) - || (op >= 0x28 && op <= 0x3d) || (op >= 0x7b && op <= 0xe2))) return "non_leaf_instruction"; + if (!(op <= 0x09 || (op >= 0x0d && op <= 0x11) || (op >= 0x12 && op <= 0x19) + || (op >= 0x27 && op <= 0x3d) || (op >= 0x7b && op <= 0xe2))) return "non_leaf_instruction"; } return null; } diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/ResourceIdRewriter.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/ResourceIdRewriter.java index e8b50c4..1817e82 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/ResourceIdRewriter.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/ResourceIdRewriter.java @@ -81,12 +81,20 @@ private ResourceIdRewriter() { } public static Result rewrite(File unpackRoot) throws Exception { + // Apktool 解包完全不含资源表的 APK 时不生成 res 目录。 + if (!new File(unpackRoot, "res").exists() && !new File(unpackRoot, "resources.arsc").exists()) + return new Result(Map.of(), 0, listDexFiles(unpackRoot).size()); File publicXml = new File(unpackRoot, "res/values/public.xml"); if (!publicXml.isFile()) { throw new IOException("resource ID reorder requires res/values/public.xml"); } Document document = parseXml(publicXml); + if (!"resources".equals(document.getDocumentElement().getTagName())) { + throw new IOException("public.xml requires a resources root"); + } NodeList nodes = document.getElementsByTagName("public"); + // 无资源 APK 的合法空表无需重排;损坏或缺失的表仍须报错。 + if (nodes.getLength() == 0) return new Result(Map.of(), 0, listDexFiles(unpackRoot).size()); Map entries = new LinkedHashMap<>(); Map> byType = new LinkedHashMap<>(); Set semanticPins = new HashSet<>(); diff --git a/tools/xop-pvm2-packer.jar b/tools/xop-pvm2-packer.jar index 0c77754..17f7718 100644 Binary files a/tools/xop-pvm2-packer.jar and b/tools/xop-pvm2-packer.jar differ