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

整体检测面可以概括为:
该库不是单一反调试函数,而是一组组合式检测。几个明显特征如下。
字符串解密的核心模式可以抽象为。不同函数的局部 key 表达可能存在差异,但整体都是“密文栈上构造 -> 循环 XOR -> 运行时得到明文”的模式:
解密出的关键字符串包括 /proc/self/task、/proc/self/maps、gum-js-loop、gmain、frida-agent、sys.usb.config、META-INF/CERT.RSA、META-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 进程启动时,argv、envp、auxv 等数据会放在初始栈上。即使后续通过 unsetenv() 或修改 environ 隐藏环境变量,原始字符串仍可能残留在栈映射中。
从常规防护直觉看,检测到 Magisk/root 痕迹后应该更严格。但这里的代码行为是:MAGISKTMP 命中后 sub_23B18() 返回 1,再由 .init_proc 跳过 sub_1BEC4、sub_13728、sub_23AD4、sub_9150 等后续初始化。
更合理的解释是:这不是最终风控决策,而是“反检测检测”或高风险环境标记。在 Magisk/Zygisk 环境下,maps、linker、ART、线程和属性读取结果都可能被改写,继续启动大量本地检测线程既容易暴露检测逻辑,也可能产生不稳定结果。因此样本选择在本地 native 层短路后续检测,把这个高风险状态交给其他链路处理,例如 Java 层、服务端风控、功能降级或静默打标。
线程调度主线从 .init_proc 开始。初始化函数会读取 /proc/<pid>/cmdline,只有当进程名不包含 : 时才进入 sub_1BEC4()。在 Android 应用中,com.xxx:push、com.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,通过 lstat 和 readlink 读取文件描述符指向的真实路径。它重点搜索:
如果 fd 链接中出现该关键字,说明当前进程可能存在注入器相关文件或匿名映射痕迹,函数会直接触发退出。
sub_1C26C() 的检测分为两层:第一层扫描 /proc/self/maps,筛选可疑映射路径;第二层打开这些映射文件,解析文件内容并搜索 Frida agent 特征。这样即使 agent 文件名被修改,只要文件内部仍保留 _AGENT_1.0 或 frida-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 内容,找到线程名右括号 ) 后面的状态字段。如果状态字符为 t 或 T,返回 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:push、com.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.so 和 pthread_create,再通过 dlopen / dlsym 动态解析线程创建函数。
创建条件为:
sub_8CAC() 的核心逻辑可以概括为:
这条线程和 sub_13728() 的区别在于:
这说明该库同时关注 加载时完整性 和 运行时完整性。即使某些 patch 在初始化后才写入内存,也可能被 sub_8CAC() 后续轮询发现。
该库存在多种退出路径,但最终目标基本一致:让整个进程退出。
动态 syscall stub 的共同结构为:
核心指令语义为:
在 ARM64 Linux/Android 上,系统调用号 94 对应 exit_group。当 x0 为 0 时,实际效果是:
这种写法的意义在于:退出调用不完全依赖导入表符号,也不会在所有路径上直接出现 _exit 或 exit_group 明文调用。对静态分析而言,需要把这些动态 stub 也纳入退出点统计。
下表按检测对象汇总主要函数和命中结果。
从检测覆盖面看,libmsaoaidsec.so 同时检查:
libmsaoaidsec.so 的检测体系可以理解为一套分层 Native 防护:
| 主线 |
入口链路 |
核心目标 |
代表函数 |
| 线程调度主线 |
.init_proc -> sub_1BEC4 -> sub_1B924 |
创建周期性检测线程,检查 Frida、调试器、ADB/USB 环境 |
sub_1C544、sub_1B8D4、sub_19E0C |
| APK/签名校验主线 |
.init_proc -> sub_13728 -> sub_2701C -> sub_26E5C -> sub_1678C |
检查 APK 路径、签名文件和签名公钥 CRC 白名单 |
sub_26E5C、sub_1678C、sub_12050 |
| 代码 CRC 主线 |
.init_proc -> sub_13728,.init_proc -> sub_9150 -> sub_8CAC |
检查内存中的 Native 代码片段是否被 patch/hook |
sub_13728、sub_8CAC、sub_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.0 或 frida-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内核攻防全技术栈,打造具备自动化能力的内核开发高手。