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

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

  1. 线程调度主线.init_proc -> sub_1BEC4 -> sub_1B924,负责创建 sub_1C544sub_1B8D4sub_19E0C 等检测线程。
  2. APK/签名校验主线.init_proc -> sub_13728 -> sub_2701C -> sub_26E5C -> sub_1678C,负责定位 APK、读取签名文件并做白名单校验。
  3. 代码 CRC 主线.init_proc -> sub_13728 执行初始化期 CRC,.init_proc -> sub_9150 -> sub_8CAC 执行周期性 CRC。

总体调用关系:从 .init_proc 展开的三条主线

整体检测面可以概括为:

主线 入口链路 核心目标 代表函数
线程调度主线 .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 检测初始化短路,更像风险打标/降级入口

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

for (i = 0; i < len; i++) {
    buf[i] ^= key[i % 3];
}

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


早期反检测检测:sub_23B18MAGISKTMP

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

__int64 sub_25A48()
{
    int n218 = sub_CC64();      // 返回 dword_48850

    if (n218 == 218)
        return sub_23B18() & 1;

    return 0;
}

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

result = sub_25A48();

if ((result & 1) == 0) {
    sub_1BEC4();    // 线程调度与运行环境检测
    sub_13728();    // 初始化期 CRC 与 APK/签名校验链
    sub_23AD4();    // 模块信息初始化与辅助校验
    sub_9150();     // 周期性代码 CRC 线程
}

return result;

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

sub_23B18 的核心检测目标

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

解密结果 用途
/proc/self/maps 扫描当前进程内存映射
%lx-%lx %s %lx %s %ld %s 解析 maps 行
MAGISKTMP 检测 Magisk 临时环境变量残留

函数语义可以概括为:

bool sub_23B18_semantic()
{
    cmdline = read("/proc/<pid>/cmdline");

    if (strchr(cmdline, ':') != NULL)
        return false;                       // 只检测主进程

    maps = fopen("/proc/self/maps", "r");
    stack_range = find_mapping_containing_local_stack_addr(maps);

    if (!stack_range.found)
        return false;

    return scan_c_strings_near_stack_top(stack_range.end - 0x200,
                                         stack_range.end,
                                         "MAGISKTMP");
}

它没有直接调用 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 层、服务端风控、功能降级或静默打标。

sub_23B18 是受配置控制的 Magisk 环境痕迹探测函数。
命中 MAGISKTMP 后,它返回 1,使 .init_proc 跳过后续 native 检测初始化。
该行为更像反检测检测、兼容降级或风险打标入口,而非直接拦截入口。

主线一:线程调度与运行环境检测

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

sub_1BEC4:保存主进程 PID 并进入线程调度器

部分恢复伪代码:

void sub_1BEC4()
{
  ...
  main_process = getpid();                      // 主进程 PID
  sub_1B924();                                  // 线程调度与运行环境检测
}

sub_1BEC4() 的语义很简洁:

sub_1BEC4()
  ├─ pid = getpid()
  ├─ 保存到全局 pid
  └─ sub_1B924()

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

sub_1B924:主检测线程调度器

部分恢复伪代码:

void __fastcall sub_1B924()
{
  ...
  handle = dlopen(libc_so, RTLD_NOW);
  if ( handle )
  {
    pthread_create = dlsym(handle, pthread_create_str);
    if ( sub_CAA8() == 248 && !sub_12D9C() )
      sub_1CEF8(pthread_create);                // Frida/注入痕迹检测
    if ( sub_CAE8() == 249 )
      (pthread_create)(&unk_49650, 0LL, sub_1B8D4, 0LL);// 反调试、TracerPid、线程状态检测
    else
      sub_1B380(handle, pthread_create);
    if ( sub_CA28() == 167 )
      (pthread_create)(&unk_49658, 0LL, sub_19E0C, 0LL);// ADB/USB 调试环境检测
    dlclose(handle);
  }
}

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

  1. 使用循环 XOR 解密字符串 libc.sopthread_create
  2. 调用 dlopen("libc.so", RTLD_NOW)
  3. 调用 dlsym(handle, "pthread_create")
  4. 按全局配置开关决定是否创建检测线程。
