首先frida启动app,发现进程终止。所以要找到在哪个so文件中做的反调试。so文件由java层的System.loadLibrary或者System.load进行加载。以安卓11为例,先分析一下System.loadLibrary的加载流程。



从nativeLoad就会进入c层

native函数的函数名都很规整,可以直接搜索Runtime_nativeLoad定位到


在vm->LoadNativeLibrary中调用代码如下,函数内容有点多截不全

大家喜欢hook的android_dlopen_ext就在这里




do_dlopen有点多,只截图部分

find_libary和si->call_constructors是重点。find_libary通过命名空间寻址、mmap 内存映射、递归加载依赖库以及 GOT/PLT 重定位,将磁盘上的 SO 文件完整转化为内存可执行状态并交付 soinfo 句柄。而call_constructors中会执行init_proc函数和init_array数组,如下图。


我喜欢hook call_constructors函数来获取刚刚加载的so文件,所以花篇幅写了下so文件的加载流程。再多一嘴,安卓11的call_function函数好像被内联优化了,导出符号中找不到call_function。如果想要hook安卓11的call_function,可以直接用偏移。下面是用ida打开的linker64中的call_constructors函数,可以对比一下。
框起来的部分应该就是call_function函数,v9应该就是上图的function,也就是要执行的函数。call_array函数中也是一样的。

linker中的get_realpath函数可以获取到so文件的名称。

所以获取so文件加载的frida脚本如下:

打印内容如下:

肯定不是系统库进行的反调试,所以优先找最后一个加载的so文件,即libmsaoaidsec.so。
然后要定位到反调试的位置,我已JNI_Onload为起始来找,如果JNI_Onload能成功执行完毕,说明反调试的位置只能在init_proc或者init_array。
代码如下:

可以看到毛都没打印,所以一定在JNI_Onload之前。

那么我尝试hook下init_proc. 把上面代码的JNI_onload的偏移改为init_proc的偏移0x1360c

可以看到只有进入,没有离开。那么肯定就是在init_proc里面做的反调试。init_proc部分截图如下,可以看到有控制流平坦化

不过不要紧,我们把真实块的起始地址hook一下,看看执行顺序,也可以看到是执行到哪里突然崩溃的。
真实块其实就是代码有用的部分,不需要看控制流图,肉眼扫一下就可以看出来哪些代码是有用的。如下。


代码如下:

可以看到执行流程如下,执行到0x137f4就退出了。我们从下往上看。

先分析0x137f4处

有两个函数调用,hook地址0x137f4和0x137f8,结果就是只走到了0x137f4,没有执行到0x137f8进程就退出了



所以现在要进入到函数0x12c28去看

还是和上面一样的方法,收集真实块,然后看看最后执行到哪里


去看0x12d38处


只调用了函数0x12dc8,去看0x12dc8,有点长就不截图了,可以知道的是虽然有混淆,但很容易就可以看出并没有什么检测。
我们回到上面分析init_proc处

0x137f4这个块就分析完了,没有进行frida检测,于是看上一个,即看0x13974


进入0x1a8a0内部

