安卓各类检测
签名校验对抗
感谢正己大佬开源:安卓逆向这档事
签名介绍
简单来说,APK 的签名主要作用有两个:
- 证明 APK 的所有者。
- 允许 Android 市场和设备校验 APK 的正确性。
Android 目前支持以下四种应用签名方案:
v1 方案:基于 JAR 签名。
v2 方案:APK 签名方案 v2(在 Android 7.0 中引入)
v3 方案:APK 签名方案 v3(在 Android 9 中引入)
v4 方案:APK 签名方案 v4(在 Android 11 中引入)
V1签名
V1 签名的机制主要就在 META-INF 目录下的三个文件,MANIFEST.MF,ANDROID.SF,ANDROID.RSA,他们都是 V1 签名的产物。
- MANIFEST.MF:这是摘要文件。程序遍历Apk包中的所有文件(entry),对非文件夹非签名文件的文件,逐个用SHA1(安全哈希算法)生成摘要信息,再用Base64进行编码。如果你改变了apk包中的文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是程序就不能成功安装。
- ANDROID.SF:这是对摘要的签名文件。对前一步生成的MANIFEST.MF,使用SHA1-RSA算法,用开发者的私钥进行签名。在安装时只能使用公钥才能解密它。解密之后,将它与未加密的摘要信息(即,MANIFEST.MF文件)进行对比,如果相符,则表明内容没有被异常修改。
- ANDROID.RSA文件中保存了公钥、所采用的加密算法等信息。
在某些情况下,直接对apk进行v1签名可以绕过apk的签名校验
V2签名
v2方案会将 APK 文件视为 blob,并对整个文件进行签名检查。对 APK 进行的任何修改(包括对 ZIP 元数据进行的修改)都会使 APK 签名作废。这种形式的 APK 验证不仅速度要快得多,而且能够发现更多种未经授权的修改。
签名校验
如何判断是否有签名校验?
不做任何修改,直接签名安装,应用闪退则说明大概率有签名校验.一般来说,普通的签名校验会导致软件的闪退,黑屏,卡启动页等.
当然,以上都算是比较好的,有一些比较狠的作者,则会直接rm -rf /,把基带都格掉的一键变砖。
kill/killProcess-----kill/KillProcess()可以杀死当前应用活动的进程,这一操作将会把所有该进程内的资源(包括线程全部清理掉).当然,由于ActivityManager时刻监听着进程,一旦发现进程被非正常Kill,它将会试图去重启这个进程。这就是为什么,有时候当我们试图这样去结束掉应用时,发现它又自动重新启动的原因.
system.exit-----杀死了整个进程,这时候活动所占的资源也会被释放。
finish----------仅仅针对Activity,当调用finish()时,只是将活动推向后台,并没有立即释放内存,活动的资源并没有被清理
普通校验
第一种方法
先随便签个名,打开发现白屏,用算法助手阻止闪退(最下面的两个选项),并且开log查看:
com.zj.wuaipojie.ui.ChallengeFifth.onCreate
这个方法很可疑,搜索一下:
check后失败就调用exit()
,直接注释掉
第二种方法
打开算法助手,勾选上读取应用签名监听(这也太好用了吧),打开软件后回去看看日志,有个读取应用签名信息,看看堆栈,果不其然是com.zj.wuaipojie.ui.ChallengeFifth.checkSign
这个方法的问题.
private final boolean checkSign(Context context) {
try {
for (Signature signature : context.getPackageManager().getPackageInfo(context.getPackageName(), 64).signatures) {
MessageDigest messageDigest = MessageDigest.getInstance("SHA");
messageDigest.update(signature.toByteArray());
String encodeToString = Base64Utils.INSTANCE.encodeToString(messageDigest.digest());
Log.e("zj2595", "sign:" + encodeToString);
if (!Intrinsics.areEqual(this.SIGNATURE, encodeToString)) {
return false;
}
}
return true;
} catch (Exception e) {
e.printStackTrace();
return true;
}
}
系统将应用的签名信息封装在 PackageInfo 中,调用 PackageManager 的 getPackageInfo(String packageName, int flags) 即可获取指定包名的签名信息
这里就是获取签名,然后sha加密,在base64编码,在去比对,和这个比对h99Ici0iopj8tC6NkfqyJgs73ss=
可以修改判断,可以改签名.
关闭算法助手可以过,看一下还有什么要过:
签名对抗的方法
方法一:核心破解插件,不签名安装应用(主要对于v1,这是我认为的)
方法二:一键过签名工具,例如MT、NP、ARMPro、CNFIX、Modex的去除签名校验功能
方法三:具体分析签名校验逻辑(手撕签名校验)
方法四:io重定向--VA&SVC:ptrace+seccomp
SVC的TraceHook沙箱的实现&无痕Hook实现思路
方法五:去作者家严刑拷打拿到.jks文件和密码
PM代理
思路源自:Android中Hook 应用签名方法
PackageManagerService(简称PMS),是Android系统核心服务之一,处理包管理相关的工作,常见的比如安装、卸载应用等。
还记得前面的PackageManager吗?就是其中之一(我推测)
不过hook还没学,不好搞,以后再说吧.而且好像只能过掉一些简单的java层校验,也是mt管理器去签名校验的原理.
过签名校验(1) - PM 去校验实践 - 吾爱破解 - 52pojie.cn
io重定向
什么是IO重定向?
例:在读A文件的时候指向B文件
IO重定向可以干嘛?
1,可以让文件只读,不可写
2,禁止访问文件
3,路径替换
具体实现:
过签名检测(在签名校验时读取原包)
风控对抗(例:一个文件记录App启动的次数)
过Root检测,Xposed检测(文件不可读,这样就不知道有)
这里正己大佬已经写好了,写在so库,也准备好了一个smali语句调用.
sget-object p10, Lcom/zj/wuaipojie/util/ContextUtils;->INSTANCE:Lcom/zj/wuaipojie/util/ContextUtils;
invoke-virtual {p10}, Lcom/zj/wuaipojie/util/ContextUtils;->getContext()Landroid/content/Context;
move-result-object p10
invoke-static {p10}, Lcom/zj/wuaipojie/util/SecurityUtil;->hook(Landroid/content/Context;)V
using namespace std;
string packname;
string origpath;
string fakepath;
int (*orig_open)(const char *pathname, int flags, ...);
int (*orig_openat)(int,const char *pathname, int flags, ...);
FILE *(*orig_fopen)(const char *filename, const char *mode);
static long (*orig_syscall)(long number, ...);
int (*orig__NR_openat)(int,const char *pathname, int flags, ...);
void* (*orig_dlopen_CI)(const char *filename, int flag);
void* (*orig_dlopen_CIV)(const char *filename, int flag, const void *extinfo);
void* (*orig_dlopen_CIVV)(const char *name, int flags, const void *extinfo, void *caller_addr);
static inline bool needs_mode(int flags) {
return ((flags & O_CREAT) == O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE);
}
bool startsWith(string str, string sub){
return str.find(sub)==0;
}
bool endsWith(string s,string sub){
return s.rfind(sub)==(s.length()-sub.length());
}
bool isOrigAPK(string path){
if(path==origpath){
return true;
}
return false;
}
//该函数的功能是在打开一个文件时进行拦截,并在满足特定条件时将文件路径替换为另一个路径
//fake_open 函数有三个参数:
//pathname:一个字符串,表示要打开的文件的路径。
//flags:一个整数,表示打开文件的方式,例如只读、只写、读写等。
//mode(可选参数):一个整数,表示打开文件时应用的权限模式。
int fake_open(const char *pathname, int flags, ...) {
mode_t mode = 0;
if (needs_mode(flags)) {
va_list args;
va_start(args, flags);
mode = static_cast<mode_t>(va_arg(args, int));
va_end(args);
}
//LOGI("open, path: %s, flags: %d, mode: %d",pathname, flags ,mode);
string cpp_path= pathname;
if(isOrigAPK(cpp_path)){
LOGI("libc_open, redirect: %s, --->: %s",pathname, fakepath.data());
return orig_open("/data/user/0/com.zj.wuaipojie/files/base.apk", flags, mode);
}
return orig_open(pathname, flags, mode);
}
//该函数的功能是在打开一个文件时进行拦截,并在满足特定条件时将文件路径替换为另一个路径
//fake_openat 函数有四个参数:
//fd:一个整数,表示要打开的文件的文件描述符。
//pathname:一个字符串,表示要打开的文件的路径。
//flags:一个整数,表示打开文件的方式,例如只读、只写、读写等。
//mode(可选参数):一个整数,表示打开文件时应用的权限模式。
//openat 函数的作用类似于 open 函数,但是它使用文件描述符来指定文件路径,而不是使用文件路径本身。这样,就可以在打开文件时使用相对路径,而不必提供完整的文件路径。
//例如,如果要打开相对于当前目录的文件,可以使用 openat 函数,而不是 open 函数,因为 open 函数只能使用绝对路径。
//
int fake_openat(int fd, const char *pathname, int flags, ...) {
mode_t mode = 0;
if (needs_mode(flags)) {
va_list args;
va_start(args, flags);
mode = static_cast<mode_t>(va_arg(args, int));
va_end(args);
}
LOGI("openat, fd: %d, path: %s, flags: %d, mode: %d",fd ,pathname, flags ,mode);
string cpp_path= pathname;
if(isOrigAPK(cpp_path)){
LOGI("libc_openat, redirect: %s, --->: %s",pathname, fakepath.data());
return orig_openat(fd,fakepath.data(), flags, mode);
}
return orig_openat(fd,pathname, flags, mode);
}
FILE *fake_fopen(const char *filename, const char *mode) {
string cpp_path= filename;
if(isOrigAPK(cpp_path)){
return orig_fopen(fakepath.data(), mode);
}
return orig_fopen(filename, mode);
}
//该函数的功能是在执行系统调用时进行拦截,并在满足特定条件时修改系统调用的参数。
//syscall 函数是一个系统调用,是程序访问内核功能的方法之一。使用 syscall 函数可以调用大量的系统调用,它们用于实现操作系统的各种功能,例如打开文件、创建进程、分配内存等。
//
static long fake_syscall(long number, ...) {
void *arg[7];
va_list list;
va_start(list, number);
for (int i = 0; i < 7; ++i) {
arg[i] = va_arg(list, void *);
}
va_end(list);
if (number == __NR_openat){
const char *cpp_path = static_cast<const char *>(arg[1]);
LOGI("syscall __NR_openat, fd: %d, path: %s, flags: %d, mode: %d",arg[0] ,arg[1], arg[2], arg[3]);
if (isOrigAPK(cpp_path)){
LOGI("syscall __NR_openat, redirect: %s, --->: %s",arg[1], fakepath.data());
return orig_syscall(number,arg[0], fakepath.data() ,arg[2],arg[3]);
}
}
return orig_syscall(number, arg[0], arg[1], arg[2], arg[3], arg[4], arg[5], arg[6]);
}
//函数的功能是获取当前应用的包名、APK 文件路径以及库文件路径,并将这些信息保存在全局变量中
//函数调用 GetObjectClass 和 GetMethodID 函数来获取 context 对象的类型以及 getPackageName 方法的 ID。然后,函数调用 CallObjectMethod 函数来调用 getPackageName 方法,获取当前应用的包名。最后,函数使用 GetStringUTFChars 函数将包名转换为 C 字符串,并将包名保存在 packname 全局变量中
//接着,函数使用 fakepath 全局变量保存了 /data/user/0/<packname>/files/base.apk 这样的路径,其中 <packname> 是当前应用的包名。
//然后,函数再次调用 GetObjectClass 和 GetMethodID 函数来获取 context 对象的类型以及 getApplicationInfo 方法的 ID。然后,函数调用 CallObjectMethod 函数来调用 getApplicationInfo 方法,获取当前应用的 ApplicationInfo 对象。
//它先调用 GetObjectClass 函数获取 ApplicationInfo 对象的类型,然后调用 GetFieldID 函数获取 sourceDir 字段的 ID。接着,函数使用 GetObjectField 函数获取 sourceDir 字段的值,并使用 GetStringUTFChars 函数将其转换为 C 字符串。最后,函数将 C 字符串保存在 origpath 全局变量中,表示当前应用的 APK 文件路径。
//最后,函数使用 GetFieldID 和 GetObjectField 函数获取 nativeLibraryDir 字段的值,并使用 GetStringUTFChars 函数将其转换为 C 字符串。函数最后调用 LOGI 函数打印库文件路径,但是并没有将其保存在全局变量中。
extern "C" JNIEXPORT void JNICALL
Java_com_zj_wuaipojie_util_SecurityUtil_hook(JNIEnv *env, jclass clazz, jobject context) {
jclass conext_class = env->GetObjectClass(context);
jmethodID methodId_pack = env->GetMethodID(conext_class, "getPackageName",
"()Ljava/lang/String;");
auto packname_js = reinterpret_cast<jstring>(env->CallObjectMethod(context, methodId_pack));
const char *pn = env->GetStringUTFChars(packname_js, 0);
packname = string(pn);
env->ReleaseStringUTFChars(packname_js, pn);
//LOGI("packname: %s", packname.data());
fakepath= "/data/user/0/"+ packname +"/files/base.apk";
jclass conext_class2 = env->GetObjectClass(context);
jmethodID methodId_pack2 = env->GetMethodID(conext_class2,"getApplicationInfo","()Landroid/content/pm/ApplicationInfo;");
jobject application_info = env->CallObjectMethod(context,methodId_pack2);
jclass pm_clazz = env->GetObjectClass(application_info);
jfieldID package_info_id = env->GetFieldID(pm_clazz,"sourceDir","Ljava/lang/String;");
auto sourceDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id));
const char *sourceDir = env->GetStringUTFChars(sourceDir_js, 0);
origpath = string(sourceDir);
LOGI("sourceDir: %s", sourceDir);
jfieldID package_info_id2 = env->GetFieldID(pm_clazz,"nativeLibraryDir","Ljava/lang/String;");
auto nativeLibraryDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id2));
const char *nativeLibraryDir = env->GetStringUTFChars(nativeLibraryDir_js, 0);
LOGI("nativeLibraryDir: %s", nativeLibraryDir);
//LOGI("%s", "Start Hook");
//启动hook
void *handle = dlopen("libc.so",RTLD_NOW);
auto pagesize = sysconf(_SC_PAGE_SIZE);
auto addr = ((uintptr_t)dlsym(handle,"open") & (-pagesize));
auto addr2 = ((uintptr_t)dlsym(handle,"openat") & (-pagesize));
auto addr3 = ((uintptr_t)fopen) & (-pagesize);
auto addr4 = ((uintptr_t)syscall) & (-pagesize);
//解除部分机型open被保护
mprotect((void*)addr, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
mprotect((void*)addr2, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
mprotect((void*)addr3, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
mprotect((void*)addr4, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
DobbyHook((void *)dlsym(handle,"open"), (void *)fake_open, (void **)&orig_open);
DobbyHook((void *)dlsym(handle,"openat"), (void *)fake_openat, (void **)&orig_openat);
DobbyHook((void *)fopen, (void *)fake_fopen, (void**)&orig_fopen);
DobbyHook((void *)syscall, (void *)fake_syscall, (void **)&orig_syscall);
}
根据视频讲(22年),mt也要使用这个方法了,而且开源了https://github.com/L-JINBIN/ApkSignatureKillerEx (可以配合这里的源码一起学习,不过我懒,就不看了,估计就是hook几个函数)
np也有,甚至有scv的(虽然也不是很懂)
其他检测
以下遇到了再说,先不看在这里学了,还得是实战.
Root检测
反制手段
1.算法助手(这个是也太牛了吧)、对话框取消等插件一键hook
2.分析具体的检测代码
3.利用IO重定向使文件不可读
4.修改Andoird源码,去除常见指纹
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()
}
}
定义了一个 isDeviceRooted()
函数,该函数调用了三个检测 root 的方法:checkRootMethod1()
、checkRootMethod2()
和 checkRootMethod3()
。
checkRootMethod1()
方法检查设备的 build tags
是否包含 test-keys
。这通常是用于测试的设备,因此如果检测到这个标记,则可以认为设备已被 root。
checkRootMethod2()
方法检查设备是否存在一些特定的文件,这些文件通常被用于执行 root 操作。如果检测到这些文件,则可以认为设备已被 root。
checkRootMethod3()
方法使用 Runtime.exec()
方法来执行 which su
命令,然后检查命令的输出是否不为空。如果输出不为空,则可以认为设备已被 root
模拟器检验
fun isEmulator(): Boolean {
return Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") || Build.MODEL.contains("google_sdk") Build.MODEL.contains("Emulator") || Build.MODEL.contains("Android SDK built for x86") || Build.MANUFACTURER.contains("Genymotion") || Build.HOST.startsWith("Build") || Build.PRODUCT == "google_sdk"
}
通过检测系统的 Build
对象来判断当前设备是否为模拟器。具体方法是检测 Build.FINGERPRINT
属性是否包含字符串 "generic"
。
反调试检测
安卓系统自带调试检测函数
fun checkForDebugger() {
if (Debug.isDebuggerConnected()) {
// 如果调试器已连接,则终止应用程序
System.exit(0)
}
}
debuggable属性
public boolean getAppCanDebug(Context context)//上下文对象为xxActivity.this
{
boolean isDebug = context.getApplicationInfo() != null &&
(context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
return isDebug;
}
ptrace检测
int ptrace_protect()//ptrace附加自身线程 会导致此进程TracerPid 变为父进程的TracerPid 即zygote
{
return ptrace(PTRACE_TRACEME,0,0,0);;//返回-1即为已经被调试
}
每个进程同时刻只能被1个调试进程ptrace ,主动ptrace本进程可以使得其他调试器无法调试
调试进程名检测
int SearchObjProcess()
{
FILE* pfile=NULL;
char buf[0x1000]={0};
pfile=popen("ps","r");
if(NULL==pfile)
{
//LOGA("SearchObjProcess popen打开命令失败!\n");
return -1;
}
// 获取结果
//LOGA("popen方案:\n");
while(fgets(buf,sizeof(buf),pfile))
{
char* strA=NULL;
char* strB=NULL;
char* strC=NULL;
char* strD=NULL;
strA=strstr(buf,"android_server");//通过查找匹配子串判断
strB=strstr(buf,"gdbserver");
strC=strstr(buf,"gdb");
strD=strstr(buf,"fuwu");
if(strA || strB ||strC || strD)
{
return 1;
// 执行到这里,判定为调试状态
}
}
pclose(pfile);
return 0;
}
frida检测
《安卓逆向这档事》十八、表哥,你也不想你的Frida被检测吧!(上) - 吾爱破解 - 52pojie.cn
1.检测方法签名信息,frida在hook方法的时候会把java方法转为native方法
2.Frida在attach进程注入SO时会显式地校验ELF_magic字段,不对则直接报错退出进程,可以手动在内存中抹掉SO的magic,达到反调试的效果。
检测点
复制代码 隐藏代码if (memcmp (GSIZE_TO_POINTER (start), elf_magic, sizeof (elf_magic)) != 0)
return FALSE;
复制代码 隐藏代码FILE *fp=fopen("/proc/self/maps","r");
while (fgets(line, sizeof(line), fp)) {
if (strstr(line, "linker64") ) {
start = reinterpret_cast<int *>(strtoul(strtok(line, "-"), NULL, 16));
*(long*)start=*(long*)start^0x7f;
}
}
3.Frida源码中多次调用somain结构体,但它在调用前不会判断是否为空,只要手动置空后Frida一附加就会崩溃
检测点
somain = api->solist_get_somain ();
gum_init_soinfo_details (&details, somain, api, &ranges);
api->solist_get_head ()
gum_init_soinfo_details (&details, si, api, &ranges);
int getsomainoff = findsym("/system/bin/linker64","__dl__ZL6somain");
*(long*)((char*)start+getsomainoff)=0;
4.通常inline hook第一条指令是mov 常数到寄存器,然后第二条是一个br 寄存器指令。检查第二条指令高16位是不是0xd61f,就可以判断目标函数是否被inline hook了!
5.还可以去hook加固壳,现在很多加固厂商都antifrida了,从壳中的代码去分析检测思路
反调试现状 | 详细说明 |
---|---|
检测方式多样 | 从通用检测、hook检测到源码检测,方式层出不穷。源码检测可以针对每行代码都能开发出不同检测方式,Frida指纹过多。 |
检测位置不确定 | 一般是单独开线程跑,也可以在关键函数执行前判断 |
强混淆加大定位难度 | 反调试通常埋几行代码,但结合混淆可达万行代码,不考虑效率可膨胀更多,定位极难。 |