__int64 __fastcall sub_CAA8()
{
  _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
  return dword_48810;
}
bool sub_12D9C()
{
  ...
  _system_property_get("ro.product.model", haystack);
  return strstr(haystack, "Firefly-RK3399") != 0LL;
}
__int64 __fastcall sub_CAE8()
{
  _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
  return dword_48814;
}
__int64 __fastcall sub_CA28()
{
  _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
  return dword_48394;
}

关键线程创建条件如下:

条件 创建的线程 线程职责
sub_CAA8() == 248 且设备型号不是 Firefly-RK3399 sub_1C544 Frida/注入痕迹检测
sub_CAE8() == 249 sub_1B8D4 反调试、TracerPid、线程状态检测
sub_CA28() == 167 sub_19E0C ADB/USB 调试环境检测

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

sub_1C544:Frida 与注入痕迹检测线程

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

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

while true:
    sub_1BFAC()       // 线程名检测
    sub_1C158()       // fd 链接检测
    sub_1C26C()       // maps / agent 检测
    sub_26334(arg)    // ART 入口指令检测
    sleep(4)
sub_1BFAC:线程名检测

部分恢复伪代码:

void sub_1BFAC()
{
  ...
  dir = opendir(proc_self_task);
  if ( dir )
  {
    dir_1 = dir;
    v2 = readdir(dir);
    if ( v2 )
    {
      v3 = v2;
      do                                        // 遍历所有线程
      {
        memset(&status, 0, 0x200uLL);
        d_name = v3->d_name;
        if ( strcmp(d_name, ".") )              // 过滤"."
        {
          if ( strcmp(d_name, "..") )           // 过滤".."
          {
            _snprintf_chk(
              &status,                          // 拼接得到实际/proc/self/task/%s/status
              512LL,
              0LL,
              512LL,
              proc_self_task_s_status,
              d_name);
            fd_1 = openat(-100, &status, O_CLOEXEC, 0LL);
            if ( fd_1 )
            {
              fd = fd_1;
              memset(name, 0, sizeof(name));
              for ( i = 0LL; i < 0x1FF; ++i )
              {
                if ( _read_chk(fd, s, 1LL, 1LL) != 1 )
                  break;
                if ( s[0] == '\n' )             // 只读一行
                  break;
                name[i] = s[0];                 // 读取的内容
              }
              if ( strstr(name, gum_js_loop) || strstr(name, gmain) )
                exit(0);                        // 命中 "gum-js-loop" / "gmain" 后退出
              close(fd);
            }
          }
        }
        v3 = readdir(dir_1);
      }
      while ( v3 );
    }
    closedir(dir_1);
  }
}

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

/proc/self/task/<tid>/status

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

明文字符串 含义
gum-js-loop Frida Gum JavaScript runtime 常见线程名
gmain GLib main loop 线程名,Frida 环境中常见

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

sub_1C158:fd 符号链接注入痕迹检测

部分恢复伪代码:

void sub_1C158()
{
  ...
  dir = opendir(proc_self_fd);
  if ( dir )
  {
    dir_1 = dir;
    v2 = readdir(dir);
    if ( v2 )
    {
      v3 = v2;
      do                                        // 遍历所有fd
      {
        memset(s, 0, sizeof(s));
        memset(s_1, 0, sizeof(s_1));
        _snprintf_chk(s_1, 512LL, 0LL, 512LL, "/proc/self/fd/%s", v3->d_name);
        lstat(s_1, &buf);
        if ( (buf.st_mode & 0xF000) == 0xA000 ) // 判断是否为符号链接
        {
          readlink(s_1, s, 0x200uLL);           // 获取符号链接真实路径
          if ( strstr(s, linjector) )
            exit(0);                            // 命中 linjector 后退出
        }
        v3 = readdir(dir_1);
      }
      while ( v3 );
    }
    closedir(dir_1);
  }
}

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

