Android Kernel Patch
Android Kernel Patch
Android逆向中,我们分析app时常常遇到如下问题:
- APP获取了哪些设备信息
- APP怎么检测调试器
- APP怎么检测Frida
- APP怎么检测root环境
同时在检测到该行为,并定位到具体代码位置后,我们如何去修改设备信息、隐藏调试器、隐藏Frida又是一个问题。
比如传统方法检测调试器是检查 /proc/pid/status
文件,调试器附加进程后,TracerPid字段的值是调试器的 PID
,要想绕过该反调试,需要解决如下问题:
- 如何定位代码
- 如何修改绕过,修改里面的
PID
定位代码可以依靠逆向工程或者Frida hook等,但是遇到混淆、SVC等会遇到问题,且针对性不强,不够通用,如果用 frida hook libc
的函数,遇到 syscall
时就无法使用了。
因此,解决问题的关键是如何定位系统调用位置并修改系统调用逻辑。
Android Linux Kernel 定制
这就涉及到 Android Linux Kernel
定制方案,具体如下:
监测 | 修改 | 动态加载/卸载 | 需要内核源码/头文件/重新编译内核 | 开发难度 | |
---|---|---|---|---|---|
内核源码定制 | 是 | 是 | 否 | 是 | 中 |
ko内核模块 | 否 | 否 | 是 | 是 | 中 |
eBPF(监测) | 是 | 否 | 是 | 是(有些也不用) | 低 |
eBPF(修改) | 是 | 是 | 是 | 是(默认不能修改返回值) | 高 |
Kernel Patch | 是 | 是 | 是 | 否 | 中 |
Binary Patch | 否 | 否 | 否 | 否 | 高 |
基于 eBPF 开发的开源工具
https://github.com/SeeFlowerX/stackplz
stackplz是一款基于eBPF的堆栈追踪工具,目前仅适用于Android平台
特性:
- 支持arm64 syscall trace,可以打印参数(包括详细的结构体信息)、调用栈、寄存器
- 支持对64位用户态动态库进行uprobe hook,可以打印参数、调用栈、寄存器
- 支持硬件断点功能,可以打印调用栈、寄存器,并且提供了frida rpc调用
- 支持进程号、线程号、线程名的黑白名单过滤
- 支持追踪fork产生的进程
要求:
- root权限,系统内核版本5.10+(可执行
uname -r
查看)- 对于4.1x的内核,内核开启了CONFIG_HAVE_HW_BREAKPOINT,硬件断点功能同样可以使用
不仅仅是真机,这些环境下也可以使用:
- arm开发板刷安卓镜像
- arm开发板/云服务器 + Docker + ReDroid
- Apple M系列设备 + 安卓官方arm64模拟器
- 有root权限,内核版本5.10+的云真机也可以
eBPF非常适合做监测类任务,但是不适合做修改类任务:
- 很难修改寄存器或参数的值
- 很难修改返回值(
CONFIG_BPF_KPROBE_OVERRIDE
配置未开) - 对用户态内存只能修改可写内存
Android Linux Kernel 定制工作流
由于eBPF适合监测任务,因此考虑
- 用 eBPF(stackplz)进行监测
- 分析修改定制逻辑
- 编写 APatch kpm 模块 hook 内核
APatch
https://github.com/bmax121/APatch
- 一种新的 root 方案,基于 Patch 内核实现
- 提供内核模块机制,支持动态加载、动态卸载、符号管理等
- 提供
syscall hook, inline hook
接口,方便定制修改内核 - 核心是 Kernel Patch
安装 Kernel Patch
Linux Kernel 基础知识
- android源码在线搜索:Android Code Search
- 系统调用
- 用户态与内核态交互的接口
- AARCH64指令集使用 SVC 指令调用系统调用函数
x8
寄存器传递系统调用号,系统调用表:https://arm64.syscall.sh/
- kpm内核模块需要调用和hook某些内核函数
- 使用目标函数的符号名来找到其函数地址:
kallsyms_lookup_name
,位于##include <linux/kallsyms.h>
使用
zcat /proc/config.gz | grep -w CONFIG_KALLSYMS
查看设备是否有内核符号导出获取所有导出符号
cat proc/kallsyms
查找某个符号是否导出
cat proc/kallsyms | grep proc_task_name
内核镜像逆向
为什么需要逆向Linux Kernel:
- 不一定找到对应的源码
- 设备内核可能内联了某些函数没法 hook
- 源码/反编译对照
步骤(设备是 Pixel 6 pro):
提取 boot.img:查看 boot 挂载点
ls /dev/block/bootdevice/by-name -l | grep boot
,由前面的分析我们知道Android会把厂商部分和系统部分分开,我们关注boot_a, boot_b
即可,可以看出分别被挂载在sda13, sda21
两个块设备中。提取镜像文件:使用
dd if=/dev/block/sda13 of=/sdcard/Download/boot_a.img
提取boot_a.img
,再用相同的方法提取boot_b.img
解压镜像文件提取 kernel:用
magiskboot
工具解包内核镜像文件 https://github.com/svoboda18/magiskboot:`magiskboot unpack boot_a.img,解压得到
kernel文件和
ramdisk.cpio文件,我们重点关注
kernel` 二进制文件重建符号表:用https://github.com/marin-m/vmlinux-to-elf把我们得到的镜像文件转化成ELF文件(Linux环境)`./vmlinux-to-elf kernel kernel_a.elf`,并得到带符号的内核文件
IDA载入:把
kernel_a.elf
拖入IDA,等待许久,内核文件符号表已全部恢复
如果是模拟器只需要三步:
- 直接在宿主文件系统里面找到
kernel
文件(该文件是gz
压缩后的) - 添加
gz
后缀并解压 - 用
vmlinux-to-elf
处理得到elf
文件
APatch修补
方法一:APatch UI 一键修补
方法二:magiskboot unpack boot.img → kptools修补 → magiskbook repack boot.img
然后执行fastboot flash boot boot.img
Kernel Patch 开发
开发环境
Ubuntu22.04+ clangd + bear + arm-gnu-toolchain
arm64交叉编译工具链下载地址:Arm GNU Toolchain Downloads – Arm Developer 找到对应平台的即可,我这里使用的是 AArch64 bare-metal target (aarch64-none-elf)
编译时临时添加环境变量 export TARGET_COMPILE=/home/ep/Desktop/arm-gnu-toolchain-14.2/bin/aarch64-none-elf-
即可。
第一次编译使用 bear -- make -B
生成 compile_commands.json
文件辅助 clangd即可(可以把makefile里面的TARGET_COMPILE去掉先用gcc编译然后再用交叉编译。
之后可以直接执行 make push
推送到手机设备。
入口函数解析
1 | static long eps1l0h_hide_init(const char *args, const char *event, void *__user reserved) |
比较重要的就是 eps1l0h_hide_init
装载回调,用来在里面添加hook代码,在 eps1l0h_hide_exit
卸载回调里面清除回调并释放内存等。控制 0/1 回调涉及到用户控制,先不管。
用 Kernel Patch hook 系统调用
kpm 安装syscall hook
安装
1 | hook_err_t fp_hook_syscalln(int nr, int narg, void *before, void *after, void *udata) |
卸载
1 | hook_err_t fp_unhook_syscall(int nr, void *before, void *after) |
hook openat
- libc/java 提供的文件操作最终都会走
openat
系统调用打开某个文件,比如检测status / maps
文件,通常都是走这个系统调用 - 其系统调用号为
56
,有四个参数,我们关注第二个参数*filename
开发流程:
- 在src目录下添加
svc_hook.c, svc_hook.h
文件,在makefile文件中添加BASE_SRC += ./src/svc_hook.c
定义
svc_hook_openat_install, svc_hook_openat_uninstall
,在main.c
中调用,负责模块的安装和卸载,安装用到之前提到的fp_hook_syscalln, fp_unhook_syscall
,逻辑如下: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
47void svc_hook_example_install(void)
{
pr_info("eps1l0h-hide: svc_hook_example_install\n");
/*
__task_pid_nr_ns是一个内核函数,用来获取task信息,即pid,tgid等
其被定义成函数指针
enum pid_type
{
PIDTYPE_PID,
PIDTYPE_TGID,
PIDTYPE_PGID,
PIDTYPE_SID,
PIDTYPE_MAX,
};
struct pid_namespace;
pid_t (*__task_pid_nr_ns)(struct task_struct *task, enum pid_type type, struct pid_namespace *ns) = 0;
初始化的时候根据符号来获取其地址
*/
__task_pid_nr_ns = (void *)kallsyms_lookup_name("__task_pid_nr_ns");
if(!__task_pid_nr_ns) {
pr_warn("eps1l0h-hide: __task_pid_nr_ns not found\n");
}
// fp_hook_syscalln 安装模块,__NR_openat是openat的系统调用号宏定义,其有4个参数,before_openat和after_openat是hook函数,NULL是数据
hook_err_t err = fp_hook_syscalln(__NR_openat, 4, before_openat, after_openat, NULL);
// hook_openat_status 表示是否正在执行hook逻辑
// 其定义 int hook_openat_status = 0
if (err) {
pr_err("eps1l0h-hide: hook openat error: %d\n", err);
} else {
hook_openat_status = 1;
pr_info("eps1l0h-hide: hook openat success\n");
}
}
void svc_hook_example_uninstall(void)
{
pr_info("eps1l0h-hide: svc_hook_example_uninstall\n");
// 根据 hook_openat_status 执行 fp_unhook_syscall
if (hook_openat_status) {
fp_unhook_syscall(__NR_openat, before_openat, after_openat);
hook_openat_status = 0;
pr_info("eps1l0h-hide: unhook openat success\n");
}
}下面开始写
before_openat, after_openat
两个回调函数,重点是before_openat
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
// hook_fargs4_t是一个结构体,表示有4个参数,由于是内核态读用户态参数,需要用syscall_argn读取,字符串用compat_strncpy_from_user进一步读取
void before_openat(hook_fargs4_t *args, void *udata) {
pr_info("eps1l0h-hide: before_openat\n");
// 重点是读取openat的第一个参数filename,注意这里是用户态的指针,要加上__user进行区分
const char __user *filename = (const char __user *)syscall_argn(args, 1);
// int fd = (int)syscall_arng(args, 0);
if(!filename) return;
// 进一步读取用户态指针的内容存入buf
char buf[1024];
compat_strncpy_from_user(buf, filename, sizeof(buf));
// 获取当前进程,current是get_current()的宏定义
struct task_struct *task = current;
pid_t pid = -1, tgid = -1;
if (__task_pid_nr_ns) {
pid = __task_pid_nr_ns(task, PIDTYPE_PID, 0); // 获取pid
tgid = __task_pid_nr_ns(task, PIDTYPE_TGID, 0); // 获取tgid
}
// 如果字符串是backdoor-skip就修改返回值和skip_origin
if(strstr(buf, "backdoor-skip")) {
pr_info("eps1l0h-hide: before_openat, filename: %s, pid: %d, tgid: %d, skip\n", buf, pid, tgid);
args->ret = -1;
args->skip_origin = 1;
} else {
pr_info("eps1l0h-hide: before_openat, filename: %s, pid: %d, tgid: %d\n", buf, pid, tgid);
}
}
测试步骤:
添加环境变量
export TARGET_COMPILE=/home/ep/Desktop/arm-gnu-toolchain-14.2/bin/aarch64-none-elf-
,运行make push
推送到手机 sdcard打开 Apatch,加载写好的kpm模块,运行
adb logcat | grep eps1l0h-hide
查看是否安装成功但是我们发现这些日志都是Apatch的用户态日志,由于我们在kpm模块中打印的都是内核日志,所以无法使用logcat获取,需要获取root权限后使用
cat /proc/kmsg | grep eps1l0h-hide
。这时候就会发现输出了一万行日志,都是我们hook的信息。代码中使用的
pr_info
是 内核级打印函数(底层基于printk
),其输出直接写入 内核环形缓冲区,而logcat
仅捕获用户空间(Android应用层)的日志。进入
data/local/tmp
,尝试创建backdoor-skip
文件,我们对该文件进行了处理,如果匹配到该文件名就直接跳过openat
的调用,尝试发现无法创建该文件,而其他名字的文件都可以创建
用 Kernel Patch 隐藏调试器特征
常见的 debugger check 方法
/proc/pid/status
的TracerPid
字段,该字段的生成相关代码如下: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
struct seq_file { // 关注该结构体的前四个字段,seq_file是一个很重要的字符串缓冲区
char *buf; // 指向内存
size_t size; // 表示最大内存空间
size_t from;
size_t count; // 表示长度
size_t pad_until;
loff_t index;
loff_t read_pos;
struct mutex lock;
const struct seq_operations *op;
int poll_event;
const struct file *file;
void *private;
};
static inline void task_state(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *p)
{
struct user_namespace *user_ns = seq_user_ns(m);
...
tracer = ptrace_parent(p);
if (tracer)
tpid = task_pid_nr_ns(tracer, ns);
tgid = task_tgid_nr_ns(p, ns);
ngid = task_numa_group_id(p);
cred = get_task_cred(p);
...
seq_put_decimal_ull(m, "\nTracerPid:\t", tpid); // 该字段就是TracerPid
...
}
int proc_pid_status(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *task)
{
struct mm_struct *mm = get_task_mm(task);
seq_puts(m, "Name:\t");
proc_task_name(m, task, true);
seq_putc(m, '\n');
task_state(m, ns, pid, task);
...
}/proc/pid/status
的State
字段,get_task_state
从task_state_array
获取静态字符串,相关代码如下,重点关注t (tracing stop)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18static const char * const task_state_array[] = {
/* states in TASK_REPORT: */
"R (running)", /* 0x00 */
"S (sleeping)", /* 0x01 */
"D (disk sleep)", /* 0x02 */
"T (stopped)", /* 0x04 */
"t (tracing stop)", /* 0x08 */
"X (dead)", /* 0x10 */
"Z (zombie)", /* 0x20 */
"P (parked)", /* 0x40 */
/* states beyond TASK_REPORT: */
"I (idle)", /* 0x80 */
};
seq_puts(m, "State:\t");
seq_puts(m, get_task_state(p));proc/pod/wchan
,wchan
文件内容是内核中进程休眠位置对应的符号名称,比如等待调试器就是ptrace_stop
,正常情况是字符"0"
,或者就是调试器的符号名称,具体如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19static int proc_pid_wchan(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *task)
{
unsigned long wchan;
char symname[KSYM_NAME_LEN];
if (!ptrace_may_access(task, PTRACE_MODE_READ_FSCREDS))
goto print0;
wchan = get_wchan(task);
if (wchan && !lookup_symbol_name(wchan, symname)) {
seq_puts(m, symname);
return 0;
}
print0:
seq_putc(m, '0');
return 0;
}`proc/pid/stat
或ps -A
,箭头所指的字段正常是R
,挂上调试器就是t
,该字段通过do_task_stat
获得,关键代码如下,state
字段就在(proc_task_name)
字段后面1
2
3
4
5
6
7
8static int do_task_stat(struct seq_file *m, struct pid_namespace *ns, struct pid *pid, struct task_struct *task, int whole) {
...
seq_puts(m, " (");
proc_task_name(m, task, false);
seq_puts(m, ") ");
seq_putc(m, state);
...
}
hook seq_put_decimal_ull 函数过 TracerPid 检测
在 task_state
函数中,系统通过 seq_put_decimal_ull
来获取,该函数是和内核导出函数,可以通过符号来获取其运行时地址并对其进行 hook
其函数定义为
1 | void seq_put_decimal_ull(struct seq_file *m, const char *delimiter, unsigned long long num) |
在 task_state
中的调用为
1 | seq_put_decimal_ull(m, "\nTracerPid:\t", tpid); |
因此只需要先检测第二个参数是否为 TracerPid
,然后再修改其第三个参数,也就是 tpid
的值即可。
获取函数地址可以用 kallsyms_lookup_name
函数,再用 hook_warp3
对seq_put_decimal_ull
修改第三个参数即可。
但是加载完模块以后发现根本就没调用,这就需要结合之前提取的内核文件的逆向结果来进行分析了,可以看出在我们这个镜像里 tsak_state
函数是没有执行before_seq_put_decimal_ull
的,而是直接调用了before_seq_put_decimal_ull_width
,那么我们直接对before_seq_put_decimal_ull_width
进行hook即可。
1 | // 比较第二个参数,同时为了避免没有附加调试器的情况需要判断 fargs->arg2 != 0 |
随意打开一个app,并挂上任意一个调试器,使用 cat /proc/pid/status
查看其 TracerPid
,并关注调试信息,发现调试信息中原本的 TracerPid
字段为 15498,但是查询出来变成了 0,说明hook成功。
hook seq_puts 函数过 State 检测
根据前面的分析, State
字段是获取如下,那么只要 hook 了seq_puts 函数,查看是否是"t (tracing stop)", /* 0x08 */
,然后将其修改为"S (sleeping)", /* 0x01 */
即可。
1 | seq_puts(m, "State:\\t"); |
具体实现如下:
1 | void before_seq_puts(hook_fargs2_t *fargs, void *udata) { |
但是由于在测试机提取的内核文件中获取该字段的方法被内联了,所以暂时还无法进行 hook。
hook proc_pid_wchan 函数过 wchan 检测
proc_pid_wchan
函数不是很好hook,因为 ptrace_may_access
如果随便hook会把系统搞崩,而且输入的参数也不好控制,注意到函数定义,其最后把结果存在了 struct seq_file *m
里面,这个seq_file
之前有提到过,在这里
1 | static int proc_pid_wchan(struct seq_file *m, struct pid_namespace *ns, struct pid *pid, struct task_struct *task) { |
而 seq_puts
函数最后会调用 seq_write
函数把数据写到 struct seq_file *m
里,可以看到写入的名称有多长,count
变量就是多大。
1 | int seq_write(struct seq_file *seq, const void *data, size_t len) |
那么我们可以直接在函数执行后修改 struct seq_file *m
中的值,如果监测到 m->buf
为 ptrace_stop
,就直接改为 "0"
1 | void after_proc_pid_wchan(hook_fargs4_t *fargs, void *udata) { |
挂上调试器测试,执行 cat /proc/pid/wchan
,返回 "0"
,同时弹出日志,说明hook成功。
hook do_task_stat 函数过 stat 检测
由于该函数中是 seq_putc
获得的字段,只用了一个字符,不像 "t (tracing stop)"
这么有区分度,所以入参判断需要注意一下,考虑到其前面必然跟着 ") "
,可以基于此来进行 hook。
1 | void after_do_task_stat(hook_fargs5_t *fargs, void *udata) { |
执行 cat /proc/pid/stat
,发现调试器标志被改成了 R
,说明hook成功。
用 kernel patch 隐藏 frida 特征
maps 文件特征 & 内存特征
/proc/pid/maps
文件内容描述了进程的内存布局信息,frida会注入一共frida-agent模块,因此在maps里面能找到对应的内存映射信息。检测代码如下:
1
2
3
4
5
6
7
8
9
10
11char line[512];
FILE *fp;
fp = fopen("/proc/self/maps", "r");
if (fp) {
while (fgets(line, 512, fp)) {
if(strstr(line, "frida")) {
// 检测到maps内存中有frida特征
}
}
fclose(fp);
}线程名特征,使用如下命令检测,特征为
gmain, gum-js-loop, gdbus, pool-frida, linjector
,由proc_pid_status
中的proc_task_name
函数获得,但是该函数有些内核文件没有导出,可以进一步hookproc_task_name
里面的__get_task_comm
,该函数可以获取线程/进程名,导出符号1
adb shell 'for taskdir in /proc/11198/task/*; do cat "$taskdir/status" | grep "Name:"; done'
D-BUS端口特征
frida 注入后在目标进程监听 27042 端口,这个端口只有
adbd
进程连接1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19for(i = 0;i <= 65535; i++) {
sock = Socket(AF INET, SOCK_STREAM, 0);
sa.sin_port = htons(i);
if(connect(sock, (struct sockaddr* )&sa, sizeof sa) != -1) {
__android_log_print(ANDROID_LOG_VERBOSE, APPNAME, "FRIDA DETECTION [1]: 0pen Port: %d", i);
memset(res, 0, 7);
//Frida Server 使用 D-Bus 协议进行通信。正常服务在收到 AUTH 命令后应返回 REJECT(表示需要认证)。
send(sock,"\x00",1,NULL);
send(sock,"AUTH\r\n",6,NULL);
usleep(100);
if(ret=recv(sock,res,6,MSG_DONTWAIT)!=-1){
// 如果响应是 REJECT,则认为该端口运行了 Frida Server。
if(strcmp(res,"REJECT")==){
/* Frida server detected.Do something. */
}
}
}
close(sock);
}
可以用 Envcheck来检测frida特征:
hook show_map_vma 函数修改 maps 文件过 maps 检测和内存特征检测
看一下源码,该函数作用是输出maps文件中的一段内存区域(一行)
get_vma_name
获取内存name
1 | show_map_vma(struct seq_file *m, struct vm_area_struct *vma) |
可以在函数执行前记录下seq_file→count,执行后再根据当前内存段是否是frida相关的进行删除,只要恢复之前保存的count值就可以删除。
1 | bool __attribute__((optimize("O0")))is_hidden_module(struct seq_file *m) { |
可以用 Envcheck
app来对 hook 效果进行检测,挂上 frida 后,Maps记录检查被绕过
hook __get_task_comm 函数过线程检测
_get_task_comm
函数定义如下
1 | char *__get_task_comm(char *buf, size_t buf_size, struct task_struct *tsk) |
buf中的就是name,可以after_hook把buf值替换成一些自定义字符。
1 | bool __attribute__((optimize("O0")))is_hidden_comm(char * buf) { |
再次检测一下,发现线程检测已经被过掉了
hook connect 过端口检测
既然知道是27042端口,直接对connect
系统调用进行 hook,当port是27042时,直接跳过该函数即可,其函数原型如下:
1 | int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); |
可以对connect系统调用进行hook,如果检测到端口为27042,就直接跳过函数执行。