24kcsplus
文章24
标签39
分类0
使用KPM来伪造环境以绕过Frida检测

使用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_wrap0hook_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 大佬的,给大佬跪了

参考文章

  1. Apatch内核模块搭建到隐藏Frida注入
  2. 绕过Inline hook的CRC校验
本文作者:24kcsplus
本文链接:https://24kblog.top/posts/1942360428/
版权声明:除非特别声明,否则本文采用 CC BY-NC-SA 3.0 CN 协议进行许可
×