linjector

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

sub_1C26C:maps / agent 检测

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

部分恢复伪代码:

while (1) {
    parser = operator new(0x158);
    init_parser_fields_to_zero(parser);

    filename = current_map_node->path;

    if ((sub_18774(parser, filename) & 1) == 0 ||
        (sub_18240(parser + 3, *parser) & 1) == 0) {
        break;                                  // 打开或解析文件失败
    }

    if ((sub_1806C(parser + 3, AGENT_1_0, frida_agent) & 1) != 0) {
        exit(0);                                // 命中 "_AGENT_1.0" / "frida-agent"
    }

    sub_18F0C(parser);
    operator delete(parser);
    current_map_node = sub_27B04(current_map_node);
}

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

字符串 检测意义
/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 操作

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

sub_26334:ART 函数入口 prologue 检测

部分恢复伪代码:

void __fastcall sub_26334(_DWORD *a1)
{
  ...
  if ( a1 && (*a1 == 0x58000050 || *a1 == 0x58000051) )
    v7 = linux_eabi_syscall(__NR_exit_group, 0LL, v1, v2, v3, v4, v5, v6);
}

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

0x58000050
0x58000051

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

sub_1B8D4:反调试、父进程和线程状态检测

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

部分恢复伪代码:

void __noreturn sub_1B8D4()
{
  ...
  n0x64 = sub_CB28();
  if ( n0x64 >= 100 )
    sleep_time = n0x64;
  else
    sleep_time = 2000000;
  while ( 1 )
  {
    TracerPid = sub_1AE48();                    // 读取TracerPid
    if ( TracerPid == -1 || TracerPid && (sub_1AB54(TracerPid) & 1) == 0 || sub_1B730() == 777 )
      exit_group();
    usleep(sleep_time);
  }
}
sub_1AE48:读取 TracerPid

部分恢复伪代码:

__int64 sub_1AE48()
{
  ...
  _sprintf_chk(s, 0LL, 1024LL, proc_d_status, main_process);
  stream = fopen(s, "r");
  if ( stream )
  {
    ...
    while ( fgets(haystack, 1024, stream) )
    {
      v23 = strstr(haystack, &TracerPid);
      if ( v23 )
      {
        v24 = atoi(v23 + 10);
        if ( v24 )
        {
          v14 = v24;
          fclose(stream);
          return v14;
        }
        break;
      }
    }
    fclose(stream);
    return 0;
  }
  else
  {
    return -1;
  }
}

sub_1AE48() 打开:

/proc/<pid>/status

逐行搜索:

TracerPid:

返回语义如下:

返回值 含义
0 当前进程未被 ptrace
> 0 当前进程正在被指定 pid ptrace
-1 status 文件打开失败,视为异常

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

sub_1AB54:校验 TracerPidPPid

部分恢复伪代码:

int __fastcall sub_1AB54(unsigned int TracerPid)
{
  ...
  _sprintf_chk(s, 0LL, 1024LL, proc_d_status, TracerPid);
  stream_1 = fopen(s, "r");
  if ( stream_1 )
  {
    ...
      if ( !fgets(haystack, 1024, stream) )
        goto LABEL_22;
      __TracerPid_PPid = strstr(haystack, PPid);// 获取TracerPid的PPid
    }
    while ( !__TracerPid_PPid );
    if ( atoi(__TracerPid_PPid + 5) == main_process )
    {
      fclose(stream);
      LODWORD(stream_1) = 1;
      return stream_1;
    }
    fclose(stream);
    ...
  }
  return stream_1;
}

sub_1AB54(tracer_pid) 会继续读取:

/proc/<tracer_pid>/status

并搜索:

PPid:

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

判断 含义
PPid == 主进程 pid 可能是应用自身 fork 出来的 self-debugger,允许
PPid != 主进程 pid 更可能是外部调试器或注入器,退出

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

sub_1B730:线程 stat 状态检测

部分恢复伪代码:

__int64 sub_1B730()
{
  ...
  pid = getpid();
  _sprintf_chk(name, 0LL, 1024LL, "/proc/%d/task", pid);
  dirp = opendir(name);
  if ( !dirp )
    return 0xFFFFFFFFLL;
  dirp_1 = dirp;
  v3 = readdir(dirp);
  if ( v3 )
  {
    while ( 1 )
    {
      d_name = v3->d_name;
      v4 = v3->d_name[0];
      if ( v4 )
      {
        v6 = &v3->d_name[1];
        while ( v4 - 48 > 9 )
        {
          v7 = *v6++;
          v4 = v7;
          if ( !v7 )
            goto LABEL_14;
        }
        pid_1 = getpid();
        _sprintf_chk(s, 0LL, 512LL, "/proc/%d/task/%s/stat", pid_1, d_name);
        fd = open(s, O_RDONLY);
        if ( fd == -1 )
          return fd;
        fd_1 = fd;
        fd = _read_chk(fd, s_1, 1024LL, 1024LL);
        if ( fd == -1 )
          return fd;
        v11 = 0LL;
        do
          n41 = s_1[v11++];
        while ( n41 != ')' );
        close(fd_1);
        if ( (s_1[(v11 + 1)] | ' ') == 't' && s_1[(v11 + 2)] == ' ' )
          return 777LL;
      }
LABEL_14:
      v3 = readdir(dirp_1);
      if ( !v3 )
        goto LABEL_15;
    }
  }
  else
  {
LABEL_15:
    closedir(dirp_1);
    return 0LL;
  }
}

sub_1B730() 遍历:

/proc/<pid>/task/<tid>/stat

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

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

sub_19E0C:ADB/USB 调试环境检测

部分恢复伪代码:

void __noreturn sub_19E0C()
{
  ...

  while ( 1 )
  {
    memset(&v9[1], 0, 112);
    v9[0] = *"sys.usb.config";
    ...
    _system_property_get(v9, haystack);
    if ( strstr(haystack, "adb") )
    {
      v0 = sub_17C8C();                         // 获取当前可用的 JNIEnv *
      if ( sub_19A58(v0) )                      // 通过 Android Framework API 查询 BATTERY_CHANGED sticky broadcast
        exit_group_1();
    }
    sleep(3u);
  }
}

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

sub_CA28() == 167

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

__system_property_get("sys.usb.config", buf)

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

adb
mtp,adb
ptp,adb
rndis,adb

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

辅助函数关系如下:

函数 作用
sub_17C8C() 动态解析 JNI_GetCreatedJavaVMs,获取当前可用的 JNIEnv *
sub_19A58(JNIEnv *) 通过 Android Framework API 查询 BATTERY_CHANGED sticky broadcast

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

ActivityThread.currentActivityThread()
  -> getApplication()
  -> registerReceiver(null, IntentFilter("android.intent.action.BATTERY_CHANGED"))
  -> Intent.getIntExtra("plugged", -1)

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


主线二:APK 路径与签名白名单校验

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

sub_13728 -> sub_2701C:签名校验线程创建入口

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

/proc/<pid>/cmdline

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

pthread_create(&tid, 0, sub_26E5C, 0)

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

sub_26E5C:一次性 APK/签名完整性校验线程

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

n = sub_C930()

if (n < 13 && n != 0 && (sub_1678C() & 1) == 0) {
    sub_269AC(0)        // 最终执行 exit_group(0)
}

其中:

函数 含义
sub_C930() 返回全局白名单数量 dword_48130
sub_1678C() 执行 APK 路径定位、签名文件读取、RSA 公钥 CRC 白名单校验
sub_269AC(0) 动态生成 syscall stub 并执行 exit_group(0)

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

  1. sub_C930() 返回值不为 0
  2. sub_C930() 返回值小于 13
  3. sub_1678C() 返回值 bit0 为 0,表示签名校验未通过。

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

sub_12050:从 cmdline 提取主包名

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

读取 /proc/<pid>/cmdline
如果进程名包含 ":",截断冒号后的后缀
返回主包名

例如:

原始 cmdline sub_12050() 结果
com.xingin.xhs com.xingin.xhs
com.xingin.xhs:push com.xingin.xhs
com.xingin.xhs:remote com.xingin.xhs

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

sub_1678C:APK 定位、签名读取与 CRC 白名单比较

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

  1. 获取主包名。
  2. 尝试定位当前应用 APK。
  3. 打开 APK/ZIP 容器。
  4. 查找 v1 签名文件。
  5. 读取签名文件内容。
  6. 将签名文件字节转为十六进制文本。
  7. 在十六进制文本中定位 RSA DER 公钥结构头。
  8. 从命中位置开始取 300 字节计算 CRC32。
  9. 与内置 CRC 白名单比较。
APK 路径定位策略

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

/data/app/<package>-1.apk
/data/app/<package>-2.apk
...
/data/app/<package>-10.apk

/data/app/<package>-1/base.apk
/data/app/<package>-2/base.apk
...
/data/app/<package>-10/base.apk

/data/app/<package>-1
/data/app/<package>-2
...
/data/app/<package>-10

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

字符串 用途
/data/app/ APK 安装路径前缀
当前包名 确认映射路径属于当前应用
libmsaoaidsec.so 从当前 so 映射路径反推 APK 所在目录
/base.apk 主 APK
/split_config.arm64_v8a.apk arm64 split APK
/lib 从 native lib 路径回退到 APK 路径时使用

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

签名文件与 RSA 公钥定位

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

META-INF/

常见目标 entry 包括:

META-INF/CERT.RSA
META-INF/ANDROID.RSA

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

%02x 

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

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 公钥结构头

命中后执行:

crc = sub_16720(match_ptr, 300)

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

白名单比较

白名单由两个函数提供:

函数 作用
sub_C930() 返回白名单数量
sub_C970() 返回白名单 CRC 数组地址

比较逻辑为:

for i in range(sub_C930()):
    if whitelist[i] == crc:
        return 1

return failure

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

语义化伪代码
bool sub_1678C_semantic(void)
{
    for each candidate in strtok(sub_CBA8(), delim) {
        package = sub_12050();                      // /proc/<pid>/cmdline,去掉 :xxx 后缀
        apk = try_open_common_apk_paths(package);

        if (!apk && get_sdk() >= 26) {
            apk = find_apk_from_proc_self_maps(package, "libmsaoaidsec.so");
        }

        if (!apk) {
            if (get_sdk() < 19)
                return true;                        // 低版本兼容分支
            continue;
        }

        entry_name = "META-INF/" + candidate;
        if (!zip_find_entry(apk, entry_name)) {
            close_apk(apk);
            if (get_sdk() < 19)
                return true;
            continue;
        }

        raw = zip_read_entry_all(apk);
        hex = bytes_to_hex_with_space(raw);          // 每字节转为 "%02x "

        p = strstr(hex, "30 81 89 02 81 81 00");
        if (!p) p = strstr(hex, "30 82 01 0a 02 82 01 01 00");
        if (!p) p = strstr(hex, "30 82 02 0a 02 82 02 01 00");

        if (!p) {
            status = (sub_CCA4() == 217) ? -1 : 0;
        } else {
            crc = sub_16720(p, 300);
            status = crc_in_whitelist(crc, sub_C970(), sub_C930()) ? 1 : -1;
        }

        close_apk(apk);

        if (status != 0)
            return status == 1;

        if (get_sdk() < 19)
            return true;
    }

    return false;
}
返回值语义
返回值语义 含义 sub_26E5C 中的结果
bit0 为 1 签名 CRC 命中白名单,或低版本兼容分支放行 不退出
bit0 为 0 APK 未找到、签名文件未找到、DER 头未找到、CRC 不在白名单等 满足开关条件时 exit_group(0)

主线三:代码 CRC 完整性校验

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

  1. 初始化期 CRC:在 sub_13728() 中执行,加载早期即可发现代码段异常。
  2. 周期性 CRC:由 sub_9150() 创建 sub_8CAC() 线程,每隔约 3 秒重复校验。

