基于mincore实现的蜜罐内存访问检测

两只羊 Lv3

在之前的实践中我都是通过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

# Actors[i]
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:
单页保护
但无法区分访问者
  • 标题: 基于mincore实现的蜜罐内存访问检测
  • 作者: 两只羊
  • 创建于 : 2026-08-25 21:50:05
  • 更新于 : 2026-09-01 12:39:32
  • 链接: https://twogoat.github.io/2026/08/25/linux内核态内存访问模块学习/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论
目录
基于mincore实现的蜜罐内存访问检测