LIAPP (libdxxcuxd.so) Frida Root 检测绕过分析
zsk Lv4

背景

目标 app: com.everysing.lysn (Lysn),使用了韩国 Lockin Company 的商业保护方案 LIAPP。
版本号: 1.6.10
设备环境: Pixel 4 XL,strongR-frida 16.5.2。
现象:

  • 直接打开 app:image
  • Frida spawn 启动:检测到 frida,进程被终止image

第一步:定位检测库

用 frida hook android_dlopen_ext 打印加载的 so:

1
2
3
4
5
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
onEnter: function(args) {
console.log(ptr(args[0]).readCString());
}
});

输出:

1
2
3
4
libstats_jni.so
/data/app/~~_MfDV2nQKO6qXqDlwdHjXQ==/com.everysing.lysn-5o0GwzOuvROK4RtaZOPd3g==/oat/arm64/base.odex
/data/app/~~_MfDV2nQKO6qXqDlwdHjXQ==/com.everysing.lysn-5o0GwzOuvROK4RtaZOPd3g==/lib/arm64/libdxxcuxd.so
Process terminated

加载 libdxxcuxd.so 后进程立即终止,说明检测逻辑在这个 so 中。
查看 AndroidManifest.xml,Application 类是 com.liapp.x——这是 LIAPP 的壳。它在 attachBaseContext 中调用 System.loadLibrary("dxxcuxd") 加载 native 检测库。

第二步:找到初始化入口

现在的壳和风控组件都喜欢把检测逻辑放在 ELF 初始化函数中。ELF 初始化有三个入口,执行顺序固定:

1
2
3
DT_INIT          →  最早(单个函数)
DT_INIT_ARRAY → 其次(函数数组)
JNI_OnLoad → 最后(Java loadLibrary 返回前)

用 IDA 打开 libdxxcuxd.so,查看 .init_array 段:
只有 DT_INIT_ARRAY 类型的函数,没有 DT_INIT 类型的。如果有 DT_INIT 类型的,那么我们优先看 DT_INIT 类型的,因为它的执行时间早于 DT_INIT_ARRAY 类型的函数。且两者都优先于 JNI_OnLoad 执行。

有两个初始化函数(已重命名):

地址 函数 作用
0xA2838 → 0xFBE0 init_arr1 主初始化,包含所有检测逻辑
0xA2840 → 0x10898 init_arr2 简单变量清零,无检测

image

第三步:分析 init_arr1

直接用ida mcp让ai分析写了注释
image
init_arr1(原名 sub_FBE0)是 LIAPP 的主初始化函数,内部按顺序做了以下事情:

检测 _progname

_progname 是 libc 导出的全局变量,保存当前进程名。LIAPP 计算它的 CRC32 存入全局变量,用于后续完整性校验。

检测 environ

遍历环境变量,检查是否包含:

  • .sandbox — 沙箱环境(VirtualApp、双开助手等会注入)
  • /data/data/com.lbe.parallel — 平行空间(双开框架)
    很多虚拟环境会修改 LD_PRELOADCLASSPATHTMPDIR 等环境变量,或添加 SANDBOX 标记,因此 environ 是反虚拟化的重要检测点。

sub_294E0:代码段完整性校验(CRC32)

image

1
2
3
4
5
6
7
8
9
10
sub_294E0(a1):
├── 首次调用:
│ ├── 遍历函数表找到代码段起始地址 (off_A4078)
│ ├── 记录代码段长度 (qword_A4080)
│ └── dword_A4088 = CRC32(代码段, 长度) ← 保存基准值

└── 后续调用:
├── 重新计算 CRC32
├── 对比 dword_A4088
└── 不一致 → 代码被篡改! → sub_15FD0(18) 杀进程

这个函数在多处被调用,意味着 如果我们直接 patch .text 段的代码,后续 CRC32 校验会发现篡改并杀进程。 这是一个重要约束。

sub_164A8:检测 BlackDex

检查 _progname 是否包含 blackdex(脱壳工具),是则杀进程。
image

sub_3DDB4:核心 Frida 检测

image
这是最关键的函数,包含 maps 扫描和线程名检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sub_3DDB4(a1):
├── [初始化] 计算 progname 的 CRC32
├── [初始化] 注册信号处理器 (SIGABRT/SIGILL/SIGALRM 等)

├── [检测1] fopen("/proc/<pid>/maps") 逐行扫描
│ ├── sub_1D25C: 检查每行是否包含可疑关键词
│ └── 发现 → kill(pid, 9)

├── [检测2] scandir("/proc/<pid>/task") 获取所有线程 tid
│ └── 对每个 tid:
│ ├── open("/proc/<tid>/comm") 读取线程名
│ ├── 白名单跳过: "Profile Saver", "HeapTaskDaemon"
│ ├── 黑名单检测:
│ │ ├── "pool-frida" (每字节-2 解密)
│ │ ├── "gum-js-loop" (每字节-9 解密)
│ │ ├── "gdbus" (XOR 0x20 解密)
│ │ └── "gmain" (明文)
│ └── 命中 → kill(tid, 9) 杀 frida 线程

└── [收尾] 如果杀了 frida 线程:
├── kill(getppid(), 9) 杀父进程
├── kill(pid, 9) 杀自己
└── dword_A5D9C |= 0x20 标记检测完成

sub_1D25C:maps 黑名单

image
这个函数检查 /proc/self/maps 的每一行是否包含以下关键词:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/data/local/tmp        ← frida server 常见路径
/data/local/
.frida.server
/frida-agent
frida
com.saurik.substrateCydia Substrate
XposedBridgeXposed 框架
libxposed
com.xmodgame ← 游戏作弊
gamekiller
gamespeed
gamecih
gamehack
jsonet.jshookJsHook 框架
cn.mc.sq/files ← 国产作弊工具
cn.mm.gk/files
libdaemon.so
libmhx.so
tools/files/binarm
lib-bulldog-daemon

返回 0 表示安全,-1 表示发现风险。

sub_15FD0:后台检测线程

启动两个 pthread 持续检测:

  • sub_16378 — 延迟后调用 sub_160E8 杀进程
  • sub_182F0 — 循环发送 kill(pid, SIGALRM)

sub_160E8:终极杀进程

多重保险机制:

1
2
3
4
5
6
7
8
9
kill(getpid(), 9);           // SIGKILL 自己
fork(); // fork 子进程
// 子进程:
kill(getppid(), SIGINT); // 杀父进程
kill(getppid(), SIGKILL); // 再杀一次
waitpid(ppid, ...);
kill(getpid(), 9); // 子进程自杀
// 父进程:
exit(-1);

fork() 子进程杀父进程是为了绕过父进程中的 kill hook——子进程虽然继承了内存,但 frida 的 Interceptor hook 可能不生效。

LIAPP 的 syscall 包装

LIAPP 不直接调用 libcopenat/read,而是通过 dlsym 获取函数指针,可能指向直接 syscall 的包装:

1
2
3
4
5
// sub_1265C (openat 包装)
if (off_A6E20)
return off_A6E20(AT_FDCWD, file, oflag, mode); // 可能是 syscall
else
return openat(-100, file, oflag, mode); // 走 libc

这意味着 hook libc 的 read/openat 不一定能拦截 LIAPP 的文件读取。

第四步:绕过策略选择

方案 A:hook call_constructors + patch 代码(有坑)

思路:hook linker 的 call_constructors,在 .init_array 执行前 patch 检测函数。

1
2
3
# 找到 call_constructors 偏移
adb shell "readelf -sW /apex/com.android.runtime/bin/linker64 | grep call_constructors"
# 输出: 754: 0000000000051120 896 FUNC LOCAL HIDDEN 11 __dl__ZN6soinfo17call_constructorsEv
1
2
3
4
5
6
7
8
9
10
Interceptor.attach(Process.findModuleByName('linker64').base.add(0x51120), {
onEnter: function(args) {
var mod = Process.findModuleByName("libdxxcuxd.so");
if (mod) {
// patch sub_1D25C 返回 0
// patch sub_3DDB4 跳过 scandir
// ...
}
}
});

问题:

  1. sub_294E0 会做 CRC32 完整性校验,patch .text 段会被检测到
  2. sub_3DDB4 内部有初始化逻辑(注册信号处理器、设置标志位),不能整个跳过
  3. 各种条件分支和标志位依赖关系复杂,容易遗漏
  4. call_constructors 偏移因设备/Android 版本不同,通用性差

方案 B:让检测正常执行,但让它的武器全部失效(最终方案)

思路:不阻止检测代码运行,而是让它的每一个”杀进程手段”都无效化。
核心观察:LIAPP 检测到 frida 后的唯一动作就是杀进程。 如果杀不死,检测就等于没用。
同时,由于 LIAPP 可能用 syscall 直接读取 /proc/task/*/comm(绕过 libc hook),我们无法保证在 read 层面过滤掉 frida 线程名。但这没关系——让它检测到也无所谓,反正杀不死。

第五步:最终绕过方案

时序分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
frida spawn app

├── frida 脚本加载 (kill/fork/exit hook 生效) ← 最早

├── linker 加载 libdxxcuxd.so
│ ├── .init_array[0] init_arr1 执行
│ │ ├── sub_294E0: CRC32 基准记录 (正常)
│ │ ├── sub_3DDB4 执行:
│ │ │ ├── 读 /proc/maps → 可能发现 frida
│ │ │ ├── 读 /proc/task/*/comm → 发现 gdbus/gmain
│ │ │ ├── kill(tid, 9) → 被 hook 拦截,返回 0
│ │ │ ├── kill(pid, 9) → 被 hook 拦截,返回 0
│ │ │ └── kill(ppid, 9) → 被 hook 拦截,返回 0
│ │ └── sub_15FD0 启动检测线程
│ │ ├── sub_16378 → kill 被 hook 拦截,无法杀进程
│ │ └── sub_182F0 → kill 被 hook 拦截,无法杀进程
│ └── .init_array 执行完毕

├── android_dlopen_ext onLeave (patch 检测线程入口,阻止后续循环)

├── JNI_OnLoad (注册 native 方法,正常)

└── App 正常启动

四层防护

时机 作用
1 脚本加载时 replace kill/fork/exit/signal,杀进程操作全部失效
2 脚本加载时 过滤 /proc/task/*/comm(对走 libc 的路径有效)
3 dlopen 返回后 patch 后台检测线程入口为 RET
4 Java VM 就绪后 拦截 System.exit / Process.killProcess

为什么不能 patch sub_3DDB4(核心 Frida 检测)?

sub_3DDB4 除了检测逻辑,还有初始化操作(注册信号处理器、设置 dword_A5D9C 标志位等)。后续 LIAPP 的 JNI 方法(字符串解密、DEX 加载)依赖这些初始化数据。直接 patch 为 RET 会导致:

1
2
3
4
5
signal 11 (SIGSEGV), code 2 (SEGV_ACCERR)
backtrace:
#00 pc 0x10914 libdxxcuxd.so ← memcpy 读取未初始化指针
#04 com.liapp.x native method ← JNI 解密调用
#08 liborganization.<clinit> ← 业务代码类初始化崩溃

另外,sub_294E0 的 CRC32 校验也会发现 .text 段被修改。

最终脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
// 绕过 com.everysing.lysn 的 LIAPP (libdxxcuxd.so) frida/root 检测

"use strict";

var myPid = Process.id;

// === 第一层: 拦截所有杀进程手段 ===

var killAddr = Module.findExportByName("libc.so", "kill");
var origKill = new NativeFunction(killAddr, "int", ["int", "int"]);
Interceptor.replace(killAddr, new NativeCallback(function(pid, sig) {
if (sig === 9 || sig === 6 || sig === 2 || sig === 14) {
console.log("[!] kill(" + pid + ", " + sig + ") blocked");
return 0;
}
return origKill(pid, sig);
}, "int", ["int", "int"]));

Interceptor.replace(Module.findExportByName("libc.so", "tgkill"),
new NativeCallback(function() { return 0; }, "int", ["int", "int", "int"]));

Interceptor.replace(Module.findExportByName("libc.so", "fork"),
new NativeCallback(function() { return -1; }, "int", []));

["exit", "_exit", "abort"].forEach(function(fn) {
var addr = Module.findExportByName("libc.so", fn);
if (addr) Interceptor.replace(addr, new NativeCallback(function() {}, "void", ["int"]));
});

Interceptor.replace(Module.findExportByName("libc.so", "raise"),
new NativeCallback(function() { return 0; }, "int", ["int"]));

Interceptor.replace(Module.findExportByName("libc.so", "pthread_kill"),
new NativeCallback(function() { return 0; }, "int", ["pointer", "int"]));

Interceptor.attach(Module.findExportByName("libc.so", "signal"), {
onEnter: function(args) {
var sig = args[0].toInt32();
if (sig === 6 || sig === 4 || sig === 14 || sig === 10 || sig === 12)
args[1] = ptr(1); // SIG_IGN
}
});

console.log("[+] 第一层: kill/fork/exit/signal 全部拦截");

// === 第二层: 过滤 /proc 文件(对走 libc 的路径有效)===

var trackedFds = {};
var FRIDA_KEYWORDS = ["frida", "gdbus", "gmain", "gum-js", "linjector",
"pool-frida", "frida-agent", "agent-"];

function isFridaRelated(str) {
var lower = str.toLowerCase();
for (var i = 0; i < FRIDA_KEYWORDS.length; i++)
if (lower.indexOf(FRIDA_KEYWORDS[i]) !== -1) return true;
return false;
}

Interceptor.attach(Module.findExportByName("libc.so", "openat"), {
onEnter: function(args) {
try { this.path = args[1].readCString(); } catch(e) { this.path = null; }
},
onLeave: function(retval) {
if (!this.path || retval.toInt32() <= 0) return;
var fd = retval.toInt32(), p = this.path;
if (p.indexOf("/task") !== -1 && p.indexOf("/comm") !== -1) trackedFds[fd] = "comm";
else if (p.indexOf("/proc") !== -1 && p.indexOf("/maps") !== -1) trackedFds[fd] = "maps";
else if (p.indexOf("/proc") !== -1 && p.indexOf("/status") !== -1) trackedFds[fd] = "status";
}
});

Interceptor.attach(Module.findExportByName("libc.so", "open"), {
onEnter: function(args) {
try { this.path = args[0].readCString(); } catch(e) { this.path = null; }
},
onLeave: function(retval) {
if (!this.path || retval.toInt32() <= 0) return;
var fd = retval.toInt32(), p = this.path;
if (p.indexOf("/task") !== -1 && p.indexOf("/comm") !== -1) trackedFds[fd] = "comm";
else if (p.indexOf("/proc") !== -1 && p.indexOf("/maps") !== -1) trackedFds[fd] = "maps";
}
});

Interceptor.attach(Module.findExportByName("libc.so", "read"), {
onEnter: function(args) {
this.fd = args[0].toInt32();
this.buf = args[1];
this.type = trackedFds[this.fd] || null;
},
onLeave: function(retval) {
var n = retval.toInt32();
if (n <= 0 || !this.type) return;
try {
var content = this.buf.readUtf8String(n);
if (!content) return;
var newContent = null;
if (this.type === "comm" && isFridaRelated(content)) {
console.log("[!] comm 过滤: " + content.trim());
newContent = "binder:" + myPid + "_1\n";
} else if (this.type === "maps" && isFridaRelated(content)) {
newContent = content.split("\n").filter(function(l) {
return !isFridaRelated(l);
}).join("\n");
} else if (this.type === "status" && content.indexOf("TracerPid") !== -1) {
newContent = content.replace(/TracerPid:\s*\d+/, "TracerPid:\t0");
}
if (newContent !== null) {
this.buf.writeUtf8String(newContent);
retval.replace(ptr(newContent.length));
}
} catch(e) {}
}
});

Interceptor.attach(Module.findExportByName("libc.so", "close"), {
onEnter: function(args) {
var fd = args[0].toInt32();
if (trackedFds[fd]) delete trackedFds[fd];
}
});

console.log("[+] 第二层: /proc/task/*/comm 和 /proc/maps 过滤");

// === 第三层: patch 后台检测线程入口 ===

Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {
onEnter: function(args) {
if (!args[0].isNull()) this.lib = args[0].readCString();
},
onLeave: function(retval) {
if (this.lib && this.lib.indexOf("dxxcuxd") !== -1) {
var mod = Process.findModuleByName("libdxxcuxd.so");
if (mod) {
console.log("[*] libdxxcuxd.so 已加载! base=" + mod.base);
var base = mod.base, RET = 0xD65F03C0;
// patch 发生在 CRC32 基准值记录之后,且后台线程被 patch 后不会再触发校验
var targets = [
[0x160E8, "sub_160E8 (终极杀进程)"],
[0x17FB4, "sub_17FB4 (检测后处理)"],
[0x182F0, "sub_182F0 (SIGALRM循环)"],
[0x16378, "sub_16378 (延迟杀进程线程)"]
];
targets.forEach(function(t) {
try {
var addr = base.add(t[0]);
Memory.protect(addr, 4, "rwx");
addr.writeU32(RET);
console.log("[+] Patched " + t[1]);
} catch(e) {}
});
console.log("[+] 杀进程函数已patch!");
}
}
}
});

// === 第四层: Java 层兜底 ===

Java.perform(function() {
Java.use("java.lang.System").exit.implementation = function(code) {
console.log("[!] System.exit(" + code + ") blocked");
};
Java.use("android.os.Process").killProcess.implementation = function(pid) {
if (pid === myPid) {
console.log("[!] Process.killProcess(self) blocked");
return;
}
this.killProcess(pid);
};
console.log("[+] 第四层: Java exit/kill 拦截");
});

console.log("[*] LIAPP bypass 已加载");
console.log("[*] 等待 libdxxcuxd.so 加载...");

image

从打开 IDA 到 app 正常启动,Claude 承担了大部分分析和编码工作,连这篇文章都是AI帮忙总结的,只能感叹 AI 还是太强大了。

参考:和爱豆更近一步——某爱豆聊天App反调试绕过

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
访客数 访问量