某 PUBG 内核辅助逆向分析
最近一个朋友发了一个 PUBG 的内核挂过来,名字就不说了,避免麻烦。久仰外挂隐藏之大名,正好拿来看看现在的内核辅助是怎么写的,用了什么技术、怎么隐藏、怎么和反作弊系统对抗。
样本是一个 .sh 自解压脚本,要求 root 权限运行。下面是完整的分析过程。
第一层:Shell 自解压包装
拿到的文件是 PUBG公益内核.sh,一个 shell 脚本。打开看头部:
#!/system/bin/sh
skip=48
tmpdir=/data/adb/XXXXXX
...
tail +$skip "$0" | gzip -cd > "$tmpdir/ditpro_main"
chmod 755 "$tmpdir/ditpro_main"
"$tmpdir/ditpro_main" &
sleep 5
rm -rf "$0"
经典的自解压结构:tail +48 跳过前 48 行 shell 头,剩下的是 gzip 压缩的 payload,解压出来就是主程序 ditpro_main。
有意思的是最后那个 sleep 5 && rm -rf "$0"——执行完 5 秒后把自己删了。不留尸体,取证的时候磁盘上已经没有原始文件了。
写个 Python 脚本把 payload 提取出来:
gz_offset = raw.find(b'\x1f\x8b')
gz_data = raw[gz_offset:]
main_bin = gzip.decompress(gz_data)
解压出来是个 40MB 的 ELF,AArch64 架构,动态链接的 PIE 可执行文件。NDK r27d 编译,target API 23。大得离谱——后面会知道为什么这么大。
第二层:主程序结构概览
把 ditpro_main.bin 丢进 IDA,总共 8484 个函数,7937 个没名字(stripped)。段布局值得注意:
| 段 |
范围 |
大小 |
说明 |
.rodata |
0x5c040 - 0x215278c |
34.5 MB |
只读数据 |
.text |
0x21b69f0 - 0x26e0018 |
5.2 MB |
代码 |
.bss |
0x26f7240 - 0x272eaa0 |
225 KB |
未初始化数据 |
.rodata 占了 34.5MB,比代码段还大好几倍。这就是那 40MB 体积的来源——所有内核模块的数据都塞在这里面。
第三层:找到内核模块
逆向外挂最核心的问题:内核驱动在哪?
搜 .ko 文件?找不到
最开始尝试在 binary 里直接搜 ELF magic \x7fELF,搜完发现除了主程序自己的 ELF 头之外,没有第二个 \x7fELF 签名。所以 .ko 不是以原始二进制形式嵌在里面的。
追字符串线索
换个思路,搜和内核模块相关的字符串。搜 insmod 找到了 sub_21BA524:
void insmod_and_delete(std::string& ko_path) {
std::string cmd = "insmod " + ko_path + " > /dev/null 2>&1";
system(cmd.c_str());
remove(ko_path.c_str());
}
加载完就删,标准的反取证操作。那 ko_path 从哪来?往上追调用链。
发现 hex 编码方案
追到 sub_21BA204,这个函数负责把数据「解码」成 .ko 文件:
void hex_decode_to_file(std::string& hex_data, std::string& output_path) {
std::ofstream ofs(output_path);
for (int i = 0; i < hex_data.length(); i += 2) {
char hex_pair[3] = { hex_data[i], hex_data[i+1], 0 };
unsigned char byte = strtoul(hex_pair, NULL, 16);
ofs.write(&byte, 1);
}
}
每次读 2 个字符,strtoul 按十六进制解析,写一个字节。所以 .ko 是以 ASCII hex 字符串的形式存储的——不是加密,就是简单的 hex 编码。
比如 ELF 头 \x7fELF\x02\x01\x01 在 .rodata 里长这样:7f454c46020101...
在 IDA 里搜这个 ASCII 字符串,一搜搜出来 17 个。所有的 .ko 都找到了。
内核版本路由
再追上一层,到 sub_21C4898。这个函数是 .ko 的分发入口:
void load_kernel_module() {
FILE* fp = popen("uname -r", "r");
fgets(kernel_version, 256, fp);
pclose(fp);
if (kernel_version starts with "5.10")
hex_data = &ko_data_510;
else if (kernel_version starts with "6.1")
hex_data = &ko_data_61;
else if (kernel_version starts with "6.6")
hex_data = &ko_data_66;
else if (kernel_version == "4.14.117")
hex_data = &ko_data_414117;
hex_decode_to_file(hex_data, random_path);
system("insmod " + random_path);
remove(random_path);
}
先 uname -r 拿内核版本,然后 switch/case 选对应版本编译的 .ko。内嵌了 17 个版本,覆盖 4.14.117 到 6.6.87,基本涵盖了主流 Android 设备的内核。
全局变量到内核版本的对应关系:
| 全局变量 |
内核版本 |
xmmword_26F7E00 |
4.14.117 |
xmmword_26F7E20 |
4.14.141 |
xmmword_26F7E40 |
4.14.180 |
xmmword_26F7E60 |
4.14.186 |
xmmword_26F7E80 |
4.19.81 |
xmmword_26F7EA0 |
4.19.113 |
xmmword_26F7F00 |
4.19.191 |
xmmword_26F7FA0 |
5.10 |
xmmword_26F7FC0 |
5.15 |
xmmword_26F7FE0 |
6.1 |
xmmword_26F8000 |
6.6 |
这些全局变量在 .init_array 的 sub_21CE444 里被初始化,就是简单的 memcpy 把 .rodata 里的 hex 字符串拷贝到 std::string 对象里。
提取 .ko
知道了编码方式,提取就很简单了:
def find_hex_ko_blobs(data: bytes):
pattern = b"7f454c46020101"
results = []
start = 0
while True:
idx = data.find(pattern, start)
if idx < 0:
break
end = idx
while end < len(data) and chr(data[end]) in "0123456789abcdef":
end += 1
hex_str = data[idx:end]
results.append((idx, hex_str))
start = end + 1
return results
提取结果:
ko_00_4.19.157_hack.ko 12,040 bytes
ko_01_4.14.180_hack.ko 12,456 bytes
ko_02_6.6.87_hack.ko 28,849 bytes
ko_03_5.4.86_hack.ko 26,248 bytes
ko_05_6.1.129_hack.ko 44,320 bytes
ko_09_4.14.117_hack.ko 12,000 bytes
ko_14_5.15.178_hack.ko 62,056 bytes ← 最大,功能最全
ko_15_5.10.234_hack.ko 30,560 bytes
...共 17 个
所有模块名都叫 hack,license 声称 GPL,作者 大大怪。
第四层:内核驱动分析
拿 5.15.178 版本(最大的那个,62KB)丢进 IDA。这次有符号,分析起来舒服多了。
设备注册与自隐藏
init_module 做了三件事:
__int64 init_module() {
misc_register(&misc);
list_del_init(&__this_module);
kobject_del(&module_kobject);
return 0;
}
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 里没入口。
ioctl 命令表
dispatch_ioctl 是核心,4 个命令:
switch (cmd) {
case 26209:
copy_from_user(&req, arg, 0x20);
ReadProcPhyMem(req.pid, req.addr, req.buf, req.size);
break;
case 26210:
copy_from_user(&req, arg, 0x20);
WriteProcPhyMem(req.pid, req.addr, req.buf, req.size);
break;
case 26211:
copy_from_user(&req, arg, 0x18);
copy_from_user(name_buf, req.name_ptr, 0xFF);
req.result = get_module_base(req.pid, name_buf);
copy_to_user(arg, &req);
break;
case 26212:
copy_from_user(&req, arg, 0x18);
req.result = 10086;
copy_to_user(arg, &req);
break;
}
26212 是握手命令——用户态打开 /dev/niuto01 后先发这个,检查返回值是不是 10086,确认驱动已经活着。
物理内存读写——绕过一切
这是整个外挂的技术核心。它不走 process_vm_readv 这种正经 API(会被反作弊监控),而是自己手动遍历页表,直接操作物理内存。
第一步:手动页表遍历
translate_linear_address 实现了完整的 AArch64 四级页表遍历:
__int64 translate_linear_address(__int64 mm, unsigned __int64 vaddr) {
pgd = *(mm->pgd + ((vaddr >> 30) & 0x1FF) * 8);
if (!pgd) return 0;
pmd = *((phys_to_virt(pgd)) + ((vaddr >> 21) & 0x1FF) * 8);
if (!pmd) return 0;
if (!(pmd & 2)) {
if (pmd & 1)
return (pmd & 0xFFFFFFFFF000) + (vaddr & 0x1FFFFF);
return 0;
}
pte = *((phys_to_virt(pmd)) + ((vaddr >> 12) & 0x1FF) * 8);
if (!(pte & 1)) return 0;
return (pte & 0xFFFFFFFFF000) | (vaddr & 0xFFF);
}
其中物理地址到内核虚拟地址的转换用的是:(phys & 0x7FFFFFF000) - memstart_addr) | 0xFFFFFF8000000000,这是 ARM64 Linux 的线性映射公式。
第二步:物理内存读取
拿到物理地址之后,read_physical_address 通过 ioremap_cache 把物理地址映射到内核虚拟空间:
bool read_physical_address(uint64_t phys_addr, void* user_buf, size_t size) {
if (!pfn_valid(phys_addr >> PAGE_SHIFT))
return false;
void* mapped = ioremap_cache(phys_addr, size);
if (!mapped) return false;
copy_to_user(user_buf, mapped, size);
iounmap(mapped);
return true;
}
先检查 mem_section(SPARSEMEM 模型下的物理页面有效性校验),然后 ioremap_cache 映射、copy_to_user 拷贝到用户态、iounmap 解除映射。一气呵成。
第三步:跨页读写
ReadProcPhyMem 处理了跨页的情况。因为页表是以 4KB 为单位映射的,如果要读的数据跨越了页面边界,就得分多次:
while (remaining > 0) {
page_remaining = 4096 - (vaddr & 0xFFF);
chunk = min(remaining, page_remaining);
phys = translate_linear_address(mm, vaddr);
kernel_va = phys_to_virt(phys);
copy_to_user(user_buf, kernel_va, chunk);
vaddr += chunk;
user_buf += chunk;
remaining -= chunk;
}
注意这个版本更暴力——它直接用 (phys - memstart_addr) | 0xFFFFFF8000000000 算出内核线性映射地址,连 ioremap 都省了。直接当内核指针用,copy_to_user 拷走。
模块基址查找
get_module_base 用来在目标进程里找某个 .so 的加载地址(比如游戏引擎 libUE4.so):
__int64 get_module_base(pid_t pid, const char* module_name) {
task = get_pid_task(find_get_pid(pid));
mm = get_task_mm(task);
vma = mm->mmap;
while (vma) {
if (vma->vm_file) {
path = file_path(vma->vm_file, buf, 255);
filename = strrchr(path, '/');
filename = filename ? filename + 1 : path;
if (strcmp(filename, module_name) == 0)
return vma->vm_start;
}
vma = vma->vm_next;
}
return 0;
}
遍历目标进程的 VMA(Virtual Memory Area)链表,对每个 VMA 用 file_path 拿到映射文件路径,strrchr 取文件名,和目标模块名比较。匹配上了就返回 vm_start。
第五层:用户态对抗技术
内核驱动只是基础设施,用户态的 ditpro_main 才是外挂本体。它还用了一堆花活来隐藏自己。
SurfaceFlinger 覆盖层隐藏
外挂要画 ESP(透视框)和准心,但不能让截图、录屏看到。sub_21BEFEC 是整个渲染隐藏的初始化函数——3900 字节,144 个基本块,做了一件事:根据 Android 版本动态解析 SurfaceFlinger 的 C++ API。
第一步:获取 Android 版本
char version_str[128] = {0};
__system_property_get("ro.build.version.release", version_str);
int android_ver = strtoul(version_str, NULL, 10);
if (android_ver <= 4) {
__android_log_print(ANDROID_LOG_FATAL, "AImGui",
"[-] Unsupported system version: %zu", android_ver);
return;
}
日志 tag 是 AImGui——Android ImGui 的缩写,整个渲染系统基于 Dear ImGui。版本低于 5 直接退出。
第二步:dlopen 系统库
void* libgui = dlopen("/system/lib64/libgui.so", RTLD_NOW);
void* libutils = dlopen("/system/lib64/libutils.so", RTLD_NOW);
没有直接调用 dlopen,而是通过传入的函数指针表间接调用。这样 GOT 表里就不会出现 dlopen 的记录,增加一点静态分析的难度。
第三步:解析 35+ 个 C++ mangled 符号
这是这个函数的主体。它往一个 288 字节的结构体里填充函数指针,每个指针对应一个 SurfaceComposerClient 的方法。结构体布局:
struct SurfaceAPI {
int64_t android_version;
void* incStrong;
void* decStrong;
void* String8_ctor;
void* String8_dtor;
void* LayerMetadata_ctor;
void* LayerMetadata_setInt32;
void* SCC_ctor;
void* createSurface;
void* createSurface_and8;
void* createSurface_and9;
void* mirrorSurface;
void* getInternalDisplayToken;
void* getBuiltInDisplay;
void* getDisplayState;
void* getDisplayInfo;
void* getPhysicalDisplayIds;
void* getPhysicalDisplayToken;
void* openGlobalTransaction;
void* closeGlobalTransaction;
void* Transaction_ctor;
void* setLayer;
void* setTrustedOverlay;
void* setLayerStack;
void* show;
void* hide;
void* reparent;
void* setMatrix;
void* setPosition;
void* apply;
void* validate;
void* getSurface;
void* SC_disconnect;
void* SC_setLayer;
void* Surface_disconnect;
};
最有意思的是 createSurface 的版本适配。Android 每个大版本都改过这个函数的签名,所以外挂针对每个版本 dlsym 不同的 mangled name:
| Android 版本 |
createSurface 签名 |
变化 |
| 5-7 |
(String8&, uint, uint, int, uint) |
基础 5 参数 |
| 8 |
(..., SurfaceControl*, uint, uint) |
加父容器 + flags |
| 9 |
(..., SurfaceControl*, int, int) |
参数类型变了 |
| 10 |
(..., SurfaceControl*, LayerMetadata) |
加 metadata |
| 11 |
(..., SurfaceControl*, LayerMetadata, uint*) |
加输出参数 |
| 12-13 |
(..., IBinder&, LayerMetadata, uint*) |
父容器改为 IBinder |
| 14+ |
(..., int, int, IBinder&, gui::LayerMetadata, uint*) |
gui 命名空间 |
7 个不同的 C++ 函数签名,7 个不同的 mangled symbol name。只要有一个对不上,dlsym 返回 NULL,Surface 就创建不了。这就是为什么函数要这么大——大部分代码都在做版本分发。
事务 API 的版本分裂
Android 9 之前用的是全局事务模型:
openGlobalTransaction();
surfaceControl->setLayer(0x7FFFFFFF);
closeGlobalTransaction(false);
Android 9+ 改成了 Transaction 对象:
Transaction txn;
txn.setLayer(surfaceControl, 0x7FFFFFFF);
txn.setTrustedOverlay(surfaceControl, true);
txn.show(surfaceControl);
txn.apply(true);
连 Transaction::apply 的参数都从 Android 13 开始多了一个 bool。外挂专门处理了这个差异:Android 9-12 用 apply(bool),13+ 用 apply(bool, bool)。
核心隐藏机制:setTrustedOverlay
if (android_ver >= 12) {
setTrustedOverlay = dlsym(libgui,
"_ZN7android21SurfaceComposerClient11Transaction"
"17setTrustedOverlayERKNS_2spINS_14SurfaceControlEEEb");
}
setTrustedOverlay(surfaceControl, true) 是 Android 12 引入的 API,本意是让系统 UI(状态栏、导航栏)标记自己为「受信任的覆盖层」。受信任的覆盖层有一个特性:不会被 SurfaceFlinger 的截图/录屏管线捕获。
这是系统级别的隐藏——不是 app 层的 FLAG_SECURE,而是 SurfaceFlinger 合成器在做帧合成的时候就把它跳过了。所以:
- 反作弊用
MediaProjection 截图?看不到
- 反作弊用
screencap 命令?看不到
- 反作弊用 SurfaceFlinger 的
capture 接口?看不到
- 只有直接拿 framebuffer 或者 GPU 合成后的数据才能看到
配合 mirrorSurface(Android 11+),外挂还能把游戏画面镜像到自己的 Surface 上做分析,而不影响游戏本身的渲染。
补充:ZoomSurface
在字符串里还搜到了 ZoomSurface 相关的日志:
[*] ZoomSurface called with dsdx: %f, dtdx: %f, dtdy: %f, dsdy: %f
[*] ZoomSurface called with scaleX: %f, scaleY: %f
这是通过 Transaction::setMatrix(surfaceControl, dsdx, dtdx, dtdy, dsdy) 实现的——对 Surface 做仿射变换(缩放/旋转)。可能用于放大镜功能或者适配不同分辨率。
输入设备伪装
sub_223120C 处理输入注入——外挂需要自动开枪/压枪,但得让触摸事件看起来像真人操作:
void setup_input() {
system("chmod 000 -R /proc/bus/input/*");
int fd = open("/dev/uinput", O_WRONLY | O_NONBLOCK);
pthread_create(&tid, NULL, input_thread, NULL);
}
先把 /proc/bus/input/ 的权限全干掉,这样反作弊读不到输入设备列表。然后通过 /dev/uinput 创建虚拟触摸设备注入事件。注入时加了随机延时,模拟人类操作的不规则性。
ImGui + OpenGL ES 渲染
外挂 UI 用的是 Dear ImGui 1.90.1 + OpenGL ES 3.x + EGL Surface。在 .rodata 里能搜到 ImGui 的版本字符串和大量 UI 相关常量。渲染管线:
EGL Surface → OpenGL ES 3.x Context → ImGui 渲染 → SurfaceFlinger 合成
Embree 光线追踪
这个比较少见——binary 里包含了 Intel Embree 光线追踪库的代码。BVH(Bounding Volume Hierarchy)加速结构相关的函数有一大堆。
用途推测是 ESP/透视功能:要判断一个敌人是否「可见」(中间有没有墙),用光线追踪对场景做 ray cast 是最精确的方法。
完整攻击链
把所有层串起来,完整的工作流程:
Shell 脚本(root 执行)
│
├─ tail + gzip 解压出 ditpro_main
├─ 启动 ditpro_main,5 秒后删除 .sh 自身
│
ditpro_main(用户态)
│
├─ uname -r 获取内核版本
├─ 从 .rodata 选择对应版本的 hex-encoded .ko
├─ hex decode → 写入随机文件名的临时文件
├─ insmod 加载 → 立即 remove() 删除文件
│ │
│ └─ .ko 内核驱动
│ ├─ misc_register("/dev/niuto01")
│ ├─ list_del + kobject_del 从模块列表/sysfs 隐身
│ └─ 等待 ioctl 指令
│
├─ open("/dev/niuto01")
├─ ioctl(26212) 握手,检查返回 10086
├─ ioctl(26211) 获取游戏模块基址
│
├─ 循环:
│ ├─ ioctl(26209) 读游戏内存(物理内存直接访问)
│ ├─ 计算敌人位置 / 物资位置 / 准心偏移
│ ├─ ioctl(26210) 写游戏内存(修改数值)
│ └─ ImGui 渲染 ESP / 准心辅助线
│
├─ SurfaceFlinger: setTrustedOverlay(true) 防截图
├─ chmod 000 /proc/bus/input/* 隐藏输入设备
└─ /dev/uinput 注入触摸事件(自动压枪)
隐藏手段总结
| 层级 |
技术 |
目的 |
| 文件层 |
5 秒自删 .sh + insmod 后删 .ko |
磁盘无痕 |
| 内核层 |
list_del + kobject_del |
lsmod/sysfs 不可见 |
| 内存层 |
物理地址直读,不走 ptrace/process_vm_readv |
绕过 API 级监控 |
| 渲染层 |
setTrustedOverlay(true) |
截图/录屏不可见 |
| 输入层 |
chmod 000 + uinput 虚拟设备 |
隐藏注入行为 |
| 命名层 |
随机 40 字符文件名 + stdout 重定向 /dev/null |
进程/文件名无特征 |
后记
从技术角度看,这个外挂的架构还是挺完整的。内核驱动本身很小(最大的也才 62KB),功能单一——就是提供物理内存读写通道。所有复杂逻辑都在用户态,驱动只是个打洞工具。
最核心的对抗点在于物理内存访问:手动遍历页表 + ioremap_cache 这条路完全绕过了内核的访问控制机制,反作弊系统如果只监控 ptrace、/proc/pid/mem、process_vm_readv 这些常规接口,是完全看不到这种读写的。
要检测这类外挂,反作弊至少需要:
- 监控
misc_register / cdev_add 等设备注册调用
- 定期扫描
/dev 下的未知设备节点
- 检测
ioremap 调用是否指向了用户态进程的物理页面
- 内核完整性校验——检查模块链表是否被篡改
写一个游戏外挂是难,但是我们可以根据已有的产出进行分析,学习其中对抗和反对抗思路,我也真的很敬佩这些写外挂的大牛,充分发挥逆向精神,让不可能变得可能
附件我感觉不能传了,各位自己可以去搜搜,然后分析一下对抗,现在大部分的内核辅助为了适用性大多数都使用sh来封装一些脚本和二进制
[招生]科锐逆向工程师培训(2026年7月3日实地,远程教学同时开班, 第56期)!