首页
社区
课程
招聘
[原创]关于Unity游戏Mono注入型外挂的一个检测思路
2020-12-20 23:42 15881

[原创]关于Unity游戏Mono注入型外挂的一个检测思路

2020-12-20 23:42
15881

前言

朋友们好啊,我是混元编程门掌门人马保国,刚才有个朋友问我马老师发生什么事了,我说怎么回事?给我发了一个张截图,我一看,哦,源赖氏佐田,有一个年轻人,10多岁,塔说,塔写了个unity(SSJJ)游戏的外挂,还是注入直接调用SDK,功能十分牛逼,问我马老师你能不能检测出我?我说可以,我说你用mono注入不好用,他不服气,我说小朋友,你两个手指来跟我一根手指比敲代码,我来检测你,你来过检测,他敲不动,他说我这个检测没用,我说我这个有用,这是化劲,传统功夫讲化劲的,他说要和我试试,我说可以,我一说,他啪就站起来了,很快啊,然后上来就是一个嫖开源代码注入器dnspy反编译游戏代码调用SDK做挂,我大意了啊,没有闪。它的挂给我游戏蹭了一下,我当时就流眼泪了,但是没关系啊,我两分钟过后就好了,把他检测了。(-_-以上是废话,可以掠过)

思路

unity游戏使用的c#虚拟机是mono,一般注入型又能调用SDK的c#dll外挂一般都会调用mono的函数来完成注入,通常来说流程是这样

1
远程写入dll数据 -> 外部获取mono导出函数表地址 -> 写入调用mono函数的shellcode -> 远程创建线程调用shellcode -> 查询加载结果

在这里我们有两个方面可以努力一下:

 

其一是在注入时,我们可以通过hook mono的载入dll函数,验证这个dll是否属于游戏自身的dll,来达到防止注入的目的;亦或是将自己的所有dll都以自己知道的加密方式加密,然后载入dll时按照加密方式解密,这样如果一个未经加密的dll想要注入进来,经过解密流程后就变得不可用

 

其二便是主动遍历模块,mono载入c#模块跟windows载入dll模块类似,会维护一个全局的链表,存储所有模块的信息,我们遍历这个链表即可得到所有载入dll的信息,然后根据需要判断该dll是否为可疑dll

过程

mono下载最新的mono源码,用VS打开,直接来到载入镜像函数mono_image_open_from_data_with_name

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
MonoImage *
mono_image_open_from_data_with_name (char *data, guint32 data_len, gboolean need_copy, MonoImageOpenStatus *status, gboolean refonly, const char *name)
{
    MonoCLIImageInfo *iinfo;
    MonoImage *image;
    char *datac;
 
    if (!data || !data_len) {
        if (status)
            *status = MONO_IMAGE_IMAGE_INVALID;
        return NULL;
    }
    datac = data;
    if (need_copy) {
        datac = g_try_malloc (data_len);
        if (!datac) {
            if (status)
                *status = MONO_IMAGE_ERROR_ERRNO;
            return NULL;
        }
        memcpy (datac, data, data_len);
    }
 
    image = g_new0 (MonoImage, 1);
    image->raw_data = datac;
    image->raw_data_len = data_len;
    image->raw_data_allocated = need_copy;
    image->name = (name == NULL) ? g_strdup_printf ("data-%p", datac) : g_strdup(name);
    iinfo = g_new0 (MonoCLIImageInfo, 1);
    image->image_info = iinfo;
    image->ref_only = refonly;
    image->ref_count = 1;
 
    image = do_mono_image_load (image, status, TRUE, TRUE);
    if (image == NULL)
        return NULL;
 
    return register_image (image);
}

可以看到,没有名字的镜像调用这个函数载入时,mono会自动为其分配一个名字,这个名字格式就是data-镜像内存地址

 

继续往下跟进,发现调用了do_mono_image_load函数,这里就是镜像载入的核心函数,最后调用的register_image是我们关注的重点,因为执行了这个函数,image就彻底被加入到了mono维护的链表中,我们点进去看一下这个函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static MonoImage *
register_image (MonoImage *image)
{
    MonoImage *image2;
    GHashTable *loaded_images = image->ref_only ? loaded_images_refonly_hash : loaded_images_hash;
 
    mono_images_lock ();
    image2 = g_hash_table_lookup (loaded_images, image->name);
 
    if (image2) {
        /* Somebody else beat us to it */
        mono_image_addref (image2);
        mono_images_unlock ();
        mono_image_close (image);
        return image2;
    }
    g_hash_table_insert (loaded_images, image->name, image);
    if (image->assembly_name && (g_hash_table_lookup (loaded_images, image->assembly_name) == NULL))
        g_hash_table_insert (loaded_images, (char *) image->assembly_name, image);   
    mono_images_unlock ();
 
    return image;
}

