一种保护应用程序的方法 模拟Windows PE加载器,从内存资源中加载DLL

标 题: 【原创】一种保护应用程序的方法 模拟Windows PE加载器,从内存资源中加载DLL
作 者: shangzh
时 间: 2012-04-12,23:14:51
链 接: http://bbs.pediy.com/showthread.php?t=149326

一种保护应用程序的方法-模拟Windows PE加载器,从内存资源中加载DLL

 作者:老实和尚  coverlove@163.com



1、前言
目前很多敏感和重要的DLL(Dynamic-link library) 都没有提供静态版本供编译器进行静态连接(.lib文件),即使提供了静态版本也因为兼容性问题导致无法使用,而只提供DLL版本,并且很多专业软件的授权部分的API,都是单独提供一个DLL来完成,而主模块通过调用DLL中的接口来完成授权功能。虽然这些软件一般都采用了加壳和反调试等保护,但是一旦这些功能失去作用,比如脱壳,反反调试,HOOK API或者干脆写一个仿真的授权DLL(模拟授权DLL的所有导出函数接口),然后仿真的DLL再调用授权DLL,这样所有的输入首先被仿真DLL截获再传递给授权DLL,而授权DLL的输出也首先传递给仿真DLL再传递给主程序,这样就可以轻易的监视二者之间的输入输出之间的关系,从而轻易的截获DLL中的授权信息进行修改再返回给主程序。
2、目前隐式调用敏感DLL中可能存在的安全隐患
以下通过两个软件的授权DLL来说明这种问题的严重性。如下是两个软件中授权DLL的部分信息,如下图所示:
 
Name:  1.jpgViews: 2202Size:  1930 KB
(图1)
通过工具OllyICE可以轻易的看出IoMonitor.exe调用授权DLL(XKeyAPI.DLL),这样就很容易在调用这些API的地方设置断点,然后判断输入输出的关系,从而达到破解的目的。
 Name:  2.jpgViews: 2209Size:  2591 KB
(图2)
通过工具OllyICE可以轻易的看出sfeng.DLL中导出了很多函数,其中含义也很明显。GetHDID获取硬盘的ID,GetCpuId获取cpu的ID,WinAntiDebug反调试接口。而这些都是主程序需要调用的,比如:主程序通过GetHDID来获取硬盘编码,以这个硬盘ID的伪码来生成授权码,破解者很容易修改这些接口的输出值或者干脆写一个sfeng.DLL来导出跟目标sfeng.DLL一模一样的导出函数,而主程序却完全不知晓。只要用户有一套授权码就可以让GetHDID不管什么机器都返回一样的值,从而达到任何机器都可以使用同一套授权码。
 Name:  3.jpgViews: 2195Size:  1177 KB
(图3)

 如上图所示,直接修改DLL中函数GetHDID(RVA地址:0093FF3C开始)的实现,让它直接返回固定的硬盘ID就可以达到一个授权到处使用的目的。其中:”WD-Z=AM9N086529ksaiy”为需要返回的已经授权的硬盘ID,我们直接返回这个值即可。把原来0093FF3C 部分的代码用nop替换掉,添加Call 008FFF60,后面添加字符串”WD-Z=AM9N086529ksaiy”,Call 008FFF60之后,ESP=Call后的返回地址(Call指令的下一行),也就是字符串”WD-Z=AM9N086529ksaiy”的首地址,然后pop EAX 后,返回值就是字符串的首地址,通过这种简单的修改就可以达到破解的目的,说明这种隐式的调用是非常危险的。

3、模拟Windows PE加载器,从资源中加载DLL

本文主要介绍将DLL文件进行加密压缩后存放在程序的资源段,然后在程序中读取资源段数据进行解压和解密工作后,从内存中加载这个DLL,然后模拟PE加载器完成DLL的加载过程。本文主要以Visual C++ 6.0为工具进行介绍,其它开发工具实现过程与此类似。
  这样作的好处也很明显,DLL文件存放在主程序的资源段,而且经过了加密压缩处理,破解者很难找到下断点的地方,也不能轻易修改资源DLL,因为只有主程序完成解压和解密工作,完成PE加载工作后此DLL才开始工作。
我们知道,要显式加载一个DLL,并取得其中导出的函数地址一般是通过如下步骤:
(1) 用LoadLibrary加载DLL文件,获得该DLL的模块句柄;
(2) 定义一个函数指针类型,并声明一个变量;
(3) 用GetProcAddress取得该DLL中目标函数的地址,赋值给函数指针变量;
(4) 调用函数指针变量。
这个方法要求DLL文件位于硬盘上面,而我们的DLL现在在内存中。现在假设我们的DLL已经位于内存中,比如通过脱壳、解密或者解压缩得到,能不能不把它写入硬盘文件,而直接从内存加载呢?答案是肯定的,方法就是完成跟Windows PE加载器同样的工作即可。
加载过程大致包括以下几个部分:

1、调用API读取DLL资源数据拷贝到内存中
2、调用解压和解密函数对内存中的DLL进行处理
3、检查DOS头和PE头判断是否为合法的PE格式
4、计算加载该DLL所需的虚拟地址空间大小
5、向操作系统申请指定大小的虚拟地址空间并提交
6、将DLL数据复制到所分配的虚拟内存块中,注意文件段对齐方式和内存段对齐方式
7、对每个 DLL文件来说都存在一个重定位节(.reloc),用于记录DLL文件的重定位信息,需要处理重定位信息
8、读取DLL的引入表部分,加载引入表部分需要的DLL,并填充需要的函数入口的真实地址
9、根据DLL每个节的属性设置其对应内存页的读写属性
10、调用入口函数DLLMain,完成初始化工作
11、保存DLL的基地址(即分配的内存块起始地址),用于查找DLL的导出函数
12、不需要DLL的时候,释放所分配的虚拟内存,释放所有动态申请的内存

以下部分分别介绍这几个步骤,以改造过的网上下载的CMemLoadDLL类为例程(原类存在几个错误的地方)  