进入0x1a5b0,太长,截图截不全,直接贴代码了
__int64 __fastcall sub_1A5B0(__int64 a1)
{
__int64 result; // x0
int v2; // w9
int v3; // w8
__int64 v4; // x0
int v5; // [xsp+Ch] [xbp-74h]
int v6; // [xsp+10h] [xbp-70h]
char v7; // [xsp+16h] [xbp-6Ah]
bool v8; // [xsp+17h] [xbp-69h]
_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
result = sub_C0A4(a1);
v2 = 1890888035;
v5 = result;
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
while ( 1 )
{
v3 = v2;
if ( v2 <= 672937260 )
break;
if ( v2 <= 1421965345 )
{
if ( v2 == 672937261 )
{
result = sub_C024();
v8 = (_DWORD)result == 167;
v2 = -1849752058;
}
else if ( v2 == 928581447 )
{
result = pthread_create(&qword_45650, 0LL, (void *(*)(void *))sub_1A574, 0LL);
v2 = 932419925;
}
else
{
v2 = 672937261;
}
}
else if ( v2 > 1525727782 )
{
if ( v2 == 1525727783 )
{
if ( v7 )
v2 = -1962676898;
else
v2 = 119870274;
}
else if ( v5 == 248 )
{
v2 = 1421965346;
}
else
{
v2 = -1962676898;
}
}
else if ( v2 == 1421965346 )
{
v4 = sub_16B00();
result = sub_1229C(v4);
v7 = result & 1;
v2 = 1525727783;
}
else
{
result = pthread_create(qword_45658, 0LL, (void *(*)(void *))sub_18C88, 0LL);
v2 = -562034154;
}
}
if ( v2 <= -1169886676 )
break;
if ( v2 == -1169886675 )
{
result = sub_1A234();
v2 = 672937261;
}
else
{
v2 = -1826849722;
if ( v3 != -562034154 )
{
v2 = v3;
if ( v3 == 119870274 )
{
result = sub_1B88C();
v2 = -1962676898;
}
}
}
}
if ( v2 > -1826849723 )
break;
if ( v2 == -1962676898 )
{
result = sub_C0E4();
v6 = result;
v2 = -1668225964;
}
else if ( v8 )
{
v2 = 1475023738;
}
else
{
v2 = -1826849722;
}
}
if ( v2 != -1668225964 )
break;
if ( v6 == 249 )
v2 = 928581447;
else
v2 = -1169886675;
}
return result;
}
用同样的方法,看执行流


0x1a824处没做什么,于是看0x1a840

pthread_create是创建线程的函数,c定义为
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
第一个参数:这是一个指向线程标识符的指针,当线程创建成功后,系统会将新线程的 ID 写入这个指针指向的内存区域。你可以通过这个 ID 在后续调用 pthread_join(等待线程)或 pthread_detach(分离线程)时指定目标。
第二个参数:指向线程属性对象的指针。
第三个参数:这是新线程启动后立即执行的函数指针。该函数必须接收一个 void * 类型的参数,并返回一个 void * 类型的结果。当这个函数返回时,线程也就结束了。
第四个参数:传递给 start_routine 的唯一参数。
其实可以简单把pthread_create函数理解为
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg){
...
start_routine(arg);
...
}
所以重点是第三个参数,点进去看下

先看0x19cfc,代码有点长,不截图了,其实只做了一件事,读取文件并检查文件中有无特定字符串,其他的都是一些字符串解密的操作。关键代码如下:


关键在于fopen函数的第一个参数,用frida动态的看一下是什么


还有strstr函数的第二个参数:


结论:打开的文件是/proc/pid/status 。检测的字符串内容为"TracerPid:"
但他只是检测了,并没有让进程退出的操作,于是我们回到函数0x1a574

hook看一下0x19cfc的返回值是什么

返回值是0,看下面这个if判断,如果返回值为0的话,那么只会执行sub_1A3D0() == 777