注意到代码的第18-22行完成了一个 g_hash_table_insert 的操作,而在glib中,hash_table是一个类似于map的映射表,我们可以看到,这个插入操作往loaded_images这个表里面插入了一项,而这个loaded_images可以在第5行代码看到是一个全局变量

 

于是乎我们找到了这个全局链表的位置

 

image.c 47-51

1
2
3
4
5
/*
 * Keeps track of the various assemblies loaded
 */
static GHashTable *loaded_images_hash;
static GHashTable *loaded_images_refonly_hash;

他们的初始化位于mono_images_init

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
 * mono_images_init:
 *
 *  Initialize the global variables used by this module.
 */
void
mono_images_init (void)
{
    InitializeCriticalSection (&images_mutex);
 
    loaded_images_hash = g_hash_table_new (g_str_hash, g_str_equal);
    loaded_images_refonly_hash = g_hash_table_new (g_str_hash, g_str_equal);
 
    debug_assembly_unload = g_getenv ("MONO_DEBUG_ASSEMBLY_UNLOAD") != NULL;
 
    mutex_inited = TRUE;
}

所以我们可以通过特征很方便的定位到这两个表的地址,然后用glib开源的方法遍历即可

 

另外,还有一个表也很有用,可以作为检测的途径,就是 assembly.c 中的 loaded_assemblies

 

assembly.c 114-117

1
2
3
4
/*
 * keeps track of loaded assemblies
 */
static GList *loaded_assemblies = NULL;

代码

定位变量

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
PVOID LocateLoadedAssemblies() {
#ifdef ver5_6_5_x86
    /*
        Function: mono_assembly_foreach
        Byte:
            56                            push    esi
            57                            push    edi
            BF 84 B4 20 10                mov     edi, offset stru_1020B484
            57                            push    edi; lpCriticalSection
            FF 15 4C 61 14 10             call    ds : EnterCriticalSection
            FF 35 7C B4 20 10             push    loaded_assemblies
    */
    PVOID monoModule = GetModuleHandleA("mono.dll");
    PVOID mono_assembly_foreach = GetProcAddress((HMODULE)monoModule, "mono_assembly_foreach");
    vector<ULONG64> res;
    int Cnt = SearchMemory((HANDLE)-1, (char*)"56 57 BF ?? ?? ?? ?? 57 ?? ?? ?? ?? ?? ?? FF 35 ?? ?? ?? ??", (unsigned long long)mono_assembly_foreach, (unsigned long long)((DWORD)mono_assembly_foreach + 50), 10, res);
    Print("SigCnt2 = %d", Cnt);
    if (Cnt == 1) {
        ULONG64 OffsetAddress = res[0] + 16;
        int LoadedAssemblies = *(int*)OffsetAddress;
        Print("LoadedAssemblies = %X", LoadedAssemblies);
        return (PVOID)(LoadedAssemblies);
    }
    return 0;
#else
    return 0;
#endif
}
 
PVOID LocateLoadedImagesHashAddress() {
#ifdef ver5_6_5_x86
    /*
        Function: mono_image_init
        Byte:
            57                            push    edi
            56                            push    esi
            E8 FA 74 FC FF                call    sub_100012C0
            57                            push    edi
            56                            push    esi
            A3 F4 B6 20 10                mov     loaded_images_hash, eax
    */
    PVOID monoModule = GetModuleHandleA("mono.dll");
    Print("MonoModule = %X", monoModule);
    PVOID mono_image_init = GetProcAddress((HMODULE)monoModule, "mono_images_init");
    Print("mono_image_init = %X", mono_image_init);
    vector<ULONG64> res;
    int Cnt = SearchMemory((HANDLE)-1, (char*)"57 56 E8 ?? ?? ?? ?? 57 56 A3 ?? ?? ?? ??", (unsigned long long)mono_image_init, (unsigned long long)((DWORD)mono_image_init + 50), 10, res);
    Print("SigCnt = %d", Cnt);
    if (Cnt == 1) {
        ULONG64 OffsetAddress = res[0] + 10;
        int LoadedImagesHashAddress = *(int*)OffsetAddress;
        Print("LoadedImagesHashAddress = %X", LoadedImagesHashAddress);
        return (PVOID)(LoadedImagesHashAddress);
    }
    return 0;
#else
    return 0;
#endif
 
}

