2025-06-28
App防逆向不是追求“永远无法分析”,而是把核心逻辑拆散、隐藏、校验、下沉,让攻击者拿到安装包后也很难直接复刻业务链路。比较实用的思路是:Java 层负责业务编排,混淆后降低可读性;Native 层承载关键计算,配合加密封装和完整性校验提高提取成本;服务端再做风控闭环,避免客户端单点失守。
比较推荐的分层方式是:
一个很实用的原则:客户端只做“参与计算”,不要做“最终裁判”。这样即使某一层被拆开,攻击者也很难独立完成整条业务链路。
Android 项目里最常见的混淆工具是 R8。它能做代码压缩、优化、混淆、资源裁剪。基础配置一般放在 release 构建类型里:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
这段配置主要解决三件事:
minifyEnabled:删除无用代码,并重命名类、方法、字段。shrinkResources:删除未被引用的资源,减少静态分析入口。proguard-rules.pro:对必须保留的接口做精细控制,避免误删和误改。很多项目一开始会写一堆 -keep class ** { *; },结果看起来启用了混淆,实际核心包名、类名、方法名都没有动。更好的方式是只保留必须对外暴露的边界。
# JNI 方法名必须保持稳定,否则 native 绑定会失败
-keepclasseswithmembernames class * {
native ;
}
# 反射入口只保留必要类,不要保留整个业务包
-keep class com.zoy.app.bridge.NativeBridge {
public static native byte[] sign(byte[]);
public static native int verifyEnv(byte[]);
}
# JSON DTO 可以用注解精确保留字段
-keep @com.zoy.app.annotation.KeepForJson class * { *; }
-keepattributes RuntimeVisibleAnnotations, Signature
# 对外 SDK 接口按最小面保留
-keep class com.zoy.app.sdk.PublicApi {
public ;
}
# 日志与调试信息收敛
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
这里的重点不是“保留更多”,而是“只保留确实不能改名的部分”。一旦把整个包都 keep 住,混淆收益就会大幅下降。
如果项目里有 Gson、Moshi、Jackson 这类序列化工具,字段名被混淆后可能导致解析失败。可以定义一个轻量注解,只给需要稳定结构的类使用。
package com.zoy.app.annotation;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface KeepForJson {
}
业务模型只在必要处标记:
package com.zoy.app.model;
import com.zoy.app.annotation.KeepForJson;
@KeepForJson
public final class LoginPacket {
public String deviceId;
public String nonce;
public String signature;
}
这样可以避免为了一个 DTO 把整个 model 包都排除在混淆之外。
混淆只会改变符号名,并不会自动保护字符串常量。下面这种写法很容易被直接搜索到:
public final class ApiConfig {
public static final String APP_SECRET = "please-do-not-hardcode-secret";
public static final String SIGN_SALT = "plain-text-salt";
}
更稳的做法是:客户端不保存完整密钥,敏感材料拆分到服务端、Native 层或动态下发流程里,并且本地只保存短期可替换的派生材料。
public final class SecretMaterial {
private SecretMaterial() {
}
public static byte[] buildLocalSeed(byte[] installSeed, byte[] serverSeed) {
if (installSeed == null || serverSeed == null) {
throw new IllegalArgumentException("seed missing");
}
byte[] out = new byte[installSeed.length + serverSeed.length];
System.arraycopy(installSeed, 0, out, 0, installSeed.length);
System.arraycopy(serverSeed, 0, out, installSeed.length, serverSeed.length);
return out;
}
}
注意,这里只是演示“不要把完整秘密写死在 Java 层”。真正的签名和密钥派生最好再放到 native 层或服务端协同完成。
JNI 的价值不是“天然安全”,而是把关键逻辑从易读的 Java 层挪到更难直接理解的 native 层。边界设计要尽量简单:输入清晰、输出稳定、异常可控。
package com.zoy.app.bridge;
import android.content.Context;
public final class NativeBridge {
static {
ShieldSoLoader.load();
}
private NativeBridge() {
}
public static native byte[] sign(byte[] canonicalPayload);
public static native int verifyEnv(byte[] envDigest);
public static byte[] signRequest(Context context, byte[] payload) {
byte[] canonical = RequestCanonicalizer.build(context, payload);
return sign(canonical);
}
}
这里有几个设计点:
对应的 native 导出也要尽量少:
#include
#include
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_zoy_app_bridge_NativeBridge_sign(
JNIEnv *env,
jclass,
jbyteArray input) {
jsize len = env->GetArrayLength(input);
std::vector buffer(static_cast(len));
env->GetByteArrayRegion(input, 0, len, reinterpret_cast(buffer.data()));
std::vector digest = sign_payload(buffer);
jbyteArray out = env->NewByteArray(static_cast(digest.size()));
env->SetByteArrayRegion(out, 0, static_cast(digest.size()),
reinterpret_cast(digest.data()));
return out;
}
真实项目里,sign_payload 内部不应该包含可直接复用的明文密钥。更推荐把本地材料、服务端材料、设备状态组合成派生输入,再在服务端做二次校验。
SO 加密的核心目标是:不要让 libxxx.so 以清晰形态直接躺在安装包里。一个常见流程是:
libshield_core.so。strip,减少符号暴露。在 native 层,先把不必要的符号隐藏掉:
add_library(shield_core SHARED
src/main/cpp/native_bridge.cpp
src/main/cpp/signature_engine.cpp
)
target_compile_options(shield_core PRIVATE
-fvisibility=hidden
-ffunction-sections
-fdata-sections
)
target_link_options(shield_core PRIVATE
-Wl,--gc-sections
-Wl,--exclude-libs,ALL
)
这样做可以减少导出符号和无用段,让静态分析时可见信息更少。JNI 必须导出的函数保留,其它内部函数尽量不暴露。
发布前可以用 Python 对 SO 做加密封装。下面示例使用 AES-GCM,同时输出哈希清单,方便构建后校验。
from __future__ import annotations
import hashlib
import json
import secrets
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def seal_so(input_so: Path, output_blob: Path, key: bytes) -> dict[str, str]:
raw = input_so.read_bytes()
nonce = secrets.token_bytes(12)
cipher = AESGCM(key)
encrypted = cipher.encrypt(nonce, raw, input_so.name.encode("utf-8"))
output_blob.write_bytes(nonce + encrypted)
return {
"name": input_so.name,
"plain_sha256": sha256_hex(raw),
"blob_sha256": sha256_hex(output_blob.read_bytes()),
}
def main() -> None:
root = Path("release-native")
key = bytes.fromhex(Path("local-seal-key.hex").read_text().strip())
manifest = seal_so(
input_so=root / "libshield_core.so",
output_blob=root / "shield_core.pack",
key=key,
)
Path("release-native/native-manifest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
if __name__ == "__main__":
main()
这段脚本只适合放在构建环境里运行,密钥不要提交到仓库。更推荐使用 CI 密钥管理、临时环境变量或内部密钥服务。
App 内部加载时要做三件事:读取封装文件、解密写入私有目录、校验后加载。
package com.zoy.app.bridge;
import android.content.Context;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.MessageDigest;
import java.util.Arrays;
public final class ShieldSoLoader {
private static final String LIB_NAME = "shield_core";
private static volatile boolean loaded;
private ShieldSoLoader() {
}
public static synchronized void load(Context context, byte[] openKey, byte[] expectedHash) {
if (loaded) {
return;
}
try {
byte[] sealed = readRaw(context, R.raw.shield_core_pack);
byte[] soBytes = SoEnvelope.open(sealed, openKey);
if (!Arrays.equals(sha256(soBytes), expectedHash)) {
throw new SecurityException("native checksum mismatch");
}
File target = new File(context.getCodeCacheDir(), "lib" + LIB_NAME + ".so");
writeAtomic(target, soBytes);
System.load(target.getAbsolutePath());
loaded = true;
} catch (IOException e) {
throw new IllegalStateException("native load failed", e);
}
}
private static byte[] readRaw(Context context, int rawId) throws IOException {
try (InputStream in = context.getResources().openRawResource(rawId)) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
return out.toByteArray();
}
}
private static byte[] sha256(byte[] data) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return digest.digest(data);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static void writeAtomic(File target, byte[] data) throws IOException {
File tmp = new File(target.getParentFile(), target.getName() + ".tmp");
try (FileOutputStream out = new FileOutputStream(tmp)) {
out.write(data);
out.flush();
}
if (!tmp.renameTo(target)) {
throw new IOException("native write failed");
}
}
}
这里把封装文件放到 res/raw,避免在 Markdown 里出现额外资源字段。实际工程还要补齐 ABI 区分、旧文件清理、加载失败降级等逻辑。
SoEnvelope.open 可以对应 AES-GCM 解密逻辑。示例只展示结构,不建议把完整 key 直接硬编码到客户端。
防护最怕“靠人记”。建议把下面这些检查放进 release 流程:
minifyEnabled 和 shrinkResources。下面是一个轻量 Python 检查脚本,用来扫描包内敏感字符串和未封装 SO:
from __future__ import annotations
import sys
import zipfile
from pathlib import Path
SENSITIVE_WORDS = [
b"APP_SECRET",
b"plain-text-salt",
b"debug_api",
b"test_private_key",
]
def inspect_package(package_path: Path) -> list[str]:
problems: list[str] = []
with zipfile.ZipFile(package_path) as zf:
names = zf.namelist()
for name in names:
if name.endswith("libshield_core.so"):
problems.append(f"core so should be sealed: {name}")
if name.endswith((".dex", ".so", ".json", ".txt", ".xml")):
data = zf.read(name)
for word in SENSITIVE_WORDS:
if word in data:
problems.append(f"sensitive word {word!r} in {name}")
return problems
def main() -> int:
package = Path(sys.argv[1])
problems = inspect_package(package)
if problems:
print("release check failed")
for item in problems:
print("-", item)
return 1
print("release check passed")
return 0
if __name__ == "__main__":
raise SystemExit(main())
这个脚本不是完整安全扫描器,但很适合作为 release 门禁:发现硬编码、调试词、明文核心 SO 就直接失败。
客户端防护还需要配合运行阶段的校验。建议关注这些信号:
签名校验可以放在 Java 层做第一道筛查,再把摘要交给 native 层参与签名计算。
public final class AppIntegrity {
private AppIntegrity() {
}
public static byte[] digestForSign(Context context) {
byte[] certDigest = PackageDigest.currentCertSha256(context);
byte[] packageDigest = PackageDigest.currentPackageSha256(context);
byte[] out = new byte[certDigest.length + packageDigest.length];
System.arraycopy(certDigest, 0, out, 0, certDigest.length);
System.arraycopy(packageDigest, 0, out, certDigest.length, packageDigest.length);
return out;
}
}
服务端收到请求后,不要只看一个签名字段。更好的方式是综合校验:
这样客户端防护和服务端风控才能形成闭环。
如果代码里使用 Class.forName()、反射调用方法、动态代理,就必须精确保留对应类和成员。
-keep class com.zoy.app.plugin.PaymentPlugin {
public ();
public void execute(...);
}
不要为了一个插件入口保留整条业务线,keep 规则越宽,防护收益越低。
解决方案是用注解精确标记,或者给字段加序列化别名。不要因为接口解析失败就关闭整个模块混淆。
public final class OrderPacket {
@SerializedName("order_id")
public String orderId;
@SerializedName("amount")
public String amount;
}
JNI 静态注册依赖方法名,混淆后容易找不到实现。可以保留 native 方法名,或者改用动态注册。
-keepclasseswithmembernames class * {
native ;
}
如果团队 native 能力比较成熟,动态注册会更灵活,也能减少可见导出。
这会让 SO 加密变成“换个地方放明文”。key 至少要拆分,核心材料由服务端参与,客户端只保留不可单独复用的片段。
加密解决“直接查看”,校验解决“被替换”。解密后必须做哈希校验,加载前也要检查目标文件是否来自当前流程。
不要随意从外部可写目录加载 native 库。优先写入 App 私有目录,并且文件名、权限、校验流程都固定。
Native 层也会被分析。更好的方式是把核心算法、协议摘要、签名片段放进去,业务决策仍由服务端复核。