接下来去看0x1a3d0函数,以下是该函数,并且我问ai给每行代码写了注释,总结就是遍历当前进程的所有线程(通过 /proc/[pid]/task/ 目录),读取每个线程的 stat 状态文件。如果发现任何一个线程的状态为 T 或 t(代表 Stopped 或 Traced,即被调试器挂起或追踪状态),则返回 777,以此判断自身正在被动态分析或调试。
__int64 sub_1A3D0()
{
unsigned int v0; // w0,存储当前进程的 PID
DIR *v1; // x0,指向打开的目录流的指针
DIR *v2; // x19,目录流指针的备份
struct dirent *v3; // x0,指向目录项(dirent)的结构体指针
unsigned __int8 v4; // w9,用于检查目录名字符
const char *d_name; // x21,当前读取到的目录名(线程ID)
char *v6; // x8,用于遍历目录名字符串的指针
int v7; // t1,临时存放读取的字符
unsigned int v8; // w0,再次获取的当前进程 PID
__int64 result; // x0,存储各种系统调用的返回值(如 open, read 的 fd 或长度)
int v10; // w21,存储打开的 stat 文件的文件描述符 (fd)
__int64 v11; // x24,用于遍历 stat 文件内容的索引(寻址下标)
int v12; // w8,读取 stat 文件中的单个字符
char v13[1024]; // [xsp+8h] [xbp-A48h] BYREF,用于存放从 stat 文件中读取的文本内容
char name[1024]; // [xsp+408h] [xbp-648h] BYREF,存放 "/proc/[pid]/task" 目录路径
char s[512]; // [xsp+808h] [xbp-248h] BYREF,存放 "/proc/[pid]/task/[tid]/stat" 文件路径
__int64 v16; // [xsp+A08h] [xbp-48h],Stack Canary(栈保护值),防止缓冲区溢出
// 1. 设置栈保护 (Stack Cookie/Canary)
v16 = *(_QWORD *)(_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2)) + 40);
// 2. 初始化缓冲区,清零
memset(s, 0, sizeof(s));
memset(name, 0, sizeof(name));
memset(v13, 0, sizeof(v13));
// 3. 获取当前进程的 PID
v0 = getpid();
// 格式化字符串,生成当前进程的 task 目录路径,例如 "/proc/1234/task"
// 该目录下包含了当前进程所有的线程 ID (TID)
_sprintf_chk(name, 0LL, 1024LL, "/proc/%d/task", v0);
// 打开这个 task 目录
v1 = opendir(name);
if ( !v1 )
return 0xFFFFFFFFLL; // 如果打开失败(通常是没有权限或路径不存在),返回 -1
v2 = v1; // 备份 DIR 指针
// 4. 遍历 task 目录下的所有子目录(每个子目录代表一个线程)
v3 = readdir(v1); // 读取第一个目录项
if ( v3 )
{
while ( 1 ) // 开始遍历循环
{
d_name = v3->d_name; // 获取目录名
v4 = v3->d_name[0]; // 获取目录名的第一个字符
if ( v4 ) // 如果字符不为空
{
v6 = &v3->d_name[1]; // 指针指向第二个字符
// 5. 校验目录名是否为纯数字(过滤掉 "." 和 ".." 等非线程目录)
// (v4 - 48 > 9) 相当于判断字符是不是不在 '0' (48) 到 '9' (57) 之间
while ( (unsigned int)v4 - 48 > 9 )
{
v7 = (unsigned __int8)*v6++; // 读取下一个字符
v4 = v7;
if ( !v7 ) // 如果遇到字符串结尾 '\0' 都没有找到数字
goto LABEL_14; // 跳过这个目录项,读取下一个
}
// 如果目录名是数字(代表线程 ID),则拼接进入该线程的 stat 文件路径
v8 = getpid();
// 格式化生成: "/proc/[pid]/task/[tid]/stat"
_sprintf_chk(s, 0LL, 512LL, "/proc/%d/task/%s/stat", v8, d_name);
// 6. 打开当前线程的 stat 状态文件(O_RDONLY = 0)
result = open(s, 0);
if ( (_DWORD)result == -1 ) // 如果打开失败
return result; // 直接返回 -1 报错退出
v10 = result; // 保存文件描述符 fd
// 读取 stat 文件内容到 v13 缓冲区,最多读 1024 字节
result = _read_chk(result, v13, 1024LL, 1024LL);
if ( result == -1 ) // 如果读取失败
return result;
v11 = 0LL; // 初始化索引为 0
// 7. 解析 stat 文件的文本内容
// stat 文件的标准格式类似: "1234 (thread_name) S 123 123 ..."
// 第一个字段是 PID,第二个是括在括号里的线程名,第三个字段是状态字符。
// 这里通过寻找右括号 ')' 来定位线程名的结束位置。
do
v12 = (unsigned __int8)v13[v11++]; // 逐字节读取
while ( v12 != 41 ); // 41 是 ')' 的 ASCII 码
close(v10); // 读取并找到右括号后,关闭 stat 文件
// 8. 检查线程状态 (核心反调试逻辑)
// 此时 v11 刚好越过 ')',v13[v11] 是括号后面的空格
// v13[v11 + 1] 是状态字符 (如 'R', 'S', 'D', 'Z', 'T', 't')
// v13[v11 + 2] 是状态字符后面的空格
//
// 运算 `v13[v11 + 1] | 0x20` 是一个将大写字母转为小写字母的位运算技巧。
// 116 是 't' 的 ASCII 码。32 是空格 ' ' 的 ASCII 码。
// 所以这里是在严格判断:状态字符是不是 'T' 或 't',并且后面紧跟一个空格。
// 在 Linux 中,'T' 或 't' 代表 "Traced" (被追踪/调试) 或 "Stopped" (暂停)。
if ( ((unsigned __int8)v13[(unsigned int)(v11 + 1)] | 0x20) == 116 && v13[(unsigned int)(v11 + 2)] == 32 )
return 777LL; // 如果发现任何一个线程处于 Traced 状态,立刻返回 777,表示检测到调试器!
}
LABEL_14:
// 读取下一个目录项 (下一个线程)
v3 = readdir(v2);
if ( !v3 ) // 如果没有更多目录了,退出循环
goto LABEL_15;
}
}
else
{
LABEL_15:
// 所有线程都检查完毕,没有发现处于 'T'/'t' 状态的线程
closedir(v2); // 关闭 task 目录
return 0LL; // 返回 0,表示安全,没有被调试
}
}
hook看一下该函数的返回值,结果是0,if判断的三个条件全为假,结果就意味着不会进入0x114b0函数。在0x1a574里面只看到检测了,并没有退出进程的操作。所以还得回到0x1a5b0里面

