diff --git a/.github/release-notes/versions/1.8.2.md b/.github/release-notes/versions/1.8.2.md new file mode 100644 index 0000000..f568e4d --- /dev/null +++ b/.github/release-notes/versions/1.8.2.md @@ -0,0 +1,12 @@ +### PVM2 方法级兼容修复 + +- 修复 UI、数据、资源、设备和媒体类因命名被整类排除的问题。对独立的基本类型计算逐方法检查,让符合条件的私有、静态和普通实例方法继续进入 PVM2 转换及产物验证。 +- 统一实际转换、覆盖报告与精确必保清单的兼容判断;框架回调、对象状态、生成桥接等不满足条件的方法继续保留原执行方式,不能用必保清单绕过兼容检查。 +- 保持严格模式转换成功率门槛,完整覆盖报告仍包含初始化器和兼容排除项。零转换或必保方法未覆盖时明确失败,不会静默关闭保护。 +- 补充通用命名、继承与接口、桥接方法、对象状态及统计口径回归;在已授权的 Android 15 真机上完成独立样例加固前后结果对照。 + +### 使用说明 + +- 升级后重新加固,才能使用新的方法判断规则;已有加固产物不会自动改变。 +- 本次扩大的是经过检查的独立计算范围,不保证任意类或全部方法均可转换。没有客户 APK 的场景,不能仅凭错误统计认定其具体方法已兼容。 +- 逐方法失败报告可能包含业务标识,分享前请手动脱敏。 diff --git a/Cargo.lock b/Cargo.lock index 22188ea..75aa77c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2201,7 +2201,7 @@ dependencies = [ [[package]] name = "mocika-shield" -version = "1.8.1" +version = "1.8.2" dependencies = [ "dunce", "reqwest 0.12.28", @@ -2229,7 +2229,7 @@ dependencies = [ [[package]] name = "mocikashield" -version = "1.8.1" +version = "1.8.2" dependencies = [ "cc", "chacha20poly1305", @@ -3635,7 +3635,7 @@ dependencies = [ [[package]] name = "shield-cli" -version = "1.8.1" +version = "1.8.2" dependencies = [ "anyhow", "clap", @@ -3653,7 +3653,7 @@ dependencies = [ [[package]] name = "shield-core" -version = "1.8.1" +version = "1.8.2" dependencies = [ "anyhow", "chacha20poly1305", diff --git a/apps/shield-cli/Cargo.toml b/apps/shield-cli/Cargo.toml index 641c7c4..beb2cfd 100644 --- a/apps/shield-cli/Cargo.toml +++ b/apps/shield-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-cli" -version = "1.8.1" +version = "1.8.2" 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 a1ed75b..16d9e21 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.1", + "version": "1.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mocika-shield-gui", - "version": "1.8.1", + "version": "1.8.2", "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 e67bfa7..dc7867a 100644 --- a/apps/shield-gui/package.json +++ b/apps/shield-gui/package.json @@ -1,6 +1,6 @@ { "name": "mocika-shield-gui", - "version": "1.8.1", + "version": "1.8.2", "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 da1cb16..62104a0 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.1" +version = "1.8.2" 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 1ba2166..a01932a 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.1", + "version": "1.8.2", "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 92a2ed7..0f0a30d 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.1", + version: "1.8.2", 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 5d41681..50a13d1 100644 --- a/apps/shield-gui/src/lib/protect-diagnostic.ts +++ b/apps/shield-gui/src/lib/protect-diagnostic.ts @@ -54,7 +54,7 @@ export function buildPvm2FailureDiagnostic(summary: Pvm2FailureSummary, locale: initializer: ["构造器/静态初始化器", "Initializer"], ui_runtime_glue: ["界面/生命周期兼容规则", "UI/lifecycle compatibility"], data_resource_runtime_glue: ["数据/资源框架兼容规则", "Data/resource compatibility"], - compiler_generated_bridge: ["编译器生成桥接类", "Compiler-generated bridge"], + compiler_generated_bridge: ["编译器生成桥接/合成方法", "Compiler-generated bridge/synthetic method"], playback_compatibility: ["媒体/辅助类兼容规则", "Media/helper compatibility"], nullable_receiver: ["接收者非空语义无法证明", "Nullable receiver"], unsupported_opcode: ["不支持的指令", "Unsupported opcode"], diff --git a/crates/shield-core/Cargo.toml b/crates/shield-core/Cargo.toml index 2deea88..c0d88fd 100644 --- a/crates/shield-core/Cargo.toml +++ b/crates/shield-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "shield-core" -version = "1.8.1" +version = "1.8.2" edition = "2021" authors = ["MocikaDev"] license = "MIT OR Apache-2.0" diff --git a/crates/shield-core/src/protection_report.rs b/crates/shield-core/src/protection_report.rs index f27fbc5..84826c6 100644 --- a/crates/shield-core/src/protection_report.rs +++ b/crates/shield-core/src/protection_report.rs @@ -101,7 +101,7 @@ impl Pvm2SkipReason { Self::Initializer => "构造器/静态初始化器", Self::UiRuntimeGlue => "界面/生命周期兼容规则", Self::DataResourceRuntimeGlue => "数据/资源框架兼容规则", - Self::CompilerGeneratedBridge => "编译器生成桥接类", + Self::CompilerGeneratedBridge => "编译器生成桥接/合成方法", Self::PlaybackCompatibility => "媒体/辅助类兼容规则", Self::NullableReceiver => "接收者非空语义无法证明", Self::UnsupportedOpcode => "不支持的指令", diff --git a/docs/usage.md b/docs/usage.md index 0c0aadb..926f34d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -217,7 +217,7 @@ GUI 安装包已经内置 Xop PVM2 Packer:Packer 路径留空即使用内置 PVM2 自 v7 起已支持普通/复杂条件分支、`switch`、异常表与 `throw`、对象/原始数组、标准反射调用所需的 invoke 路径,以及 `monitor-enter/exit` 同步语义。每次构建会改变虚拟 Opcode、虚拟寄存器映射、立即数编码、Handler 顺序,并从三套 Dispatcher 模板中选择一套;覆盖报告中的 `isa` 可用于确认变体。仍不满足契约的方法(例如个别 `invoke-custom`/`invoke-polymorphic`、超出寄存器或代码预算的方法)必须记入跳过原因,不能当作成功。 -为保证音频/视频拖动和解码器状态,内置 Packer 会把播放器、解码器、音频/波形类及其 Kotlin/R8 合成访问器整体留在 ART;RecyclerView 适配器、Widget/View/Binding、资源/文件 Helper(兼容历史包名中的 `hepler`)也留在 ART,避免空接收者、回调和资源 ID 语义被 PVM2 改写。报告会以 `compatibility` 记录这些跳过项。资源 PAS2 同样默认跳过常见音视频扩展名,避免把需要随机访问的媒体流包装成只读顺序流。 +为保证音频/视频拖动、解码器状态和框架回调,内置 Packer 对播放器、音频/波形、RecyclerView 适配器、Widget/View/Binding、资源/文件 Helper(兼容历史包名中的 `hepler`)执行方法兼容检查。涉及框架或对象状态的方法及 Kotlin/R8 合成访问器保留在 ART;符合下文 PVM2 方法级规则的独立计算方法可以转换。逐方法报告保留兼容排除分类。资源 PAS2 同样默认跳过常见音视频扩展名,避免把需要随机访问的媒体流包装成只读顺序流。 这些是按 DEX 描述符、方法指令和 Android 生命周期边界计算的通用规则,不依赖某个业务包名或某个应用的类名。PVM2 对对象接收者执行路径敏感的控制流保护:`if-eqz`/`if-nez` 已证明非空的路径可以继续虚拟化,多个路径合流时只保留所有路径都能证明的非空事实;直线调用仍保持 ART 与 PVM2 相同的空接收者异常语义。跨分支或异常边界、又无法证明非空的接收者记为 `nullable_receiver` 并保留 ART,本地创建且可证明非空的对象仍可继续虚拟化。静态调用不会被误当作需要接收者证明的实例调用。工具对无法证明语义等价的方法失败关闭并保留 ART;这能显著降低兼容风险,但不能对任意第三方 APK、厂商 ROM 或未提供的业务 Native SDK 做绝对“零崩溃”保证,正式发布仍需用目标 APK 和设备矩阵回归。 @@ -543,7 +543,11 @@ shield protect --input input.apk --output protected.apk \ 启用 PVM2 的成功输出旁会生成 `protected.apk.protection.json` 或 `protected.aab.protection.json`。统计分别展示转换成功率、选中方法覆盖率、原始指令字节覆盖率和必保方法覆盖率;未声明必保方法时该指标为 `null`,不记为 100%。这些百分比仅针对选择范围,不代表整个应用。报告包含逐方法原因、最终索引与产物摘要,不包含密钥或明文镜像,也不进入安装包。 -选择范围在兼容性过滤**之前**统计。前缀命中的 UI/导航、生成桥接和数据/资源基础设施方法仍计入分母,分别记录 `ui_runtime_glue`、`compiler_generated_bridge`、`data_resource_runtime_glue`,保持 ART 执行。不能用排除这些方法之后的候选数量作为整个前缀的覆盖率分母;精确必保清单只对该清单负责,不表示应用的关键业务已经全部识别。升级前的报告可能遗漏这三类排除项,完整前缀的覆盖率需用新打包器重新生成。 +选择范围在兼容性过滤**之前**统计。UI/导航、数据/资源辅助和媒体命名不再直接排除整类:没有对象/数组参数、对象状态访问、外部调用、同步或异常处理的基本类型计算方法,可以继续进入 PVM2 指令与产物检查。私有和静态方法可在框架类中单独转换;其它实例方法仅在直接继承 `Object`、不实现接口且不覆盖 `hashCode/finalize` 契约时适用此放行规则。分支、循环和数值运算仍须通过现有编译器检查,不能只凭命名认定可转换。 + +构造器、静态初始化器、生成桥接/合成方法和仍涉及框架状态的候选保持原兼容边界;未解析的外部或跨 DEX 继承契约不据此扩大虚方法范围。跳过项仍计入覆盖率分母,并保留 `ui_runtime_glue`、`compiler_generated_bridge`、`data_resource_runtime_glue` 或 `playback_compatibility` 等实际分类。精确必保清单同样受方法兼容检查约束,必保项被排除时任务失败,不会自动降级。所有候选都不可转换时仍返回 `PVM2_ZERO_METHODS`,不会把零转换记为成功。这些规则不代表已识别应用全部关键业务;完整覆盖率需用新打包器重新生成。 + +严格模式的 70% 转换成功率门槛保持原口径:类兼容预筛排除项不算编译尝试,媒体准入失败仍计入。该指标与完整选择范围覆盖率分别报告;例如 1 个计算方法转换、1 个界面回调保留时,转换成功率可以为 100%,选中方法覆盖率仍只有 50%。 使用 `shield sign` 或 GUI 自动签名时,公共签名流程会重新比对 DEX 并更新最终文件摘要。另用外部签名工具会改变文件摘要,原报告不再对应签名后的文件。报告为本地构建记录,不是不可伪造的安全认证。新能力要求从当前源码重建的 Packer(`make build-pvm2`),旧 JAR 即使镜像版本相同也会被明确拒绝。 diff --git a/shield-stub/compat/api19-rust/Cargo.lock b/shield-stub/compat/api19-rust/Cargo.lock index 4dcd226..605f2be 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.1" +version = "1.8.2" dependencies = [ "cc", "chacha20poly1305", diff --git a/shield-stub/compat/api19-rust/Cargo.toml b/shield-stub/compat/api19-rust/Cargo.toml index 7f09ba4..2a674b6 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.1" +version = "1.8.2" 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 63de6a8..c4f1e64 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.1" +version = "1.8.2" 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 d4a2dec..340d32f 100644 --- a/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java +++ b/tests/java/com/yqsh/protector/packer/Pvm2ProtectionCheck.java @@ -4,6 +4,7 @@ import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile; import com.android.tools.smali.dexlib2.immutable.*; import com.android.tools.smali.dexlib2.immutable.instruction.*; +import com.android.tools.smali.dexlib2.immutable.reference.*; import com.android.tools.smali.dexlib2.writer.io.FileDataStore; import com.android.tools.smali.dexlib2.writer.pool.DexPool; import org.json.*; @@ -100,6 +101,7 @@ 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]); checkZeroMethodReports(root); + checkMethodCompatibility(root); Path dex = Files.createDirectory(root.resolve("dex")); fixture(dex.resolve("classes.dex")); fixture(dex.resolve("classes3.dex")); @@ -209,11 +211,11 @@ 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", 9, Set.of(), Set.of(), + if (!kind.equals("native")) methods.add(new ImmutableMethod(owner, "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()); - pool.internClass(new ImmutableClassDef(owner, 1, "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), methods)); + pool.internClass(new ImmutableClassDef(owner, 1, kind.equals("ui") ? "Landroid/app/Activity;" : "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), methods)); FileDataStore store = new FileDataStore(dex.resolve("classes.dex").toFile()); try { pool.writeTo(store); } finally { store.close(); } Path config = work.resolve("config.json"), report = work.resolve("report.json"); @@ -239,6 +241,146 @@ private static void checkZeroMethodReports(Path root) throws Exception { } } + private static ImmutableMethod calculation(String owner, String name, int flags) { + boolean isStatic = (flags & 8) != 0; + return new ImmutableMethod(owner, name, List.of(new ImmutableMethodParameter("I", Set.of(), null)), + "I", flags, Set.of(), Set.of(), new ImmutableMethodImplementation(isStatic ? 2 : 3, + List.of(new ImmutableInstruction22b(Opcode.ADD_INT_LIT8, 0, isStatic ? 1 : 2, 7), + new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of())); + } + + private static ImmutableClassDef calculationOwner(String type, String parent, List interfaces, List methods) { + return new ImmutableClassDef(type, 1, parent, interfaces, null, Set.of(), List.of(), methods); + } + + private static void writeClasses(Path path, List classes) throws Exception { + DexPool pool = new DexPool(Opcodes.getDefault()); + for (var cls : classes) pool.internClass(cls); + FileDataStore store = new FileDataStore(path.toFile()); + try { pool.writeTo(store); } finally { store.close(); } + } + + private static void checkMethodCompatibility(Path root) throws Exception { + for (String reason : List.of("ui_runtime_glue", "data_resource_runtime_glue", "compiler_generated_bridge", "playback_compatibility")) + assert Pvm2Admission.classify(reason) == Pvm2Admission.SkipReason.compatibility; + // 相同计算体不应因 UI、数据、资源、设备或媒体命名而整类消失。 + for (String type : List.of("Lfixture/ui/Rules;", "Lfixture/navigation/Rules;", "Lfixture/MainActivity;", + "Lfixture/adapter/Rules;", "Lfixture/QuoteView;", "Lfixture/RouteNavigator;", + "Lfixture/data/Rules;", "Lfixture/QuoteRepository;", "Lfixture/PriceViewModel;", + "Lfixture/dao/Rules;", "Lfixture/resource/Rules;", "Lfixture/helper/Rules;", + "Lfixture/hepler/Rules;", "Lfixture/device/ble/Rules;", "Lfixture/PermissionManager;", + "Lfixture/audio/Rules;", "Lfixture/TrackRules;", "Lfixture/PlainRules;")) { + for (int flags : List.of(1, 2, 9)) { + var method = calculation(type, "calculate", flags); + var cls = calculationOwner(type, "Ljava/lang/Object;", List.of(), List.of(method)); + assert AutoVmpPolicy.exclusionReason(cls, method) == null : type; + } + } + String type = "Lfixture/ui/Rules;"; + var publicMethod = calculation(type, "calculate", 1); + var privateMethod = calculation(type, "local", 2); + 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"); + 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"); + 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; + 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"); + assert AutoVmpPolicy.exclusionReason(calculationOwner("Lfixture/a;", "Ljava/lang/Object;", List.of("Landroid/view/View$OnClickListener;"), List.of()), obfuscated).equals("ui_runtime_glue"); + // 原始 opcode、对象类型、字段/数组访问、调用和锁都不能借纯计算规则放行。 + var field = new ImmutableFieldReference(type, "state", "I"); + var call = new ImmutableMethodReference(type, "external", List.of(), "I"); + for (var instruction : List.of(new ImmutableInstruction12x(Opcode.MOVE_OBJECT, 0, 1), + new ImmutableInstruction21c(Opcode.SGET, 0, field), new ImmutableInstruction22c(Opcode.IGET, 0, 1, field), + new ImmutableInstruction23x(Opcode.AGET, 0, 1, 2), + new ImmutableInstruction35c(Opcode.INVOKE_STATIC, 0, 0, 0, 0, 0, 0, call), + new ImmutableInstruction35c(Opcode.INVOKE_VIRTUAL, 1, 1, 0, 0, 0, 0, call), + new ImmutableInstruction11x(Opcode.MONITOR_ENTER, 1), + new ImmutableInstruction21c(Opcode.NEW_INSTANCE, 0, new ImmutableTypeReference(type)))) { + var method = new ImmutableMethod(type, "stateful", List.of(), "I", 9, Set.of(), Set.of(), + new ImmutableMethodImplementation(3, List.of(instruction, new ImmutableInstruction11x(Opcode.RETURN, 0)), List.of(), List.of())); + assert AutoVmpPolicy.exclusionReason(plain, method).equals("ui_runtime_glue") : instruction.getOpcode(); + } + for (String reference : List.of("Ljava/lang/Object;", "[I")) { + var method = new ImmutableMethod(type, "objectArgument", List.of(new ImmutableMethodParameter(reference, Set.of(), null)), + "I", 9, Set.of(), Set.of(), staticMethod.getImplementation()); + assert AutoVmpPolicy.exclusionReason(plain, method).equals("ui_runtime_glue"); + } + + Path work = Files.createDirectory(root.resolve("method-compatibility")); + Path dex = Files.createDirectory(work.resolve("dex")); + Path config = work.resolve("config.json"), code = work.resolve("code.bin"), report = work.resolve("report.json"); + JSONObject selection = new JSONObject().put("schema_version", 2) + .put("modules", new JSONObject().put("classes.dex", "base")).put("required", new JSONArray()); + Files.writeString(config, selection.toString()); + List classes = new ArrayList<>(); + for (String owner : List.of("Lfixture/ui/PriceRules;", "Lfixture/data/BalanceRepository;", "Lfixture/player/TrackRules;")) { + List methods = new ArrayList<>(); + 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()))); + for (int i = 0; i < 6; i++) methods.add(calculation(owner, "calculate" + i, List.of(1, 2, 9).get(i % 3))); + classes.add(calculationOwner(owner, "Ljava/lang/Object;", List.of(), methods)); + } + writeClasses(dex.resolve("classes.dex"), classes); + run(dex, config, code, report, "Lfixture/"); + JSONObject result = new JSONObject(Files.readString(report)); + assert result.getJSONArray("methods").length() == 21; + int verified = 0, initializers = 0; + for (Object value : result.getJSONArray("methods")) { + JSONObject row = (JSONObject) value; + if (row.getBoolean("artifact_verified")) verified++; + else { assert row.getString("reason").equals("initializer"); initializers++; } + } + assert verified == 18 && initializers == 3; + // 精确必保清单也必须服从同一兼容判断;声明回调不会使其自动变得安全。 + String activity = "Lfixture/ui/ScreenActivity;"; + var sensitive = calculationOwner(activity, "Landroid/app/Activity;", List.of(), + List.of(calculation(activity, "callback", 1), calculation(activity, "local", 2))); + writeClasses(dex.resolve("classes.dex"), List.of(sensitive)); + var captured = new java.io.ByteArrayOutputStream(); + var previous = System.out; + try (var stream = new java.io.PrintStream(captured, true, java.nio.charset.StandardCharsets.UTF_8)) { + System.setOut(stream); + run(dex, config, code, report, "Lfixture/"); + } finally { System.setOut(previous); } + assert captured.toString(java.nio.charset.StandardCharsets.UTF_8) + .contains("candidates=1 attempted=1 success=1 fallback=0 rate=100.0%"); + var mixed = new JSONObject(Files.readString(report)).getJSONArray("methods"); + assert mixed.length() == 2; + int kept = 0, protectedCount = 0; + for (Object value : mixed) { + var row = (JSONObject) value; + if (row.getBoolean("artifact_verified")) protectedCount++; + else { assert row.getString("reason").equals("ui_runtime_glue"); kept++; } + } + 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"))); + Files.writeString(config, selection.toString()); + try { + run(dex, config, code, report, false); + 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"); + } + } + System.out.println("方法级兼容检查通过:通用命名、独立计算、回调保留、必保约束、3 类 21 方法中的 18 个计算方法转换"); + } + private static void fixture(Path path) throws Exception { List methods = new ArrayList<>(); for (boolean parameter : List.of(false, true)) { @@ -249,10 +391,10 @@ 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", 9, Set.of(), Set.of(), + var unchanged = new ImmutableMethod(excluded, "value", 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, "Ljava/lang/Object;", List.of(), null, Set.of(), List.of(), List.of(unchanged))); + pool.internClass(new ImmutableClassDef(excluded, 1, "Landroid/app/Activity;", List.of(), null, Set.of(), List.of(), List.of(unchanged))); FileDataStore store = new FileDataStore(path.toFile()); try { pool.writeTo(store); } finally { store.close(); } } 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 45d3d27..fe1490c 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 @@ -1,5 +1,9 @@ package com.yqsh.protector.packer; +import com.android.tools.smali.dexlib2.Opcode; +import com.android.tools.smali.dexlib2.Opcodes; +import com.android.tools.smali.dexlib2.iface.ClassDef; +import com.android.tools.smali.dexlib2.iface.Method; import java.util.List; import java.util.Locale; @@ -7,6 +11,7 @@ * Resolve and match auto True-VMP policy per {@code doc/auto-true-vmp-contract.md}. */ public final class AutoVmpPolicy { + private static final Opcodes OPCODES = Opcodes.getDefault(); private AutoVmpPolicy() { } @@ -106,11 +111,63 @@ public static boolean shouldTrueVmp(String typeDescriptor, boolean industryEffective, List trueVmpPrefixes, String appPackagePrefix) { - return exclusionReason(typeDescriptor) == null - && matchesRequestedScope(typeDescriptor, paymentEffective, industryEffective, + // 类前缀只决定选择范围;兼容性必须在读取方法体后判断。 + return matchesRequestedScope(typeDescriptor, paymentEffective, industryEffective, trueVmpPrefixes, appPackagePrefix); } + /** 报告与实际转换共用,精确必保清单也不能绕过方法兼容检查。 */ + static String exclusionReason(ClassDef owner, Method method) { + String reason = exclusionReason(owner.getType()); + int flags = method.getAccessFlags(); + // 生成桥接与合成方法保持原有接收者和调用约定。 + if ((flags & (0x40 | 0x1000)) != 0 || (owner.getAccessFlags() & 0x1000) != 0 + || "compiler_generated_bridge".equals(reason)) return "compiler_generated_bridge"; + // 直接父类和接口仍暴露框架契约时,混淆后的类名不能使回调漏过检查。 + if (reason == null && owner.getSuperclass() != null) + reason = exclusionReason(owner.getSuperclass()); + if (reason == null) for (String contract : owner.getInterfaces()) { + reason = exclusionReason(contract); + if (reason != null) break; + } + if (reason == null && PlaybackCompatibility.keepOnArt(owner.getType(), method.getName())) + reason = "playback_compatibility"; + if (reason == null) return null; + return isIndependentCalculation(owner, method) ? null : reason; + } + + private static boolean isIndependentCalculation(ClassDef owner, Method method) { + int flags = method.getAccessFlags(); + var code = method.getImplementation(); + if (code == null || method.getName().startsWith("<") || !code.getTryBlocks().isEmpty() + || (flags & (0x20 | 0x20000)) != 0) return false; + // 私有/静态方法不覆盖框架回调;其它实例方法仅放行无继承/接口契约的普通类。 + // ponytail: 未解析外部及跨 DEX 父类的回调契约,保守保留其虚方法;扩展需完整继承图验证。 + if ((flags & (0x2 | 0x8)) == 0 && (!"Ljava/lang/Object;".equals(owner.getSuperclass()) + || !owner.getInterfaces().isEmpty() || method.getName().equals("hashCode") + || method.getName().equals("finalize"))) return false; + if (!primitive(method.getReturnType(), true)) return false; + for (CharSequence parameter : method.getParameterTypes()) + if (!primitive(parameter.toString(), false)) return false; + // ponytail: 命名兼容排除仅放行无对象/字段/数组/外部调用的数值叶子方法;其余语义仍由 ART 执行。 + // 后续扩展须先证明对象状态与框架调用语义,不能直接移除包路径或类名规则。 + for (var instruction : code.getInstructions()) { + Opcode opcode = instruction.getOpcode(); + if (opcode == Opcode.PACKED_SWITCH_PAYLOAD || opcode == Opcode.SPARSE_SWITCH_PAYLOAD) continue; + Short value = OPCODES.getOpcodeValue(opcode); + if (value == null) return false; + int op = Short.toUnsignedInt(value); + if (!(op <= 0x06 || (op >= 0x0e && op <= 0x10) || (op >= 0x12 && op <= 0x19) + || (op >= 0x28 && op <= 0x3d) || (op >= 0x7b && op <= 0xe2))) return false; + } + return true; + } + + private static boolean primitive(String type, boolean allowVoid) { + return type != null && type.length() == 1 + && ((allowVoid && type.equals("V")) || "ZBSCIJFD".indexOf(type.charAt(0)) >= 0); + } + /** 兼容性排除只影响执行准入,不能从用户选择范围的覆盖率分母中消失。 */ static String exclusionReason(String typeDescriptor) { // D8/R8 bridge bodies only forward captured state to real business methods. diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/PackerMain.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/PackerMain.java index 8550242..7567fc6 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/PackerMain.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/PackerMain.java @@ -1701,6 +1701,17 @@ private HollowResult hollowDex(File inputDex, File outputDex, byte[] aesKey, int int autoIndustryTypes = 0; int autoIndustryMethods = 0; Set trueVmpMethodKeys = new HashSet<>(); + Map pvm2Exclusions = new HashMap<>(); + var input = new com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile( + com.android.tools.smali.dexlib2.Opcodes.getDefault(), data); + for (var cls : input.getClasses()) { + if (!shouldTrueVmp(cls.getType()) + && !(methodReport != null && methodReport.requiresType(cls.getType()))) continue; + for (var method : cls.getMethods()) { + String reason = AutoVmpPolicy.exclusionReason(cls, method); + if (reason != null) pvm2Exclusions.put(Pvm2Report.descriptor(method).replace("->", "#"), reason); + } + } // Phase 1: TRUE_VMP only. DexPool rebuilds the constant pool; any hollow // payload captured before that rewrite has stale string/type/method indices @@ -1709,7 +1720,7 @@ private HollowResult hollowDex(File inputDex, File outputDex, byte[] aesKey, int com.android.dex.Dex dex = new com.android.dex.Dex(Files.readAllBytes(outputDex.toPath())); try (RandomAccessFile raf = new RandomAccessFile(outputDex, "rw")) { int[] typeCounters = walkDexMethods(dex, raf, aesKey, ExtractPhase.TRUE_VMP_ONLY, - records, trueVmpTargets, trueVmpMethodKeys, dexIndex, null); + records, trueVmpTargets, trueVmpMethodKeys, dexIndex, null, pvm2Exclusions); autoPaymentTypes += typeCounters[0]; autoIndustryTypes += typeCounters[1]; autoPaymentMethods += typeCounters[2]; @@ -1733,7 +1744,7 @@ private HollowResult hollowDex(File inputDex, File outputDex, byte[] aesKey, int dex = new com.android.dex.Dex(Files.readAllBytes(outputDex.toPath())); try (RandomAccessFile raf = new RandomAccessFile(outputDex, "rw")) { int[] typeCounters = walkDexMethods(dex, raf, aesKey, ExtractPhase.HOLLOW_OR_VMP, - records, trueVmpTargets, trueVmpMethodKeys, dexIndex, trueVmpMethodKeys); + records, trueVmpTargets, trueVmpMethodKeys, dexIndex, trueVmpMethodKeys, pvm2Exclusions); // Type counters already counted in phase 1 for overlapping types; only // add methods newly hollowed that are payment/industry (rare). autoPaymentMethods += typeCounters[2]; @@ -1759,7 +1770,7 @@ private int[] walkDexMethods(com.android.dex.Dex dex, RandomAccessFile raf, byte ExtractPhase phase, List records, List trueVmpTargets, Set trueVmpMethodKeys, int dexIndex, - Set skipMethodKeys) throws Exception { + Set skipMethodKeys, Map pvm2Exclusions) throws Exception { int autoPayTypes = 0; int autoIndustryTypes = 0; int autoPayMethods = 0; @@ -1783,7 +1794,7 @@ private int[] walkDexMethods(com.android.dex.Dex dex, RandomAccessFile raf, byte com.android.dex.ClassData classData = dex.readClassData(classDef); for (com.android.dex.ClassData.Method method : classData.getDirectMethods()) { notePvm2MethodScanned(phase); - InsnRecord rec = extractOne(dex, raf, method, aesKey, type, phase, skipMethodKeys); + InsnRecord rec = extractOne(dex, raf, method, aesKey, type, phase, skipMethodKeys, pvm2Exclusions); if (rec == null) { continue; } @@ -1809,7 +1820,7 @@ private int[] walkDexMethods(com.android.dex.Dex dex, RandomAccessFile raf, byte } for (com.android.dex.ClassData.Method method : classData.getVirtualMethods()) { notePvm2MethodScanned(phase); - InsnRecord rec = extractOne(dex, raf, method, aesKey, type, phase, skipMethodKeys); + InsnRecord rec = extractOne(dex, raf, method, aesKey, type, phase, skipMethodKeys, pvm2Exclusions); if (rec == null) { continue; } @@ -2062,7 +2073,7 @@ private boolean shouldTrueVmp(String typeDescriptor) { private InsnRecord extractOne(com.android.dex.Dex dex, RandomAccessFile raf, com.android.dex.ClassData.Method method, byte[] aesKey, String typeDescriptor, ExtractPhase phase, - Set skipMethodKeys) throws Exception { + Set skipMethodKeys, Map pvm2Exclusions) throws Exception { if (method.getCodeOffset() == 0) return null; // Constructors/clinit must keep invokespecial/super; returning stubs fail ART verify. com.android.dex.MethodId methodId = dex.methodIds().get(method.getMethodIndex()); @@ -2097,14 +2108,19 @@ private InsnRecord extractOne(com.android.dex.Dex dex, RandomAccessFile raf, && !(methodReport != null && methodReport.requires(key))) { return null; } - if (PlaybackCompatibility.keepOnArt(typeDescriptor, methodName)) { - if (phase == ExtractPhase.TRUE_VMP_ONLY) { + if (phase == ExtractPhase.TRUE_VMP_ONLY && pvm2Exclusions.containsKey(key)) { + String reason = pvm2Exclusions.get(key); + // 保持原转换成功率口径:类兼容预筛不算编译尝试;媒体准入失败仍计入。 + // 完整选择范围的覆盖率由逐方法报告统计,不能在这里删除跳过项。 + if (reason.equals("playback_compatibility")) { pvm2Admission.noteCandidate(); - pvm2Admission.noteFail("playback compatibility"); - if (methodReport != null) methodReport.skipped(key, "playback_compatibility"); - System.out.println("TRUE_VMP skip " + typeDescriptor + "->" + methodName - + ": playback compatibility"); + pvm2Admission.noteFail(reason); } + if (methodReport != null) methodReport.skipped(key, reason); + System.out.println("TRUE_VMP skip " + typeDescriptor + "->" + methodName + ": " + reason); + return null; + } + if (phase == ExtractPhase.HOLLOW_OR_VMP && PlaybackCompatibility.keepOnArt(typeDescriptor, methodName)) { return null; } if (phase == ExtractPhase.HOLLOW_OR_VMP diff --git a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Admission.java b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Admission.java index 032d3d4..84e562e 100644 --- a/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Admission.java +++ b/third_party/xopprotector/packer/src/main/java/com/yqsh/protector/packer/Pvm2Admission.java @@ -117,7 +117,9 @@ public static SkipReason classify(String failReason) { if (r.contains("try ") || r.contains("tries") || r.contains("handler")) { return SkipReason.try_catch; } - if (r.contains("playback compatibility") || r.contains("media compatibility")) { + if (r.contains("playback compatibility") || r.contains("media compatibility") + || r.equals("playback_compatibility") || r.equals("ui_runtime_glue") + || r.equals("data_resource_runtime_glue") || r.equals("compiler_generated_bridge")) { return SkipReason.compatibility; } if (r.contains("nullable receiver") || r.contains("receiver register")) { 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 b92f7b6..2e5bbf9 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 @@ -67,9 +67,9 @@ void inventory(List files, Predicate selectedType) throws Exceptio var impl = method.getImplementation(); if (impl != null) for (var instruction : impl.getInstructions()) size += instruction.getCodeUnits() * 2; String reason = impl == null ? "no_code" : method.getName().startsWith("<") ? "initializer" : "not_attempted"; - String exclusion = AutoVmpPolicy.exclusionReason(cls.getType()); + String exclusion = AutoVmpPolicy.exclusionReason(cls, method); // 没有方法体和初始化器先按实际结构计数,避免被类级兼容规则掩盖。 - if (reason.equals("not_attempted") && !required.contains(id) && exclusion != null) reason = exclusion; + if (reason.equals("not_attempted") && exclusion != null) reason = exclusion; JSONObject row = new JSONObject().put("module", module).put("descriptor", signature) .put("required", required.contains(id)).put("input_instruction_bytes", size) .put("input_dex", file.getName()).put("transform_status", "skipped") diff --git a/tools/xop-pvm2-packer.jar b/tools/xop-pvm2-packer.jar index e734323..08c1bfb 100644 Binary files a/tools/xop-pvm2-packer.jar and b/tools/xop-pvm2-packer.jar differ