sub_16720:标准 CRC32 的查表实现

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

uint32_t sub_16720(uint8_t *buf, int len)
{
    uint32_t crc;

    if (!len)
        return 0;

    crc = 0xFFFFFFFF;
    while (len--) {
        crc = dword_2FAEC[(uint8_t)(crc ^ *buf++)] ^ (crc >> 8);
    }
    return ~crc;
}

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

uint32_t crc32_bitwise(const uint8_t *data, size_t len)
{
    uint32_t crc = 0xFFFFFFFF;

    for (size_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 1)
                crc = (crc >> 1) ^ 0xEDB88320;
            else
                crc >>= 1;
        }
    }

    return crc ^ 0xFFFFFFFF;
}

区别在于:

实现方式 特点
逐 bit 实现 每个字节循环 8 次,逻辑直观
查表实现 每个字节查一次 256 项表,速度更快

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

sub_13728:初始化期代码 CRC 校验

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

  1. 在配置满足时执行代码 CRC。
  2. 调用 sub_2701C() 创建 sub_26E5C()
  3. 调用 sub_198D8()sub_25964() 等辅助检测或初始化路径。
  4. 初始化与 libmsaoaidsec.so 模块信息相关的数据。

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

if (sub_CEE4() == 203) {
    crc = sub_16720(base + sub_CE24(), sub_CE64())
    if (crc != sub_CEA4())
        sub_2676C(0)
}

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

说明
起始偏移 0x83F0 sub_CE24() 返回的目标区域起始偏移
校验长度 0x26A04 dword_48840 / sub_CE64() 返回的长度
校验结束位置 0x2EDF4 0x83F0 + 0x26A04
期望 CRC 0x536953F0 sub_CEA4() 返回的预期值

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

从边界看:

start = 0x83F0
len   = 0x26A04
end   = 0x2EDF4

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

  1. 覆盖关键检测逻辑:如果反调试、反注入或退出逻辑被 patch,CRC 很容易变化。
  2. 避免易变区域:只校验相对稳定的代码区域,减少重定位、数据段变化带来的误报。
  3. 降低计算成本:CRC32 计算较快,但固定范围仍能控制启动期开销。
  4. 版本绑定:每个 so 版本的代码布局不同,偏移、长度和期望 CRC 可以随版本更新。

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

sub_9150 / sub_8CAC:周期性代码 CRC 检测线程

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

创建条件为:

if (sub_CDE4() == 204 || sub_CEE4() == 203) {
    pthread_create(&tid, 0, sub_8CAC, 0)
}

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

while true:
    lock(module_mutex)
    module = find_module("libmsaoaidsec.so")

    if sub_CDE4() == 204:
        start = module_base + sub_CD24()
        len = sub_CD64()
        expected = sub_CDA4()
        if sub_16720(start, len) != expected:
            sub_260B0(0)

    if sub_CEE4() == 203:
        start = calculated_base + sub_CE24()
        len = sub_CE64()
        expected = sub_CEA4()
        if sub_16720(start, len) != expected:
            sub_269AC(0)

    unlock(module_mutex)
    usleep(0x2DC6C0)   // 3000000 us,约 3 秒

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

维度 sub_13728 sub_8CAC
执行时机 初始化期执行一次 后台线程周期执行
主要目的 尽早发现已被 patch 的代码 持续发现运行时 inline hook / memory patch
失败处理 sub_2676C(0) sub_260B0(0)sub_269AC(0)
CRC 函数 sub_16720() sub_16720()

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


统一退出机制

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

退出路径 触发来源 退出方式
exit(0) sub_1BFACsub_1C158sub_1C26C 直接调用 libc exit
sub_11FA4() sub_1B8D4sub_19E0C 做辅助处理后进入 sub_234E0(0)
sub_234E0(0) ART hook 检测、反调试检测等 动态 syscall stub -> exit_group(0)
sub_2676C(0) sub_13728 初始化期 CRC 失败 动态 syscall stub -> exit_group(0)
sub_269AC(0) sub_26E5C 签名失败、sub_8CAC CRC 失败 动态 syscall stub -> exit_group(0)
sub_260B0(0) sub_8CAC 周期 CRC 失败 动态 syscall stub -> exit_group(0)

