网络安全——App逆向防护




2025-06-28

blog_main_img

App防逆向不是追求“永远无法分析”,而是把核心逻辑拆散、隐藏、校验、下沉,让攻击者拿到安装包后也很难直接复刻业务链路。比较实用的思路是:Java 层负责业务编排,混淆后降低可读性;Native 层承载关键计算,配合加密封装和完整性校验提高提取成本;服务端再做风控闭环,避免客户端单点失守。

比较推荐的分层方式是:

  • Java层:尽量只保留流程编排、UI、参数整理,避免直接暴露签名算法、密钥片段、风控规则。
  • JNI 边界:把敏感输入整理成稳定结构,再交给 native 层,减少 Java 层可搜索的关键字符串。
  • SO 层:关键逻辑用 C/C++ 实现,符号隐藏、字符串处理、完整性校验一起上。
  • 构建链路:release 包必须经过混淆、裁剪、加密、哈希清单生成、自动检查。
  • 服务端:不要完全信任客户端,关键决策必须能在服务端复核。

一个很实用的原则:客户端只做“参与计算”,不要做“最终裁判”。这样即使某一层被拆开,攻击者也很难独立完成整条业务链路。

Java 混淆:先把可读性降下来

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 安全边界

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 加密金库

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 是否启用 minifyEnabledshrinkResources
  • 检查 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 层也会被分析。更好的方式是把核心算法、协议摘要、签名片段放进去,业务决策仍由服务端复核。