最近一个朋友发了一个 PUBG 的内核挂过来,名字就不说了,避免麻烦。久仰外挂隐藏之大名,正好拿来看看现在的内核辅助是怎么写的,用了什么技术、怎么隐藏、怎么和反作弊系统对抗。
样本是一个 .sh 自解压脚本,要求 root 权限运行。下面是完整的分析过程。
拿到的文件是 PUBG公益内核.sh,一个 shell 脚本。打开看头部:
经典的自解压结构:tail +48 跳过前 48 行 shell 头,剩下的是 gzip 压缩的 payload,解压出来就是主程序 ditpro_main。
有意思的是最后那个 sleep 5 && rm -rf "$0"——执行完 5 秒后把自己删了。不留尸体,取证的时候磁盘上已经没有原始文件了。
写个 Python 脚本把 payload 提取出来:
解压出来是个 40MB 的 ELF,AArch64 架构,动态链接的 PIE 可执行文件。NDK r27d 编译,target API 23。大得离谱——后面会知道为什么这么大。
把 ditpro_main.bin 丢进 IDA,总共 8484 个函数,7937 个没名字(stripped)。段布局值得注意:
.rodata 占了 34.5MB,比代码段还大好几倍。这就是那 40MB 体积的来源——所有内核模块的数据都塞在这里面。
逆向外挂最核心的问题:内核驱动在哪?
最开始尝试在 binary 里直接搜 ELF magic \x7fELF,搜完发现除了主程序自己的 ELF 头之外,没有第二个 \x7fELF 签名。所以 .ko 不是以原始二进制形式嵌在里面的。
换个思路,搜和内核模块相关的字符串。搜 insmod 找到了 sub_21BA524:
加载完就删,标准的反取证操作。那 ko_path 从哪来?往上追调用链。
追到 sub_21BA204,这个函数负责把数据「解码」成 .ko 文件:
每次读 2 个字符,strtoul 按十六进制解析,写一个字节。所以 .ko 是以 ASCII hex 字符串的形式存储的——不是加密,就是简单的 hex 编码。
比如 ELF 头 \x7fELF\x02\x01\x01 在 .rodata 里长这样:7f454c46020101...
在 IDA 里搜这个 ASCII 字符串,一搜搜出来 17 个。所有的 .ko 都找到了。
再追上一层,到 sub_21C4898。这个函数是 .ko 的分发入口:
先 uname -r 拿内核版本,然后 switch/case 选对应版本编译的 .ko。内嵌了 17 个版本,覆盖 4.14.117 到 6.6.87,基本涵盖了主流 Android 设备的内核。
全局变量到内核版本的对应关系:
这些全局变量在 .init_array 的 sub_21CE444 里被初始化,就是简单的 memcpy 把 .rodata 里的 hex 字符串拷贝到 std::string 对象里。
知道了编码方式,提取就很简单了:
提取结果:
所有模块名都叫 hack,license 声称 GPL,作者 大大怪。
拿 5.15.178 版本(最大的那个,62KB)丢进 IDA。这次有符号,分析起来舒服多了。
init_module 做了三件事:
misc 结构体里写的很清楚:minor 号 255(MISC_DYNAMIC_MINOR,动态分配),设备名 niuto01,file_operations 指向 dispatch_functions。
关键是后面两步:list_del_init 把自己从内核的模块链表里摘掉,kobject_del 把 /sys/module/hack/ 目录干掉。这样 lsmod 看不到、/sys 下找不到,但设备节点 /dev/niuto01 已经注册好了,ioctl 通道照常工作。
配合用户态的 insmod 后立即 remove() 删文件,三重消失:磁盘上没文件、模块列表里没记录、sysfs 里没入口。
dispatch_ioctl 是核心,4 个命令:
26212 是握手命令——用户态打开 /dev/niuto01 后先发这个,检查返回值是不是 10086,确认驱动已经活着。
这是整个外挂的技术核心。它不走 process_vm_readv 这种正经 API(会被反作弊监控),而是自己手动遍历页表,直接操作物理内存。
第一步:手动页表遍历
translate_linear_address 实现了完整的 AArch64 四级页表遍历:
其中物理地址到内核虚拟地址的转换用的是:(phys & 0x7FFFFFF000) - memstart_addr) | 0xFFFFFF8000000000,这是 ARM64 Linux 的线性映射公式。
第二步:物理内存读取
拿到物理地址之后,read_physical_address 通过 ioremap_cache 把物理地址映射到内核虚拟空间:
先检查 mem_section(SPARSEMEM 模型下的物理页面有效性校验),然后 ioremap_cache 映射、copy_to_user 拷贝到用户态、iounmap 解除映射。一气呵成。
第三步:跨页读写
ReadProcPhyMem 处理了跨页的情况。因为页表是以 4KB 为单位映射的,如果要读的数据跨越了页面边界,就得分多次:
注意这个版本更暴力——它直接用 (phys - memstart_addr) | 0xFFFFFF8000000000 算出内核线性映射地址,连 ioremap 都省了。直接当内核指针用,copy_to_user 拷走。
get_module_base 用来在目标进程里找某个 .so 的加载地址(比如游戏引擎 libUE4.so):
遍历目标进程的 VMA(Virtual Memory Area)链表,对每个 VMA 用 file_path 拿到映射文件路径,strrchr 取文件名,和目标模块名比较。匹配上了就返回 vm_start。
内核驱动只是基础设施,用户态的 ditpro_main 才是外挂本体。它还用了一堆花活来隐藏自己。
外挂要画 ESP(透视框)和准心,但不能让截图、录屏看到。sub_21BEFEC 是整个渲染隐藏的初始化函数——3900 字节,144 个基本块,做了一件事:根据 Android 版本动态解析 SurfaceFlinger 的 C++ API。
第一步:获取 Android 版本
日志 tag 是 AImGui——Android ImGui 的缩写,整个渲染系统基于 Dear ImGui。版本低于 5 直接退出。
第二步:dlopen 系统库
没有直接调用 dlopen,而是通过传入的函数指针表间接调用。这样 GOT 表里就不会出现 dlopen 的记录,增加一点静态分析的难度。
第三步:解析 35+ 个 C++ mangled 符号
这是这个函数的主体。它往一个 288 字节的结构体里填充函数指针,每个指针对应一个 SurfaceComposerClient 的方法。结构体布局:
最有意思的是 createSurface 的版本适配。Android 每个大版本都改过这个函数的签名,所以外挂针对每个版本 dlsym 不同的 mangled name:
7 个不同的 C++ 函数签名,7 个不同的 mangled symbol name。只要有一个对不上,dlsym 返回 NULL,Surface 就创建不了。这就是为什么函数要这么大——大部分代码都在做版本分发。
事务 API 的版本分裂
Android 9 之前用的是全局事务模型:
Android 9+ 改成了 Transaction 对象:
连 Transaction::apply 的参数都从 Android 13 开始多了一个 bool。外挂专门处理了这个差异:Android 9-12 用 apply(bool),13+ 用 apply(bool, bool)。
核心隐藏机制:setTrustedOverlay
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。