首页
社区
课程
招聘
[原创]libmsaoaidsec.so 检测体系分析
发表于: 9小时前 339

[原创]libmsaoaidsec.so 检测体系分析

9小时前
339

libmsaoaidsec.so 是一个 Android Native 层共享库,文件格式为 ELF64 AArch64。从初始化入口、线程创建逻辑和大量 /proc、系统属性、APK/ZIP 访问行为来看,该库主要承担运行环境检测、反调试、反注入、签名校验和代码完整性校验等职责。

本文以 IDA 静态分析结果为基础,将该库的检测逻辑整理为三条主线:

图片描述

整体检测面可以概括为:

该库不是单一反调试函数,而是一组组合式检测。几个明显特征如下。

字符串解密的核心模式可以抽象为。不同函数的局部 key 表达可能存在差异,但整体都是“密文栈上构造 -> 循环 XOR -> 运行时得到明文”的模式:

解密出的关键字符串包括 /proc/self/task/proc/self/mapsgum-js-loopgmainfrida-agentsys.usb.configMETA-INF/CERT.RSAMETA-INF/ANDROID.RSA 等。这些字符串基本覆盖了后文的检测逻辑。

在进入线程调度、APK 签名校验和代码 CRC 之前,.init_proc 会先调用 sub_25A48()。该函数受全局开关 dword_48850 控制,当 sub_CC64() 返回 218 时才进入 sub_23B18()

.init_proc 对返回值的使用方式比较关键:

也就是说,sub_23B18() 返回 1 时并不是直接 exit_group(0),而是让 .init_proc 跳过后续 native 检测初始化。这一点决定了它更像“反检测检测/风险打标/降级入口”,而不是最终风控拦截点。

sub_23B18() 运行时解密出三个关键字符串:

函数语义可以概括为:

它没有直接调用 getenv("MAGISKTMP"),而是通过 /proc/self/maps 找到当前栈映射,再扫描栈顶附近的原始字符串区域。Android/Linux 进程启动时,argvenvpauxv 等数据会放在初始栈上。即使后续通过 unsetenv() 或修改 environ 隐藏环境变量,原始字符串仍可能残留在栈映射中。

从常规防护直觉看,检测到 Magisk/root 痕迹后应该更严格。但这里的代码行为是:MAGISKTMP 命中后 sub_23B18() 返回 1,再由 .init_proc 跳过 sub_1BEC4sub_13728sub_23AD4sub_9150 等后续初始化。

更合理的解释是:这不是最终风控决策,而是“反检测检测”或高风险环境标记。在 Magisk/Zygisk 环境下,maps、linker、ART、线程和属性读取结果都可能被改写,继续启动大量本地检测线程既容易暴露检测逻辑,也可能产生不稳定结果。因此样本选择在本地 native 层短路后续检测,把这个高风险状态交给其他链路处理,例如 Java 层、服务端风控、功能降级或静默打标。

线程调度主线从 .init_proc 开始。初始化函数会读取 /proc/<pid>/cmdline,只有当进程名不包含 : 时才进入 sub_1BEC4()。在 Android 应用中,com.xxx:pushcom.xxx:remote 这类进程名通常表示子进程或带后缀进程。该库选择在主进程中创建主要检测线程,避免在多进程场景下重复执行重检测逻辑。

部分恢复伪代码:

sub_1BEC4() 的语义很简洁:

这个全局 pid 后续会被 sub_1B8D4 使用,尤其是在 sub_1AB54() 中用于判断 TracerPid 的父进程是否为主进程。换句话说,它不仅是记录当前进程 ID,还参与了后续的 self-debugger 白名单判断

部分恢复伪代码:

sub_1B924() 是一个经过控制流扁平化处理的调度函数。剥离状态机后,它的核心工作是:

关键线程创建条件如下:

这种设计说明检测功能并非全部固定开启,而是受全局配置值控制。全局配置可能由静态数据、初始化逻辑或上层参数共同决定。

sub_1C544()sub_1CEF8() 创建。sub_1CEF8() 会解析 libart.so 中与 PrettyMethod 相关的函数入口,并将该入口地址作为参数传给 sub_1C544()。因此 sub_1C544() 不只检查进程环境,也会检查 ART 函数入口是否出现可疑 prologue。

该线程的主循环可以概括为:

部分恢复伪代码:

sub_1BFAC() 遍历 /proc/self/task,逐个读取线程状态文件:

它重点搜索线程名中是否出现:

命中后会调用 exit(0) 退出进程。这里的检测思路非常直接:Frida 注入后通常会带来较稳定的线程名特征,因此扫描 /proc/self/task 可以在无需 ptrace 的情况下发现运行时注入痕迹。

部分恢复伪代码:

sub_1C158() 遍历 /proc/self/fd,通过 lstatreadlink 读取文件描述符指向的真实路径。它重点搜索:

如果 fd 链接中出现该关键字,说明当前进程可能存在注入器相关文件或匿名映射痕迹,函数会直接触发退出。

sub_1C26C() 的检测分为两层:第一层扫描 /proc/self/maps,筛选可疑映射路径;第二层打开这些映射文件,解析文件内容并搜索 Frida agent 特征。这样即使 agent 文件名被修改,只要文件内部仍保留 _AGENT_1.0frida-agent 等特征,也可能被检测到。

部分恢复伪代码:

sub_1C26C() 打开 /proc/self/maps,关注可执行映射及可疑路径。重点字符串包括:

这段循环中的几个辅助函数可以按下表理解:

因此,该函数不仅简单搜索 maps 字符串,还会结合映射文件内容检查 agent 特征。其目标是发现放在 /data/local/tmp 等位置的注入组件,尤其是 Frida agent 一类动态加载对象。

部分恢复伪代码:

sub_26334(a1) 检查传入地址处的 4 字节指令。如果入口指令为以下值之一,会触发动态 exit_group(0)

这类指令常见于 AArch64 上的字面量加载跳板形态,可用于识别某些 inline hook 或 trampoline。结合 sub_1CEF8() 传入的 ART 函数入口来看,该检测用于判断 libart 中关键函数是否被改写。

sub_1B8D4() 是一个周期性反调试线程。默认轮询间隔约为 2000000 us,即 2 秒;如果 sub_CB28() 返回值大于等于 100,则使用该返回值作为轮询间隔。

部分恢复伪代码:

部分恢复伪代码:

sub_1AE48() 打开:

逐行搜索:

返回语义如下:

TracerPid 是 Linux/Android 反调试中最常见的检测点之一。调试器、部分注入器或 ptrace 型保护工具都会改变该字段。

部分恢复伪代码:

sub_1AB54(tracer_pid) 会继续读取:

并搜索:

随后将 tracer 进程的父进程 pid 与 sub_1BEC4() 保存的主进程 pid 比较:

这个逻辑不是简单地禁止所有 TracerPid != 0,而是保留了 自调试保护 的可能性。若 tracer 是由当前主进程派生出来的子进程,函数会返回通过;否则认为调试来源异常。

部分恢复伪代码:

sub_1B730() 遍历:

读取每个线程的 stat 内容,找到线程名右括号 ) 后面的状态字段。如果状态字符为 tT,返回 777

在 Linux /proc/<pid>/task/<tid>/stat 中,T/t 常用于 stopped、tracing stop 等状态。该检测可用于发现线程被调试器暂停、单步或 trace 造成的异常状态。

部分恢复伪代码:

sub_19E0C()sub_1B924() 创建,创建条件为:

线程每 3 秒读取一次系统属性:

若属性值中包含 adb,例如:

则继续通过 JNI 判断设备是否处于插电/连接状态。

辅助函数关系如下:

sub_19A58() 的 Java 层语义可以概括为:

plugged != 0 时,通常表示设备正在通过 AC、USB 或无线方式供电。结合 sys.usb.config 中包含 adb,该线程试图识别 开启 ADB 且设备正处于连接/供电状态的调试环境

APK/签名校验主线不是从 sub_1B924() 创建,而是从 sub_13728() 进入 sub_2701C(),再创建 sub_26E5C()。这条链路更偏向一次性完整性校验,而不是长期轮询。

sub_13728() 中会调用 sub_2701C()sub_2701C() 与前面的主线程调度逻辑类似,也会读取:

如果 cmdline 中包含冒号 :,说明当前可能是 com.xxx:pushcom.xxx:remote 一类子进程,此时跳过 sub_26E5C() 创建。如果没有冒号,则动态解析 pthread_create 并创建:

这样做的目的很明确:APK 路径和签名校验只在主进程执行,避免多进程环境中重复打开 APK、解析 ZIP 和读取签名文件。

sub_26E5C() 不是死循环,而是后台执行的一次性校验任务。其核心判断为:

其中:

触发退出需要同时满足三个条件:

这说明 sub_C930() 不只是白名单数量,也承担了签名校验开关的作用。当白名单数量为 0 时,该线程不会触发签名失败退出。

sub_1678C() 依赖 sub_12050() 获取当前应用主包名。sub_12050() 的语义如下:

例如:

这个处理非常关键。APK 安装目录通常基于主包名,而 Android 子进程名会带冒号后缀。如果不截断,后续拼接 /data/app/<package>-N/base.apk 时会得到错误路径。

sub_1678C() 是 APK/签名校验链的核心函数。它并不是简单检查 APK 文件是否存在,而是执行一套较完整的签名白名单验证:

该函数先尝试旧版和常见安装路径:

如果这些路径找不到,并且 SDK 版本大于等于 26,则扫描 /proc/self/maps 兜底。扫描时关注以下信息:

这种兜底方式可以适配 Android 新版安装目录、split APK 和不同设备上的路径差异。

APK 打开后,函数关注 v1 签名目录:

常见目标 entry 包括:

读取签名文件后,函数将每个字节格式化为:

也就是带空格分隔的十六进制文本。随后搜索 RSA 公钥 DER 结构头:

命中后执行:

这里的 match_ptr 指向十六进制文本中的 DER 头位置,而不是原始二进制签名字节。因此 CRC 计算对象是 签名内容的十六进制文本片段,长度固定为 300 字节。

白名单由两个函数提供:

比较逻辑为:

因此,sub_1678C() 的本质不是验证整个 APK 哈希,也不是验证整个证书链,而是验证 签名文件中 RSA 公钥附近固定片段的 CRC32 是否命中内置白名单。这是一种轻量级签名绑定方式:只要重打包后签名公钥发生变化,CRC 大概率会变化,从而触发失败。

代码 CRC 主线用于发现 Native 代码被静态 patch、运行时 inline hook 或内存改写。该主线包含两个层次:

sub_16720(buf, len) 是标准 CRC32/IEEE 的查表实现。反编译语义如下:

它与常见逐 bit 写法看起来不同,是因为这里使用了 预计算 CRC 表 dword_2FAEC。两者本质一致:

区别在于:

dword_2FAEC 对应的就是由多项式 0xEDB88320 推导出的查表结果。因此,sub_16720() 可以视为标准 CRC32,而不是自定义哈希。

sub_13728().init_proc 调用,是初始化期完整性链的核心函数之一。其主要动作包括:

初始化期 CRC 的核心条件为:

结合当前样本中的常量,可整理为:

关于 LOAD:0000000000048840 dword_48840 DCD 0x26A04 中的 0x26A04,它不是 CRC32 算法要求的固定长度,也不是某种通用 magic number。更合理的解释是:该值是样本作者为当前版本选定的代码保护区间长度

从边界看:

该范围覆盖了库内主要可执行代码区域的一大段,并在进入大量只读数据、字符串或其他不适合校验的区域前结束。这样做有几个目的:

因此,0x26A04 的原因应理解为 代码完整性保护配置的一部分,不是标准 CRC32 的特性。

sub_9150() 负责创建周期性 CRC 线程。它同样会解密 libc.sopthread_create,再通过 dlopen / dlsym 动态解析线程创建函数。

创建条件为:

sub_8CAC() 的核心逻辑可以概括为:

这条线程和 sub_13728() 的区别在于:

这说明该库同时关注 加载时完整性运行时完整性。即使某些 patch 在初始化后才写入内存,也可能被 sub_8CAC() 后续轮询发现。

该库存在多种退出路径,但最终目标基本一致:让整个进程退出

动态 syscall stub 的共同结构为:

核心指令语义为:

在 ARM64 Linux/Android 上,系统调用号 94 对应 exit_group。当 x0 为 0 时,实际效果是:

这种写法的意义在于:退出调用不完全依赖导入表符号,也不会在所有路径上直接出现 _exitexit_group 明文调用。对静态分析而言,需要把这些动态 stub 也纳入退出点统计。

下表按检测对象汇总主要函数和命中结果。

从检测覆盖面看,libmsaoaidsec.so 同时检查:

libmsaoaidsec.so 的检测体系可以理解为一套分层 Native 防护:

主线 入口链路 核心目标 代表函数
线程调度主线 .init_proc -> sub_1BEC4 -> sub_1B924 创建周期性检测线程,检查 Frida、调试器、ADB/USB 环境 sub_1C544sub_1B8D4sub_19E0C
APK/签名校验主线 .init_proc -> sub_13728 -> sub_2701C -> sub_26E5C -> sub_1678C 检查 APK 路径、签名文件和签名公钥 CRC 白名单 sub_26E5Csub_1678Csub_12050
代码 CRC 主线 .init_proc -> sub_13728.init_proc -> sub_9150 -> sub_8CAC 检查内存中的 Native 代码片段是否被 patch/hook sub_13728sub_8CACsub_16720
特征 表现 分析意义
字符串运行时解密 大量字符串在栈上构造后进行循环 XOR 解密,常见模式为 3 字节循环 key 静态字符串搜索难以直接定位检测点
控制流扁平化 / BCF 多个函数被 IDA 标记为 The function seems has been flattened 伪代码中出现大量状态变量和跳转噪声,需要抽象语义
动态 API 解析 通过 dlopen("libc.so")dlsym("pthread_create") 创建线程 弱化导入表特征,增加静态追踪难度
动态生成退出代码 mmap RWX 内存,写入 AArch64 syscall stub,再执行 exit_group(0) 避免所有退出路径都直接暴露为 _exit / exit_group 符号
多线程轮询检测 Frida、反调试、ADB/USB、代码 CRC 均有独立线程或初始化旁路 绕过某一个检测点并不等于绕过整套保护
签名白名单校验 从 APK v1 签名文件中提取 RSA 公钥片段并计算 CRC32 检查当前安装包签名是否符合内置白名单
早期反检测检测 sub_23B18 在初始栈附近搜索 MAGISKTMP 命中后使 .init_proc 后续 native 检测初始化短路,更像风险打标/降级入口
解密结果 用途
/proc/self/maps 扫描当前进程内存映射
%lx-%lx %s %lx %s %ld %s 解析 maps 行
MAGISKTMP 检测 Magisk 临时环境变量残留
条件 创建的线程 线程职责
sub_CAA8() == 248 且设备型号不是 Firefly-RK3399 sub_1C544 Frida/注入痕迹检测
sub_CAE8() == 249 sub_1B8D4 反调试、TracerPid、线程状态检测
sub_CA28() == 167 sub_19E0C ADB/USB 调试环境检测
明文字符串 含义
gum-js-loop Frida Gum JavaScript runtime 常见线程名
gmain GLib main loop 线程名,Frida 环境中常见
字符串 检测意义
/data/local/tmp Android 调试、注入工具常见落点
_AGENT_1.0 agent 标识
frida-agent Frida agent 典型关键词
函数/变量 语义化理解 说明
operator new(0x158) 创建文件解析上下文 0x158 字节对象用于保存 fd、映射地址、ELF 解析状态等字段
sub_18774(parser, filename) 打开或映射目标文件 目标文件来自 maps 中筛出的可疑路径
sub_18240(parser + 3, *parser) 初始化 ELF/文件内容解析结构 成功后才继续搜索 agent 特征
sub_1806C(parser + 3, AGENT_1_0, frida_agent) 搜索 Frida agent 特征字符串 命中 _AGENT_1.0frida-agent 后返回成功
sub_18F0C(parser) 清理解析上下文 释放 fd、mmap、缓冲区等内部资源
sub_27B04(current_map_node) 取下一个 maps 节点 类似链表/容器迭代器的 next 操作
返回值 含义
0 当前进程未被 ptrace
> 0 当前进程正在被指定 pid ptrace
-1 status 文件打开失败,视为异常
判断 含义
PPid == 主进程 pid 可能是应用自身 fork 出来的 self-debugger,允许
PPid != 主进程 pid 更可能是外部调试器或注入器,退出
函数 作用
sub_17C8C() 动态解析 JNI_GetCreatedJavaVMs,获取当前可用的 JNIEnv *
sub_19A58(JNIEnv *) 通过 Android Framework API 查询 BATTERY_CHANGED sticky broadcast
函数 含义
sub_C930() 返回全局白名单数量 dword_48130
sub_1678C() 执行 APK 路径定位、签名文件读取、RSA 公钥 CRC 白名单校验
sub_269AC(0) 动态生成 syscall stub 并执行 exit_group(0)
原始 cmdline sub_12050() 结果
com.xingin.xhs com.xingin.xhs
com.xingin.xhs:push com.xingin.xhs
com.xingin.xhs:remote com.xingin.xhs
字符串 用途
/data/app/ APK 安装路径前缀
当前包名 确认映射路径属于当前应用
libmsaoaidsec.so 从当前 so 映射路径反推 APK 所在目录
/base.apk 主 APK
/split_config.arm64_v8a.apk arm64 split APK
/lib 从 native lib 路径回退到 APK 路径时使用
DER 头十六进制文本 对应含义
30 81 89 02 81 81 00 常见 1024-bit RSA 公钥结构头
30 82 01 0a 02 82 01 01 00 常见 2048-bit RSA 公钥结构头
30 82 02 0a 02 82 02 01 00 常见 4096-bit RSA 公钥结构头
函数 作用
sub_C930() 返回白名单数量
sub_C970() 返回白名单 CRC 数组地址
返回值语义 含义 sub_26E5C 中的结果
bit0 为 1 签名 CRC 命中白名单,或低版本兼容分支放行 不退出
bit0 为 0 APK 未找到、签名文件未找到、DER 头未找到、CRC 不在白名单等 满足开关条件时 exit_group(0)

[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 8
支持
分享
最新回复 (3)
雪    币: 880
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
tql
6小时前
0
雪    币: 2
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3
11
6小时前
0
雪    币: 185
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
4
666
6小时前
0
游客
登录 | 注册 方可回帖
返回