Android 签名校验
---
markmap:
height: 600
---
- Android 签名校验
- 系统内置的签名校验机制(普通签名校验)
- V1(JAR签名)
- V2(APK Signature Scheme v2)
- V3(密钥轮替支持)
- V4(Merkle哈希树签名)
- 应用层的自定义校验手段
- JAVA层签名校验
- PackageManager校验
- Dex/APK完整性校验
- 基于类名的 Application 校验
- 新的API签名校验
- 签名哈希上传服务器校验
- 运行时检测 Frida/ 调试器附加 /PMS动态代理
- Native层签名校验
- 手搓Context
- 直接解析APK签名块
- 分块校验技术
- ELF节校验
- 使用 Android NDK 提供的安全 API
- 反调试
- 逆向对抗层:混淆
- 代码混淆(ProGuard/R8)
- 字符串混淆
- 代码逻辑混淆
- 控制流伪装
- 动态代码生成
- 反动态调试与反Hook
- 分层混淆
- 工具支持
参考:
聊聊Android签名检测7种核心检测方案详解-Android安全
《安卓逆向这档事》——正己
安卓签名校验-探讨-看雪
Android 签名校验
系统内置的签名校验机制(普通签名校验)
- 对应 Android 系统的 V1/V2/V3/V4 签名方案
- 校验时机:仅在 应用安装或更新时 进行,非运行时
- 执行主体:由 Android 系统自动完成,无需用户干预
- 实现机制:
- 安装流程中由
PackageManagerService (PMS)
执行签名验证 - 调用
collectCertificates
等方法校验 APK 签名 - 校验失败则安装被拒绝
- 安装流程中由
V1(JAR签名)
原理:基于传统的JAR签名方案,对APK中的每个文件生成哈希摘要(存储在MANIFEST.MF
),再使用私钥加密生成签名文件(如CERT.RSA
)。
V2(APK Signature Scheme v2)
原理:引入全文件签名,对整个APK文件(除签名块外)计算哈希值并加密,存储于APK的签名块中。
在Android 7.0上版本才支持
在安装过程中,如果有 v2 签名块,则必须走 v2 签名机制,不能绕过。否则降级走 v1 签名机制。
v1 和 v2 签名机制是可以同时存在的,其中对于 v1 和 v2 版本同时存在的时候,v1 版本的 META_INF 的 .SF 文件属性当中有一个 X-Android-APK-Signed 属性:
X-Android-APK-Signed: 2
相比v1签名方案,v2签名方案不再以文件为单位计算摘要,而是以1MB为单位将文件拆分为多个连续的块(chunk),每个分区的最后一个块可能会小于1MB。
计算每个块的摘要,然后再计算这些摘要的签名
V3(密钥轮替支持)
原理:在V2基础上增加密钥历史链,允许开发者轮换签名密钥而保持应用更新连续性。
V4(Merkle哈希树签名)
原理:基于APK所有字节构建Merkle哈希树,根哈希值与盐值作为签名数据,独立存储于.apk.idsig
文件。
应用层的自定义校验手段
虽然系统仅在安装时校验签名,但开发者可通过代码实现额外校验逻辑,增强安全性
JAVA层签名校验
注意,以下方法都是通过某种方法获得签名值后与预设值比对。以下只探讨某种方法来获取签名值,而预设值的获取方法假设都是统一的预定义好的。
PackageManager校验
通过PackageManager
获取当前应用签名信息,与预设值比对
代码中的Context
是一个 Android 提供的核心类,定义了应用运行时的上下文环境,提供了对应用资源和系统服务的访问接口。
// 获取当前签名SHA1值
public static String getCurrentSignature(Context context) {
try {
PackageInfo packageInfo = context.getPackageManager()
.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signatures = packageInfo.signatures;
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(signatures[0].toByteArray());
return bytesToHex(md.digest());
} catch (Exception e) { return ""; }
}
// 比对预设值(需提前用keytool获取)
public static boolean verifySignature(Context context, String expectedSHA1) {
String currentSHA1 = getCurrentSignature(context);
return currentSHA1.equals(expectedSHA1);
}
对此,我们可以通过重写Context
和PackageManager
的方式来伪造签名。
如何获取Context
对象:
// 下面几行代码展示如何任意获取Context对象,在jni中也可以使用这种方式
Class<?> activityThreadClz = Class.forName("android.app.ActivityThread");
Method currentApplication = activityThreadClz.getMethod("currentApplication");
Application application = (Application) currentApplication.invoke(null);
在Java层,PMS实例通过ActivityThread
的静态字段sPackageManager
或ApplicationPackageManager
的mPM
字段暴露。要替换PMS实例,需通过反射访问这些私有字段:
// 示例:Hook ActivityThread中的sPackageManager
var ActivityThread = Java.use("android.app.ActivityThread");
var currentThread = ActivityThread.currentActivityThread();
var originalPMS = currentThread.getPackageManager();
var proxyPMS = createProxy(originalPMS); // 创建代理对象
currentThread.sPackageManager.value = proxyPMS; // 反射修改字段
反射仅适用于Java层的字段和方法替换,但无法直接修改Native代码或系统服务内部逻辑
Dex/APK完整性校验
校验Dex文件的CRC或APK整体哈希,并将其与预设的CRC值或哈希值进行比较
防止重打包后Dex被篡改,但需注意资源文件(如strings.xml)的安全性
// 校验classes.dex的CRC值
public static void checkDexCRC(Context context) {
try {
ZipFile zipFile = new ZipFile(context.getPackageCodePath());
ZipEntry dexEntry = zipFile.getEntry("classes.dex");
long realCRC = dexEntry.getCrc();
long expectedCRC = Long.parseLong(context.getString(R.string.expected_crc));
if (realCRC != expectedCRC) {
System.exit(0); // 触发退出
}
} catch (IOException e) { /* 异常处理 */ }
}
// APK整体SHA-1校验
public final boolean check_Hash(Context context0) {
String apkPath = this.getApkPath(this);
try (FileInputStream fis = new FileInputStream(new File(apkPath))) {
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = fis.read(buffer)) != -1) {
digest.update(buffer, 0, bytesRead);
}
String hash = new BigInteger(1, digest.digest()).toString(16);
Log.e("zj2595", "hash:" + hash);
return Intrinsics.areEqual(hash, this.ApkHash);
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
可以通过io重定向绕过,本质上是hook open( )、openat( )、fopen( )、fake_syscall( )
等读取文件函数,把io后的路径写死成原来修改前的apk文件。
[!MT去除签名校验]
旧版的MT去签名校验的原理是
GitHub - L-JINBIN/ApkSignatureKiller: 一键破解APK签名校验
通过hook PMS来过校验,对那些仅通过PackageManager.getPackageInfo().signatures
来校验签名的应用有效。新版MT中的“去除签名校验”在原来的基础上加入了 io重定向原理
GitHub - L-JINBIN/ApkSignatureKillerEx: 新版MT去签及对抗
去除了之后可以看到apk里加入了一个dex文件来实现去除签名校验。
具体的,可以看到有killPM和killOpen两个方法,即hook PMS 和 io重定向 都加上了。
基于类名的 Application
校验
检查当前应用的 Application
类是否为 MainApplication
private final boolean checkApplication() {
return Intrinsics.areEqual("MainApplication", this.getApplication().getClass().getSimpleName());
}
新的API签名校验
- 如果
Build.VERSION.SDK_INT < 28
(即 Android 版本低于 9.0),使用PackageInfo.signatures
获取签名。 - 如果
Build.VERSION.SDK_INT >= 28
(即 Android 版本 9.0 及以上),使用SigningInfo.getApkContentsSigners()
获取签名。
private final boolean useNewAPICheck() {
String s1 = "";
try {
byte[] arr_b;
if (Build.VERSION.SDK_INT < 28) {
arr_b = this.getPackageManager().getPackageInfo("com.zj.wuaipojie", PackageManager.GET_SIGNATURES).signatures[0].toByteArray();
} else {
arr_b = this.getPackageManager().getPackageInfo("com.zj.wuaipojie", PackageManager.GET_SIGNING_CERTIFICATES).signingInfo.getApkContentsSigners()[0].toByteArray();
}
s1 = MD5Utils.INSTANCE.MD5(Base64Utils.INSTANCE.encodeToString(arr_b));
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
return "074f64af5821ae6aa1ac1779ad5687ad".equals(s1);
}
签名哈希上传服务器校验
tips:需处理网络延迟或拦截攻击(如中间人伪造响应)
// 客户端发送签名至服务端验证
public static void remoteVerify(Context context) {
String signature = getCurrentSignature(context);
ApiService.verifySignature(signature, new Callback() {
@Override
public void onSuccess() { /* 正常流程 */ }
@Override
public void onFailure() { killProcess(); }
});
}
运行时检测 Frida/ 调试器附加 /PMS动态代理 / root
有很多检测方法,下面举例几种
// 检测Frida/Xposed存在
public static boolean checkHookFramework() {
return checkFiles("/data/local/tmp/frida", "/data/local/bin/magisk")
|| checkPort(27042); // Frida默认端口
}
// 检测调试器附加
public static boolean isDebuggerAttached() {
return Debug.isDebuggerConnected() || Debug.waitingForDebugger();
}
// 检查PackageManager是否被动态代理,通过反射获取 `PackageManager` 的内部字段 `mPM`,并检查该字段的类型是否为 `android.content.pm.IPackageManager$Stub$Proxy`
private final boolean checkPMProxy() {
try {
PackageManager packageManager0 = this.getPackageManager();
Field field0 = packageManager0.getClass().getDeclaredField("mPM");
field0.setAccessible(true);
return Intrinsics.areEqual("android.content.pm.IPackageManager$Stub$Proxy", field0.get(packageManager0).getClass().getName());
}
catch(Exception exception0) {
exception0.printStackTrace();
return Intrinsics.areEqual("android.content.pm.IPackageManager$Stub$Proxy", "");
}
}
// `checkRootMethod1()` 方法检查设备的 `build tags` 是否包含 `test-keys`。这通常是用于测试的设备,因此如果检测到这个标记,则可以认为设备已被 root。
// `checkRootMethod2()` 方法检查设备是否存在一些特定的文件,这些文件通常被用于执行 root 操作。如果检测到这些文件,则可以认为设备已被 root。
// `checkRootMethod3()` 方法使用 `Runtime.exec()` 方法来执行 `which su` 命令,然后检查命令的输出是否不为空。如果输出不为空,则可以认为设备已被 root。
fun isDeviceRooted(): Boolean {
return checkRootMethod1() || checkRootMethod2() || checkRootMethod3()
}
fun checkRootMethod1(): Boolean {
val buildTags = android.os.Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
fun checkRootMethod2(): Boolean {
val paths = arrayOf("/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su",
"/system/bin/failsafe/su", "/data/local/su", "/su/bin/su")
for (path in paths) {
if (File(path).exists()) return true
}
return false
}
fun checkRootMethod3(): Boolean {
var process: Process? = null
return try {
process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
val bufferedReader = BufferedReader(InputStreamReader(process.inputStream))
bufferedReader.readLine() != null
} catch (t: Throwable) {
false
} finally {
process?.destroy()
}
}
反制手段
1.算法助手、对话框取消等插件一键hook
2.分析具体的检测代码
让它检测root全部返回false
3.利用IO重定向使文件不可读
4.修改Andoird源码,去除常见指纹
Native层签名校验
以上Java层方法大部分都可以迁移到Native层上,但是单独移植 效果并不会得到太大提升。一般都是结合上Native层的反调试反静态手段。
//native-lib.cpp
JNIEXPORT jboolean JNICALL Java_com_example_SignCheck_nativeCheck
(JNIEnv *env, jobject obj, jobject context) {
// 获取Context实例(需反射调用ActivityThread)
jclass activityThreadClass = env->FindClass("android/app/ActivityThread");
jmethodID currentApplication = env->GetStaticMethodID(activityThreadClass,
"currentApplication", "()Landroid/app/Application;");
jobject app = env->CallStaticObjectMethod(activityThreadClass, currentApplication);
// 获取PackageManager并提取签名
jclass contextClass = env->GetObjectClass(app);
jmethodID getPackageManager = env->GetMethodID(contextClass,
"getPackageManager", "()Landroid/content/pm/PackageManager;");
jobject pm = env->CallObjectMethod(app, getPackageManager);
// 此处省略签名提取与校验逻辑...
return env->NewStringUTF("预设签名哈希") == calculatedHash;
}
Java层一般是通过以下方法来获取签名的,其实现大部分是通过简单对比来确认签名是否正确:
getPackageManager // 获取 `PackageManager` 实例
getPackageInfo // 获取指定包名的应用信息
PackageManager.GET_SIGNATURES // 获取应用的签名信息
PackageManager.GET_SIGNING_CERTIFICATES // 从 Android 9.0(API 28)开始
Signature.toByteArray() // 将签名对象转换为字节数组。
MessageDigest.getInstance("SHA-1"); // 获取指定算法的消息摘要实例,如SHA-1
update(byte[] input) // 更新消息摘要实例,将输入的字节数组加入到摘要计算中。
digest //完成摘要计算,返回最终的哈希值。
MD5Utils.MD5(String input) // 计算输入字符串的 MD5 哈希值
Base64Utils.encodeToString(byte[] input)`// 将字节数组编码为 Base64 字符串
getApplication() // 获取当前应用的 Application 实例。
getClass().getSimpleName() // 获取对象的类名(不包含包名)
getPackageCodePath() // 获取 APK 文件的路径。
ZipFile.ZipFile(String name) // 打开一个 ZIP 文件
ZipFile.getEntry(String name) // 获取 ZIP 文件中的指定条目(如 classes.dex)
ZipEntry.getCrc():获取 ZIP 条目的 CRC32 校验值
手搓Context
通过在Native层中,开发者自己在Native层通过反射获取Context
对象,避免传递Context
导致伪造签名。
但还是实质上还是通过调用Context的PMS来获取签名信息。所以还是可以通过hook PMS来hook native层的手搓Context获取的PMS。
代码在以下文章中:
Android安全系列之:如何在native层保存关键信息 - dongweiq - 博客园
直接解析APK签名块
直接读取apk的签名块,绕过PackageManager的潜在篡改,直接验证签名证书链。
// 读取APK的META-INF/*.RSA文件
FILE* fp = fopen("/data/app/xxx/base.apk", "rb");
fseek(fp, centralDirOffset, SEEK_SET); // 定位签名块偏移
parsePKCS7SignatureBlock(fp); // 解析PKCS#7结构
分块校验技术
每个分块计算SHA-256哈希值,并将这些哈希值组合成一个总的哈希值用于验证完整性。
void verifyDexIntegrity() {
int fd = open("/data/app/xxx/classes.dex", O_RDONLY);
struct stat st;
fstat(fd, &st);
// 分块计算哈希
for(int i=0; i<st.st_size; i+=4096){
void* map = mmap(0, 4096, PROT_READ, MAP_PRIVATE, fd, i);
calculate_sha256(map, 4096);
munmap(map, 4096);
}
close(fd);
}
ELF节校验
tips:好像没看到相关Android实现资料。
通过在应用程序中添加特定的字段,这些字段中在编译期给应用程序添加一些类似校验的信息。这个应用程序在运行前需要先经过一个装载程序的校验,校验成功才继续运行此应用程序。
通过在 ELF 文件中加入额外的节(Section),可以存储:
- 应用程序的完整校验信息(如哈希值或签名)。
- 应用关键代码段或数据段的校验摘要。
- 自定义的元信息。
在应用启动时或动态库装载(通过动态加载器dlopen
)时,可以对这些节进行完整性校验,确保 ELF 文件没有被篡改。
#include <elf.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <openssl/sha.h>
void check_elf_integrity(const char *elf_path) {
int fd = open(elf_path, O_RDONLY);
if (fd < 0) {
printf("Failed to open ELF file: %s\n", elf_path);
return;
}
// 示例:假设增加了 .custom_verify 的节并对 ELF 文件的整体内容进行校验
lseek(fd, 0, SEEK_SET);
char buffer[2048];
SHA256_CTX sha256;
SHA256_Init(&sha256);
ssize_t bytes_read;
while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
SHA256_Update(&sha256, buffer, bytes_read);
}
unsigned char calculated_hash[SHA256_DIGEST_LENGTH];
SHA256_Final(calculated_hash, &sha256);
printf("Calculated ELF Hash: ");
for (int i = 0; i < SHA256_DIGEST_LENGTH; ++i) {
printf("%02x", calculated_hash[i]);
}
printf("\n");
// 校验:与 ELF 文件中的特定节哈希值对比(伪代码)
unsigned char expected_hash[SHA256_DIGEST_LENGTH];
// 从 ELF 文件中的 .custom_verify 节中读取 expected_hash
// 假设 find_custom_verify_section() 是找节的函数
if (memcmp(calculated_hash, expected_hash, SHA256_DIGEST_LENGTH) == 0) {
printf("ELF verification passed.\n");
} else {
printf("ELF verification failed.\n");
}
close(fd);
}
使用 Android NDK 提供的安全 API
利用 NDK 提供的加密和哈希相关库,在 Native 层完成签名校验流程。
- 使用 OpenSSL 或 BoringSSL 提供的 API 计算签名哈希值。
- 通过 Native 层直接加载签名文件,如 CERT.RSA。
- 对签名内容的公钥与预期结果进行解密对比。
示例:
基于 OpenSSL:
#include <openssl/sha.h>
unsigned char *SHA1Hash(const unsigned char *data, size_t len) {
unsigned char *hash = (unsigned char *)malloc(SHA_DIGEST_LENGTH);
SHA1(data, len, hash);
return hash;
}
反调试
Native层能实现的反调试手段比较多,如使用ptrace、检查TracerPid、断点检测等方法。比如,主动调用ptrace(PTRACE_TRACEME)阻止调试器附加,或者轮询检查/proc/self/status中的TracerPid等
逆向对抗层:混淆
代码混淆是一种常见的逆向工程对抗技术,旨在通过变更代码的结构、符号名称和逻辑路径,让逆向工程师难以理解代码逻辑。安卓应用通常使用以下混淆技术:
代码混淆(ProGuard/R8)
基本思路:
通过改变代码中的类名、方法名、字段名,使代码逻辑变得难以理解。ProGuard是常用的Android代码混淆工具,而R8是其替代版本,具有更高效的混淆能力。
特点:
- 自动混淆类名、方法名和字段名。
- 删除未使用的代码(优化)以减小APK文件大小。
- 提供配置文件支持,以便定义混淆规则。
示例:
ProGuard配置文件示例:
-keep class com.example.** { *; }
-keepclassmembers class com.example.** { *; }
-keepnames class com.example.** { *; }
其中:
-keep
: 保留指定的类和成员名不被混淆。-keepclassmembers
: 保留特定类的成员名。-keepnames
: 保留类名以避免反射调用失败。
注意事项:
- 混淆后的类名常呈现类似
a.a.a
的形式。 - 反射机制问题:如果应用中大量使用反射调用,混淆后的类名可能会导致运行时调用失败,需要配置
-keep
规则避免关键类名被混淆。
字符串混淆
基本思路:
对代码中的关键字符串(如 API Key、签名哈希值、敏感路径等)进行加密处理,避免直接暴露给逆向工程师。字符串混淆的方法包括:
- Base64编码
- XOR加密
- AES或DES对称加密
示例:
// 示例:对关键字符串进行XOR加密
public static String decryptString(String encrypted, int key) {
char[] chars = encrypted.toCharArray();
for (int i = 0; i < chars.length; i++) {
chars[i] = (char) (chars[i] ^ key);
}
return new String(chars);
}
// 示例使用
String encrypted = encryptString("SensitiveString", 123);
String decrypted = decryptString(encrypted, 123);
通过这种方式,可以有效隐藏代码中的敏感信息。
代码逻辑混淆
基本思路:
使用复杂的逻辑结构或冗余代码伪装真正的应用逻辑,扰乱逆向工程师的分析过程。可以通过中间层调用、多分支逻辑或意图干扰手段来实现。
示例:
// 示例:冗余逻辑与陷阱代码
if (randomCondition()) {
fakeMethod1();
} else if (anotherRandomCondition()) {
fakeMethod2();
} else {
realLogicMethod();
}
public void fakeMethod1() {
// 无效逻辑
}
public void fakeMethod2() {
// 陷阱逻辑
}
通过嵌入大量伪逻辑,可以有效干扰自动化分析工具的行为。
控制流伪装
基本思路:
改变代码执行的控制流,使其呈现复杂而非线性的特征。这种方法通常伴随以下技术:
- 分支混淆:插入大量无关的分支条件。
- 循环嵌套:嵌套循环以混淆逻辑。
- 跳跃指令:通过硬编码的跳跃指令扰乱代码结构。
示例:
public int exampleFunction(int input) {
if (input > 100) {
return input * 2; // 主逻辑
} else {
triggerTrap(); // 陷阱逻辑
}
}
// 示例:通过跳跃语句扰乱分析
void triggerTrap() {
for (int i = 0; i < 5; i++) {
if (i == 3) {
return; // 扰乱分析工具
}
}
}
动态代码生成
基本思路:
在运行时动态生成部分代码以实现关键逻辑,避免静态分析工具提取重要信息。动态代码生成通常依赖于DEX动态加载或反射调用,例如:
- 使用
ClassLoader
加载动态生成的 DEX 文件。 - 将关键代码嵌入 Native 模块并通过 JNI 调用。
示例:
DexClassLoader dexLoader = new DexClassLoader("/path/to/dynamic.dex",
context.getCacheDir().getAbsolutePath(),
null,
context.getClassLoader());
Class<?> dynamicClass = dexLoader.loadClass("com.example.dynamic.DynamicCode");
Method method = dynamicClass.getDeclaredMethod("execute");
method.invoke(null);
反动态调试与反Hook
基本思路:
对抗动态调试或Hook框架(如Frida/Xposed)所附加的行为,尽可能干扰逆向工程师的调试操作。以下是常见的反动态调试手段:
- 轮询检测调试器附加状态:
public static boolean isDebuggerAttached() { return Debug.isDebuggerConnected() || Debug.waitingForDebugger(); }
- 检测Frida或Xposed框架文件是否存在:
public static boolean checkFiles(String... paths) { for (String path : paths) { if (new File(path).exists()) { return true; } } return false; }
- 动态检查被Hook的函数是否被代理:
- 通过反射检查被Hook的方法调用是否来自于代理对象(如Xposed的
XC_MethodHook
)。
- 通过反射检查被Hook的方法调用是否来自于代理对象(如Xposed的
分层混淆
基本思路:
将关键代码逻辑分散到多个层级(如Java层、Native层或动态加载模块),并对每个层级进行单独混淆。同时可以采用多种语言(如C/C++/Rust)混编以增加逆向难度。
示例:
- Java层包含部分代码逻辑,其余核心逻辑存储在Native层。
- Native层通过自定义的ELF节校验或硬编码逻辑实现校验完整性,确保难以通过反编译获取完整逻辑。
工具支持
以下是常用的混淆与逆向对抗工具:
- ProGuard: Android项目中的基本混淆工具。
- R8: Google推荐的ProGuard替代工具,效果更强。
- DexGuard: 一款付费的混淆工具,提供更多高级混淆特性(如字符串加密、动态字节码操作)。
- Obfuscator-LLVM: 针对Native层代码的混淆工具,可以改变代码结构、控制流等。