在之前的实践中我都是通过process_vm_readv的系统调用进行跨进程内存读的,可以说基本上也是没有遇到过太大的阻碍。而虽然不太常见,实用性也有待商榷,但似乎确实有一种蜜罐的检测思路
mincore 系统调用用于确定调用进程的虚拟内存页面是否驻留在内存中,其用法如下
p为需要检测的虚拟内存,然后是n个系统页的size,接着就会返回对n个vec写入状态
这里的vec虽然是一个字节,但目前只有最低位有意义,其值为1时则表示对应的页面resident,驻留在内存中
1 2 3 4 5
| int pageSize = getpagesize(); void* p = mmap(nullptr, pageSize * 4, ...); unsigned char vec[4];
mincore(p, pageSize * 4, vec);
|
而对于mmap后得到的指针,在第一次真正访问之前,其实都是没有分配物理页的,没有有效的PTE,也就说此时对应的vec为0。
说实话,确实如果只是普通的应用,也基本没有这样的需求,我认为这个思路只有在游戏反作弊中才能真正起效果。
对于游戏外挂来说,想要通过非hook的方式获取到玩家对象,然后读取坐标或者别的信息之类的,很重要的就是要找到一个维护实体的列表对象。像是在Unity中我之前遇到过的有自定义的EntityManager,而虚幻则是肯定会存在的UWorld->PersistentLevel->ActorsArray的这条路径,然后进行遍历读取信息。
也就是说是前提是外挂制作者一定会全部遍历这个列表结果并访问/那么思路就是,在这个列表当中插入一个只mmap,但没有任何初始化的fake对象地址,默认情况下这个虚拟内存没有实际分配物理页,而一旦外挂通过用户态访问内存并尝试解引用获取对象数据,那么就会马上激活物理页分配,然后就可以通过mincore来检测到这个信息了。
下面就是一个简单的demo,虽然mmap分配出来默认是页对齐的,但这里还是稳健一点手动对齐了
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
| void check_Actor(void* trapPage) { while(true) { { std::shared_lock<std::shared_mutex> lock(mutex); int pageSize = getpagesize(); unsigned char vec = 0; unsigned long addr= reinterpret_cast<unsigned long>(trapPage); unsigned long start = addr & (~(pageSize - 1)); mincore((void *)start, pageSize, &vec); if (vec & 1) std::cout << "Find hacker!" << std::endl; else std::cout << "Ok" << std::endl;
} sleep(2); } }
int main() { ... for(int i = 0; i < 10; i++) { Actor* new_Actor = new Actor(i, 100); Actors.push_back(new_Actor); }
void* trapPage = mmap()
Actor* fakeActor = reinterpret_cast<Actor*>(trapPage); Actors.push_back(fakeActor);
for(int i = 10; i < 20; i++) { Actor* new_Actor = new Actor(i, 100); Actors.push_back(new_Actor); }
std::thread thread1(check_Actor, trapPage);
thread1.detach(); ... }
|
接下来就用之前写的memdump工具通过process_vm_readv来读取内存并遍历这个Actors
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| for i in range(count): slot = begin + i * 8
actor = mem.read_ptr(slot, pid)
if actor == 0: print(f"[{i:3}] nullptr") continue
field1 = mem.read_i32(actor + 0x00, pid) field2 = mem.read_i32(actor + 0x04, pid)
print( f"[{i:3}] " f"slot={hex(slot)} " f"actor={hex(actor)} " f"field1={field1} " f"field2={field2}" )
mem.close()
|
可以看到这里正常访问了idx 10的fakeActor,并成功解引用了数据,id和hp读出来都是初始的全0值。这里本身不会造成崩溃
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
| twogoat@twogoat-virtual-machine:~/Desktop/linux_kernel/detect_test/Memory_dump$ python main.py pid: 181906 base: 0x5e4b8a506000 Actors @ 0x5e4b8a50d2c0 begin = 0x5e4b975595c0 end = 0x5e4b97559668 capacity_end = 0x5e4b975596c0 count = 21 [ 0] slot=0x5e4b975595c0 actor=0x5e4b975592b0 field1=0 field2=100 [ 1] slot=0x5e4b975595c8 actor=0x5e4b975592f0 field1=1 field2=100 [ 2] slot=0x5e4b975595d0 actor=0x5e4b975592d0 field1=2 field2=100 [ 3] slot=0x5e4b975595d8 actor=0x5e4b97559310 field1=3 field2=100 [ 4] slot=0x5e4b975595e0 actor=0x5e4b97559360 field1=4 field2=100 [ 5] slot=0x5e4b975595e8 actor=0x5e4b975593d0 field1=5 field2=100 [ 6] slot=0x5e4b975595f0 actor=0x5e4b975593f0 field1=6 field2=100 [ 7] slot=0x5e4b975595f8 actor=0x5e4b97559410 field1=7 field2=100 [ 8] slot=0x5e4b97559600 actor=0x5e4b97559430 field1=8 field2=100 [ 9] slot=0x5e4b97559608 actor=0x5e4b975594e0 field1=9 field2=100 [ 10] slot=0x5e4b97559610 actor=0x71013ab42000 field1=0 field2=0 [ 11] slot=0x5e4b97559618 actor=0x5e4b97559500 field1=10 field2=100 [ 12] slot=0x5e4b97559620 actor=0x5e4b97559520 field1=11 field2=100 [ 13] slot=0x5e4b97559628 actor=0x5e4b97559540 field1=12 field2=100 [ 14] slot=0x5e4b97559630 actor=0x5e4b97559560 field1=13 field2=100 [ 15] slot=0x5e4b97559638 actor=0x5e4b97559580 field1=14 field2=100 [ 16] slot=0x5e4b97559640 actor=0x5e4b975595a0 field1=15 field2=100 [ 17] slot=0x5e4b97559648 actor=0x5e4b975596d0 field1=16 field2=100 [ 18] slot=0x5e4b97559650 actor=0x5e4b975596f0 field1=17 field2=100 [ 19] slot=0x5e4b97559658 actor=0x5e4b97559710 field1=18 field2=100 [ 20] slot=0x5e4b97559660 actor=0x5e4b97559730 field1=19 field2=100
|
所以这里也是一个比较有问题的点,如果对象中包含大量需要继续解引用的指针成员,那么全零诱饵会使这些字段表现为 null 或无效指针,而且很明显也能看出来这个fakeActor的虚拟地址和别人明显不在一个内存区域。
1 2 3 4 5 6 7
| class Actor{ public: int id; int hp; SomeObject* component; std::string name; };
|
所以总结一下这个蜜罐检测的缺点
1 需要作弊者遍历对应的列表对象,不遍历直接硬读偏移就没用。
2 发现地址和其他对象明显不在一个内存区域直接跳过。
3 fake对象访问后整个区域为\x00,数据明显异常,而且作弊器相关操作后如果崩溃就能很快找出问题。
4 有可能出现误报的情况,而且也有可能和游戏引擎本身的一些枚举行为起冲突,非常麻烦。
不得不说安卓在行为检测上,用户层的限制确实是太大了,对于这种跨进程内存读都没有太好的办法,更别提内核层的外挂了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| mincore: 单页粒度很好 但只能观察 residency → trap 必须 non-resident
Referenced: 能观察 present page 的访问痕迹 但 reset 接口是 process-wide → 不适合作为干净的单页 watchpoint
userfaultfd: 真正 event-driven 但 Android普通App不能捕获 remote GUP kernel fault
mprotect: 单页保护 但无法区分访问者
|