-
-
[原创]AI静态分析 内核模块隐藏 Frida 特征 绕过linker私有结构遍历崩溃链
-
发表于: 2小时前 79
-
某sec.so反调试分析:AI静态分析+内核模块隐藏 Frida 特征+绕过linker私有结构遍历崩溃链
1. 背景介绍
跟随feicong大佬学习后,对某sec.so进行了反调试分析,发现其反调试手段主要集中在:
- 用
/proc/<pid>/status检查TracerPid - 用
/proc/<pid>/task/<tid>/status/stat检查线程状态和线程名 - 用
/proc/self/maps、/proc/self/fd检查 Frida 注入痕迹 - 再辅以 linker 私有结构遍历,做更底层的模块探测
从已有分析可以确认,这个 so 在初始化阶段会拉起多条保护链,其中与 Frida 最直接相关的一条是:
- 扫描线程列表
- 检查线程状态
- 匹配 Frida 常见线程名
典型命中目标包括:
gum-js-loopgmainfrida
一旦命中,样本要么直接触发退出,要么进入更激进的异常路径,最终导致进程崩溃。
本文的重点
- 在内核里拦截
/proc相关输出 - 在目标进程读取
/proc/<pid>/task/<tid>/status时临时改写task_struct->comm - 从源头隐藏 Frida 线程名
可以先用一张总览图把本文涉及的检测面放在一起:

2. 反调试机制分析
详细静态分析是利用agent工具 codex + tenrec(ida的mcp)进行的,不得不感叹AI正在彻底颠覆行业。
完整的反调试机制可以先用一张图概括:

2.1 两条主检测链
目前最准确的整理方式是:
init_proc -> sub_1BEC4 -> sub_1B924是共同初始化/分发入口- 真正进入持续检测并最终触发退出的,是
sub_1B8D4和sub_1C544
其中:
sub_1B8D4
- 负责
TracerPid、PPid、线程 trace-stop 状态检测 - 命中后走
sub_11FA4 -> sub_234E0 -> exit_group
sub_1C544
- 负责反 Frida 扫描
- 会周期性扫描:
/proc/self/task/proc/self/fd/proc/self/maps
- 命中后触发
_exit/exit
2.2 与 Frida 线程名直接相关的检测点
sub_1C544 的主循环里至少包含三个检测点:
sub_1BFAC
- 遍历
/proc/self/task/<tid>/status - 匹配线程名
gum-js-loop、gmain
sub_1C158
- 遍历
/proc/self/fd/* readlink后匹配注入痕迹字符串
sub_1C26C
- 遍历
/proc/self/maps - 匹配
/data/local/tmp、frida-agent、_AGENT_1.0
其中和本文最直接相关的是 sub_1BFAC。
可以把它还原成下面这种可读伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | void scan_task_status_for_frida_threads(void) { DIR *dir = opendir("/proc/self/task"); if (!dir) return; while ((de = readdir(dir)) != NULL) { if (!is_digits(de->d_name)) continue; char path[0x100]; snprintf(path, sizeof(path), "/proc/self/task/%s/status", de->d_name); char buf[0x400] = {0}; if (!read_text_file(path, buf, sizeof(buf))) continue; if (strstr(buf, "gum-js-loop") || strstr(buf, "gmain")) { _exit(0); // 或 exit(0) } } closedir(dir);} |
用户态检测流程可以概括成:
- 枚举
/proc/self/task - 拿到每个线程的
tid - 读取
/proc/self/task/<tid>/status - 在文本中查找 Frida 特征线程名
这意味着,只要用户态看到的 status 内容被改写,检测就会失效。
对应的用户态线程名检测流程如下:

3. Frida 特征产生原因
很多人第一次分析这类检测时,会把 gum-js-loop、gmain 当成“样本自己创建的线程”。
其实不是,这些线程名来自 Frida 运行时自身。
3.1 gum-js-loop
Frida 的脚本执行、消息循环、调度逻辑依赖 Gum。
在目标进程里注入 agent 后,Gum 会创建自己的事件循环线程,典型线程名就是:
gum-js-loop
这个线程基本可以视作“Frida JavaScript runtime 正在进程内运行”的直接信号。
3.2 gmain
Frida 底层依赖 GLib/GIO 事件循环。
一旦使用消息通信、异步 IO 或内部调度,GLib 相关线程也会出现,典型名就是:
gmain
3.3 为什么样本喜欢扫线程名
原因很简单:
- 成本低
- 不需要复杂反汇编、也不需要解析 linker 私有结构
- 命中率高
- Frida 默认行为下,这几个线程名非常稳定
- 输出简单
/proc/<pid>/task/<tid>/status本身就是纯文本- 一个
strstr就能完成判定
换句话说,线程名检测是最典型、也最容易落地的 Frida 识别手段之一。
4. 利用内核模块绕过检测
既然样本依赖的是 /proc 输出,那最直接的思路就是:
- 不在用户态和它拼输出
- 直接在内核里改它能看到的
/proc内容
本文使用的模块就是:
78fK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6F1N6h3!0W2L8W2)9J5c8X3E0W2M7X3&6W2L8q4)9#2k6X3#2G2k6s2g2D9k6i4x3`.
4.1 总体思路
hookmodule 主要做了三类事:
- hook
proc_pid_status
- 在生成
/proc/<pid>/task/<tid>/status文本前,临时修改task_struct
- hook
proc_task_name
- 在某些
/proc名称输出路径上,直接改写线程名显示
- hook
show_map_vma
- 隐藏
/proc/<pid>/maps里的目标库路径
这三类 hook 对应三条不同检测面:
| 检测面 | 目标路径 | 模块对应处理 |
|---|---|---|
| 线程名 | /proc/<pid>/task/<tid>/status |
proc_pid_status kretprobe |
| 任务名显示 | /proc/.../task/... 相关 seq 输出 |
proc_task_name ftrace |
| 映射路径 | /proc/<pid>/maps |
show_map_vma ftrace |
从实现层面看,hookmodule 的核心思路是把“用户态读取 /proc 的结果”前移到内核里处理:

4.2 关键参数
模块支持的关键参数有:
1 2 3 4 | static int target_pid = 0;static int target_uid = -1;static char *hide_so[MAX_NAMES];static int hide_so_cnt; |
示例加载方式:
1 | insmod hookmodule.ko hide_so="frida,gum,gmain,AGENT" debug=true |
这里的 hide_so 并不只是“隐藏 so 名字”,更准确地说是:
- 作为一组统一关键字
- 同时用于线程名、maps 路径、状态输出中的字符串过滤
5. 针对每一类检测的绕过实现
5.1 绕过 /proc/self/task/<tid>/status 线程名检测
这是本文最核心的一部分。
用户态样本读取的 status 文本最终来自内核的 proc_pid_status()。
因此最稳的方式不是去改用户态 fopen/read/fgets,而是直接在内核里拦 proc_pid_status。
5.1.1 核心 hook 点
模块里使用了 kretprobe:
1 2 3 4 5 6 7 8 | static struct kretprobe_wrap my_kretprobes[] = { KRETPROBEHOOK( kretprobe_ret_handler_porc_pid_status, kretprobe_entry_handler_proc_pid_status, sizeof(struct kretprobe_data), "proc_pid_status", true),}; |
进入 proc_pid_status 时:
1 2 3 4 5 6 7 | task = (struct task_struct *)regs->regs[3];get_task_struct(task);task_lock(task);data->task = task;data->original_ptrace = task->ptrace;strscpy(data->original_comm, task->comm, TASK_COMM_LEN);data->original_state = READ_ONCE(task->__state); |
如果线程名命中隐藏关键字,就临时改写:
1 2 3 4 5 6 | for (int i = 0; i < hide_so_cnt; i++) { if (strstr(data->original_comm, hide_so[i])) { strscpy(task->comm, REPLAE_COMM, TASK_COMM_LEN); }}task->ptrace = 0; |
返回 proc_pid_status 后再恢复:
1 2 3 4 5 6 7 | task_lock(task);task->ptrace = data->original_ptrace;if (strcmp(task->comm, REPLAE_COMM) == 0) { memcpy(task->comm, data->original_comm, TASK_COMM_LEN);}task_unlock(task);put_task_struct(task); |
5.1.2 为什么这能绕过
因为用户态样本最终看到的是:
1 | /proc/self/task/<tid>/status |
而 status 文件中的线程名字段,本质上就是内核按 task_struct->comm 生成的文本。
我们在文本生成前把 task->comm 临时改成替身值,样本读到的就不再是:
gum-js-loopgmainfrida
而是:
replace_comm
等到文本生成完成,再恢复原值。
这样既能骗过检测,又不会永久破坏线程本身。
这条关键绕过链可以用时序图来表示:

5.1.3 这和用户态 hook 的差别
用户态 hook 的问题在于:
- 容易被对方先发现
- 需要拦多个 libc 调用点
- 很容易和目标样本互相对抗
而内核里拦 proc_pid_status 的优点是:
- 只改最终输出源头
- 对上层
fopen/fgets/read全透明 - 对样本来说,看到的是“正常
/proc结果”
5.2 绕过 proc_task_name 路径上的线程名显示
除了 status,模块还用 ftrace hook 了 proc_task_name:
1 2 3 | static struct ftrace_hook my_hooks[] = { FTRACEHOOK("proc_task_name", fh_proc_task_name, &real_proc_task_name, true)}; |
关键实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | static asmlinkage void fh_proc_task_name(struct seq_file *m, struct task_struct *p, bool escape) { if (!escape) { char tcomm[64]; __get_task_comm(tcomm, sizeof(tcomm), p); for (int i = 0; i < hide_so_cnt; i++) { if (strstr(tcomm, hide_so[i])) { const char *hide_str = "hidding"; strscpy(tcomm, hide_str, 64); seq_printf(m, "%.64s", tcomm); return; } } } real_proc_task_name(m, p, escape);} |
5.2.1 作用
这个 hook 并不替代 proc_pid_status,而是补位:
- 某些
/proc输出路径不会直接走status - 但仍会走基于
seq_file的任务名格式化逻辑
对这些路径,fh_proc_task_name 可以直接把线程名输出改成:
hidding
5.2.2 为什么需要这一层
因为有些样本并不只读 status,还会:
- 枚举 task 目录
- 走其他 proc 文本生成路径
- 直接读取任务名相关输出
这时只拦 proc_pid_status 不一定够,proc_task_name 相当于多了一层兜底。
它和 proc_pid_status 的分工关系如下:

5.3 绕过 /proc/self/maps 中的 Frida 模块痕迹
sub_1C26C 这类检测会扫 /proc/self/maps。
模块里对应的处理是 hook show_map_vma:
1 2 3 | static struct ftrace_hook my_hooks[] = { FTRACEHOOK("show_map_vma", fh_show_map_vma, &real_show_map_vma, true)}; |
关键逻辑:
1 2 3 4 5 6 7 | pathname = d_path(&vma->vm_file->f_path, path_buf, sizeof(path_buf));for (int i = 0; i < hide_so_cnt; i++) { if (strstr(pathname, hide_so[i])) { return; // 不输出这个 maps 条目 }}real_show_map_vma(m, vma); |
5.3.1 为什么能绕过
因为 /proc/<pid>/maps 的每一行最终都是内核把 vma 格式化成文本。
我们在输出层直接跳过匹配项,用户态样本看到的 maps 就不会包含:
fridagumAGENT
5.3.2 但这不是万能的
这点需要说清楚。
show_map_vma 只能隐藏:
/proc/maps文本视图
它不能隐藏:
- linker 私有
solist - 已经实际加载进进程的
soinfo
所以它能绕过 sub_1C26C 这类 /proc/maps 检测,
但无法直接解决 sub_20BDC 那种 linker 私有结构遍历。
5.4 对 TracerPid 与 TASK_TRACED 的处理
模块还在 proc_pid_status kretprobe 里顺手做了两件事:
1 2 3 4 5 | if (data->original_state == TASK_TRACED) { WRITE_ONCE(task->__state, TASK_RUNNING);}task->ptrace = 0; |
5.4.1 作用
这可以绕过另一类典型用户态反调试:
- 读取
TracerPid - 检查线程是否处于
T/t(trace stop)
这正对应 so 另一条链路里的:
sub_1AE48sub_1B730
6. 实验结果
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 | [ 7621.344945] hookmodule: Modified TracerPid for process 2055 to 0[ 7621.345031] hookmodule: [SEQ:358] KRETPROBE HANDLER :proc_pid_status return (entry was SEQ:357)[ 7621.345050] hookmodule: [SEQ:358] Restored TracerPid for process 2055 to 0[ 7621.346354] hookmodule: orig getdents64 address is 00000000191697cc[ 7621.346528] hookmodule: orig getdents64 address is 00000000191697cc[ 7623.020180] hookmodule: [SEQ:359] KREPROBE ENTRY_HANDLER:proc_pid_status entry[ 7623.020255] hookmodule: Modified TracerPid for process 13612 to 0[ 7623.020571] hookmodule: [SEQ:360] KRETPROBE HANDLER :proc_pid_status return (entry was SEQ:359)[ 7623.020614] hookmodule: [SEQ:360] Restored TracerPid for process 13612 to 0[ 7623.020859] hookmodule: orig getdents64 address is 00000000191697cc[ 7623.030068] hookmodule: orig getdents64 address is 00000000191697cc[ 7624.322863] hookmodule: orig getdents64 address is 00000000191697cc[ 7624.323171] hookmodule: orig getdents64 address is 00000000191697cc[ 7624.342313] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.342400] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.342441] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.342486] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.420313] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.420355] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.420376] hookmodule: Hiding target library frida form PID 13646 maps[ 7624.420396] hookmodule: Hiding target library frida form PID 13646 maps[ 7625.677320] hookmodule: orig getdents64 address is 00000000191697cc[ 7625.694435] hookmodule: orig getdents64 address is 00000000191697cc[ 7629.417787] hookmodule: [SEQ:361] KREPROBE ENTRY_HANDLER:proc_pid_status entry[ 7629.417859] hookmodule: Modified TracerPid for process 4725 to 0[ 7629.418097] hookmodule: [SEQ:362] KREPROBE ENTRY_HANDLER:proc_pid_status entry[ 7629.418108] hookmodule: [SEQ:363] KRETPROBE HANDLER :proc_pid_status return (entry was SEQ:361)[ 7629.418160] hookmodule: [SEQ:363] Restored TracerPid for process 4725 to 0[ 7629.418190] hookmodule: Modified TracerPid for process 4725 to 0[ 7629.418328] hookmodule: [SEQ:364] KRETPROBE HANDLER :proc_pid_status return (entry was SEQ:362)[ 7629.418367] hookmodule: [SEQ:364] Restored TracerPid for process 4725 to 0[ 7629.422463] hookmodule: [SEQ:365] KREPROBE ENTRY_HANDLER:proc_pid_status entry[ 7629.422506] hookmodule: Modified TracerPid for process 4725 to 0[ 7629.422785] hookmodule: [SEQ:366] KRETPROBE HANDLER :proc_pid_status return (entry was SEQ:365)[ 7629.422818] hookmodule: [SEQ:366] Restored TracerPid for process 4725 to 0 |
7. sub_20BDC linker私有结构遍历崩溃链的原理分析
7.1 sub_20BDC 的作用
sub_20BDC 本质上不是 /proc 检测函数,而是:
- 从 linker 私有结构中拿到
solist头指针 - 遍历已加载模块链表
- 按名字查找目标模块
可以把它理解成一个私有版:
1 | void *find_loaded_module_by_name_via_solist(const char *name); |
7.2 sub_20BDC 的伪代码
根据 tenrec 分析后结果,可以整理成:
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 | void *find_loaded_module_by_name_via_solist(const char *name) { if (!guard_initialized) solist_head = get_linker_solist_head(); // sub_2082C() cur = solist_head; if (!cur) return 0; found = 0; sdk = *off_47FB8; if (sdk <= 22) { do { if (strlen((char *)cur) <= 0x7F && strstr((char *)cur, name)) found = cur; cur = *(void **)(cur + 176); } while (cur); return found; } if (sdk <= 25) { do { soname = *(const char **)(cur + 416); if (soname && strstr(soname, name)) found = cur; cur = *(void **)(cur + 48); } while (cur); return found; } if (sdk == 31) { while (cur) { if ((*(uint8_t *)(cur + 408) & 1) != 0) soname = *(const char **)(cur + 424); else soname = (const char *)(cur + 409); if (soname && strstr(soname, name)) found = cur; cur = *(void **)(cur + 40); } return found; } if (sdk > 31) { while (cur) { if ((*(uint8_t *)(cur + 392) & 1) != 0) soname = *(const char **)(cur + 408); else soname = (const char *)(cur + 393); if (soname && strstr(soname, name)) found = cur; cur = *(void **)(cur + 40); } return found; } do { soname = *(const char **)(cur + 408); if (soname && strstr(soname, name)) found = cur; cur = *(void **)(cur + 40); } while (cur); return found;} |
7.3 检测逻辑使用frida后,sub_20BDC必崩
sub_20BDC 的危险点有两个:
- 它依赖 linker 私有结构偏移
- 它命中目标模块后不会立即返回,而是继续遍历整个
solist
这意味着:
- 即使它已经找到了目标模块
- 只要后续节点里存在一个它用错偏移无法正确处理的节点
- 它仍然会在继续遍历时把自己扫崩
这也解释了为什么:
- 不挂 Frida 时通常不崩
- 挂 Frida 后更容易崩
根因不是“Frida 改坏了 linker 偏移”,而是:
- Frida 注入后,
solist中节点更多、更复杂 sub_20BDC在命中目标后继续遍历- 最终扫到一个不兼容节点
这条“正常环境通常不崩、Frida 环境更容易崩”的差异,可以用下面这张图概括:

7.4 调用流程
从当前分析可以确认,sub_20BDC 至少有两条从初始化阶段可达的路径。
路径一:长链
1 2 3 4 5 6 7 | .init_proc-> sub_13728-> sub_8784-> sub_DF74-> sub_FD08-> sub_148F0-> sub_20BDC |
路径二:短链
1 2 3 4 | .init_proc-> sub_95C8-> sub_11F38-> sub_20BDC |
结合动态日志,当前更直接的崩溃短链是:
1 2 3 4 5 | sub_95C8-> sub_11F38-> sub_C7F4 => "**sec.so"-> sub_20BDC-> SIGSEGV / Process terminated |
可以把这条崩溃链画成:

7.5 这条链路我采用frida绕过而不是内核模块
原因很简单:
sub_1BFAC / sub_1C158 / sub_1C26C依赖的是/procsub_20BDC依赖的是 linker 进程内存里的私有solist
也就是说:
show_map_vma能骗/proc/mapsproc_pid_status能骗/proc/task/.../status- 但都骗不了 linker 私有模块链
因此,从对抗角度看:
- 内核模块适合解决
/proc检测链 sub_20BDC这类 linker 私有遍历链,仍需要用户态来做
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | function patch_sub_20BDC(secmodule) { const addr = secmodule.base.add(0x20BDC); Interceptor.replace(addr, new NativeCallback(function (namePtr) { let name = '<null>'; try { if (!namePtr.isNull()) { name = namePtr.readCString(); } } catch (_) {} console.log('[+] patch sub_20BDC @ ' + addr + ' =====> return NULL for name=' + name); return ptr(0); }, 'pointer', ['pointer'])); console.log('[+] semantic patch installed sub_20BDC @ ' + addr);} |
8. 总结
**sec.so 的反调试并不是单点检测,而是一组层次化的保护:
/proc/status、TracerPid、线程 trace-stop/proc/task/<tid>/status里的线程名/proc/self/maps与/proc/self/fd- linker 私有
solist遍历
本文给出的内核模块方案,核心价值在于:
- 不和用户态 libc/stdio 对抗
- 直接改写
/proc最终输出源头 - 对
gum-js-loop、gmain、frida这类线程名检测非常有效
这使得:
sub_1BFAC这类基于/proc/task/.../status的反 Frida 检测可以被稳定绕过TracerPid/TASK_TRACED相关检测也可以顺手处理/proc/maps里的显式 Frida 痕迹也能被隐藏
但同样需要明确边界:
- 内核模块能骗
/proc - 不能直接骗 linker 私有结构
因此在实战中,更合理的策略不是“只用一种手段”,而是分层处理:
- 用内核模块压住
/proc检测链 - 用用户态 patch 或 uprobe 处理
sub_20BDC这类 linker 私有遍历链
这才是对抗 **sec.so 这类综合型反调试模块的更稳妥方案。
最后用一张总图收敛本文结论:
