码到成功
网络安全——App逆向防护

比较推荐的分层方式是:
- Java层:尽量只保留流程编排、UI、参数整理,避免直接暴露签名算法、密钥片段、风控规则。
- JNI 边界:把敏感输入整理成稳定结构,再交给 native 层,减少 Java 层可搜索的关键字符串。
- SO 层:关键逻辑用 C/C++ 实现,符号隐藏、字符串处理、完整性校验一起上。
- 构建链路:release 包必须经过混淆、裁剪、加密、哈希清单生成、自动检查。
- 服务端:不要完全信任客户端,关键决策必须能在服务端复核。
一个很实用的原则:客户端只做“参与计算”,不要做“最终裁判”。这样即使某一层被拆开,攻击者也很难独立完成整条业务链路。
Java 混淆:先把可读性降下来
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 层只负责递送
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);
}
}
这里有几个设计点:
- Java 层只拼装标准化数据,不直接暴露签名细节。
- Native 方法数量尽量少,方法名和参数稳定,减少 keep 规则范围。
- 返回值使用基础类型或字节数组,避免复杂对象跨层传递导致规则膨胀。
- 异常和错误码要可观测,避免线上问题只能靠猜。
对应的 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 层加密:把 native 库封装起来
SO 加密的核心目标是:不要让 libxxx.so 以清晰形态直接躺在安装包里。一个常见流程是:
- 编译生成
libshield_core.so。 - 对 SO 做
strip,减少符号暴露。 - 使用构建脚本加密 SO,生成封装文件。
- 将封装文件放入应用资源中。
- App 启动后在受控流程中解密、校验、加载。
CMake 侧先收紧符号
在 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 包
发布前可以用 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 密钥管理、临时环境变量或内部密钥服务。
Java 侧安全加载
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 流程:
- 检查 release 是否启用
minifyEnabled和shrinkResources。 - 检查 APK/AAB 里是否存在未封装的核心 SO。
- 检查 mapping 文件是否归档到内部安全位置。
- 检查包内是否包含测试接口、调试日志、明文密钥。
- 检查 native 哈希清单是否与产物一致。
下面是一个轻量 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 就直接失败。
运行阶段:完整性校验和风险信号
客户端防护还需要配合运行阶段的校验。建议关注这些信号:
- 安装包签名是否符合预期。
- 核心 dex 和封装 SO 的哈希是否符合清单。
- JNI 返回值是否符合服务端校验规则。
- 同一账号、设备、网络环境的请求特征是否异常。
- 关键接口是否出现重复提交、参数重放、签名异常。
签名校验可以放在 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;
}
}
服务端收到请求后,不要只看一个签名字段。更好的方式是综合校验:
- 签名是否由当前版本算法产生。
- 请求参数是否被规范化处理。
- 设备摘要是否与账号行为匹配。
- 风险分是否超过业务阈值。
这样客户端防护和服务端风控才能形成闭环。
Java 混淆常见坑
反射被混淆导致崩溃
如果代码里使用 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;
}
Native 方法名被改掉
JNI 静态注册依赖方法名,混淆后容易找不到实现。可以保留 native 方法名,或者改用动态注册。
-keepclasseswithmembernames class * {
native ;
}
如果团队 native 能力比较成熟,动态注册会更灵活,也能减少可见导出。
SO 加密常见坑
把 key 直接写进 Java
这会让 SO 加密变成“换个地方放明文”。key 至少要拆分,核心材料由服务端参与,客户端只保留不可单独复用的片段。
只加密不校验
加密解决“直接查看”,校验解决“被替换”。解密后必须做哈希校验,加载前也要检查目标文件是否来自当前流程。
加载路径不收敛
不要随意从外部可写目录加载 native 库。优先写入 App 私有目录,并且文件名、权限、校验流程都固定。
所有逻辑都塞进 SO
Native 层也会被分析。更好的方式是把核心算法、协议摘要、签名片段放进去,业务决策仍由服务端复核。