使用KPM来伪造环境以绕过Frida检测
前言
这篇文章是填上次遇到的坑接着讲的,主要内容就是绕过 Frida 检测,不过这次主要是伪造环境来绕过 Frida 检测,而且是在内核态的。
KPM 是一种运行在内核空间内的模块,可以让代码运行在内核空间中,类似于 Loadable Kernel Modules(LKM),这是这篇文章主要要讲的东西,就是 KPM
初始化项目
git clone https://github.com/bmax121/KernelPatch.git
将代码克隆下来后,需要配置交叉编译的编译器,这里我们可以从 ARM 开发者官网 找到并下载,然后配置好环境变量
cd KernelPatch/kpms/demo-hello
make
如果编译没有什么问题,就差不多了
编写 KPM 模块
我们要伪造一个没有 Frida 的环境,主要要关注几个 Frida 特征,maps 及 内存、线程名和端口
maps 及 内存
使用 Frida 注入时,在 /proc/[pid]/maps 中会有一个内存映射信息,是 Frida 注入的 frida-agent 模块,以及内存中会有 frida 特征,比如 frida-agent frida gum-js-loop GumJS gmain 等
我们需要 hook 一个内核函数叫 show_map_vma,这个函数是 Linux 系统下有程序读取 /proc/[pid]/maps 时,负责将相关信息写入这个文件的函数,只需要 hook 这个函数并改变它的行为,用户态的程序就无法察觉
如何 hook 呢,参考 KPM 开发文档 就可以知道,要先使用 kallsyms_lookup_name 函数找到要 hook 的地址,然后使用内联 hook 的 hook_wrap 函数即可 hook
同时,这个 hook_wrap 还有类型化便捷封装,即使用 hook_wrap0 – hook_wrap12 这样的函数,就不用再传入 argno 即被 hook 函数参数数量这个参数,在 hook.h 中这些包装函数是这样的:
static inline hook_err_t hook_wrap2(void *func, hook_chain2_callback before, hook_chain2_callback after, void *udata)
{
return hook_wrap(func, 2, before, after, udata);
}
根据上面的内容,我们可以写出相关安装 hook 的代码:
show_map_vma = (void *) kallsyms_lookup_name("show_map_vma");
if (show_map_vma) {
hook_err_t err = hook_wrap2(show_map_vma, before_show_map_vma, after_show_map_vma, NULL);
}
在安装 hook 后就需要考虑怎么 hook,我们通过传入 hook_wrap2 的两个函数来修改这个内核函数调用前后的行为
show_map_vma 的源码是这样的:
static void
show_map_vma(struct seq_file *m, struct vm_area_struct *vma)
{
const struct path *path;
const char *name_fmt, *name;
vm_flags_t flags = vma->vm_flags;
unsigned long ino = 0;
unsigned long long pgoff = 0;
unsigned long start, end;
dev_t dev = 0;
if (vma->vm_file) {
const struct inode *inode = file_user_inode(vma->vm_file);
dev = inode->i_sb->s_dev;
ino = inode->i_ino;
pgoff = ((loff_t)vma->vm_pgoff) << PAGE_SHIFT;
}
start = vma->vm_start;
end = vma->vm_end;
// 输出内存区间范围,标志等
show_vma_header_prefix(m, start, end, flags, pgoff, dev, ino);
get_vma_name(vma, &path, &name, &name_fmt); // 获取内存区间 name
// 输出路径相关
if (path) {
seq_pad(m, ' ');
seq_path(m, path, "\n");
} else if (name_fmt) {
seq_pad(m, ' ');
seq_printf(m, name_fmt, name);
} else if (name) {
seq_pad(m, ' ');
seq_puts(m, name);
}
seq_putc(m, '\n');
}
这个函数的第一个参数 m 就包含了这段内存中映射的所有文件,那么我们就需要把相关特征的对应行给去掉,这里我们采用的是调用前先记录这个参数中 count 的值,然后在返回前检测是否包含关键词,如果是,则将 count 改为原先记录的值,具体流程参考下图:
具体源码如下,主要参考了 这篇文章
// 内核环境下的 memmem 实现
static void *memmem_local(const void *haystack, size_t haystacklen, const void *needle, size_t needlelen)
{
if (!haystack || !needle || haystacklen < needlelen || needlelen == 0)
return NULL;
for (size_t i = 0; i <= haystacklen - needlelen; ++i) {
if (memcmp((const char *)haystack + i, needle, needlelen) == 0)
return (void *)((const char *)haystack + i);
}
return NULL;
}
// 检查 seq_file 缓冲区中是否包含敏感关键词
static int is_hiden_module(struct seq_file *m)
{
if (!m || !m->buf || m->count == 0) return false;
// 需要隐藏的关键词列表
static const char *keywords[] = {
"frida-agent",
"frida",
"gum-js-loop",
"GumJS",
"gmain",
NULL
};
for (int i = 0; keywords[i] != NULL; ++i) {
if (memmem_local(m->buf, m->count, keywords[i], strlen(keywords[i])))
return 1;
}
return 0;
}
void before_show_map_vma(hook_fargs2_t *args, void *udata)
{
struct seq_file *m = (struct seq_file *)args->arg0;
args->local.data0 = 0;
if (m && m->buf) {
// 记录 seq_file 中的count,在 after hook 中设置 count 为记录值
args->local.data0 = m->count;
}
}
void after_show_map_vma(hook_fargs2_t *args, void *udata)
{
struct seq_file *m = (struct seq_file *)args->arg0;
if (m && m->buf) {
if (args->local.data0 && is_hiden_module(m)) { // is_hiden_module 查找 frida-agent 等字符串
pr_info("inject-hide: maps hide -> frida-agent \n");
m->count = args->local.data0; // 恢复原来的 count 值,下一次写入缓冲区时将从此开始,覆盖此前写入的含frida的部分
}
}
}
void frida_hide_install(void)
{
show_map_vma = (void *) kallsyms_lookup_name("show_map_vma");
if (show_map_vma) {
hook_err_t err = hook_wrap2(show_map_vma, before_show_map_vma, after_show_map_vma, NULL);
}
}
void frida_hide_uninstall(void)
{
if (show_map_vma) {
unhook(show_map_vma);
show_map_vma = 0;
}
}
线程名
Frida 注入后,会在 /proc/[pid]/task/*/status 下有相关痕迹
而这个文件通常是由 proc_task_name (4.18及以上)/task_name (4.18以下) 负责,里面会调用 __get_task_comm (4.18-6.12内核版本)/get_task_comm (其它内核版本)来获取线程信息,我们可以在它返回到 proc_task_name 之前来将关键词替换为空格
内核源码如下(此处版本为 4.4.302):
static inline void task_name(struct seq_file *m, struct task_struct *p)
{
char *buf;
size_t size;
char tcomm[sizeof(p->comm)];
int ret;
get_task_comm(tcomm, p);
seq_puts(m, "Name:\t");
size = seq_get_buf(m, &buf);
ret = string_escape_str(tcomm, buf, size, ESCAPE_SPACE | ESCAPE_SPECIAL, "\n\\");
seq_commit(m, ret < size ? ret : -1);
seq_putc(m, '\n');
}
extern char *__get_task_comm(char *to, size_t len, struct task_struct *tsk);
#define get_task_comm(buf, tsk) ({ \
BUILD_BUG_ON(sizeof(buf) != TASK_COMM_LEN); \
__get_task_comm(buf, sizeof(buf), tsk); \
})
char *__get_task_comm(char *buf, size_t buf_size, struct task_struct *tsk)
{
task_lock(tsk);
strncpy(buf, tsk->comm, buf_size);
task_unlock(tsk);
return buf;
}
KPM 代码如下:
char *(*__get_task_comm)(char *buf, size_t buf_size, struct task_struct *tsk) = 0; // 为了后续能够调用,定义成函数指针变量
int __get_task_comm_hook_status = 0;
int is_hiden_comm(const char *comm)
{
// 需要隐藏的线程名关键词列表
static const char *keywords[] = {
"gmain",
"gum-js-loop",
"gdbus",
"pool-frida",
"linjector",
};
for (int i = 0; i < sizeof(keywords) / sizeof(keywords[0]); i++) {
if (strstr(comm, keywords[i])) {
return 1;
}
}
return 0;
}
void __attribute__((optimize("O0"))) after_get_task_comm(hook_fargs3_t *args, void *udata)
{
char *comm = (char *)args->arg0;
size_t comm_buf_len = (size_t)args->arg1;
if (comm && comm_buf_len) {
if (is_hiden_comm(comm)){
pr_info("inject-hide: get_task_comm hide -> %s\n", comm);
size_t hide_len = strlen(comm);
for(size_t i = 0; i < hide_len; i++) {
comm[i] = ' ';
}
}
}
}
void frida_hide_install(void)
{
show_map_vma = (void *) kallsyms_lookup_name("show_map_vma");
if (show_map_vma) {
hook_err_t err = hook_wrap2(show_map_vma, before_show_map_vma, after_show_map_vma, NULL);
}
__get_task_comm = (void *) kallsyms_lookup_name("__get_task_comm"); // 注意这里需要按照自己的内核版本来修改
if (__get_task_comm) {
hook_err_t err = hook_wrap3(__get_task_comm, 0, after_get_task_comm, 0);
__get_task_comm_hook_status = err ? 0 : 1;
}
}
端口
-
frida 注入后在目标进程监听 27042 端口,等待外部工具(如 frida-server 或 frida-client)连接
-
只有名为 adbd 的进程会主动连接这个 27042 端口。
adbd 是 Android 设备上的调试守护进程,只有它会通过 adb 端口转发机制连接 frida-agent 监听的 27042 端口,其他普通应用或进程不会主动连接 27042 端口。
所以我们需要限制一下,只允许 adbd 连接这个端口,这样用户态的程序也无法检测到端口了
KPM 代码如下
struct sockaddr_in {
short sin_family;
unsigned short sin_port;
unsigned int sin_addr;
char sin_zero[8];
};
unsigned long (*__arch_copy_from_user)(void *to, const void __user *from, unsigned long n) = 0;
int connect_hook_status = 0;
u16 ntohs(u16 port) {
return port >> 8 | port << 8;
}
void before_connect(hook_fargs3_t *args, void *udata) {
struct sockaddr_in addr_kernel;
const char __user *addr = (typeof(addr))syscall_argn(args, 1);
if (!addr) return;
__arch_copy_from_user(&addr_kernel, addr, sizeof(struct sockaddr_in));
u16 port = ntohs(addr_kernel.sin_port);
if (port == 27042) {
char comm[16];
__get_task_comm(comm, sizeof(comm), current);
pr_warn("inject-hide: connect to frida-agent, comm: %s, port: %d\n", comm, port);
if (!strstr(comm, "adbd")) { // 只允许 adbd 连接 frida
pr_warn("inject-hide: connect to frida-agent blocked, comm: %s, port: %d\n", comm, port);
args->skip_origin = 1; // 跳过原始的 connect 函数
args->ret = -1; // 返回 -1 表示拒绝连接
}
}
}
完整性校验
这部分的代码我没有看过,使用的是 Yuuki 写的 mem_crc,参考了他的 这篇文章
我在前期逆向这个游戏的时候,拿 unidbg 来查看了 syscall 调用情况,发现会打开 libc.so,且 Frida 只 attach 不会闪退,而 spawn 时 hook 会闪退,猜测是有完整性校验,所以参考了上面提到的文章
Frida 进行 hook 时会修改一些函数入口处的值,程序可以通过 CRC 或其它方式来检测内存中对应 so 的 .text 段是否与磁盘中的 so 相同,以此校验内存中的指令是否被篡改
不过如果将内存中的 so dump 下来,然后用某种方式让程序读取磁盘中的 so 为 dump 下来的 so,就可以让程序认为指令没有被修改,从而绕过检测
使用 mem_crc 模块中通过 getcwd 函数控制模块的功能,我们可以很轻松的在合适时机进行重定向,来绕过检测
function dumpModule(soName = '', output_path = '') { // dump so
var module = Process.getModuleByName(soName);
if (module === null) {
console.log("[!] Module not found: " + soName);
return;
}
console.log("[*] Found module: " + module.name);
console.log("[*] Base address: " + module.base);
console.log("[*] Size: " + module.size);
// 读取内存内容
try {
var buffer = module.base.readByteArray(module.size);
console.log("[*] Successfully read " + module.size + " bytes");
if (buffer === null) {
console.log("[!] Failed to read memory");
return;
}
// 保存到文件
var file = new File(output_path, "wb");
file.write(buffer);
file.close();
console.log("[*] Dump saved to: " + output_path);
} catch (e) {
console.log("[!] Error: " + e);
}
}
function callGetcwd(buf = '') { // write cmd
var getcwd = new NativeFunction(
Process.getModuleByName("libc.so").getExportByName('getcwd'),
'pointer',
['pointer', 'int']
);
var buffer_size = 256;
var buffer;
if (buf !== '') {
buffer = Memory.allocUtf8String(buf);
buffer_size = buf.length + 1;
} else {
buffer = Memory.alloc(buffer_size);
}
try {
getcwd(buffer, buffer_size);
} catch (e) {
console.log("[!] Error: " + e);
return null;
}
}
function hook_dlopen(soName = '') {
var linkerName = Process.pointerSize === 4 ? "linker" : "linker64";
let linkerModule = Process.getModuleByName(linkerName);
let Address = linkerModule.base.add(0x51C5C) // 这里是 linker dlopen 的地址
Interceptor.attach(Address, {
onEnter: function (args) {
var libraryName = args[0].readCString();
// console.log(libraryName);
if (libraryName && libraryName.indexOf(soName) !== -1) {
console.log("[*] 检测到目标库 " + soName);
dumpModule("libc.so", "/storage/emulated/0/Documents/libc.so")
callGetcwd("MAP:10213:/apex/com.android.runtime/lib64/bionic/libc.so:/storage/emulated/0/Documents/libc.so")
// il();
// 用完用命令清 echo CMD:CLEAR:TYPE:1 > /dev/yuuki_misc
}
}
});
}
hook_dlopen("libFairGuard.so")
不过 Frida 部分与上面提到的文章中不同的是,由于此样本负责检测的 so 文件不是由 libc 的 dlopen 打开的,所以在 Frida 里 Hook 这个 dlopen会找不到加载时机,所以我这里改成了 hook linker 中的 dlopen,代码中的偏移可能会因为版本等原因不同,可能需要自己找地址
尾声
这篇文章写完,这个游戏的逆向也算是告一段落了,不过这个样本也确实是有待探索,比如说 FairGuard 是怎么加密解密 libil2cpp.so 的,还有他们是怎么处理,来去除被保护的 so 的导入导出表,又怎么在运行时正常加载需要的函数
绕过之后的 hook,主要还是使用 frida-il2cpp-bridge 来进行的
KPM 的编写也有很大学问,我这篇文章主要还是参考 zsk 和 Yuuki 大佬的,给大佬跪了
琼公网安备46010602001577号