首页
论坛
课程
招聘
findreamwang
雪    币: 6734
活跃值: 活跃值 (28)
能力值: ( LV9,RANK:290 )
在线值:
发帖
12
回帖
39
粉丝
8

[原创]一篇文章带你理解PE三表

2019-4-21 15:14 5303

[原创]一篇文章带你理解PE三表

2019-4-21 15:14
5303

0x0 前言

         刚刚结束春招,投了好几家公司,结果不是很理想,原因无外乎自身实力和行业寒冬。

 

         这次春招面试题主要集中在PE相关,HOOK技术和DLL注入技术,还有一些杂七杂八的问题上面。从中也暴露处自己技能栈上的不足,通过这一段时间的沉淀希望可以补足。

 

         四月初就开始谋划写一系列的文章,但是加上在校实习比较忙碌,所以进展很慢,这些文章主要面向受众是那些入坑新人,借此希望能够让那些小伙伴能够少走弯路。同时也能多多总结自身的不足,共同进步。

 

         这系列文章取啥名?想了很久,决定参考某一师傅的系列文章---<一篇文章带你·····>,主要希望总结PE文件,HOOK,DLL注入,以及其他方面的知识,主要的参考文献是看雪加密解密第四版,以及其他资料。(打了广告,希望相关师傅记得打点广告费)

 

         这系列是我边总结技术边写文章,可能部分内容会以后补全技能栈,例如R0下的DLL注入等。但是尽量做到不鸽,关于代码,不提供自己写的代码,原因有二,第一,这些代码网上都有现成的,我只是理解修改部分罢了,也怕自己的代码误导小伙伴们。第二,拒绝伸手党

 

         由于自身能力有限,文章中难免出现错误,希望各位师傅少喷我。

0x1 PE导入表

0x1.1 输入函数的调用

         DLL动态链接库文件主要实现代码的复用。当一个程序调用DLL文件中的数据和代码的时候,有两种链接方式,第一种是隐式链接,这个过程是由windows装载器完成的,另外一种是显式链接,通过使用LoadLibrary和GetProcAddress这两个API函数实现的。

 

         当隐式的调用一个API函数的时候,同样也存在类似于LoadLibrary和GetProcAddress函数的功能实现,但是,这个操作是由windows装载器完成的,所以称为隐式链接,当程序使用隐式链接调用DLL代码的时候,装载器需要完成以下几个步骤(IAT填充):

  • 首先将所需要的DLL文件载入内存,Kernel32.dll等是通过映射的方式载入的
  • 定位IID,寻找IID的第四个字段Name。
  • 接着根据OrginalFirstThunk指向,获取INT。
  • 根据INT执行的IMAGE_IMPORT_BY_NAME结构获取函数名称
  • 利用类似于GetProcAddress函数功能的操作,获取函数地址VA
  • 将获取的API函数地址填充入IAT。
  • 断链,将FirstThunk断开

         程序一般使用CALL-JMP的方式调用API方式,显然,这种方式是低效的,不然直接使用CALL高效,之所以使用这种方式,因为编译器无法判断哪些调用是API,哪些调用是普通函数。JMP的地址其实是IAT所在的地址VA。

0x1.2 导入表结构

         在PE文件可选头中,数据目录项的第一个成员指向的导入表。可以看到2040是一个RVA,这是在内存中的偏移量。我们需要将它转化为文件偏移。

 

         我们可以看到2040位于.rdata段中。可以使用公式section[i].PointOfRawData+(offset-VirtuallAddress)来计算文件偏移。计算出来的文件偏移为600+(2040-2000)=640.也就是说PE导入表在文件中640H的地方。

 

         同样的,我们可以使用代码实现这一个需求,代码如下:

  • 0.定位第一个节区地址
  • 1.获取节区数目
  • 2.判断RVA在那个节区
  • 3.计算:section[i].PointOfRawData+(offset-VirtuallAddress)
    DWORD RvaToOffset(DWORD ImageAddr, LPVOID lpBaseAddress)
    {
      //NtHeader
      PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((ULONG_PTR)lpBaseAddress + ((PIMAGE_DOS_HEADER)lpBaseAddress)->e_lfanew);
      //获取第一个节区的RawtoData
      PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)(((ULONG_PTR)&pNtHeaders->OptionalHeader) + pNtHeaders->FileHeader.SizeOfOptionalHeader);
      if (ImageAddr > pNtHeaders->OptionalHeader.SizeOfImage)
      {
          printf("ImageAddr Is Error\n");
          return NULL;
      }
      if (ImageAddr < pSectionHeader[0].PointerToRawData)
      {
          return ImageAddr;
      }
      DWORD i = 0;
      for (i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
      {
          //节区下限
          DWORD lower = pSectionHeader[i].VirtualAddress;
          //节区上线
          DWORD maxer = pSectionHeader[i].VirtualAddress + pSectionHeader[i].Misc.VirtualSize;
          if (ImageAddr >= lower && ImageAddr < maxer)
          {
              return pSectionHeader[i].PointerToRawData + (ImageAddr - pSectionHeader[i].VirtualAddress);
          }
      }
    }
    

         这时候,我们需要用到新的知识IMAGE_IMPORT_DIRECTORY结构。简称IID。IID结构对应着一个被隐式链接的DLL,每个IID的结束标志为NULL。关于IID结构:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD Characteristics;
        DWORD OriginalFirstThunk;            //INT(RBA)
    };
    DWORD TimeDateStamp;                    //时间戳
    DWORD ForwarderChain;
    DWORD Name;                                //DllName(RVA)
    DWORD FirstThunk;                        //IAT(RVA)
} IMAGE_IMPORT_DESCRIPTOR;

         需要我们关心的成员有三个:

  • OriginalFirstThunk:一个指向导入名称表(INT)首地址的RVA.
  • Name:一个指向隐式映射的dll的名称的RVA
  • FirstThunk:一个指向导入地址表(IAT)首地址的RVA

         OriginalFirstThunk和FirstThunk都是指向一个名为IMAGE_THUNK_DATA的结构体,其中被OriginalFirstThunk指向的是导入名称表(INT),被FirstThunk指向的是导入地址表(IAT)。而INT和IAT同时都指向一个新的结构IAMGE_IMPORT_BY_NAME。

 

         接下来,依照IMAGE_IMPORT_DESCRIPTOR来解析上面我们在文件中获取的IID数据如下。但是这些都是小端序显示的,首先需要转化为大端序,然后在使用上面讲的方法将其转化为文件偏移。

 

         首先查看774和7B4对应的DLL名称。

 

         然后再来查看一下OriginalFirstThunk对应的INT数据,在此之前,我们需要了解一下IMAGE_THUNK_DATA这个数据结构。但是u1是一个共用体,怎么判断IAT中的IMAGE_THUNK_DATA中存储的是Ordinal,还是AddressOfData?当IMAGE_THUNK_DATA最高位为1的时候,表示序号导入,否则为字符导入,此时保存的是AddressOfData,一个指向IMAGE_IMPORT_BY_NAM的RVA。一个IMAGE_THUNK_DATA对应一个函数(_IMAGE_IMPORT_BY_NAME)。

typedef struct _IMAGE_THUNK_DATA
{
    union 
    {
         PBYTE ForwarderString;
         PDWORD Function;     //被导入的函数的入口地址
         DWORD Ordinal;       // 该函数的序数
         PIMAGE_IMPORT_BY_NAME AddressOfData;   // 一个RVA地址,指向IMAGE_IMPORT_BY_NAME
     }u1;
} IMAGE_THUNK_DATA32;

         在上面,我们了解了IMAGE_THUNK_DAT结构,而且知道了OriginalFirstThunk指向的是IMAGE_THUNK_DAT(INT)。所以,在文件偏移68Ch处,找到数据10210000,同样的,我们将它转化端序和文件偏移得到10210000--->2110--->710。在710处,我们应该可以得到IMAGE_IMPORT_BY_NAME这个结构体。现在,我们需要了解一下这个结构体的形式.

typedef struct _IMAGE_IMPORT_BY_NAME {
     WORD    Hint;       //函数需序号
     BYTE    Name[1];    //函数名称
 } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

         了解完IMAGE_IMPORT_BY_NAME这个结构体,在文件710H处查看IMAGE_IMPORT_BY_NAME

 

         由于IID是一个双桥结构,刚刚我们通过OriginalFirstThunk间接通过IAT寻找到了IMAGE_IMPORT_BY_NAME。接下来使用FirstThunk寻找IMAGE_IMPORT_BY_NAME。可以发现两处都是指向同一个地址,而且这个地址就是IMAGE_IMPORT_BY_NAME

0x1.3 导入表编程

         编程实现文件中导入表获取,首先利用ReadFile函数将对象PE文件读入内存,这里可以使用多种方法读取。

if (!ReadFile(hFile, lpBaseAddress, dwFileSize, &dwNumberOfBytesRead, NULL))
{
    printf("ReadFile:%d\n", GetLastError());
    return FALSE;
}
PrintImportTable(lpBaseAddress);

         然后在数据目录第二项获取导入表的RVA。但是需要注意的是,必须加上文件在内存中的基地址,这样才是IID的地址。

//获取导入表地址
DWORD Rav_Import_Table = pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
PIMAGE_IMPORT_DESCRIPTOR ImportTable = PIMAGE_IMPORT_DESCRIPTOR((ULONG_PTR)lpBaseAddress + Rav_Import_Table);

         接下来是遍历IMAGE_IMPORT_DESCRIPTOR,因为IMAGE_IMPORT_DESCRIPTOR个数是和隐式链接的dll数一致的,但是,IID结束的标志为全0。所以只需要比较从第一个IID开始,如果有sizeof(IMAGE_IMPORT_DESCRIPTOR)个0的话,说明IID遍历结束

for (i = 0; memcmp(ImportTable + i, &null_iid, sizeof(null_iid)); i++){}

         打印DLLNAME,利用IMAGE_IMPORT_DIRECTORY->Name打印DllName。同上,需要加上基地址

DllName = (LPCSTR)((ULONG_PTR)lpBaseAddress + ImportTable->Name);

         获取OriginalFirstThunk。和IID一样的原理遍历INT。

PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((ULONG_PTR)lpBaseAddress + ImportTable[i].OriginalFirstThunk);
//遍历同一个IID下的OriginalFirstThunk
for (j = 0; memcmp(pThunk + j, &null_thunk, sizeof(PIMAGE_THUNK_DATA)); j++){}

         之前说过根据OriginalFirstThunk高位是否为1判断导入方式,如果高位为1,使用序号的方式导入,否则按照函数名称导入

if (pThunk[j].u1.AddressOfData&IMAGE_ORDINAL_FLAG)   //按标号导入
{
    //
}
else   //按名称导入
{
    //
}

0x2 导出表

0x2.1 导出表的作用

         我们都知道DLL是实现代码复用的重要方式,同时为了让调用DLL的PE文件(包括exe和dll)知道哪些函数是可以被复用的,所以dll会将可以被导出的函数的RVA值保存在导出表中。

 

         导出表事实上并不只是存在于DLL中,同时也可能存在于exe中。当一个PE文件被装载的时候,装载器会将PE文件中所有被登记的DLL一起载入,然后根据DLL的导出表对导入表中的IAT进行修正。

0x2.2 导出表结构

         导出表描述信息位于可选头的数据目录中的第一项。4000是导出表的RVA值,需要转化为FOA值。利用导入表的公式section[i].PointOfRawData+(RVA-VirtuallAddress)进行计算,得到FOA为C00.

 

         这时候,我们在文件中的C00处就可以找到我们导出目录了。我们现在需要了解一下导出目录IMPORT_EXPORT_DIRECTORY这个结构.其中我们需要重点关心的成员有以下几个。

  • Name:导出函数的文件名RVA
  • Base:导出函数起始需要,在导出函数序号表中的值,需要加上此值才是导出函数真正的序号
  • NumberOfFunctions:导出函数个数
  • NumberOfNames:名称导出函数个数
  • AddressOfFunctions; //指向到处函数地址表的RVA
  • AddressOfNames; //指向函数名地址表的AVA
  • AddressOfNameOrdinals; //指向函数名序号表的RVA
    typedef struct _IMAGE_EXPORT_DIRECTORY {
      DWORD Characteristics;
      DWORD TimeDateStamp;            //输出表的创建时间
      WORD MajorVersion;                      //输出表的主版本号。未使用设置为0
      WORD MinorVersion;                      //输出表的次版本号。未使用设置为0
      DWORD Name;                //指向一个与输出函数关联的文件名的RVA 
      DWORD Base;                //导出函数的起始序号
      DWORD NumberOfFunctions;        //导出函数的总数
      DWORD NumberOfNames;            //以名称导出的函数总数
      DWORD AddressOfFunctions;        //指向到处函数地址表的RVA
      DWORD AddressOfNames;            //指向函数名地址表的AVA
      DWORD AddressOfNameOrdinals;            //指向函数名序号表的RVA
    } IMAGE_EXPORT_DIRECTORYM, *pIMAGE_EXPORT_DIRECTORY
    

         如下图是导出表的数据。可以看到32 40 00 00对应的是Name这个成员变量的RVA,我们调整端序,计算FOA(32400000--->00004032--->C32)可到Name在文件中的位置是C32.正好指向DllDemo.dll这个字符串。根据上述方法解析导出表数据如下

  • Name:[C06]=C32-->"DllDemo.dll" (rva)
  • Base:[C10]="00000001"
  • NumberOfFunctions:[C14]="00000001"
  • NumberOfNames:[C18]="00000001"
  • AddressOfFunctions:[C1C]=C28--->08100000 (RVA)
  • AddressOfNames:[C20]=C2C--->"MsgBox" (RVA)
  • AddressOfNameOrdinals[C24]=C30--->0000 (RVA)

         【重点】PE装载器调用GetProcAddress来填充IAT,这时候需要了解GetProcAddress原理。

  • 定位到IMAGE_EXPORT_DIRECTORY结构
  • 判断Name是否是传入的DllName
  • 获取ENT数组的起始地址,寻找FuncName,如果找到记录在ENT的数组索引。
  • 然后读取导出函数序号数据的第一项的序号值
  • 使用Base+序号的值到EAT中寻找地址

0x2.3 导出表编程

         首先需要在数据目录中获取导出表地址,需要注意的是这个地址是个RVA的值,需要加上BaseAddress。

DWORD Rav_Export_Table = pNtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
PIMAGE_EXPORT_DIRECTORY ExportTable = (PIMAGE_EXPORT_DIRECTORY)((ULONG_PTR)lpBaseAddress + Rav_Export_Table);

         由于AddressOfNames,AddressOfFunctions,和AddressOfNameOrdinals都是RVA值,且都需要进程RVA->FOA的转化。所以如果要获取这三个成员变量的FOA的话需要进行两部,第一将RVA转化为FOA,然后加上BaseAddress。

//获取导出函数名数组
DWORD* dwAddressOfNames =(DWORD*)((ULONG_PTR)lpBaseAddress+ RvaToOffset(ExportTable->AddressOfNames,lpBaseAddress));
//获取导出函数数组
DWORD* dwAddressOfFunctions = (DWORD*)((ULONG_PTR)lpBaseAddress + RvaToOffset(ExportTable->AddressOfFunctions, lpBaseAddress));
//获取导出函数索引数组
DWORD* dwAddressOfNameOrdinals = (DWORD*)((ULONG_PTR)lpBaseAddress + RvaToOffset(ExportTable->AddressOfNameOrdinals, lpBaseAddress));

         因为NumberOfFunctions>=NumberOfNames,适合放在外部循环进行比较。

for (i = 0; i < dwNumberOfFunctions; i++){...}

         在提到关于GetProcAddress用法时说道,如果目标函数在AddressOfFuns被找到记录其数组索引。然后在导出函数序号数组中寻找对应序号。

if (*(WORD*)(dwAddressOfNameOrdinals + j * sizeof(WORD)) == i)
{}

         接着取出我们的需要序号,以及在导出函数地址数组对应的序号所对应的导出函数地址

//函数名称
FunName = (LPCSTR)((ULONG_PTR)lpBaseAddress + dwAddressOfNames[j * sizeof(WORD)]);  //VA值
//函数索引
FunOrdinal = *(WORD*)(dwAddressOfNameOrdinals + j * sizeof(WORD));
//函数地址   i=(dwAddressOfNameOrdinals + j * sizeof(WORD))   其实是索引值
//这里需要取其值,注意*(DWORD*)
FunAddress = *(DWORD*)(dwAddressOfFunctions + FunOrdinal * sizeof(DWORD));
![](upload/attach/201904/739734_MBN75X4WTBHFV9B.jpg)

0x3 重定位表

0x3.1 重定位原理

         PE装载的时候,大多选用相对地址RVA,这样做的原因有二,第一是为了方便装载器,二是为了重定位。那么什么时候需要重定位呢,或者说重定位的条件是什么呢,我们应该知道每个进程内存是相互独立的,也就是说exe文件发生重定位的可能性不高,这样看来由于DLL的装载位置的不同,DLL文件发生重定位的可能性就比较高了,因为同一个进程空间里面可能存在多个DLL文件,有时候多个dll设定的原始的基地址是相同的,但是对于操作系统来说这是不允许存在的,所以需要对他们进行重定位。

 

         这时候就需要将那些需要被重定位的数据保存在一张表里面,然后取出里面的地址,利用某一个特定的公式,重新计算加载地址即可。

0x3.2 重定位表结构

         重定位表位于数据目录项中第6项,通过上述的方法可以定位到文件中重定位表地址为0xE00

 

         接下来,我们开始解析重定位表,在解析之前,需要了解重定位表结构。重定位表是由多个IMAGE_BASE_RELOCATION结构体构成的。有三个成员变量

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress;//RVA
    DWORD   SizeOfBlock;   //重定位数据大小
    WORD    TypeOffset;    // 重定位项数组
} IMAGE_BASE_RELOCATION,* PIMAGE_BASE_RELOCATION;
  • VirtualAddress是重定位数组的RVA,但是需要每个重定位数组地址加上这个RVA才是真正的重定位数组的地址
  • SizeofBlock:重定位结构大小
  • TypeOffset:两个字节,16位,高4位表示重定位类型,低12位表示重定位地址

         如图所有,解析如下:

  • VirtualAddress:00001000
  • sizeofBlock:10H-->(10H-8H)/2=4,一共有四个重定位数组,8H指的是VirtualAddress和sizeofBlock所占的字节数为8,除以2H,表示一个TypeOffset为2个字节。
  • Data1:0F30-->300F-->Type:3-->Addr:00F-->RVA:100Fh--->FOA:60F
  • Data2:2330-->3023-->Type:3-->Addr:023-->RVA:1023h--->FOA:623
  • Data3:0000
  • Data4:0000

         得到需要重定位的数据为00402000和00403030,假设当前基地址为00400000,目标基地址为01000000 则重定位后的地址为01002000和01003030

0x3.3 重定位表编程

         首先获取重定位表地址

//重定位表地址
DWORD RelocTableRva = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
//    printf("\t\t [*]RelocTableRva:%p\n", RelocTableRva);
PIMAGE_BASE_RELOCATION RelocTable =(PIMAGE_BASE_RELOCATION)((ULONG)lpBaseAddress+RvaToOffset(RelocTableRva,lpBaseAddress));
printf("\t\t [*]RelocTable:%p\n", RelocTable);

         获取每个重定位数组的RVA地址和大小,原因有二,第一,获取完TypeOffset的第12位需要加上VirtualAddress才是真正的RVA,第二,sizeofBlock是指向下一个重定位数组(块)的重要参数。

DWORD VirtualAddress = RelocTable->VirtualAddress;
printf("\t\t [*]VirtualAddress:%p", VirtualAddress);
DWORD Cout = (RelocTable->SizeOfBlock - 8) / 2;

         定位到重定位数组,每个Typeoffset在偏移处第八个字节,所以需要加8。

WORD* RecAddr = (WORD*)((BYTE*)RelocTable + 8);

         解析TypeOffset

//取第三位地址,并加上VirtualAddress才是真的RVA
DWORD offset = VirtualAddress + (RecAddr[j] & 0x0FFF);
//TYPE
DWORD type = RecAddr[j] >> 12;
printf("\t\t Type:[%d] \t RVA:[%p]\n", type, offset);

         利用sizeofblock定位下一个重定位表

RelocTable = (IMAGE_BASE_RELOCATION *)((BYTE *)RelocTable + RelocTable->SizeOfBlock);
最新回复 (10)
kanxue
雪    币: 48
活跃值: 活跃值 (250)
能力值: (RANK:350 )
在线值:
发帖
2275
回帖
16358
粉丝
23
kanxue 活跃值 8 2019-4-21 16:12
2
1
由于重复发了,删除其他,留了一帖。

UzJu
雪    币: 1922
活跃值: 活跃值 (25)
能力值: ( LV12,RANK:360 )
在线值:
发帖
23
回帖
328
粉丝
5
UzJu 活跃值 7 2019-4-21 18:37
3
0
我是二楼 我要沙发
风吹鸡蛋壳
雪    币: 2295
活跃值: 活跃值 (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
1
回帖
20
粉丝
0
风吹鸡蛋壳 活跃值 2019-4-22 09:07
4
0
小白一样的我先赞一个
yichen115
雪    币: 795
活跃值: 活跃值 (744)
能力值: ( LV12,RANK:220 )
在线值:
发帖
26
回帖
69
粉丝
5
yichen115 活跃值 2 2019-4-23 07:56
5
0
赞!!
KevinsBobo
雪    币: 12138
活跃值: 活跃值 (132)
能力值: (RANK:380 )
在线值:
发帖
24
回帖
207
粉丝
6
KevinsBobo 活跃值 8 2019-4-26 10:03
6
0
unionepro
雪    币: 74
能力值: ( LV1,RANK:0 )
在线值:
发帖
0
回帖
1
粉丝
0
unionepro 活跃值 2019-4-26 21:42
7
0
当程序使用隐式链接调用DLL代码的时候,装载器应该是先定位定位IID,寻找Name吧?要不然怎么知道要加载哪个dll文件?
niuzuoquan
雪    币: 265
活跃值: 活跃值 (13)
能力值: ( LV2,RANK:10 )
在线值:
发帖
1
回帖
405
粉丝
0
niuzuoquan 活跃值 2019-4-28 08:43
8
0
mark
fujintcc
雪    币: 452
活跃值: 活跃值 (11)
能力值: ( LV2,RANK:10 )
在线值:
发帖
19
回帖
97
粉丝
0
fujintcc 活跃值 2019-4-28 15:29
9
0
mark 学习下先
5ilent
雪    币: 11
活跃值: 活跃值 (10)
能力值: ( LV2,RANK:10 )
在线值:
发帖
1
回帖
11
粉丝
0
5ilent 活跃值 2019-9-11 00:37
10
0
从dll注入那看你的文章,看到了这里。我觉的导入表关键是双桥结构,映像加载时桥2断裂,重新填充为函数的真实地址;导出表关键是,导出索引的初始值,还有就是从导出名称索引找到导出函数地址索引,比如病毒用到的根据导出表找GetProcAddress的地址;重定位表关键是高4位和低12位。另外,想写地址无关的代码,汇编很简单,一个call,紧跟着一个pop,再计算一个sub,就得到了相对偏移,放到一个寄存器里当重定位的偏移就可以了。个人拙见。
最后于 2019-9-11 00:38 被5ilent编辑 ,原因: 错别字,见谅,
findreamwang
雪    币: 6734
活跃值: 活跃值 (28)
能力值: ( LV9,RANK:290 )
在线值:
发帖
12
回帖
39
粉丝
8
findreamwang 活跃值 6 2019-9-11 23:08
11
0
5ilent 从dll注入那看你的文章,看到了这里。我觉的导入表关键是双桥结构,映像加载时桥2断裂,重新填充为函数的真实地址;导出表关键是,导出索引的初始值,还有就是从导出名称索引找到导出函数地址索引,比如病毒用到 ...
对,本来想写花指令有这个内容,没时间就没写了,层主总结很棒
游客
登录 | 注册 方可回帖
返回