前言
GameGuardian是Android平台最知名的内存浏览工具(下文简称GG),被广泛用于游戏反作弊测试、安全性测试、在游戏厂商允许的前提下制作MOD等。
GG涉及到各种Android/Linux平台的技术,比较值得学习研究。
从它官网的数据,下载量将近2亿,2012年推出了第一个版本,目前最新版本是2021年3月21发布的101.1,由于target版本过低,Android14就已经无法直接安装,目前看来是不打算再更新了。
GG把内存空间划分为不同的类型,比如"Ca"、"Xa"等。Android11正式版,在2020年9月8被推出之后,网络上逐渐出现了不少用户反馈GG找不到"Ca"内存,但是简单从互联网搜索后,并没发现有合理的回答。
在Android11之前,也有极小部分用户遇到了找不到"Ca"内存的情况。GG作者Enyby答复:
虚拟内存区域划分
在进程空间中,不同地址区间的内存有不同的来源,比如有些内存是直接映射的磁盘上某个文件,有些内存是通过C语言代码通过malloc函数申请得到的。
某个数据所在的内存地址,如果在代码中,是通过C语言调用libc的malloc函数申请的内存空间,那么每次启动进程,它都会在libc的malloc函数管理的区域中。GG依此把内存划分为多个区域,以便于可以更精确和快速的找到目标数据的内存地址。
为了方便程序员修复BUG和分析内存占用,Linux内核提供了获取虚拟内存信息的接口,主要是/proc/{pid}/maps和/proc/{pid}/smaps两个文件。pmap命令也仅仅是对这个接口的封装,Android14源码目录/external/toybox/toys/other/pmap.c
// ...忽略
sprintf(toybuf, "/proc/%u/%smaps", pid, "s"+!FLAG(x));
if (!(fp = fopen(toybuf, "r"))) {
error_msg("no %s", toybuf);
continue;
}
// ...
maps和smaps具体内容包含了虚拟内存地址起止范围、权限、映射名称等。两个文件节选如下:
sunstone:/ $ cat /proc/self/maps
58e143c000-58e1468000 r--p 00000000 fd:08 690 /system/bin/toybox
58e1468000-58e14b0000 r-xp 0002c000 fd:08 690 /system/bin/toybox
58e14b0000-58e14b4000 r--p 00074000 fd:08 690 /system/bin/toybox
58e14b4000-58e14b8000 rw-p 00077000 fd:08 690 /system/bin/toybox
58e14b8000-58e14bc000 rw-p 00000000 00:00 0 [anon:.bss]
7d6ac00000-7d6aec7000 ---p 00000000 00:00 0 [anon:cfi shadow]
7d6aec7000-7d6aec8000 r--p 00000000 00:00 0 [anon:cfi shadow]
7d6aec8000-7d6afef000 ---p 00000000 00:00 0 [anon:cfi shadow]
7d6afef000-7d6aff0000 r--p 00000000 00:00 0 [anon:cfi shadow]
7d6aff0000-7deac00000 ---p 00000000 00:00 0 [anon:cfi shadow]
7deac00000-7deb400000 rw-p 00000000 00:00 0 [anon:libc_malloc]
7deb575000-7deb578000 r--p 00000000 fd:08 2947 /system/lib64/libnetd_client.so
7deb578000-7deb57d000 r-xp 00003000 fd:08 2947 /system/lib64/libnetd_client.so
7deb57d000-7deb57e000 r--p 00008000 fd:08 2947 /system/lib64/libnetd_client.so
7deb57e000-7deb57f000 rw-p 00008000 fd:08 2947 /system/lib64/libnetd_client.so
7deb5b1000-7deb615000 rw-p 00000000 00:00 0 [anon:linker_alloc]
7deb615000-7deb617000 r--p 00000000 fd:08 2709 /system/lib64/libcgrouprc.so
7deb617000-7deb618000 r-xp 00002000 fd:08 2709 /system/lib64/libcgrouprc.so
7deb618000-7deb619000 r--p 00003000 fd:08 2709 /system/lib64/libcgrouprc.so
7deb619000-7deb61a000 rw-p 00003000 fd:08 2709 /system/lib64/libcgrouprc.so
.. 省略
sunstone:/ $ cat /proc/self/smaps
64a3d37000-64a3d63000 r--p 00000000 fd:08 690 /system/bin/toybox
Size: 176 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 64 kB
Pss: 64 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 64 kB
Private_Dirty: 0 kB
Referenced: 64 kB
Anonymous: 0 kB
LazyFree: 0 kB
AnonHugePages: 0 kB
ShmemPmdMapped: 0 kB
FilePmdMapped: 0 kB
Shared_Hugetlb: 0 kB
Private_Hugetlb: 0 kB
Swap: 0 kB
SwapPss: 0 kB
Locked: 0 kB
THPeligible: 0
VmFlags: rd mr mw me dw
64a3d63000-64a3dab000 r-xp 0002c000 fd:08 690 /system/bin/toybox
Size: 288 kB
KernelPageSize: 4 kB
... 省略
Android的sysdump meminfo具体的获取内存占用的实现代码在/frameworks/base/core/jni/android_os_Debug.cpp,也是解析的/proc/{pid}/smaps。可以从这里看到如何根据内存名称对内存进行分类(留意这里[anon:libc_malloc]和[anon:scudo:):
uint32_t namesz = name.size();
if (base::StartsWith(name, "[heap]")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:libc_malloc]")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:scudo:")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[anon:GWP-ASan")) {
which_heap = HEAP_NATIVE;
} else if (base::StartsWith(name, "[stack")) {
which_heap = HEAP_STACK;
} else if (base::StartsWith(name, "[anon:stack_and_tls:")) {
which_heap = HEAP_STACK;
} else if (base::EndsWith(name, ".so")) {
which_heap = HEAP_SO;
is_swappable = true;
} else if (base::EndsWith(name, ".jar")) {
which_heap = HEAP_JAR;
is_swappable = true;
} else if (base::EndsWith(name, ".apk")) {
which_heap = HEAP_APK;
is_swappable = true;
} else if (base::EndsWith(name, ".ttf")) {
which_heap = HEAP_TTF;
is_swappable = true;
而GG对内存的分类,也是基于maps文件的。
可以把GG拖到JADX,搜索相关字符串定位到代码:
a(arrayList, luaTable, "REGION_JAVA_HEAP", 2);
a(arrayList, luaTable, "REGION_C_HEAP", 1);
a(arrayList, luaTable, "REGION_C_ALLOC", 4);
a(arrayList, luaTable, "REGION_C_DATA", 8);
a(arrayList, luaTable, "REGION_C_BSS", 16);
a(arrayList, luaTable, "REGION_PPSSPP", 262144);
a(arrayList, luaTable, "REGION_ANONYMOUS", 32);
a(arrayList, luaTable, "REGION_JAVA", 65536);
a(arrayList, luaTable, "REGION_STACK", 64);
a(arrayList, luaTable, "REGION_ASHMEM", 524288);
a(arrayList, luaTable, "REGION_VIDEO", 1048576);
a(arrayList, luaTable, "REGION_OTHER", -2080896);
a(arrayList, luaTable, "REGION_BAD", 131072);
a(arrayList, luaTable, "REGION_CODE_APP", 16384);
a(arrayList, luaTable, "REGION_CODE_SYS", 32768);
GG的ELF文件位于/res/raw/目录中,其中ydw2文件拖到IDA,里面打开maps的其中一处函数sub_109E0如下:
// ... 忽略
strcpy(format, "0qspd0&e0nbqt");
v1 = &format[1];
v2 = 48;
do
{
*(v1 - 1) = v2 - 1;
v2 = *v1++;
}
while ( v2 );
v3 = getpid();
snprintf(v0, 0x10000uLL, format, v3);
v4 = (unsigned int *)__errno();
*v4 = 0;
v5 = fopen(v0, "r");
//... 忽略
while ( fgets(v0, 0x10000, v6) )
{
// 忽略
v12 = sscanf(v0, "%zx-%zx %s %zx %*s %*s %n", &v50, &v49, &v51, &v48, &v47);
if ( v12 == 4 )
//... 忽略
这里0qspd0&e0nbqt不知为何做要做加密,把这段伪代码跑一下可以确定就是/proc/%d/maps。
在这个函数中,间接调用了sub_10DC0,这正是GG划分内存区域的逻辑,部分伪代码如下:
if ( haystack )
{
if ( strstr(haystack, "[anon:.bss]") )
{
if ( !byte_5DF410 )
{
byte_5DF410 = 1;
sub_5C8D90(4LL, "android-daemon", "BSS: 2");
}
result = 16LL;
if ( dword_5DD048 != 8 )
result = 2048LL;
dword_5DD048 = result;
return result;
}
if ( !strncmp(haystack, "/system/", 8uLL) )
{
dword_5DD048 = 1024;
return 1024LL;
}
if ( strstr(haystack, "/dev/zero") )
goto LABEL_91;
if ( strstr(haystack, "PPSSPP_RAM") )
{
dword_5DD048 = 0x40000;
return 0x40000LL;
}
if ( !strstr(haystack, "system@")
&& !strstr(haystack, "gralloc")
&& strncmp(haystack, "[vdso]", 6uLL)
&& strncmp(haystack, "[vectors]", 9uLL)
&& (strncmp(haystack, "/dev/", 5uLL) || !strncmp(haystack, "/dev/ashmem", 0xBuLL)) )
{
if ( strstr(haystack, "dalvik") )
{
if ( (strstr(haystack, "eap")
|| strstr(haystack, "dalvik-alloc")
|| strstr(haystack, "dalvik-main")
|| strstr(haystack, "dalvik-large")
|| strstr(haystack, "dalvik-free"))
&& !strstr(haystack, "itmap")
&& !strstr(haystack, "ygote")
&& !strstr(haystack, "ard")
&& !strstr(haystack, "jit")
&& !strstr(haystack, "inear") )
{
dword_5DD048 = 2;
result = 2LL;
}
else
{
dword_5DD048 = 0x10000;
result = 0x10000LL;
}
return result;
}
if ( strstr(haystack, "/lib") && strstr(haystack, ".so") )
{
if ( byte_5E66B0[0] )
{
v7 = byte_5E66B0;
}
else
{
if ( strstr(haystack, "/data/") )
{
LABEL_89:
dword_5DD048 = 8;
return 8LL;
}
v7 = "/mnt/";
}
if ( strstr(haystack, v7) )
goto LABEL_89;
}
if ( strstr(haystack, "malloc") )
{
LABEL_91:
dword_5DD048 = 4;
return 4LL;
}
if ( strstr(haystack, "[heap]") )
{
dword_5DD048 = 1;
return 1LL;
}
if ( strstr(haystack, "[stack") )
{
LABEL_95:
dword_5DD048 = 64;
return 64LL;
}
这里的dword_5DD048正对应着刚刚贴出的Java代码的参数四,例如dword_5DD048 = 4;对应着 a(arrayList, luaTable, "REGION_C_ALLOC", 4);的4,也就是说,GG所谓的Ca内存是通过查找/proc/{pid}/maps的最后一列是否包含malloc字符串而确定的。
内存分配器 & VMA NAME
C语言常用*alloc系列函数分配内存,malloc只是libc提供的接口,而具体的实现,取决于Android系统。
内存分配器通过mmap系统调用,向系统申请一大段内存空间,内存分配器管理这段空间,当malloc每次被调用时,会从内存分配器管理的空间中划分出一部分,返回给调用者。
在Android平台,Android5之后的版本使用jemalloc作为libc的内存分配器的实现。Android10的jemalloc路径是/external/jemalloc/。其中/external/jemalloc/src/pages.c有为虚拟内存地址设置名称的代码:
#if defined(__ANDROID__)
if (ret != NULL) {
/* Name this memory as being used by libc */
prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, ret,
size, "libc_malloc");
}
#endif
这也就是GG通过名称中是否存在"malloc"而确定是否为"C++ Alloca"内存的依据。
但是在Android11开始,默认的内存分配器从jemalloc改为了scudo,Adnroid11源码路径/external/scudo/。sudo更安全但是会占用更多内存空间,所以在一些设备上仍然在使用jemalloc(我手边的红米note和一加都是如此)。
在Android平台,scudo内部通过common.h的map函数通过mmap向系统申请内存并设置VMA名称,实现位于linux.cpp:
void *map(void *Addr, uptr Size, UNUSED const char *Name, uptr Flags,
UNUSED MapPlatformData *Data) {
int MmapFlags = MAP_PRIVATE | MAP_ANONYMOUS;
int MmapProt;
if (Flags & MAP_NOACCESS) {
MmapFlags |= MAP_NORESERVE;
MmapProt = PROT_NONE;
} else {
MmapProt = PROT_READ | PROT_WRITE;
#if defined(__aarch64__) && defined(ANDROID_EXPERIMENTAL_MTE)
if (Flags & MAP_MEMTAG)
MmapProt |= PROT_MTE;
#endif
}
if (Addr) {
// Currently no scenario for a noaccess mapping with a fixed address.
DCHECK_EQ(Flags & MAP_NOACCESS, 0);
MmapFlags |= MAP_FIXED;
}
void *P = mmap(Addr, Size, MmapProt, MmapFlags, -1, 0);
if (P == MAP_FAILED) {
if (!(Flags & MAP_ALLOWNOMEM) || errno != ENOMEM)
dieOnMapUnmapError(errno == ENOMEM);
return nullptr;
}
#if SCUDO_ANDROID
if (!(Flags & MAP_NOACCESS))
prctl(ANDROID_PR_SET_VMA, ANDROID_PR_SET_VMA_ANON_NAME, P, Size, Name);
#endif
return P;
}
而Android11开始,一些设备找不到带有"malloc"字符串的内存,正是因为scudo设置VMA名称不含"malloc",而是"scudo:primary"、"scudo:secondary"...。
在Android系统中,使用PR_SET_VMA_ANON_NAME设置内存区域名称,可以理解为传入的是字符串的内存地址,而不是字符串本身,也就是说,直接修改这个地址的字符串,就立刻可以在/proc/{pid}/maps中看到。
解决思路
回归到最开始的GG没有Ca内存的话题,如果要解决,最简单的方式就是直接修改进程libc.so.rodata的"scudo:"开头的字符串,改为"malloc",大概30行C语言代码,至于具体代码,其实我个人用不到GG,所以先不写了。