刚才分析了0x1a840,没发现进程退出的操作,往上分析,0x1a7d4处没做什么,接下来就来到了0x1a720处

有一个函数跳转,进入函数0x1b88c

进入函数0x1aee4,代码太多不贴了,基本上都是在做字符串解密的操作,但发现有一处

看一下这四个函数,先看0x1a940,以下是ai写的注释,总结就是打开byte_45020,遍历该目录下的所有文件,逐行读取每个文件的第一行内容。如果第一行内容中包含byte_4504A或者byte_45056,就会直接调用 exit(0) 强行关闭当前进程。
DIR *sub_1A940()
{
DIR *result; // x0,用于存放 opendir 返回的目录流指针
DIR *v1; // x19,目录流指针的备份
struct dirent *v2; // x0,指向读取到的目录项结构体
struct dirent *v3; // x25,目录项结构体的备份
const char *d_name; // x25,当前遍历到的文件/目录名称
int v5; // w0,openat 返回的文件描述符 (fd)
unsigned int v6; // w25,保存文件描述符 (fd)
unsigned __int64 i; // x29,循环计数器,用于逐字节读取文件
char v8[4]; // [xsp+14h] [xbp-46Ch] BYREF,单字节读取缓冲区(虽然大小是4,但每次只读1个字节)
char haystack[512]; // [xsp+18h] [xbp-468h] BYREF,用于存放从文件中读取的第一行内容
char s[520]; // [xsp+218h] [xbp-268h] BYREF,用于拼接完整的文件路径
// 1. 读取系统寄存器,设置栈保护(Stack Canary),防止缓冲区溢出
_ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
// 2. 打开目标目录。byte_45020 是全局变量,存放目录路径(例如 "/proc/self/task" 或 "/proc/self/maps")
result = opendir(byte_45020);
if ( result ) // 如果目录打开成功
{
v1 = result; // 备份 DIR 指针
v2 = readdir(result); // 读取目录下的第一个条目
if ( v2 ) // 如果目录不为空
{
v3 = v2;
do // 开始遍历目录
{
memset(s, 0, 0x200u); // 清空路径拼接缓冲区 s (512字节)
d_name = v3->d_name; // 获取当前遍历到的文件或文件夹名称
// 3. 忽略当前目录 "." 和 父目录 ".."
if ( strcmp(d_name, ".") )
{
if ( strcmp(d_name, "..") )
{
// 4. 拼接完整的文件路径。byte_45030 是格式化字符串 (例如 "%s/status" 或 "%s/cmdline")
_snprintf_chk(s, 512LL, 0LL, 512LL, byte_45030, d_name);
// 5. 打开拼接好的文件路径
// -100 代表 AT_FDCWD (当前工作目录),这里结合绝对路径使用
// 0x80000 通常代表 O_CLOEXEC 或 O_NOFOLLOW 等特定标志位,0LL是mode
v5 = openat(-100, s, 0x80000, 0LL);
if ( v5 ) // 如果文件打开成功 (获取到有效的文件描述符 fd)
{
v6 = v5; // 备份文件描述符
memset(haystack, 0, sizeof(haystack)); // 清空用于保存文件内容的缓冲区
// 6. 逐字节读取文件内容,最多读取 511 字节 (0x1FF)
for ( i = 0LL; i < 0x1FF; ++i )
{
// 每次只读取 1 个字节到 v8 中
if ( _read_chk(v6, v8, 1LL, 1LL) != 1 )
break; // 如果读取失败或到达文件末尾(EOF),跳出读取循环
// 10 是换行符 '\n' 的 ASCII 码
// 这说明它只想读取文件的【第一行】内容
if ( v8[0] == 10 )
break;
haystack[i] = v8[0]; // 将读取到的字节追加到 haystack 缓冲区中
}
// 7. 核心检测逻辑:字符串匹配
// byte_4504A 和 byte_45056 是全局特征字符串 (极有可能是 "frida", "xposed", "magisk" 等)
// 检查读取到的第一行内容中,是否包含这些被拉黑的敏感词
if ( strstr(haystack, byte_4504A) || strstr(haystack, byte_45056) )
{
// 如果发现了敏感词,说明存在注入或调试环境
exit(0); // 直接强制退出当前进程 (闪退)
}
close(v6); // 检查完毕,关闭当前文件
}
}
}
// 读取目录中的下一个条目
v3 = readdir(v1);
}
while ( v3 ); // 循环直到遍历完目录中的所有文件
}
// 遍历结束后关闭目录,并强制转换返回值
return (DIR *)closedir(v1);
}
// 如果一开始目录就打开失败,直接返回 0 (NULL)
return result;
}
所以要看一下byte_45020,byte_4504A,byte_45056,haystack分别是什么