遍历

1
2
3
4
5
6
7
8
GHashTable* loaded_images_hash;
 
GList* loaded_assemblies;
 
loaded_images_hash = *(GHashTable**)LocateLoadedImagesHashAddress();
loaded_assemblies = *(GList**)LocateLoadedAssemblies();
g_hash_table_foreach(loaded_images, image_Foreach_routine,NULL);
g_list_foreach(loaded_assemblies, assemblies_Foreach_routine, NULL);

g_hash_table_foreachg_list_foreach等都是glib的函数,在mono的源码里有,自己去CV吧

 

遍历回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
static void
image_Foreach_routine(gpointer key, gpointer val, gpointer user_data)
{
    Print("Foreach key = %X",key);
    Print("Foreach val = %X", val);
    Print("Foreach key->string = %s", key);
    string test = (char*)key;
    MonoImage* image = (MonoImage*)val;
    if (test.find("data-") != string::npos) {
        Print("Find Cheat!");
        Print("Foreach key->string = %s", image->assembly_name);
    }
}
1
2
3
4
5
6
7
8
9
10
static void
assemblies_Foreach_routine(gpointer data, gpointer user_data) {
    MonoAssembly* ass = (MonoAssembly*) data;
    string assName = ass->aname.name;
    if (assName.find("BobHSSJJ") != string::npos) {
        Print("Find Cheat!");
    }
    Print("AssName = %s", ass->aname.name);
    Print("BaseDir = %s", ass->basedir);
}

后记

小伙子忙说对不起对不起,我不懂规矩啊,我说他是乱打的,他可不是乱打啊,铮铮鞭腿左刺拳训练有素,后来他说他练过三四年泰拳,看来是有备而来

 

他说他把链表断了,然后把PE头也清掉了,还把所有字符串都清空了

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
void CleanPE(PVOID PEStart) {
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)(PEStart);
    PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)((DWORD)PEStart + pDosHeader->e_lfanew);
    PIMAGE_FILE_HEADER pFileHeader = &pNtHeader->FileHeader;
    pFileHeader->Machine = 0;
    pFileHeader->NumberOfSections = 0;
    pFileHeader->TimeDateStamp = 0;
    PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = &pNtHeader->OptionalHeader;
    pOptionalHeader->AddressOfEntryPoint = 0;
    pOptionalHeader->SizeOfCode = 0;
    pOptionalHeader->SizeOfHeaders = 0;
    pOptionalHeader->SizeOfImage = 0;
    pOptionalHeader->ImageBase = 0;
    pOptionalHeader->BaseOfCode = 0;
    pOptionalHeader->BaseOfData = 0;
}
 
void CleanImageInfo(MonoImage* image) {
    Print("Destroying mono image info...");
    //清除PE头
    PVOID PEStart = image->raw_data;
    Print("PEAdd: %X",PEStart);
    CleanPE(PEStart);
    memset(PEStart, 0x66, 0x200);
    image->raw_data = NULL;
    image->raw_data_len = 0;
    int namelen = strlen(image->name);
    for (int i = 0; i < namelen; i++) {
        //把原来的名字地址上的内容也清掉
        image->name[i] = '\0';
    }
    image->name = NULL;
    namelen = strlen(image->assembly_name);
    for (int i = 0; i < namelen; i++) {
        //把原来的名字地址上的内容也清掉
        ((char*)image->assembly_name)[i] = '\0';
    }
    namelen = strlen(image->module_name);
    for (int i = 0; i < namelen; i++) {
        //把原来的名字地址上的内容也清掉
        ((char*)image->module_name)[i] = '\0';
    }
    char* fakeVersion = new char[20];
    strcpy(fakeVersion, "0.0.0.0");
    image->version = fakeVersion;
    image->raw_metadata = NULL;
}
 