A.  调用API读取DLL资源数据拷贝到内存中
//加载资源DLL
#define strKey (char)0x15
char DLLtype[4]={'D' ^ strKey ,'l'^ strKey,'l'^ strKey,0x00};
HINSTANCE hinst=AfxGetInstanceHandle();
HRSRC hr=NULL;
HGLOBAL hg=NULL;
//对资源名称字符串进行简单的异或操作,达到不能通过外部字符串参考下断点
for(int i=0;i<sizeof(DLLtype)-1;i++) 
{
  DLLtype[i]^=strKey;
}
hr=FindResource(hinst,MAKEINTRESOURCE(IDR_DLL),TEXT(DLLtype));
if (NULL == hr) return FALSE;
//获取资源的大小
DWORD dwSize = SizeofResource(hinst, hr); 
if (0 == dwSize) return FALSE;
hg=LoadResource(hinst,hr);
if (NULL == hg) return FALSE;
//锁定资源
LPVOID pBuffer =(LPSTR)LockResource(hg);
if (NULL == pBuffer) return FALSE;
FreeResource(hg); //在资源使用完毕后我们不需要使用UnlockResource和FreeResource来手动地释放资源,因为它们都是16位Windows遗留下来的,在Win32中,在使用完毕后系统会自动回收


B.  调用解压和解密函数对内存总的DLL进行处理

对于上面获取的pBuffer可以进行解压和解密操作,算法应该跟你加入的资源采取的算法进行逆变换即可,具体算法可以自己选择,此处省略。

C.  检查DOS头和PE头判断是否为合法的PE格式

//CheckDataValide函数用于检查缓冲区中的数据是否有效的DLL文件
//返回值: 是一个可执行的DLL则返回TRUE,否则返回FALSE。
//lpFileData: 存放DLL数据的内存缓冲区
//DataLength: DLL文件的长度
BOOL CMemLoadDLL::CheckDataValide(void* lpFileData, int DataLength)
{
  //检查长度
  if(DataLength < sizeof(IMAGE_DOS_HEADER)) return FALSE;
  pDosHeader = (PIMAGE_DOS_HEADER)lpFileData; // DOS头
  //检查dos头的标记
  if(pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; //0*5A4D : MZ
  //检查长度
  if((DWORD)DataLength < (pDosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS)) ) return FALSE;
  //取得pe头
  pNTHeader = (PIMAGE_NT_HEADERS)( (unsigned long)lpFileData + pDosHeader->e_lfanew); // PE头
  //检查pe头的合法性
  if(pNTHeader->Signature != IMAGE_NT_SIGNATURE) return FALSE; //0*00004550 : PE00
  if((pNTHeader->FileHeader.Characteristics & IMAGE_FILE_DLL) == 0) //0*2000 : File is a DLL
  return FALSE;
  if((pNTHeader->FileHeader.Characteristics & IMAGE_FILE_EXECUTABLE_IMAGE) == 0) //0*0002 : 指出文件可以运行
  return FALSE;
  if(pNTHeader->FileHeader.SizeOfOptionalHeader != sizeof(IMAGE_OPTIONAL_HEADER)) return FALSE;
//取得节表(段表)
  pSectionHeader = (PIMAGE_SECTION_HEADER)((int)pNTHeader + sizeof(IMAGE_NT_HEADERS));
  //验证每个节表的空间
  for(int i=0; i< pNTHeader->FileHeader.NumberOfSections; i++)
  {
if((pSectionHeader[i].PointerToRawData + pSectionHeader[i].SizeOfRawData) > (DWORD)DataLength)return FALSE;
  }
  return TRUE;
}
D.  计算加载该DLL所需的虚拟地址空间大小

计算整个DLL映像文件的尺寸,最大映像尺寸应该为VOffset最大的一个段的VOffset+VSize,然后补齐段对齐即可。如下图中,最大映像尺寸应该为0x0000D000+0x00000DA6,然后按段对齐(如为:0x1000对齐)则结果为0x0000E000。其中DOS Header和PE Header就占用0x1000字节,代码段.text从0x1000开始占用了0x7000字节。
段名称   虚拟地址  虚拟大小  物理地址 物理大小  标志
 
Name:  4.jpgViews: 2181Size:  598 KB

int CMemLoadDLL::CalcTotalImageSize()
{
  int Size;
  if(pNTHeader == NULL)return 0;
  int nAlign = pNTHeader->OptionalHeader.SectionAlignment; //段对齐字节数
  // 计算所有头的尺寸。包括dos, coff, pe头 和 段表的大小
  Size = GetAlignedSize(pNTHeader->OptionalHeader.SizeOfHeaders, nAlign);
  // 计算所有节的大小
  for(int i=0; i < pNTHeader->FileHeader.NumberOfSections; ++i)
  {
    //得到该节的大小
    int CodeSize = pSectionHeader[i].Misc.VirtualSize ;
    int LoadSize = pSectionHeader[i].SizeOfRawData;
    int MaxSize = (LoadSize > CodeSize)?(LoadSize):(CodeSize);
    int SectionSize = GetAlignedSize(pSectionHeader[i].VirtualAddress + MaxSize, nAlign);
    if(Size < SectionSize)
    Size = SectionSize; //Use the Max;
  }
  return Size;
}

//计算对齐边界
int CMemLoadDLL::GetAlignedSize(int Origin, int Alignment)
{
  return (Origin + Alignment - 1) / Alignment * Alignment;
}

E.  向操作系统申请指定大小的虚拟地址空间并提交
调用操作系统API  VirtualAlloc保留指定大小的虚拟内存并提交内存,VirtualAlloc的第一个参数不能指定地址,如果指定地址已经被占用或者指定地址后面没有足够的连续的地址空间来满足提交的大小则会调用失败,而我们也没有必要获取指定地址空间,这样第一个参数必须保留为NULL(0)。
void *pMemoryAddress=VirtualAlloc((LPVOID)NULL, ImageSize,MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);
  if(pMemoryAddress == NULL)
  {
    return FALSE;
  }

F.  将DLL数据复制到所分配的虚拟内存块中,注意文件段对齐方式和内存段对齐方式

拷贝内存DLL到提交的虚拟地址空间,拷贝的部分包括PE文件的所有部分,DOS Header、 PE Header 、Section Table、Section 1~Section N,如下图所示:

Name:  5.jpgViews: 2186Size:  391 KB


//CopyDLLDatas函数将DLL数据复制到指定内存区域,并对齐所有节
//pSrc: 存放DLL数据的原始缓冲区
//pDest:目标内存地址
void CMemLoadDLL::CopyDLLDatas(void* pDest, void* pSrc)
{
  // 计算需要复制的PE头+段表字节数
  int HeaderSize = pNTHeader->OptionalHeader.SizeOfHeaders;
  int SectionSize = pNTHeader->FileHeader.NumberOfSections * sizeof(IMAGE_SECTION_HEADER);

  int MoveSize = HeaderSize + SectionSize;

  //复制头和段信息
  memmove(pDest, pSrc, MoveSize);

//复制每个节
for(int i=0; i < pNTHeader->FileHeader.NumberOfSections; ++i)
{
  if(pSectionHeader[i].VirtualAddress == 0 || pSectionHeader[i].SizeOfRawData == 0) continue;
  // 定位该节在内存中的位置
  void *pSectionAddress = (void *)((unsigned long)pDest + pSectionHeader[i].VirtualAddress);
  // 复制段数据到虚拟内存
  memmove((void *)pSectionAddress,
  (void *)((DWORD)pSrc + pSectionHeader[i].PointerToRawData),
  pSectionHeader[i].SizeOfRawData);
}
  //修正指针,指向新分配的内存
  //新的dos头
  pDosHeader = (PIMAGE_DOS_HEADER)pDest;
  //新的pe头地址
  pNTHeader = (PIMAGE_NT_HEADERS)((int)pDest + (pDosHeader->e_lfanew));
  //新的节表地址
  pSectionHeader = (PIMAGE_SECTION_HEADER)((int)pNTHeader + sizeof(IMAGE_NT_HEADERS));
  return ;
}
G.  每个 DLL文件来说都存在一个重定位节(.reloc),用于记录DLL文件的重定位信息,需要处理重定位信息

Windows加载DLL时就可以按照该节的信息对需要重定位的地址进行修正,在32位代码中,凡涉及到直接寻址的指令都是需要重定位的,而PE文件的的(.reloc)段则是可选的,因为PE文件一般都可以加载到默认地址(如:0x00400000)。当然系统的DLL其默认加载地址都能满足要求,因为这些DLL都在系统加载其它程序前首先被加载(如:Kernel32.DLL,User32.DLL)等。
对于操作系统来说,其任务就是在对可执行程序透明的情况下完成重定位操作,在现实中,重定位信息是在编译的时候由编译器生成并被保留在可执行文件中的,在程序被执行前由操作系统根据重定位信息修正代码,这样在开发程序的时候就不用考虑重定位问题了。

重定位信息在DLL文件中被存放在重定位表中,重定位的算法可以描述为:将直接寻址指令中的双字地址加上模块实际装入地址与模块建议装入地址之差。为了进行这个运算,需要有3个数据,首先是需要修正的机器码地址;其次是模块的建议装入地址;最后是模块的实际装入地址。
在这3个数据中,模块的建议装入地址已经在PE文件头中定义了(编译后就已经确定),而模块的实际装入地址是Windows装载器确定的,到装载文件的时候自然会知道,所以被保存在重定位表中的仅仅是需要修正的代码的地址。 
事实上正是如此,DLL文件的重定位表中保存的就是一大堆需要修正的代码的地址。 
重定位表一般会被单独存放在一个可丢弃的以“.reloc”命名的节中,但是这并不是必然的,因为重定位表放在其他节中也是合法的,惟一可以肯定的是,假如重定位表存在的话,它的地址肯定可以在DLL文件头中的数据目录中找到。重定位表的位置和大小可以从数据目录中的第6个 IMAGE_DATA_DIRECTORY结构中获取,虽然重定位表中的有用数据是那些需要重定位机器码的地址指针,但为了节省空间,DLL文件对存放的方式做了一些优化。 
在正常的情况下,每个32位的指针占用4个字节,假如有n个重定位项,那么重定位表的总大小是4×n字节大小。 直接寻址指令在程序中还是比较多的,在比较靠近的重定位表项中,32位指针的高位地址总是相同的,假如把这些相近表项的高位地址统一表示,那么就可以省略一部分的空间,当按照一个内存页来分割时,在一个页面中寻址需要的指针位数是12位(一页等于4096字节,等于2的12次方),假如将这12位凑齐16 位放入一个字类型的数据中,并用一个附加的双字来表示页的起始指针,另一个双字来表示本页中重定位项数的话,那么占用的总空间会是4+4+2×n字节大 小,计算一下就可以发现,当某个内存页中的重定位项多于4项的时候,后一种方法的占用空间就会比前面的方法要小。
// 重定向PE用到的地址
void CMemLoadDLL::DoRelocation( void *NewBase)
{
  /* 重定位表的结构:
  // DWORD sectionAddress, DWORD size (包括本节需要重定位的数据)
  // 例如 1000节需要修正5个重定位数据的话,重定位表的数据是
  // 00 10 00 00 14 00 00 00 xxxx xxxx xxxx xxxx xxxx 0000
  // ———– ———– —-
  // 给出节的偏移 总尺寸=8+6*2 需要修正的地址 用于对齐4字节
  // 重定位表是若干个相连,如果address 和 size都是0 表示结束
  // 需要修正的地址是12位的,高4位是形态字,intel cpu下是3
  */
  //假设NewBase是0×600000,而文件中设置的缺省ImageBase是0×400000,则修正偏移量就是0×200000
  DWORD Delta = (DWORD)NewBase - pNTHeader->OptionalHeader.ImageBase;
  //注意重定位表的位置可能和硬盘文件中的偏移地址不同,应该使用加载后的地址
  PIMAGE_BASE_RELOCATION pLoc = (PIMAGE_BASE_RELOCATION)((unsigned long)NewBase
  + pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
  while((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) //开始扫描重定位表
  {
    WORD *pLocData = (WORD *)((int)pLoc + sizeof(IMAGE_BASE_RELOCATION));
    //计算本节需要修正的重定位项(地址)的数目
    int NumberOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION))/sizeof(WORD);

    for( int i=0 ; i < NumberOfReloc; i++)
    {
      if( (DWORD)(pLocData[i] & 0xF000) == 0x00003000) //这是一个需要修正的地址
      {
      // 举例:
      // pLoc->VirtualAddress = 0×1000;
      // pLocData[i] = 0×313E; 表示本节偏移地址0×13E处需要修正
      // 因此 pAddress = 基地址 + 0×113E
      // 里面的内容是 A1 ( 0c d4 02 10) 汇编代码是: mov eax , [1002d40c]
      // 需要修正1002d40c这个地址
      DWORD * pAddress = (DWORD *)((unsigned long)NewBase + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));

      *pAddress += Delta;
      
      }
    }

    //转移到下一个节进行处理
    pLoc = (PIMAGE_BASE_RELOCATION)((DWORD)pLoc + pLoc->SizeOfBlock);

  }

}
H.  读取DLL的引入表部分,加载引入表部分需要的DLL,并填充需要的函数入口的真实地址
对引入表中的DLL,通过GetModuleHandle获得其加载基地址,如果这些DLL在加载本DLL之前还没有加载,那么先调用LoadLibrary进行加载,如果加载失败则不能继续处理直接报错,说明找不到依赖的DLL。
//填充引入地址表
BOOL CMemLoadDLL::FillRavAddress(void *pImageBase)
{
  // 引入表实际上是一个 IMAGE_IMPORT_DESCRIPTOR 结构数组,全部是0表示结束
  // 数组定义如下:
  //
  // DWORD OriginalFirstThunk; // 0表示结束,否则指向未绑定的IAT结构数组
  // DWORD TimeDateStamp;
  // DWORD ForwarderChain; // -1 if no forwarders
  // DWORD Name; // 给出DLL的名字
  // DWORD FirstThunk; // 指向IAT结构数组的地址(绑定后,这些IAT里面就是实际的函数地址)
  unsigned long Offset = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress ;
  if(Offset == 0) return TRUE; //No Import Table
  PIMAGE_IMPORT_DESCRIPTOR pID = (PIMAGE_IMPORT_DESCRIPTOR)((unsigned long) pImageBase + Offset);
  while(pID->Characteristics != 0 )
  {
    PIMAGE_THUNK_DATA pRealIAT = (PIMAGE_THUNK_DATA)((unsigned long)pImageBase + pID->FirstThunk);

    PIMAGE_THUNK_DATA pOriginalIAT = (PIMAGE_THUNK_DATA)((unsigned long)pImageBase + pID->OriginalFirstThunk);
//获取DLL的名字
char buf[256]; //DLL name;
//修改,需要将buf清零,否则DLL名称不对
memset(buf,0,sizeof(buf));
BYTE* pName = (BYTE*)((unsigned long)pImageBase + pID->Name);
for(int i=0;i<256;i++)
{
  if(pName[i] == 0)break;
  buf[i] = pName[i];
}
HMODULE hDLL = GetModuleHandle(buf);
if(hDLL == NULL) 
{
  hDLL = LoadLibrary (buf); //有可能依赖的DLL还没有加载,如果没有加载加载后再判断是否加载成功
if (hDLL == NULL)
return FALSE; //NOT FOUND DLL
} //获取DLL中每个导出函数的地址,填入IAT
//每个IAT结构是 :
// union { PBYTE ForwarderString;
// PDWORD Function;
// DWORD Ordinal;
// PIMAGE_IMPORT_BY_NAME AddressOfData;
// } u1;
// 长度是一个DWORD ,正好容纳一个地址。
for(i=0; ;i++)
{
if(pOriginalIAT[i].u1.Function == 0)  break;
FARPROC lpFunction = NULL;
if(pOriginalIAT[i].u1.Ordinal & IMAGE_ORDINAL_FLAG) //这里的值给出的是导出序号
{
lpFunction = GetProcAddress(hDLL, (LPCSTR)(pOriginalIAT[i].u1.Ordinal & 0x0000FFFF));
}
else //按照名字导入
{
//获取此IAT项所描述的函数名称
PIMAGE_IMPORT_BY_NAME pByName = (PIMAGE_IMPORT_BY_NAME)
        ((DWORD)pImageBase + (DWORD)(pOriginalIAT[i].u1.AddressOfData));
// if(pByName->Hint !=0)
// lpFunction = GetProcAddress(hDLL, (LPCSTR)pByName->Hint);
// else
lpFunction = GetProcAddress(hDLL, (char *)pByName->Name);
}
if(lpFunction != NULL) //找到了!
{
    pRealIAT[i].u1.Function = (PDWORD) lpFunction;
}
else return FALSE;
}
//move to next
pID = (PIMAGE_IMPORT_DESCRIPTOR)( (DWORD)pID + sizeof(IMAGE_IMPORT_DESCRIPTOR));
}

  return TRUE;
}
I.  根据DLL每个节的属性设置其对应内存页的读写属性

修改段属性。应该根据每个段的属性单独设置其对应内存页的属性。这里简化一下。
统一设置成一个属性PAGE_EXECUTE_READWRITE,如果代码段没有执行属性,调用的时候会产生异常,页属性的设置单位至少为一个页。
unsigned long old;
VirtualProtect(pMemoryAddress, ImageSize, PAGE_EXECUTE_READWRITE,&old);

J.  调用入口函数DLLMain,完成初始化工作
  接下来要调用一下DLL的入口函数,做初始化工作,每个PE文件都有一个OEP, 它就是AddressOfEntryPoint,一切代码都是从这里开始,OEP+DLL基地址就是其真实入口地址,当然这个入口地址一般都不是你所写的main或者DLLMain,而是运行库提供的一段代码,先完成全局变量的一些初始化和库函数相关的初始化等,而这段代码最后会调用真正的main或者DLLMain。

pDLLMain = (ProcDLLMain)(pNTHeader->OptionalHeader.AddressOfEntryPoint +(DWORD) pMemoryAddress);
  BOOL InitResult = pDLLMain((HINSTANCE)pMemoryAddress,DLL_PROCESS_ATTACH,0);
if(!InitResult) //初始化失败
{
  pDLLMain((HINSTANCE)pMemoryAddress,DLL_PROCESS_DETACH,0);
  VirtualFree(pMemoryAddress,0,MEM_RELEASE);
  pDLLMain = NULL;

    return FALSE;
}

K.   保存DLL的基地址(即分配的内存块起始地址),用于查找DLL的导出函数

//修正基地址
pNTHeader->OptionalHeader.ImageBase = (DWORD)pMemoryAddress;
//MemGetProcAddress函数从dll中获取指定函数的地址
//返回值: 成功返回函数地址 , 失败返回NULL
//lpProcName: 要查找函数的名字或者序号
FARPROC CMemLoadDll::MemGetProcAddress(LPCSTR lpProcName)
{
  if(pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress == 0 ||
  pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size == 0)
  return NULL;
  if(!isLoadOk) return NULL;
  DWORD OffsetStart = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
  DWORD Size = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size;
  PIMAGE_EXPORT_DIRECTORY pExport = (PIMAGE_EXPORT_DIRECTORY)((DWORD)pImageBase + pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
int iBase = pExport->Base;
int iNumberOfFunctions = pExport->NumberOfFunctions;
int iNumberOfNames = pExport->NumberOfNames; //<= iNumberOfFunctions
LPDWORD pAddressOfFunctions = (LPDWORD)(pExport->AddressOfFunctions + pImageBase);
LPWORD pAddressOfOrdinals = (LPWORD)(pExport->AddressOfNameOrdinals + pImageBase);
LPDWORD pAddressOfNames = (LPDWORD)(pExport->AddressOfNames + pImageBase);
int iOrdinal = -1;
if(((DWORD)lpProcName & 0xFFFF0000) == 0) //IT IS A ORDINAL!
{
  iOrdinal = (DWORD)lpProcName & 0x0000FFFF - iBase;
}
else //use name
{
  int iFound = -1;
  for(int i=0;i<iNumberOfNames;i++)
  {
    char* pName= (char* )(pAddressOfNames[i] + pImageBase);
    if(strcmp(pName, lpProcName) == 0)
    {
      iFound = i; break;
    }
  }
  if(iFound >= 0)
  {
      iOrdinal = (int)(pAddressOfOrdinals[iFound]);
  }
  }
  if(iOrdinal < 0 || iOrdinal >= iNumberOfFunctions ) return NULL;
  else
  {
    DWORD pFunctionOffset = pAddressOfFunctions[iOrdinal];
    if(pFunctionOffset > OffsetStart && pFunctionOffset < (OffsetStart+Size))//maybe Export Forwarding
    return NULL;
    else return (FARPROC)(pFunctionOffset + pImageBase);
  }
}

L.  不需要DLL的时候,释放所分配的虚拟内存,释放所有动态申请的内存

CMemLoadDll::~CMemLoadDll()
{
  if(isLoadOk)
  {
    ASSERT(pImageBase != NULL);
    ASSERT(pDllMain != NULL);
    //脱钩,准备卸载dll
    pDllMain((HINSTANCE)pImageBase,DLL_PROCESS_DETACH,0);
    VirtualFree((LPVOID)pImageBase, 0, MEM_RELEASE);
  }
}

4、全部详细代码


//以下代码经过Win2k Sp4/WinXp Sp2下测试通过

// MemLoadDll.h: interface for the CMemLoadDll class.
//
//

#if !defined(AFX_MEMLOADDLL_H__E1F5150A_B534_4940_9FBF_1E6CA0E50576__INCLUDED_)
#define AFX_MEMLOADDLL_H__E1F5150A_B534_4940_9FBF_1E6CA0E50576__INCLUDED_

#if _MSC_VER > 1000
#pragma once
#endif // _MSC_VER > 1000


typedef   BOOL (__stdcall *ProcDllMain)(HINSTANCE, DWORD,  LPVOID );

class CMemLoadDll  
{
public:
  CMemLoadDll();
  virtual ~CMemLoadDll();

  BOOL    MemLoadLibrary( void* lpFileData , int DataLength);  // Dll file data buffer
  FARPROC MemGetProcAddress(LPCSTR lpProcName);
private:
   BOOL isLoadOk;
   BOOL CheckDataValide(void* lpFileData, int DataLength);
   int  CalcTotalImageSize();
   void CopyDllDatas(void* pDest, void* pSrc);
   BOOL FillRavAddress(void* pBase);
   void DoRelocation(void* pNewBase);
   int  GetAlignedSize(int Origin, int Alignment); 
   
private:

    ProcDllMain pDllMain;


private:

   DWORD  pImageBase;
   PIMAGE_DOS_HEADER pDosHeader;
   PIMAGE_NT_HEADERS pNTHeader;
   PIMAGE_SECTION_HEADER pSectionHeader;
 

};

#endif // !defined(AFX_MEMLOADDLL_H__E1F5150A_B534_4940_9FBF_1E6CA0E50576__INCLUDED_)

// MemLoadDll.cpp: implementation of the CMemLoadDll class.
//
//

#include "stdafx.h"
#include "MemLoadDll.h"

#ifdef _DEBUG
#undef THIS_FILE
static char THIS_FILE[]=__FILE__;
#define new DEBUG_NEW
#endif

//
// Construction/Destruction
//
CMemLoadDll::CMemLoadDll()
{
  isLoadOk = FALSE;
  pImageBase = NULL;
  pDllMain = NULL;
}
CMemLoadDll::~CMemLoadDll()
{
  if(isLoadOk)
  {
    ASSERT(pImageBase != NULL);
    ASSERT(pDllMain != NULL);
    //脱钩,准备卸载dll
    pDllMain((HINSTANCE)pImageBase,DLL_PROCESS_DETACH,0);

    VirtualFree((LPVOID)pImageBase, 0, MEM_RELEASE);
  }
}

//MemLoadLibrary函数从内存缓冲区数据中加载一个dll到当前进程的地址空间,缺省位置0×10000000
//返回值: 成功返回TRUE , 失败返回FALSE
//lpFileData: 存放dll文件数据的缓冲区
//DataLength: 缓冲区中数据的总长度
BOOL CMemLoadDll::MemLoadLibrary(void* lpFileData, int DataLength)
{
  if(pImageBase != NULL)
  {
    return FALSE; //已经加载一个dll,还没有释放,不能加载新的dll
  }

  //检查数据有效性,并初始化
  if(!CheckDataValide(lpFileData, DataLength))return FALSE;

  //计算所需的加载空间
  int ImageSize = CalcTotalImageSize();

  if(ImageSize == 0) return FALSE;

  // 分配虚拟内存
  //void *pMemoryAddress = VirtualAlloc((LPVOID)0x10000000, ImageSize,MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);

  //修改,不指定dll基址申请内存

  void *pMemoryAddress = VirtualAlloc((LPVOID)NULL, ImageSize,MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE);

  if(pMemoryAddress == NULL)
  {
    return FALSE;
  }
  else
  {
    CopyDllDatas(pMemoryAddress, lpFileData); //复制dll数据,并对齐每个段
    //重定位信息
    if(pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress >0
    && pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size>0)
    {
      DoRelocation(pMemoryAddress);
    }
    //填充引入地址表
    if(!FillRavAddress(pMemoryAddress)) //修正引入地址表失败
    {
      VirtualFree(pMemoryAddress,0,MEM_RELEASE);

      return FALSE;
    }
    //修改页属性。应该根据每个页的属性单独设置其对应内存页的属性。这里简化一下。
    //统一设置成一个属性PAGE_EXECUTE_READWRITE

    unsigned long old;

    VirtualProtect(pMemoryAddress, ImageSize, PAGE_EXECUTE_READWRITE,&old);

  }
  
  //修正基地址
  pNTHeader->OptionalHeader.ImageBase = (DWORD)pMemoryAddress;

  //接下来要调用一下dll的入口函数,做初始化工作。
  
  pDllMain = (ProcDllMain)(pNTHeader->OptionalHeader.AddressOfEntryPoint +(DWORD) pMemoryAddress);

  BOOL InitResult = pDllMain((HINSTANCE)pMemoryAddress,DLL_PROCESS_ATTACH,0);

  if(!InitResult) //初始化失败
  {
    pDllMain((HINSTANCE)pMemoryAddress,DLL_PROCESS_DETACH,0);

    VirtualFree(pMemoryAddress,0,MEM_RELEASE);

    pDllMain = NULL;

    return FALSE;
  }

  isLoadOk = TRUE;

  pImageBase = (DWORD)pMemoryAddress;
  
  return TRUE;
}

//MemGetProcAddress函数从dll中获取指定函数的地址
//返回值: 成功返回函数地址 , 失败返回NULL
//lpProcName: 要查找函数的名字或者序号
FARPROC CMemLoadDll::MemGetProcAddress(LPCSTR lpProcName)
{
  if(pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress == 0 ||
  pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size == 0)
  return NULL;

  if(!isLoadOk) return NULL;

  DWORD OffsetStart = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;

  DWORD Size = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].Size;

  PIMAGE_EXPORT_DIRECTORY pExport = (PIMAGE_EXPORT_DIRECTORY)((DWORD)pImageBase + pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);

  int iBase = pExport->Base;

  int iNumberOfFunctions = pExport->NumberOfFunctions;

  int iNumberOfNames = pExport->NumberOfNames; //<= iNumberOfFunctions

  LPDWORD pAddressOfFunctions = (LPDWORD)(pExport->AddressOfFunctions + pImageBase);

  LPWORD pAddressOfOrdinals = (LPWORD)(pExport->AddressOfNameOrdinals + pImageBase);

  LPDWORD pAddressOfNames = (LPDWORD)(pExport->AddressOfNames + pImageBase);

  int iOrdinal = -1;

  if(((DWORD)lpProcName & 0xFFFF0000) == 0) //IT IS A ORDINAL!
  {
    iOrdinal = (DWORD)lpProcName & 0x0000FFFF - iBase;
  }
  else //use name
  {
    int iFound = -1;

    for(int i=0;i<iNumberOfNames;i++)
    {
      char* pName= (char* )(pAddressOfNames[i] + pImageBase);
      if(strcmp(pName, lpProcName) == 0)
      {
        iFound = i; break;
      }
    }

    if(iFound >= 0)
    {
      iOrdinal = (int)(pAddressOfOrdinals[iFound]);
    }
  }

  if(iOrdinal < 0 || iOrdinal >= iNumberOfFunctions ) return NULL;
  else
  {
    DWORD pFunctionOffset = pAddressOfFunctions[iOrdinal];

    if(pFunctionOffset > OffsetStart && pFunctionOffset < (OffsetStart+Size))//maybe Export Forwarding
    return NULL;
    else return (FARPROC)(pFunctionOffset + pImageBase);
  }

}

// 重定向PE用到的地址
void CMemLoadDll::DoRelocation( void *NewBase)
{
  /* 重定位表的结构:
  // DWORD sectionAddress, DWORD size (包括本节需要重定位的数据)
  // 例如 1000节需要修正5个重定位数据的话,重定位表的数据是
  // 00 10 00 00 14 00 00 00 xxxx xxxx xxxx xxxx xxxx 0000
  // ———– ———– —-
  // 给出节的偏移 总尺寸=8+6*2 需要修正的地址 用于对齐4字节
  // 重定位表是若干个相连,如果address 和 size都是0 表示结束
  // 需要修正的地址是12位的,高4位是形态字,intel cpu下是3
  */
  //假设NewBase是0×600000,而文件中设置的缺省ImageBase是0×400000,则修正偏移量就是0×200000

  DWORD Delta = (DWORD)NewBase - pNTHeader->OptionalHeader.ImageBase;

  //注意重定位表的位置可能和硬盘文件中的偏移地址不同,应该使用加载后的地址
  PIMAGE_BASE_RELOCATION pLoc = (PIMAGE_BASE_RELOCATION)((unsigned long)NewBase
  + pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);

  while((pLoc->VirtualAddress + pLoc->SizeOfBlock) != 0) //开始扫描重定位表
  {
    WORD *pLocData = (WORD *)((int)pLoc + sizeof(IMAGE_BASE_RELOCATION));

    //计算本节需要修正的重定位项(地址)的数目
    int NumberOfReloc = (pLoc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION))/sizeof(WORD);

    for( int i=0 ; i < NumberOfReloc; i++)
    {
      if( (DWORD)(pLocData[i] & 0xF000) == 0x00003000) //这是一个需要修正的地址
      {
      // 举例:
      // pLoc->VirtualAddress = 0×1000;
      // pLocData[i] = 0×313E; 表示本节偏移地址0×13E处需要修正
      // 因此 pAddress = 基地址 + 0×113E
      // 里面的内容是 A1 ( 0c d4 02 10) 汇编代码是: mov eax , [1002d40c]
      // 需要修正1002d40c这个地址
      DWORD * pAddress = (DWORD *)((unsigned long)NewBase + pLoc->VirtualAddress + (pLocData[i] & 0x0FFF));

      *pAddress += Delta;
      
      }
    }

    //转移到下一个节进行处理
    pLoc = (PIMAGE_BASE_RELOCATION)((DWORD)pLoc + pLoc->SizeOfBlock);

  }

}

//填充引入地址表
BOOL CMemLoadDll::FillRavAddress(void *pImageBase)
{
  // 引入表实际上是一个 IMAGE_IMPORT_DESCRIPTOR 结构数组,全部是0表示结束
  // 数组定义如下:
  //
  // DWORD OriginalFirstThunk; // 0表示结束,否则指向未绑定的IAT结构数组
  // DWORD TimeDateStamp;
  // DWORD ForwarderChain; // -1 if no forwarders
  // DWORD Name; // 给出dll的名字
  // DWORD FirstThunk; // 指向IAT结构数组的地址(绑定后,这些IAT里面就是实际的函数地址)
  unsigned long Offset = pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress ;

  if(Offset == 0) return TRUE; //No Import Table

  PIMAGE_IMPORT_DESCRIPTOR pID = (PIMAGE_IMPORT_DESCRIPTOR)((unsigned long) pImageBase + Offset);

  while(pID->Characteristics != 0 )
  {
    PIMAGE_THUNK_DATA pRealIAT = (PIMAGE_THUNK_DATA)((unsigned long)pImageBase + pID->FirstThunk);

    PIMAGE_THUNK_DATA pOriginalIAT = (PIMAGE_THUNK_DATA)((unsigned long)pImageBase + pID->OriginalFirstThunk);

    //获取dll的名字
    char buf[256]; //dll name;

    //修改,需要buf清零,否则dll名称不对
    memset(buf,0,sizeof(buf));

    BYTE* pName = (BYTE*)((unsigned long)pImageBase + pID->Name);

    for(int i=0;i<256;i++)
    {
      if(pName[i] == 0)break;

      buf[i] = pName[i];
    }

    HMODULE hDll = GetModuleHandle(buf);

    if(hDll == NULL) 
    {
      hDll = LoadLibrary (buf); //有可能依赖的dll还没有加载,如果没有加载加载后再判断是否加载成功

      if (hDll == NULL)

      return FALSE; //NOT FOUND DLL

    } //获取DLL中每个导出函数的地址,填入IAT

    //每个IAT结构是 :
    // union { PBYTE ForwarderString;
    // PDWORD Function;
    // DWORD Ordinal;
    // PIMAGE_IMPORT_BY_NAME AddressOfData;
    // } u1;
    // 长度是一个DWORD ,正好容纳一个地址。
    for(i=0; ;i++)
    {
      if(pOriginalIAT[i].u1.Function == 0)  break;

      FARPROC lpFunction = NULL;

      if(pOriginalIAT[i].u1.Ordinal & IMAGE_ORDINAL_FLAG) //这里的值给出的是导出序号
      {
        lpFunction = GetProcAddress(hDll, (LPCSTR)(pOriginalIAT[i].u1.Ordinal & 0x0000FFFF));
      }
      else //按照名字导入
      {
        //获取此IAT项所描述的函数名称
        PIMAGE_IMPORT_BY_NAME pByName = (PIMAGE_IMPORT_BY_NAME)
        ((DWORD)pImageBase + (DWORD)(pOriginalIAT[i].u1.AddressOfData));
        // if(pByName->Hint !=0)
        // lpFunction = GetProcAddress(hDll, (LPCSTR)pByName->Hint);
        // else
        lpFunction = GetProcAddress(hDll, (char *)pByName->Name);
      }

      if(lpFunction != NULL) //找到了!
      {
        pRealIAT[i].u1.Function = (PDWORD) lpFunction;
      }
      else return FALSE;
    }

    //move to next
    pID = (PIMAGE_IMPORT_DESCRIPTOR)( (DWORD)pID + sizeof(IMAGE_IMPORT_DESCRIPTOR));

  }

  return TRUE;
}

//CheckDataValide函数用于检查缓冲区中的数据是否有效的dll文件
//返回值: 是一个可执行的dll则返回TRUE,否则返回FALSE。
//lpFileData: 存放dll数据的内存缓冲区
//DataLength: dll文件的长度
BOOL CMemLoadDll::CheckDataValide(void* lpFileData, int DataLength)
{
  //检查长度
  if(DataLength < sizeof(IMAGE_DOS_HEADER)) return FALSE;

  pDosHeader = (PIMAGE_DOS_HEADER)lpFileData; // DOS头

  //检查dos头的标记
  if(pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) return FALSE; //0*5A4D : MZ

  //检查长度
  if((DWORD)DataLength < (pDosHeader->e_lfanew + sizeof(IMAGE_NT_HEADERS)) ) return FALSE;

  //取得pe头
  pNTHeader = (PIMAGE_NT_HEADERS)( (unsigned long)lpFileData + pDosHeader->e_lfanew); // PE头
  //检查pe头的合法性
  if(pNTHeader->Signature != IMAGE_NT_SIGNATURE) return FALSE; //0*00004550 : PE00

  if((pNTHeader->FileHeader.Characteristics & IMAGE_FILE_DLL) == 0) //0*2000 : File is a DLL
  return FALSE;

  if((pNTHeader->FileHeader.Characteristics & IMAGE_FILE_EXECUTABLE_IMAGE) == 0) //0*0002 : 指出文件可以运行
  return FALSE;

  if(pNTHeader->FileHeader.SizeOfOptionalHeader != sizeof(IMAGE_OPTIONAL_HEADER)) return FALSE;


  //取得节表(段表)
  pSectionHeader = (PIMAGE_SECTION_HEADER)((int)pNTHeader + sizeof(IMAGE_NT_HEADERS));

  //验证每个节表的空间
  for(int i=0; i< pNTHeader->FileHeader.NumberOfSections; i++)
  {
    if((pSectionHeader[i].PointerToRawData + pSectionHeader[i].SizeOfRawData) > (DWORD)DataLength)return FALSE;
  }


  return TRUE;
}

//计算对齐边界
int CMemLoadDll::GetAlignedSize(int Origin, int Alignment)
{
  return (Origin + Alignment - 1) / Alignment * Alignment;
}
//计算整个dll映像文件的尺寸
int CMemLoadDll::CalcTotalImageSize()
{
  int Size;

  if(pNTHeader == NULL)return 0;

  int nAlign = pNTHeader->OptionalHeader.SectionAlignment; //段对齐字节数

  // 计算所有头的尺寸。包括dos, coff, pe头 和 段表的大小
  Size = GetAlignedSize(pNTHeader->OptionalHeader.SizeOfHeaders, nAlign);

  // 计算所有节的大小
  for(int i=0; i < pNTHeader->FileHeader.NumberOfSections; ++i)
  {
    //得到该节的大小
    int CodeSize = pSectionHeader[i].Misc.VirtualSize ;
    int LoadSize = pSectionHeader[i].SizeOfRawData;
    int MaxSize = (LoadSize > CodeSize)?(LoadSize):(CodeSize);

    int SectionSize = GetAlignedSize(pSectionHeader[i].VirtualAddress + MaxSize, nAlign);
    if(Size < SectionSize)
    Size = SectionSize; //Use the Max;
  }

  return Size;

}
//CopyDllDatas函数将dll数据复制到指定内存区域,并对齐所有节
//pSrc: 存放dll数据的原始缓冲区
//pDest:目标内存地址
void CMemLoadDll::CopyDllDatas(void* pDest, void* pSrc)
{
  // 计算需要复制的PE头+段表字节数

  int HeaderSize = pNTHeader->OptionalHeader.SizeOfHeaders;

  int SectionSize = pNTHeader->FileHeader.NumberOfSections * sizeof(IMAGE_SECTION_HEADER);

  int MoveSize = HeaderSize + SectionSize;

  //复制头和段信息
  memmove(pDest, pSrc, MoveSize);


//复制每个节
for(int i=0; i < pNTHeader->FileHeader.NumberOfSections; ++i)
{
  if(pSectionHeader[i].VirtualAddress == 0 || pSectionHeader[i].SizeOfRawData == 0) continue;

  // 定位该节在内存中的位置
  void *pSectionAddress = (void *)((unsigned long)pDest + pSectionHeader[i].VirtualAddress);
  
  // 复制段数据到虚拟内存
  memmove((void *)pSectionAddress,
  (void *)((DWORD)pSrc + pSectionHeader[i].PointerToRawData),
  pSectionHeader[i].SizeOfRawData);

}

  //修正指针,指向新分配的内存
  //新的dos头
  pDosHeader = (PIMAGE_DOS_HEADER)pDest;
  //新的pe头地址
  pNTHeader = (PIMAGE_NT_HEADERS)((int)pDest + (pDosHeader->e_lfanew));
  //新的节表地址
  pSectionHeader = (PIMAGE_SECTION_HEADER)((int)pNTHeader + sizeof(IMAGE_NT_HEADERS));

  return ;
}



//加载资源DLL
#define strKey (char)0x15
char DLLtype[4]={'D' ^ strKey ,'l'^ strKey,'l'^ strKey,0x00};
HINSTANCE hinst=AfxGetInstanceHandle();
HRSRC hr=NULL;
HGLOBAL hg=NULL;
//对资源名称字符串进行简单的异或操作,达到不能通过外部字符串参考下断点
for(int i=0;i<sizeof(DLLtype)-1;i++) 
{
  DLLtype[i]^=strKey;
}
hr=FindResource(hinst,MAKEINTRESOURCE(IDR_DLL),TEXT(DLLtype));
if (NULL == hr) return FALSE;
//获取资源的大小
DWORD dwSize = SizeofResource(hinst, hr); 
if (0 == dwSize) return FALSE;
hg=LoadResource(hinst,hr);
if (NULL == hg) return FALSE;
//锁定资源
LPVOID pBuffer =(LPSTR)LockResource(hg);
if (NULL == pBuffer) return FALSE;

//对pBuffer进行处理
pMemLoadDll=new CMemLoadDll();
if(pMemLoadDll->MemLoadLibrary(pBuffer, dwSize)) //加载dll到当前进程的地址空间
{
  for(int i=0;i<sizeof(dllname)-1;i++) 
  {
    dllname[i]^=strKey;
  }
       
SENSE3 = (DllSENSE3)pMemLoadDll->MemGetProcAddress(dllname);
  if(SENSE3 == NULL)
  {
     return TRUE;
  }
}

FreeResource(hg); //在资源使用完毕后我们不需要使用UnlockResource和FreeResource来手动地释放资源,因为它们都是16位Windows遗留下来的,在Win32中,在使用完毕后系统会自动回收。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值