可以看到的是成功被检测了,于是进程退出。
再看0x1aaec
所以要知道byte_4505C,和strstr的两个参数。对了,由于上一个函数进行检测并退出了,所以代码不会走到现在这里,要想办法让代码走到现在这里,我直接把上一个函数exit(0)处替换成空函数了。

日志如下:

可以看到并没有检测成功,且没走到exit(0处)。
再来看下一个函数,即sub_1AC00,直接定位到exit(0)处,hook一下发现没走到这里


于是注意到他在else里面

那就hook一下if ( v20 == &v18 )里面,看看是不是走到了if ( v20 == &v18 )里面

好吧,既然没走到,就看下一个函数了。

可以看到该函数中没看到有啥检测,猜测在sub_232C4进行的检测。先不去看,先看看这个动态内存分配是啥。hook v5(0LL)处


妙啊,他不仅用了svc #0,还用了动态内存分配。就算想动态扫描内存段中的svc指令,他恰好也就退出了。

有少侠可能不知道svc #0,我贴一下ai讲的。


系统调用号网址:c53K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6U0K9s2u0G2L8h3W2#2L8g2)9J5k6h3N6G2L8$3N6D9k6i4y4G2N6i4u0U0k6g2)9J5k6h3y4G2L8g2)9J5c8X3y4Z5M7X3!0E0K9i4g2E0L8%4y4Q4x3V1k6V1L8$3y4K6i4K6u0r3i4K6u0n7i4K6u0r3L8h3q4K6N6r3g2J5i4K6u0r3j5$3!0F1M7%4c8S2L8Y4c8K6i4K6u0r3M7%4W2K6j5$3q4D9L8s2y4Q4x3X3g2E0k6l9`.`.
简单来说就是,比如我想执行exit_group系统调用让进程退出,那么我可以直接
mov x8, #0x5e
svc #0
exit函数底层就是exit_group系统调用。
接下来去看sub_232C4,有点多,首先上来根据v1值的不同,他走了三个不同的分支


hook看一下,我的v1是0x1e,即十进制30,然后我想到我的系统是安卓11,api 即 30,那v1可能就是系统 API 级别。

那我直接去看if ( v1 >= 26 )分支了

最终会走到箭头处,然后跳到LABEL_36去执行,如果sub_17CD8的返回值右移1位等于0x2C000028(十进制为738197544)的话,那么就返回真,如果返回真,那么外层函数就会走到sv #0处退出进程。

hook一下sub_17CD8,看看入参和返回值

打印的有点看不懂,进入sub_17CD8看看,断点

接下来分析一下a1+328是从哪来的,发现是参数,那就往外找,可以看到是sub_17BC4的返回值

进入sub_17BC4看看,发现他new了v2,最后返回的也是v2,可以hook验证一下这里的v2+328也就是上面的a1+328,于是我们去找v1[1],看伪代码看不出来

看v1 = (char **)ptr[0];对应的汇编代码,x20是v1,那么x20+8就是v1[1]了,x20来自于[SP,#0x30+ptr],且注意到sp给到x8,然后调用了一个函数。

在 ARM64 的函数调用约定中,参数通常通过 X0 到 X7 传递。而 X8 是一个非常特殊的寄存器,被称为 间接结果位置寄存器 。当一个函数需要返回一个大于 16 字节的结构体或对象时,它无法通过 X0 返回。此时,调用者会提前在自己的栈上分配好一块内存(这里就是 SP 指向的 ptr 数组),然后把这块内存的地址放在 X8 里传过去。
于是我们去看sub_179AC,伪代码看着太乱了,直接看汇编吧,框起来的是关键指令,x8给到x19, x0给到x19寄存器中的地址, x0给到栈顶, x8 x19给到x0寄存器中的地址,此时,x0中存放的是一个地址, 该地址处存放的是"libart.so"和一个对象(记为fd),再调用sub_17868

去看sub_17868

hook看看a1+24偏移处是哪个函数

去看0x17a4c,明了了,辛辛苦苦找的a1+328就是libart.so在内存中的的起始地址

然后回到sub_17CD8,有了a1+328=libart.so在内存中的的起始地址和sub_16D80的入参和出参。其实大致就可以猜出sub_16D80就相当于dlsym。(分析这个函数要对so文件的格式很了解,我目前火候还不够,抱歉)

而sub_17CD8的功能就是返回入参a2在内存的绝对地址,即libart.so中的PrettyMethod在内存的绝对地址。
可以hook验证一下,确实是这样。

然后在看到这里

*v0 >> 1 == 738197544相当于*v0==738197544<<1,即*v0==0x58000050,对应的汇编指令为LDR X16, [PC, #8]。
所以只要用frida或者其他inline hook,那么PrettyMethod函数前就会多出这么一个特征。就可以被检测到。
至于绕过,hook pthread_create然后把检测线程nop掉就可以,本篇主要以分析为主。
得到12.18.0
完结。
参考文章:https://bbs.kanxue.com/thread-268586.htm
https://bbs.kanxue.com/thread-289545-7.htm
https://bbs.kanxue.com/thread-277034.htm
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2天前
被伊苏尔德编辑
,原因: