资源文件保护:图片资源加密、布局文件混淆、资源文件完整性校验
资源文件保护,说实话,是很多开发者在做加固时最容易忽略的一环。大家往往把精力都放在代码混淆、Dex加固上,觉得资源文件嘛,无非就是几张图、几个布局文件,被反编译了又能怎样?
嗯,我以前也这么想。直到有一次,我帮一个金融类App做安全审计,发现攻击者通过替换App里的几张启动图,搞了个钓鱼界面出来。用户打开App,看到的是假的登录页,账号密码就这么被套走了。从那以后,我再也不敢小看资源文件保护了。
说白了,资源文件虽然不直接包含执行逻辑,但它们承载着App的UI、文案、图标。攻击者篡改这些资源,就能伪造界面、诱导用户。更严重的是,如果资源文件被篡改,App的完整性就被破坏了,后续的运行时校验、签名验证都可能失效。
核心观点:资源文件保护不是锦上添花,而是安全防线中不可或缺的一环。图片加密、布局混淆、完整性校验,这三招缺一不可。
图片资源加密:让攻击者拿不到原始素材
图片资源加密,说白了就是把你的PNG、JPG文件在打包前先做一层加密处理。运行时再解密加载。这样攻击者就算解压了APK,看到的也是一堆乱码文件,没法直接使用。
我个人习惯的做法是:在Gradle构建脚本中插入一个自定义Task,在打包前对指定目录下的图片进行AES加密。密钥可以硬编码在Native层,或者通过KeyStore动态生成。
下面是一个简单的加密示例,我用Python脚本在构建前执行:
# encrypt_images.py
import os
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
key = b'your-32-byte-key-here!'
iv = b'your-16-byte-iv-here!'
def encrypt_file(input_path, output_path):
cipher = AES.new(key, AES.MODE_CBC, iv)
with open(input_path, 'rb') as f:
plaintext = f.read()
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
with open(output_path, 'wb') as f:
f.write(ciphertext)
# 遍历res目录下的图片
for root, dirs, files in os.walk('app/src/main/res'):
for file in files:
if file.endswith(('.png', '.jpg', '.jpeg')):
input_path = os.path.join(root, file)
output_path = input_path + '.enc'
encrypt_file(input_path, output_path)
print(f'Encrypted: {input_path}')
在Android端,我写了一个Native方法来做解密。为什么用Native?因为Java层的代码太容易被Hook了。我在项目中遇到过,攻击者直接用Frida Hook了BitmapFactory.decodeStream,把解密后的图片流截走了。后来我把解密逻辑移到Native层,配合ollvm做控制流平坦化,才勉强防住。
// native-lib.cpp
extern "C" JNIEXPORT jobject JNICALL
Java_com_example_app_ImageUtils_decryptImage(
JNIEnv* env, jobject thiz, jstring enc_path) {
const char* path = env->GetStringUTFChars(enc_path, nullptr);
// 读取加密文件
FILE* file = fopen(path, "rb");
fseek(file, 0, SEEK_END);
long size = ftell(file);
rewind(file);
unsigned char* buffer = (unsigned char*)malloc(size);
fread(buffer, 1, size, file);
fclose(file);
// AES解密
unsigned char key[32] = { ... }; // 密钥来自KeyStore
unsigned char iv[16] = { ... };
AES_KEY aes_key;
AES_set_decrypt_key(key, 256, &aes_key);
AES_cbc_encrypt(buffer, buffer, size, &aes_key, iv, AES_DECRYPT);
// 转为Bitmap
jobject bitmap = create_bitmap_from_raw(env, buffer, size);
free(buffer);
env->ReleaseStringUTFChars(enc_path, path);
return bitmap;
}
小技巧:不要把所有图片都加密。启动图、Logo这类必须第一时间展示的图片,可以保留明文。把那些敏感的业务图片、引导图、弹窗图加密就好。全量加密会影响启动速度,得不偿失。
布局文件混淆:让反编译工具看不懂你的UI
布局文件混淆,很多人觉得没必要。XML文件嘛,反编译出来也就是看看布局结构,能有什么安全问题?
你想想看,如果攻击者拿到了你的布局文件,他能做什么?他能找到所有控件的ID,然后通过Xposed或Frida直接操作这些控件。比如,他可以把你的登录按钮的点击事件替换成自己的逻辑,或者把隐藏的TextView显示出来——我见过有人用这招把App里的隐藏功能给挖出来了。
布局文件混淆的核心思路,就是把控件ID、资源引用、字符串值做一层变换。让反编译工具输出的XML变得难以阅读。
我常用的做法是自定义一个Gradle插件,在打包前对布局XML做以下处理:
- ID重命名:把
@+id/btn_login改成@+id/a1b2c3,让攻击者猜不出这个控件是干嘛的。 - 字符串加密:布局中的
android:text="登录",改成android:text="@string/enc_0x1234",运行时再解密。 - 布局结构打乱:在不影响UI显示的前提下,调整View的层级顺序,或者插入一些无意义的占位View。
举个例子,原始布局是这样的:
<LinearLayout ...>
<Button
android:id="@+id/btn_login"
android:text="登录"
android:onClick="onLoginClick" />
<TextView
android:id="@+id/tv_hint"
android:text="请输入密码" />
</LinearLayout>
混淆之后变成:
<FrameLayout ...>
<Space android:id="@+id/_dummy" />
<Button
android:id="@+id/x7y8z9"
android:text="@string/enc_0x5678"
android:onClick="a1b2c3" />
<TextView
android:id="@+id/p2q3r4"
android:text="@string/enc_0x1234" />
</FrameLayout>
你看,btn_login变成了x7y8z9,tv_hint变成了p2q3r4。攻击者反编译后,根本不知道哪个控件是按钮,哪个是文本。而且onClick方法名也被改了,他连事件入口都找不到。
注意:布局混淆后,所有引用这些ID的Java/Kotlin代码也必须同步修改。我建议在混淆插件中同时生成一个映射表,用于代码中的R.id引用替换。否则编译会报错。
资源文件完整性校验:防止资源被篡改
图片加密和布局混淆,防的是「静态分析」。但攻击者还可以在运行时动态替换资源文件。比如,他通过Root权限直接修改APK包内的文件,或者用Xposed Hook住资源加载函数,返回篡改后的内容。
完整性校验,就是用来防这个的。它的原理很简单:在App启动时,计算所有关键资源文件的哈希值,跟预埋的签名做比对。一旦发现不匹配,立即终止运行或进入安全模式。
我在项目中实现过一套方案,大致流程如下:
- 构建阶段:遍历res目录下的所有文件,计算每个文件的SHA256哈希值,生成一个签名文件
res_signatures.json。 - 打包阶段:把这个签名文件加密后,塞进APK的assets目录下。
- 运行时:App启动时,在Application.onCreate中执行校验。读取签名文件,解密,然后逐个比对当前文件的哈希值。
签名文件长这样:
{
"res/drawable/ic_launcher.png": "a1b2c3d4e5f6...",
"res/layout/activity_main.xml": "7f8g9h0i1j2k...",
"res/raw/sensitive_data.bin": "3l4m5n6o7p8q..."
}
校验的核心代码:
public class IntegrityChecker {
public static boolean checkResources(Context context) {
try {
// 读取加密的签名文件
InputStream is = context.getAssets().open("res_signatures.enc");
byte[] encrypted = readBytes(is);
// 解密
byte[] decrypted = decryptWithNative(encrypted);
JSONObject signatures = new JSONObject(new String(decrypted));
// 遍历签名,逐个校验
AssetManager am = context.getAssets();
Iterator<String> keys = signatures.keys();
while (keys.hasNext()) {
String resPath = keys.next();
String expectedHash = signatures.getString(resPath);
// 计算当前文件的哈希
InputStream ris = am.open(resPath);
String actualHash = sha256(ris);
if (!expectedHash.equals(actualHash)) {
// 校验失败,记录日志并退出
Log.e("Integrity", "File tampered: " + resPath);
return false;
}
}
return true;
} catch (Exception e) {
// 异常也视为校验失败
return false;
}
}
}
关键点:签名文件本身必须被保护。如果攻击者能篡改签名文件,那校验就形同虚设了。我建议把签名文件加密后放在assets里,解密密钥从Native层获取。更狠一点的做法,是把签名文件的内容直接编译进Native so库的只读数据段里。
知识体系总览
下面这张图,我把资源文件保护的三个核心维度画出来了。你可以看到,它们分别对应不同的攻击面,组合起来才能形成完整防线。
避坑指南
讲完了三个核心方法,我再分享几个实际项目中踩过的坑:
- 不要只校验不处理:我曾经只做了校验,发现文件被篡改后只是打了个日志。结果攻击者根本不在乎日志,App照样正常运行。后来我改成校验失败直接闪退,并在下次启动时弹出安全警告。
- 校验时机要选对:不要在Activity的onCreate里做全量校验,那会卡住UI。我建议在Application的attachBaseContext里启动一个后台线程做校验,校验完成前先显示一个安全加载页。
- 密钥保护是重中之重:图片加密的密钥、签名文件的解密密钥,如果硬编码在Java层,等于没加密。我习惯把密钥拆成多段,分别存在Native so的不同节区,运行时再拼接。
- 布局混淆要保留功能:混淆ID时,记得保留那些被系统或第三方库引用的ID。比如
android:id="@android:id/content"这种,混淆了反而会出问题。
我的建议:资源文件保护不是一锤子买卖。每次版本更新,都要重新生成签名文件、重新加密图片。我建议把这些步骤自动化,集成到CI/CD流水线里。手动操作太容易遗漏了,我吃过这个亏。
好了,关于资源文件保护,核心就是这三板斧:图片加密防静态提取,布局混淆防UI逆向,完整性校验防动态篡改。三者配合使用,才能让攻击者在你App的资源文件面前无从下手。