weg2022/strguard
GitHub: weg2022/strguard
一个 Kotlin Gradle 插件,通过 ASM 重写字节码并借助 Rust JNI 库对 JVM/Android 应用中的字符串字面量进行加密混淆,以抵御静态提取和逆向分析。
Stars: 2 | Forks: 1
# StrGuard
[English](README.md) | [简体中文](README.zh-CN.md)
StrGuard 是一个使用 ASM 重写 JVM 类文件的 Kotlin Gradle 插件。符合条件的字符串字面量会转换为特定于构建的功能调用,这些调用指向一个由 ChaCha20-Poly1305 vault 支持的生成的 Rust JNI 库。
StrGuard 提供了经过认证的混淆功能,旨在提高静态提取的成本,而非分发后的密码学保密性。每个构建产物不可避免地包含解码其字符串所需的运行时材料,并且控制或检测该过程的代码可以在其成为 JVM `String` 后观察到该值。请勿在应用代码中发送密码、签名密钥或长期有效的凭据。
## 适用范围
StrGuard 支持:
* Java、Java Library、Application 和 Kotlin/JVM 模块
* 带有 Java 或 Kotlin 类的 Android Application 和 Android Library 变体
* Kotlin Multiplatform JVM 和 Android 目标;JS、Native 和 Wasm 将直接透传
* Windows x64/arm64、Linux glibc x64/arm64 和 macOS x64/arm64
* Android `armeabi-v7a`、`arm64-v8a`、`x86` 和 `x86_64`
* 经过 ProGuard/R8 验证的构建产物,以及 Compose Desktop 的 release ProGuard 集成
* `LDC`、static final 字符串、Java 9+ `StringConcatFactory`、Kotlin 字符串模板,以及当这些相同的字面量被用于数组、集合、switch、lambda、反射调用或其他普通 bytecode 时
* 可选的 Kotlin metadata 注解移除
支持的 plugin ID 包括 `java`、`java-library`、`application`、`org.jetbrains.kotlin.jvm`、`org.jetbrains.kotlin.android`、`com.android.application`、`com.android.library` 和 `org.jetbrains.kotlin.multiplatform`。Kotlin Android 必须与 Android Application 或 Library 插件配合使用。
## 环境要求
* 需要 JDK 17 或 21 来运行 Gradle;受保护的输出要求 Java 11 或原始 bytecode 版本(以较新者为准)
* ASM 9.10.1 接受并保留 Java 11 到 Java 27 的类文件(major 55-71),包括 Java 27 的预览类;不支持此范围之外的输入
* 已发布的插件会将 ASM 打包并重定位到一个私有命名空间中,因此由 Gradle、`buildSrc` 或其他插件加载的旧版 ASM 无法降级对类文件的支持
* Gradle 8.14.4
* Rust/Cargo 1.94.1 以及所选的 target 和 linker
* Kotlin Gradle Plugin 2.1.21
* Android Gradle Plugin 8.13.2,Android SDK 34 和 NDK 27.2.12479018
`STRGUARD_CARGO_EXECUTABLE` 可用于指定特定的 Cargo 二进制文件。其配套的 `rustc` 以及所选的 linker/archiver 会包含在工具链指纹中。
## 安装
对于每一个包含已编译类的 JVM 或 Android 模块,从 Gradle Plugin Portal 应用 StrGuard:
```
plugins {
id("io.github.weg2022.strguard") version "2.0.1"
}
```
不要仅将其应用于聚合器(aggregator)根项目。
## 发布 seed
每次启用的构建都需要一个 256 位的 seed,编码为恰好 64 个十六进制字符。从 CI 中注入它,且切勿打印或提交它:
```
STRGUARD_RELEASE_SEED_HEX=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```
相同的 seed、模块标识、target、输入和工具链会产生相同的转换类和 Native 输入。在不同的信任域中请使用不同的 seed。
## 配置
```
strGuard {
enabled.set(true)
releaseSeedHex.set(providers.environmentVariable("STRGUARD_RELEASE_SEED_HEX"))
// Desktop JVM/KMP only. Defaults to the current host.
targetTriple.set("x86_64-pc-windows-msvc")
// Android only. Defaults to all four official ABIs.
androidAbis.set(setOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64"))
stringGuardPackages.set(listOf("com.example.app"))
keepStringPackages.set(listOf("com.example.app.generated"))
java9StringConcatEnabled.set(true)
strictStringCoverage.set(true)
consoleOutput.set(false)
removeMetadata.set(false)
removeMetadataPackages.set(listOf("com.example.app"))
keepMetadataPackages.set(listOf("com.example.app.reflective"))
}
```
| 属性 | 默认值 | 效果 |
| --- | --- | --- |
| `enabled` | `true` | 为 false 时,禁用重写、seed/target/API/ABI 验证以及 Native 相关工作。 |
| `releaseSeedHex` | `STRGUARD_RELEASE_SEED_HEX` | 启用时所需的 64 位十六进制 seed。 |
| `targetTriple` | 环境变量或当前主机 | Desktop JVM/KMP Rust target;每个 JAR 对应一个 runtime。 |
| `androidAbis` | 四个官方 ABI | 非空的 Android ABI 集合;AGP 过滤器和最终的 splits 必须是其子集。 |
| `java9StringConcatEnabled` | `true` | 保护受支持的 Java 9+ concat 配方常量。 |
| `strictStringCoverage` | `false` | 写入完整的聚合覆盖率,并在扫描后,如果某个选定的类存在不受支持的非空字符串位置或未知的自定义 attribute,则构建失败。 |
| `consoleOutput` | `false` | 打印基于 schema 的摘要信息,不包含字面量或关键材料。 |
| `removeMetadata` | `false` | 移除符合条件的 Kotlin metadata 注解。 |
| `stringGuardPackages` | 空 | 包含的前缀;为空表示所有非 StrGuard 的应用程序类。 |
| `keepStringPackages` | 空 | 字符串保护排除的前缀。 |
| `removeMetadataPackages` | 空 | Metadata 移除的包含前缀。 |
| `keepMetadataPackages` | 空 | Metadata 移除的排除前缀。 |
包名条目接受点号或斜杠分隔的名称,并包含其后代。Keep 列表具有优先权。
## 字符串覆盖率
在选定的应用程序类文件中,StrGuard 会保护普通的字符串常量,无论生成它们的源码语法是什么。这包括方法字面量、static final 字段和 Kotlin `const val`、数组/集合元素、Java `switch` 和 Kotlin `when` 分支、lambda 体、反射参数、Java 9+ concat 配面片段以及 Kotlin 模板。值会保留其精确的 UTF-16 码元,包括 NUL、控制字符、换行符、非 BMP 字符和不完整的代理对(surrogates)。一个受保护的值最多可包含 30,000 个 UTF-16 码元(60,000 个 UTF-16LE 字节)。
StrGuard 无法透明地保护与应用程序相关的所有字符串:
* 注解值和默认值是类文件和框架契约的一部分;
* 诸如 XML、JSON、properties、manifests、assets 和 Android 资源等资源不在 JVM bytecode 转换的范围内;
* 依赖项 JAR、生成的输出或 `stringGuardPackages` 之外的包不会被作为输入,除非拥有它们的模块应用了 StrGuard;
* 在运行时生成、从服务接收、通过 JNI 加载或已经内联到另一个未受保护模块中的字符串,无法作为源码字面量被恢复;
* 任意的 `ConstantDynamic`、不受支持的 `invokedynamic`、空字符串以及超过大小限制的值将保持不变;
* `keepStringPackages` 和 `@KeepString` 是有意设置的排除项。
`strictStringCoverage` 会在应用了这些明确边界之后,审核每一个选定的类。它会在不记录字面量或位置的情况下汇总原因,写入 `summary.txt`,然后当仍然存在非空的不受支持的位置或未知的自定义 attribute 时,在提交当前转换的类或 Native 输入之前使构建失败。它无法为从未进入类转换器的资源、依赖项、排除的包或运行时生成的数据声明覆盖率。
受支持的 Desktop target:
```
x86_64-pc-windows-msvc
aarch64-pc-windows-msvc
x86_64-unknown-linux-gnu
aarch64-unknown-linux-gnu
x86_64-apple-darwin
aarch64-apple-darwin
```
Android 使用与 ABI 无关的已转换类和 vault 数据,然后构建配置好的 Rust target。Native runtime 的最低版本要求为 API 21。当 `minSdk < 21` 或 AGP ABI 过滤器/最终 split 不在 `androidAbis` 中时,构建会失败。AAR marker 会声明确切的 ABI 集合和 minSdk。
## 排除类
在 Java 和 Kotlin 编译期间可以使用 `KeepString` 和 `KeepMetadata`:
```
import io.github.weg2022.strguard.annotation.KeepString
@KeepString
class DiagnosticStrings {
fun value() = "This literal stays in the class file"
}
```
## 输出与代码压缩工具
```
build/strguard/classes/main
build/strguard/native-input/main
build/strguard/native-resources/main
build/reports/strguard/main
```
KMP JVM 输出包含 target 名称。Android 输出使用变体名称。JVM JAR 包含一个随机化的 bridge、模块唯一的 loader、一个 target Native library、强制的 shrinker 规则以及一个带版本的 marker。Android APK 使用 `lib/`,而 AAR 使用 `jni/`。
Desktop shrinker 必须按以下顺序运行:
```
compile -> StrGuard protected JAR -> ProGuard/R8 -> StrGuard verifier -> publication/distribution
```
```
val artifact = strGuardArtifacts.jvm("main")
val shrink = tasks.register("proguardMain") {
dependsOn(artifact.protectedJar)
injars(artifact.protectedJar.get().asFile)
configuration(artifact.requiredShrinkerRules.get().asFile)
}
val verifiedJar = artifact.verifyShrunkJar(
shrink.map { layout.buildDirectory.file("shrinker/raw.jar").get() },
"proguard:7.9.1",
)
```
仅发布/分发已验证的输出。应用程序仍需自行管理有关公共 API、反射、序列化、`ServiceLoader`、主类、Kotlin metadata 和框架的规则。Compose Desktop 的 release ProGuard 会自动连接,无需更改用户的 shrinker 设置。
## Gradle Build Cache
StrGuard 的 transform、Desktop/Android Native 构建、Android 合并和 shrinker 验证任务都是可缓存的。在输入不变的情况下,第二次构建将显示为 `UP-TO-DATE`。在执行 `clean` 之后、在新的 worktree 中或在与另一个兼容的 CI worker 上,如果启用了 Build Cache,Gradle 可以将缺失的输出恢复为 `FROM-CACHE`:
```
# gradle.properties
org.gradle.caching=true
```
或者,传入 `--build-cache`。缓存键包含输入类的内容和相对路径、seed 指纹(而非原始 seed)、模块标识、保护选项、target/ABI/minSdk、捆绑的 runtime 实现、Rust 工具链选择以及 Rust/NDK linker/archiver/library 的指纹。Native 构建使用任务私有的 `CARGO_HOME` 和 `HOME`。更改这些输入中的任何一个都会导致重新执行,而不是命中过期的缓存。如果项目或其祖先目录提供了 `.cargo/config` 或 `.cargo/config.toml`,StrGuard 会保守地禁用 Native 任务的 up-to-date 检查和 Build Cache 复用,因为任意的 Cargo 键可能会选择额外的未跟踪工具;但 transform 缓存仍保持启用状态。
原始的 release seed 既不是任务输入也不是输出,并且不会存储在缓存条目中。然而,transform 条目不可避免地会包含由 seed 派生的 Native 输入、已转换的类和 vault 数据。请将每个本地或远程的 Build Cache 视为受保护构建产物信任边界的一部分。切勿使用公共或不受信任的远程缓存;要求使用经过身份验证的 TLS,隔离信任域,并尽可能只允许受信任的 CI 构建进行推送,而开发者在实际操作中只进行只读加载。有能力替换缓存条目的方同样能够替换可执行的构建输出。
## 安全性与兼容性
* StrGuard 可以抵御直接的常量池扫描、`strings` 命令以及通用的静态提取。它无法在运行时被访问后对值提供保护。
* 已转换的类、vault 和 Native library 是一个特定于构建的单元,必须一起分发。
* 确定性任务支持带有完整规范化输入的 Gradle up-to-date 检查和 Build Cache 恢复。如上所述,远程缓存访问是一个安全边界。
* ChaCha20-Poly1305 认证失败是致命的。JNI 返回内部驻留的字符串(interned strings)以保持 Java/Kotlin 字面量的同一性。
* Cargo 接收的是一个不含原始 seed 的白名单环境。输出是有边界的,超时/中断会终止子孙进程。
* 派生的字节数组会被清除。而 JVM 的 `String` 值无法从托管内存中被可靠地擦除。
* 输出会作为一个具备回滚能力的集合进行暂存和提交。
* Desktop 加载过程会枚举所有匹配的 classpath 资源,仅在同一个容器内将 Native 二进制文件和 marker 进行配对,验证构建产物/bridge/loader 的 metadata,并校验为执行 `System.load` 而复制的确切字节的 SHA-256。
* 必须存在且仅存在一个有效的 Native 容器。除非 `java.io.tmpdir/strguard` 和每个 `sg-*` 解压文件归当前用户所有,并受到仅限所有者的 POSIX 权限或仅限所有者的 ACL 保护,否则 loader 将关闭并失败。
* Hash 校验和 `System.load` 会在清理前在生成的 loader 内部运行。启动时绝不会扫描或删除另一个进程的解压文件;被锁定的文件会被安排在 JVM 退出时删除。
* 操作系统临时父目录仍然是一个信任边界:可写的 POSIX 父目录必须设置粘滞位(sticky bit),而 ACL 文件系统必须公开一个归当前用户所有的临时目录。同账户和特权本地主体不在威胁模型范围内。
* Marker 摘要可在 marker 受信任时检测到不匹配和二进制篡改;它不是签名或 MAC。如果攻击者能够在 classpath 上同时替换 marker 和二进制文件,他就已经控制了应用程序代码,这仍然超出了本威胁模型的范围。
* Static final 字符串和 Kotlin `const val` 会失去 `ConstantValue` 并在 `` 中初始化。请勿将受保护的值用作跨模块的编译时常量或注解参数。
* 注解字符串、任意的 `ConstantDynamic`、不受支持的 `invokedynamic`、空字符串以及超过 30,000 个 UTF-16 码元的字符串将保持不变。启用严格覆盖率可使非空且不受支持的位置导致构建失败。
* 移除 Metadata 可能会破坏反射、序列化和编译器工具。
请通过 GitHub 私密漏洞报告来报告漏洞。请勿在公开 issue发布 release seed、密钥分片、受保护的 vault、凭据、漏洞利用细节或私密的应用程序代码。
## 报告与迁移
每次 transform 都会将 `build/reports/strguard//summary.txt` 作为带有 `schemaVersion=1` 的 Java properties 写入。稳定的字段包括 `enabled`、`strictStringCoverage`、`runtimeTarget`、类选择计数、`stringCandidates`、`protectedStrings`、`skippedStrings`、`strictViolations`、`coverageUnknowns`、按原因分类的 `skipped*` 计数、`removedMetadata` 以及未匹配的 keep 选择器。`stringCandidates = protectedStrings + skippedStrings`;`strictViolations` 也包含 `coverageUnknowns`。报告绝不包含字面量、seed、密钥分片、调用点身份或解码后的值。消费者必须忽略未知的可选 schema 1 字段。
`2.0.0` 版本更改了 vault、Native runtime、task graph 和 shrinker 契约。请清理每个受保护的模块。Vault v2 会被拒绝。在更改 seed、target、插件或 Native 工具链时,请删除模块的 `build/` 目录。请勿将 1.x 或预发布版(pre-GA)2.0 的类与另一个生成的 Native library 混合使用。
## 开发
[`samples`](samples/README.md) 目录涵盖了所有受支持的项目类型。
```
.\gradlew.bat :strguard-plugin:check :strguard-plugin:validatePlugins
cargo fmt --manifest-path native/strguard-runtime/Cargo.toml -- --check
cargo clippy --manifest-path native/strguard-runtime/Cargo.toml --all-targets --locked -- -D warnings
cargo test --manifest-path native/strguard-runtime/Cargo.toml --locked
```
设置 `STRGUARD_ANDROID_NATIVE_TEST=true` 和 `ANDROID_NDK_VERSION=27.2.12479018` 以在功能测试中编译所有四个 Android runtime。本地测试不会启动模拟器。
## CI
`ci.yml` 涵盖了 JDK 17/21、Java 11、六个 Desktop 运行器、四 ABI Android/R8、Rust audit/deny/coverage/performance/RSS、可重复性、依赖验证、分发和 samples。
## 许可证
Apache License 2.0。参见 [LICENSE](LICENSE)。
标签:Gradle插件, JVM, Kotlin, Rust, SOC Prime, 代码混淆, 可视化界面, 后台面板检测, 开发工具, 网络流量审计