动态 syscall stub 的共同结构为:

mmap(NULL, 0x1c, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANON, -1, 0)
写入解密后的 AArch64 指令
__clear_cache()
调用该内存地址
munmap()

核心指令语义为:

mov x8, #94
svc #0
ret

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

exit_group(0)

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


检测点汇总

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

类别 函数 检测对象 关键文件/API 命中结果
Frida 线程名 sub_1BFAC gum-js-loopgmain /proc/self/task/<tid>/status exit(0)
fd 注入痕迹 sub_1C158 fd 符号链接中的 linjector /proc/self/fdlstatreadlink exit(0)
maps / agent sub_1C26C /data/local/tmp_AGENT_1.0frida-agent /proc/self/maps exit(0)
ART hook sub_26334 函数入口指令 0x58000050/0x58000051 libart PrettyMethod 相关入口 exit_group(0)
TracerPid sub_1AE48sub_1B8D4 当前进程是否被 ptrace /proc/<pid>/status exit_group(0)
tracer 父进程 sub_1AB54 TracerPidPPid 是否为主进程 /proc/<tracer_pid>/status 不匹配则退出
线程 trace 状态 sub_1B730 线程状态是否为 t/T /proc/<pid>/task/<tid>/stat 返回 777 后退出
ADB/USB sub_19E0Csub_17C8Csub_19A58 adb + plugged != 0 sys.usb.config、JNI、BATTERY_CHANGED exit_group(0)
Magisk 环境残留 sub_25A48sub_23B18 初始栈附近是否残留 MAGISKTMP /proc/self/maps、栈顶原始字符串 命中后短路后续 native 检测初始化
APK 签名 sub_26E5Csub_1678C RSA 公钥片段 CRC 是否命中白名单 APK/ZIP、META-INF/*.RSA、CRC32 失败则退出
初始化期代码 CRC sub_13728 base+0x83F0 起始的 0x26A04 字节 sub_16720() CRC 不一致则退出
周期性代码 CRC sub_9150sub_8CAC 内存中目标代码片段 模块基址、偏移、长度、CRC32 CRC 不一致则退出

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

  1. 环境痕迹:Frida 线程名、fd、maps、agent 文件。
  2. 调试状态TracerPid、tracer 父进程、线程 t/T 状态。
  3. 反检测环境:初始栈中是否残留 MAGISKTMP 等 Magisk 环境痕迹。
  4. 设备连接状态:ADB 属性和电池 plugged 状态。
  5. 安装包可信度:APK 路径、v1 签名文件、公钥 CRC 白名单。
  6. Native 代码完整性:初始化期和周期性代码 CRC。

结论

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

  1. 入口层通过 .init_proc(0x14400) 在 Java 层业务逻辑运行前启动检测,而真实 JNI_OnLoad(0x13A4C) 并不是主要检测线程的直接创建点。
  2. 早期反检测检测层sub_25A48 -> sub_23B18 完成,命中 MAGISKTMP 后更像风险打标或降级入口,会使 .init_proc 短路后续 native 检测初始化,而不是直接退出。
  3. 线程调度层sub_1BEC4 -> sub_1B924 创建多个后台检测线程,分别覆盖 Frida/注入、ptrace 调试、线程异常状态和 ADB/USB 调试环境。
  4. 签名校验层sub_13728 -> sub_2701C -> sub_26E5C -> sub_1678C 完成,核心是对 APK v1 签名文件中的 RSA 公钥片段计算 CRC32,并与内置白名单比较。
  5. 代码完整性层sub_13728sub_8CAC 共同完成,前者做初始化期 CRC,后者做周期性 CRC,用于发现静态 patch 和运行时内存改写。
  6. 退出层通过直接 exit(0) 和动态生成 exit_group(0) syscall stub 两类方式终止进程,检测失败通常不会向上层返回可恢复错误。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