首页
社区
课程
招聘
[原创]过企业级梆梆加固
发表于: 1天前 586

[原创]过企业级梆梆加固

1天前
586

一台小米 9,一款加固过的 App,六个星期。
这篇讲的是:当所有常规工具都失效之后,我是怎么把代码塞进目标进程的。

(系列第一篇 · 用户态注入)
第二篇:为了不让加固发现,我给自己编了个内核
第三篇:加固不让你抓包?那就到内核层动手

先看抓包效果

事情的开始很普通:我想看看一个加固 App 的网络请求。

按惯例上 frida。结果进程秒死。

一开始以为是姿势问题,于是把能想到的全试了一遍:

六种,全部死。存活时间从 0.4 秒到 1.2 秒,没有一个活过 2.5 秒。

对照组很重要:不注入的时候,这个 App 稳定存活 63 秒以上。

所以这不是"自然退出",是被杀了。

死法也一样:SIGKILL,干净利落,连个 signal handler 都不给你留。

真正让我开窍的,是把六次实验的数据摆在一起看。

六种手法干的事情完全不同,但它们有一个唯一的共同产物:

每次 attach,目标进程的线程数都精确 +5。

这不是巧合。

frida-agent 是一个完整的运行时,进场必然带五条线程:JS 引擎、消息循环、心跳、GC、反调试。

而加固方在做什么?

它在轮询 /proc/self/task/ 数线程数,周期不到一秒。

真相一下子清楚了:

最后这点也被验证过:我找了两个"魔改版"的 frida-server,改包名改符号,结果死在同一个 2.5 秒。

这个结论不是猜出来的,是对照实验逼出来的。
每次只改一个变量,其余全部固定。
遇到"不知道对方怎么检测的",第一件事就是设计对照组。

知道它在数线程之后,有两条路:

A. 用 frida,想办法把线程藏起来。

B. 不用 frida,写一个零线程的东西。

A 走不通 —— 你不能既要求一个运行时常驻,又要求它没有线程。

只能走 B。

CPU 执行代码需要一条执行流:取指令 → 执行 → 下一条。

线程是执行流的载体,但载体不是非得自己造。

只要让"别人的执行流"路过你的代码,你的代码就跑起来了。

整个方案一共两次借流:

不是它不懂这个技巧,是它的产品形态要求常驻:

"电脑上敲一行 JS、手机立刻生效" = 进程里必须随时有东西在监听消息 = 需要一条永远醒着的执行流 = 必须有自己的线程。

我的取舍很明确:放弃常驻监听,换零线程。

constructor 是一次性的,跑完就把线程还回去。想再来一次?从外面再 ptrace 敲一下。

ptrace 是 Linux 的调试接口,gdb 就是靠它做的。对我们最有用的三个能力:

关键:attach、改内存、改寄存器,全是"外部动作",目标进程里不增加任何东西。

具体怎么"调用 dlopen":

整个过程目标进程只知道"卡了一下",没有任何新线程、新进程、新 fd 留下。

这一步有个反直觉的事实。

同一个 so,换路径加载,结果天差地别:

一开始我以为是加固做了路径黑名单。

后来做了个跨进程对照:把同一个 so 注入一个完全没有加固的系统进程,一样失败。

那就不是加固干的。

真相是 Android 10 之后的 linker namespace:每个进程有个"允许从哪里加载 so"的白名单,App 进程的白名单里只有系统 so 目录 + 它自己的 /data/app/.../lib/arm64/。

这是 AOSP 的系统级设计,跟加固无关。

判定方法很简单:跨进程对照。找个没加固的进程试同样的事,如果它也失败,就不是加固干的。

所以操作流程是:把 so cp 到 App 自己的 lib 目录(root 下可写),再 dlopen。

能加载了,但还有个坑。

第一版 so 是用 glibc 交叉编译器编的,依赖清单长这样:

Android 的 libc 叫 libc.so,链接器叫 linker64。

这两个名字在 Android 上根本不存在。

所以它在任何路径、任何进程下都必然加载失败。

ELF 的 .dynamic 段里有张依赖清单,linker 加载时会逐项去找。

清单为空 = 没有任何依赖要解析 = 绕开整个依赖查找。

编译命令(本机 LLVM clang,不用 NDK):

其中 -ffreestanding -fno-builtin 这两个不能省,原因见下面。

没有这两个参数时,clang 会做一件很聪明的事:

把"长得像 memcpy 的循环"优化成对 memcpy 的调用。

问题是我用了 -nostdlib,没人提供 memcpy,依赖清单又是空的。

结果:linker 解析失败 → dlopen 返回 NULL → 日志里一个字都看不到。

排查了很久,最后靠反汇编才看到那个 bl memcpy。

从此养成一个习惯:每次编译完必须复查动态符号表。

不能调 libc,就自己发系统调用。ARM64 上是 svc #0:

常用的号记一下:openat=56、close=57、read=63、write=64、mmap=222、getpid=172。

这一条差点让我误判"constructor 没执行"。

App 进程的 stdout/stderr 被重定向到 /dev/null。

你往 fd 1/2 写东西,logcat 里什么都看不到。

验证代码有没有跑,唯一可靠的办法:

用 openat 在 App 自己的 data 目录建文件,写进去,从外部 cat 出来。

constructor 会在这个 so 被 dlopen 时,在发起调用的那个线程里同步执行。

也就是说:只要让 App 的线程调了 dlopen,我们的代码就自动在 App 的线程里跑起来了 —— 不需要、也没有机会开新线程。

第一次跑通的时候,日志是这样的:

对照 frida 的"+5 线程 → 1 秒被杀",这一步就是能活和必死的分水岭。

linker 对已加载的 so 有缓存。

同一个路径第二次 dlopen,直接返回旧 handle,constructor 不再执行。

而我一路用 cp 覆盖同名文件 —— inode 不变 —— 从第二次起就静默不执行了。

于是伪造出两种假象:

铁律:每次测试换新文件名。 libv001、libv002、libv003……

到这一步,我们已经在目标进程里跑起了一段 C 代码。

但它还只是个"哑巴"—— 能读写文件、发系统调用,摸不到 Java 世界。

想摸 Java,需要 JNIEnv。

dlsym 是 libdl 的导出函数,我们的 freestanding so 没法直接调。

所以自己实现一遍符号查找。分三步:

① 从 /proc/self/maps 找模块基址


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

收藏
点赞 ・33
打赏
分享
最新回复 (6)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
向大佬学习
1天前
0
雪    币: 220
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
3 楼
666
17小时前
0
雪    币: 1901
活跃值: (6108)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
4 楼
666
16小时前
0
雪    币: 158
活跃值: (5956)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5 楼
感谢分享
13小时前
0
雪    币: 3708
活跃值: (2119)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
6 楼
学习
12小时前
0
雪    币: 31
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7 楼
感谢你的贡献,论坛因你而更加精彩!
11小时前
0
游客
登录 | 注册 方可回帖
返回