Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/release-notes/versions/1.8.4.md
Original file line number Diff line number Diff line change
@@ -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 或更高版本;分享逐方法报告前请手动脱敏。
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/shield-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
4 changes: 2 additions & 2 deletions apps/shield-gui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/shield-gui/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/shield-gui/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/shield-gui/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion apps/shield-gui/src/hooks/use-about-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ type AppInfo = {
};

const defaultAppInfo: AppInfo = {
version: "1.8.3",
version: "1.8.4",
git_hash: "dev",
build_date: "unknown",
};
Expand Down
2 changes: 1 addition & 1 deletion apps/shield-gui/src/lib/protect-diagnostic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
2 changes: 1 addition & 1 deletion apps/shield-gui/tests/protect-diagnostic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
2 changes: 1 addition & 1 deletion crates/shield-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
27 changes: 27 additions & 0 deletions crates/shield-core/src/protect/resource_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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)保持不变。
Expand Down
57 changes: 56 additions & 1 deletion crates/shield-core/src/protect/resource_format_tests.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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();
Expand Down
28 changes: 28 additions & 0 deletions crates/shield-core/src/protect/xop_pvm2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
}
6 changes: 5 additions & 1 deletion crates/shield-core/src/protect_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
Expand Down Expand Up @@ -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("、"));
Expand Down
4 changes: 2 additions & 2 deletions crates/shield-core/src/protection_report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ impl Pvm2SkipReason {
Self::DataResourceRuntimeGlue => "数据/资源框架兼容规则",
Self::CompilerGeneratedBridge => "编译器生成桥接/合成方法",
Self::PlaybackCompatibility => "媒体/辅助类兼容规则",
Self::CompatibilityVirtualContract => "兼容范围:继承/接口虚方法契约未验证",
Self::CompatibilityVirtualContract => "兼容范围:特殊对象方法或虚方法契约未验证",
Self::CompatibilitySynchronization => "兼容范围:同步方法",
Self::CompatibilityExceptionRegion => "兼容范围:含异常处理区",
Self::CompatibilityNonLeafInstruction => "兼容范围:含叶子方法白名单之外的指令",
Expand Down Expand Up @@ -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!(
Expand Down
9 changes: 9 additions & 0 deletions docs/process/test-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 仍是独立门禁。

## 使用时机
Expand Down
Loading
Loading