static void
image_Foreach_routine(gpointer key, gpointer val, gpointer user_data)
{
    //Print("Foreach key = %X",key);
    //Print("Foreach val = %X", val);
    //Print("Foreach key->string = %s", key);
    string test = (char*)key;
    MonoImage* image = (MonoImage*)val;
    if (test.find("data-") != string::npos) {
        Print("Foreach key->string = %s", key);
        Print("Find Cheat!");
        Print("Foreach key->string = %s", image->assembly_name);
        g_hash_table_remove(loaded_images_hash,image->name);
        g_hash_table_remove(loaded_images_hash, image->assembly_name);
        CleanImageInfo(image);
        Print("Remove finished!");
    }
}
static void
assemblies_Foreach_routine(gpointer data, gpointer user_data) {
    MonoAssembly* ass = (MonoAssembly*) data;
    string assName = ass->aname.name;
    if (assName.find("BobHSSJJ") != string::npos) {
        g_list_remove(loaded_assemblies, data);
    }
    //Print("AssName = %s", ass->aname.name);
    //Print("BaseDir = %s", ass->basedir);
    //Print("gac = %02X", ass->in_gac);
    //Print("corlib_internal = %02X", ass->corlib_internal);
    //Print("--------------");
}

我去~

 

这个年轻人,不讲武德

 

来,骗!

 

来,偷袭!

 

我69岁的老同志。

 

这好吗?这不好。我劝这位年轻人,耗子尾汁。好好反思。以后不要再犯这样的小聪明。武林要以和为贵,要讲武德,不要搞窝里斗。


[培训]《安卓高级研修班(网课)》月薪三万计划,掌 握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

最后于 2020-12-20 23:44 被renbohan编辑 ,原因: 原创
收藏
点赞3
打赏
分享
最新回复 (10)
雪    币: 8139
活跃值: (5335)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
mudebug 2020-12-21 04:04
2
0

好像根本不用這樣,遊戲大部分有熱更系統,把東西丟到熱更系統就好了,需要數據直接讓遊戲內嵌的json模塊輸出來。il2cpp 導出的東西太全,基本防不住外掛。主要難點還是在調用的地方需要遊戲主線程操作。一般自己申請一個組件掛接到遊戲系統裏,這一步操作完了一路暢通。

最后于 2020-12-21 04:06 被mudebug编辑 ,原因:
雪    币: 457
活跃值: (1862)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
renbohan 2020-12-21 07:07
3
0
mudebug 好像根本不用這樣,遊戲大部分有熱更系統,把東西丟到熱更系統就好了,需要數據直接讓遊戲內嵌的json模塊輸出來。il2cpp&nbsp;導出的東西太全,基本防不住外掛。主要難點還是在調用的地方需 ...
沒有明白你什麼意思,我這個是針對使用mono的unity遊戲,而不是用il2cpp的遊戲,還有是在模塊層面上進行檢測,如果要檢測GameObject組件也是有方法,但不是本文討論點
雪    币: 92
活跃值: (463)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
香风一族 2020-12-21 12:43
4
0
雪    币: 137
活跃值: (1240)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
huojier 2020-12-21 16:36
5
0
好像几年前一款unity游戏的方法是扫那个unity的function然后做一个hash 服务端也保留一份hash 有注入函数进来的时候hash会变
雪    币: 1112
活跃值: (2709)
能力值: ( LV3,RANK:30 )
在线值:
发帖
回帖
粉丝
Amun 2020-12-21 16:45
6
0

好,又来个叫 BepInEx 的小伙子,在跃跃欲试,看老同志禁不禁打

雪    币: 237
活跃值: (29)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
酱油啊啊啊啊 2020-12-21 17:42
7
0
请问SSJJ 的你怎么还原DLL的?记得新版某易DLL PE无法还原出来
雪    币: 123
活跃值: (316)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
taizhong 2021-1-17 09:48
8
0
huojier 好像几年前一款unity游戏的方法是扫那个unity的function然后做一个hash 服务端也保留一份hash 有注入函数进来的时候hash会变
对比hash值似乎很容易解决掉
雪    币: 1449
活跃值: (642)
能力值: ( LV3,RANK:20 )
在线值:
发帖
回帖
粉丝
半个大西瓜 2021-3-8 20:59
10
0
可以.net层面直接检测有哪些Component,基于程序集的逃不开这个
雪    币: 228
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
肾虚公子 2022-1-22 10:20
11
0
楼主 方便 给个联系方式吗 
游客
登录 | 注册 方可回帖
返回