分析pe文件资源(学习)

PE文件,全称Portable
      Executable文件,是Windows系统可执行文件采用的普遍格式,像我们平时接触的EXE、DLL、OCX,甚至SYS文件都属于PE文件的范畴。为了在可执行文件中方便的引用其它类型资源内容,PE文件有一个独立的资源段,将程序执行所需要的全部资源文件链接到PE文件内部方便使用。今天我们就来研究一下PE文件资源段的内容与格式。

在研究PE文件资源段之前,我们必须了解PE文件头结构。下面我们来分析PE文件头的相关格式。PE文件头总体结构如图1所示,可见PE文件的格式是相对复杂的。我将在下文依次分析各个部分的含义。

      
      图1 PE文件结构

      MS-DOS头部
      第一个结构是MS-DOS
      头,这个是为了兼容旧的DOS程序而设计的,如果一个Win32程序在DOS模式下运行(所谓的DOS模式是指纯DOS环境,而不是Windows控制台),DOS头部会把执行定位到MS-DOS实模式残余程序,该程序会调用int
      21中断输出一个字符串“This program cannot be run in DOS
      mode”,然后直接退出。MS-DOS头部在“winnt.h”里面有定义。
      typedef struct _IMAGE_DOS_HEADER {// DOS .EXE header
      WORD   e_magic; // Magic number
      WORD   e_cblp; // Bytes on last page of file
      WORD   e_cp;// Pages in file
      WORD   e_crlc; // Relocations
      WORD   e_cparhdr;   // Size of header in paragraphs
      WORD   e_minalloc; // Minimum extra paragraphs needed
      WORD   e_maxalloc; // Maximum extra paragraphs needed
      WORD   e_ss;// Initial (relative) SS value
      WORD   e_sp;// Initial SP value
      WORD   e_csum; // Checksum
      WORD   e_ip;// Initial IP value
      WORD   e_cs;// Initial (relative) CS value
      WORD   e_lfarlc;// File address of relocation table
      WORD   e_ovno; // Overlay number
      WORD   e_res[4];// Reserved words
      WORD   e_oemid; // OEM identifier (for e_oeminfo)
      WORD   e_oeminfo;   // OEM information; e_oemid specific
      WORD   e_res2[10]; // Reserved words
      LONG   e_lfanew;// File address of new exe header
      } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

      第一成员变量e_magic被称为魔术数字,它被用于表示一个MS-DOS兼容的文件类型。所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ。MS-DOS头部之所以有的时候被称为MZ头部,就是这个缘故。

      至于其余的成员变量,基本上都是为了DOS下实模式设计,如今已经没有什么实际作用,除了最后一个成员变量e_lfanew。这个成员变量用来表示PE头部在这个PE文件中的偏移量。通过如下代码可以获得PE头部地址:


      BYTE *pFileImage = (BYTE*)pPeImage;
      PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
      PIMAGE_FILE_HEADER pFileHeader
      =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);

      注意,在计算偏移地址的时候除了偏移量pDosHeader->e_lfanew,还有一个DWORD的偏移,这个DWORD
      是存储PE文件标志的,值为0x4550,对应ASCII字符“PE”。
      PE头部
      下面我们来介绍PE头部的内容。PE头部在“winnt.h”中定义如下。

      typedef struct _IMAGE_FILE_HEADER {
      WORDMachine;
      WORDNumberOfSections;
      DWORD   TimeDateStamp;
      DWORD   PointerToSymbolTable;
      DWORD   NumberOfSymbols;
      WORDSizeOfOptionalHeader;
      WORDCharacteristics;
      } IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

      这个结构比较简单,Machine表示这个可执行文件被构建的目标机器种类,本程序获得的Machine值是0x14c,代表i386;NumberOfSection表示本PE文件具有多少个有多少个段头部和多少个段实体,每一个段头部和段实体都在文件中连续地排列着,所以要决定段头部和段实体在哪里结束的话,段的数目是必需的;TimeDataStamp是一个时间戳变量;PointerToSymbolTable和NumberOfSymbols确定了符号表的位置和大小。SizeOfOptionalHeader表示选项头部的大小,选项头部就在PE文件头部后面线性排列,这个结构容后介绍,但是大家不要被名称迷惑,选项头部是对PE文件执行至关重要的结构,并非“Optional”。Characteristics表示文件的一些特征,比如对于一个可执行文件而言,分离调试文件是如何操作的。

      选项头
      选项头在“winnt.h”中的定义如下。

      typedef struct _IMAGE_OPTIONAL_HEADER {
      // Standard fields.
      WORDMagic;
      BYTEMajorLinkerVersion;
      BYTEMinorLinkerVersion;
      DWORD   SizeOfCode;
      DWORD   SizeOfInitializedData;
      DWORD   SizeOfUninitializedData;
      DWORD   AddressOfEntryPoint;
      DWORD   BaseOfCode;
      DWORD   BaseOfData;
      // NT additional fields.
      DWORD   ImageBase;
      DWORD   SectionAlignment;
      DWORD   FileAlignment;
      WORDMajorOperatingSystemVersion;
      WORDMinorOperatingSystemVersion;
      WORDMajorImageVersion;
      WORDMinorImageVersion;
      WORDMajorSubsystemVersion;
      WORDMinorSubsystemVersion;
      DWORD   Win32VersionValue;
      DWORD   SizeOfImage;
      DWORD   SizeOfHeaders;
      DWORD   CheckSum;
      WORDSubsystem;
      WORDDllCharacteristics;
      DWORD   SizeOfStackReserve;
      DWORD   SizeOfStackCommit;
      DWORD   SizeOfHeapReserve;
      DWORD   SizeOfHeapCommit;
      DWORD   LoaderFlags;
      DWORD   NumberOfRvaAndSizes;
      IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
      } IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

      此结构成员按照功能的区别可以分为两个域:标准域和NT附加域。标准域由前九个成员变量构成,是和UNIX可执行文件的COFF格式所公共的部分。虽然标准域保留了COFF中定义的名字,但是Windows
      NT仍然将它们用作了不同的目的。其中,Magic表示了不同PE文件的种类,我们一般的Win32程序这个值都是0x10b;MajorLinkerVersion、MinorLinkerVersion表示链接此映像的链接器版本;SizeOfCode表示可执行代码尺寸;SizeOfInitializedData表示已初始化的数据尺寸;SizeOfUninitializedData表示未初始化的数据尺寸;AddressOfEntryPoint表示本PE文件执行的入口点;BaseOfCode表示已载入映像的代码(“.text”段)的相对偏移量;BaseOfData表示已载入映像的未初始化数据(“.bss”段)的相对偏移量。

      剩下的变量构成了NT附加域。顾名思义,这个域主要存储一些和程序在Windows系统下运行相关的信息,所示如下。
              ImageBase:进程映像地址空间中的首选基地址。Windows NT的Microsoft Win32
      SDK链接器将这个值默认设为0x00400000。
             
      SectionAlignment:从ImageBase开始,每个段都被相继的装入进程的地址空间中。SectionAlignment则规定了装载时段能够占据的最小空间数量。Windows
      NT虚拟内存管理器规定,段对齐不能少于页尺寸(当前的x86平台是4096字节),并且必须是成倍的页尺寸。4096字节是x86链接器的默认值,但是它可以通过-ALIGN:
      linker开关来设置。
              FileAlignment:映像文件首先装载的最小的信息块间隔。
              MajorOperatingSystemVersion:系统主板本号。
              MinorOperatingSystemVersion:系统次版本号。
              MajorImageVersion:应用程序主板本号。
              MinorImageVersion:应用程序次版本号。
              MajorSubsystemVersion:Windows Win32子系统主板本号。
              MinorSubsystemVersion:Windows Win32子系统次版本号。
              Win32VersionValue:保留,一般被链接器设为零。
             
      SizeOfImage:表示载入的可执行映像的地址空间中要保留的地址空间大小,其值等于本PE文件各个段所占的地址空间之和。注意,各段所占的地址空间是考虑SectionAlignment对齐后的空间,所以说SectionAlignment的大小会影响本变量的值。

             
      SizeOfHeaders:本PE文件全部头结构所占的体积,包括DOS头,PE文件头和PE选项头等,也可以把这个值看成一个偏移地址,本PE文件的第一个段实体就开始于此。

              CheckSum:校验和,某些文件可以设置该值对本身进行完整性保护。
              Subsystem:表示该可执行文件的目标子系统。
              DllCharacteristics:用来表示一个DLL映像是否为进程和线程的初始化及终止包含入口点的标记,现在已经废弃。
              SizeOfStackReserve:程序保留的栈大小。
              SizeOfStackCommit:栈提交大小。
              SizeOfHeapReserve:程序堆保留大小。
              SizeOfHeapCommit:堆提交大小。
              默认情况下,程序的堆栈空间都是1个页面的申请大小和16个页面的保留大小。
              LoaderFlags:告知装载器是否在装载时中止和调试。
              NumberOfRvaAndSizes:表示后面的DataDirectory数组个数,一般都为16。
             
      DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]:数据目录表示文件中其它可执行信息重要组成部分的位置。它事实上就是一个IMAGE_DATA_DIRECTORY结构的数组,位于可选头部结构的末尾。当前的PE文件格式定义了16种可能的数据目录,这之中的11种现在在使用中。

      数据目录数组各项含义在“winnt.h”中的定义如下。

      #define IMAGE_DIRECTORY_ENTRY_EXPORT 0   // Export Directory
      #define IMAGE_DIRECTORY_ENTRY_IMPORT 1   // Import Directory
      #define IMAGE_DIRECTORY_ENTRY_RESOURCE2   // Resource Directory
      #define IMAGE_DIRECTORY_ENTRY_EXCEPTION   3   // Exception Directory
      #define IMAGE_DIRECTORY_ENTRY_SECURITY4   // Security Directory
      #define IMAGE_DIRECTORY_ENTRY_BASERELOC   5   // Base Relocation Table
      #define IMAGE_DIRECTORY_ENTRY_DEBUG   6   // Debug Directory
      // IMAGE_DIRECTORY_ENTRY_COPYRIGHT   7   // (X86 usage)
      #define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE7   // Architecture Specific
      Data
      #define IMAGE_DIRECTORY_ENTRY_GLOBALPTR   8   // RVA of GP
      #define IMAGE_DIRECTORY_ENTRY_TLS 9   // TLS Directory
      #define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG10   // Load Configuration
      Directory
      #define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT   11   //Bound Import Directory
      in headers
      #define IMAGE_DIRECTORY_ENTRY_IAT12   // Import Address Table
      #define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT   13   //Delay Load Import
      Descriptors
      #define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14   //COM Runtime descriptor


      上面各项的含义注释已经明确说明,不再赘述。我们通过下面的代码可以定位到选项头:

      PIMAGE_OPTIONAL_HEADER32 pOptionalHeader = (PIMAGE_OPTIONAL_HEADER32)
      (pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
      PE文件段
      PE文件段没有什么特定的结构特点,它几乎可以被链接器链接到PE文件的任何地方,程序执行时从PE文件定位段全靠段头部。
      段头部每个40字节长,以数组的形式存放在Image Optional Header后面,可以使用如下代码获得该数组的起始地址。

      PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char
      *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));

      我们可以读取PE文件头部的NumberOfSections变量获取该数组的大小。IMAGE_SECTION_HEADER在“winnt.h”中的定义如下。


      typedef struct _IMAGE_SECTION_HEADER {
      BYTEName[IMAGE_SIZEOF_SHORT_NAME];
      union {
      DWORD   PhysicalAddress;
      DWORD   VirtualSize;
      } Misc;
      DWORD   VirtualAddress;
      DWORD   SizeOfRawData;
      DWORD   PointerToRawData;
      DWORD   PointerToRelocations;
      DWORD   PointerToLinenumbers;
      WORDNumberOfRelocations;
      WORDNumberOfLinenumbers;
      DWORD   Characteristics;
      } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

      其中,Name储存区段的名称,这个名称最大长度8字节,且开头第一个字符必须是“.”,如“.text”、“.data”等。接下来的Union现在已经不再使用,没有什么实际意义。VirtualAddress:这个域标识了进程地址空间中要装载这个段的虚拟地址。实际的地址由将这个域的值加上可选头部结构中的ImageBase虚拟地址得到。切记,如果这个映像文件是一个DLL,那么这个DLL就不一定会装载到ImageBase要求的位置。所以一旦这个文件被装载进入了一个进程,实际的ImageBase值应该通过使用GetModuleHandle来检验。SizeOfRawData:表示原始数据的大小,也就是根据FileAlignment进行对齐之前的数据大小。PointerToRawData:这是一个文件中段实体位置的偏移量。接下来的四个变量PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers在PE格式中不使用。Characteristics定义了段的特征。表1显示了不同Characteristics值对应的不同含义。

      可能取值        对应含义
      0x00000020        代码段
      0x00000040        已初始化数据段
      0x00000080        未初始化数据段
      0x04000000        该段数据不能被缓存
      0x08000000        该段不能被分页
      0x10000000        共享段
      0x20000000        可执行段
      0x40000000        可读段
      0x80000000        可写段
      表1 Characteristics取值范围及含义
      虚拟地址
      虚拟地址的概念最早在数据目录数组中就有涉及,其标示的各个关键域位置就是虚拟地址。PE文件是需要在进程运行时加载到进程地址空间的,其加载方式不是简单的将文件读入内存,而是按照段头的标示,将各个段分别加载到进程地址空间的特定位置。在PE文件头部有一个变量ImageBase是该PE文件加载入进程地址空间的首选地址,其他各段加载的地址是以ImageBase为基地址,以其段头部变量VirtualAddress为偏移量计算出来的。所谓某变量的虚拟地址就是在PE文件加载后,该变量相对于PE文件加载基地址的偏移量。

      所以,如果需要把某个虚拟地址转换为其在PE文件中的偏移量,我们需要首先知道该虚拟地址所指变量属于的区段,然后根据区段头部的VitrualAddress变量和该虚拟地址的差值求出该变量相对本区段起始位置的偏移量,再根据区段在文件中的位置求出该变量在文件中的偏移量。

      解析资源文件
      下面我们开始解析资源文件的工作。在我的示例程序中,首先将选定的PE文件映射到进程内存中,pFileImage即为映射基地址。下一步程序需要确定资源段,一般可以采用遍历全部段头部寻找名称为“.rsrc”段的方式,但是此方式并不可靠,因为资源段的名称并非固定。在我的程序中使用根据数据目录特定项确定资源段的方式,代码如下。


      PIMAGE_SECTION_HEADER GetResoucreSectionHeader(char *pFileImage)
      {
      DWORD dwDataDirectoryIndex =IMAGE_DIRECTORY_ENTRY_RESOURCE;
      PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
      PIMAGE_FILE_HEADER pFileHeader
      =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
      PIMAGE_OPTIONAL_HEADER32 pOptionalHeader
      =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));

      PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char
      *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
      if(dwDataDirectoryIndex>=pOptionalHeader->NumberOfRvaAndSizes)
      {
      return NULL;
      }
      PIMAGE_SECTION_HEADER pSectionBelong=NULL;
      for(int i=0;i<pFileHeader->NumberOfSections;i++)
      {
      if(pSectionHeader[i].VirtualAddress<=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress&&pSectionHeader[i].VirtualAddress+pSectionHeader[i].SizeOfRawData>=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress+pOptionalHeader->DataDirectory[dwDataDirectoryIndex].Size)

      {
      pSectionBelong=&(pSectionHeader[i]);
      break;
      }
      }
      return pSectionBelong;
      }

      资源段的构成比较复杂,是一个树状结构,主要由两种结构维护。

      typedef struct _IMAGE_RESOURCE_DIRECTORY {
      DWORD   Characteristics;
      DWORD   TimeDateStamp;
      WORDMajorVersion;
      WORDMinorVersion;
      WORDNumberOfNamedEntries;
      WORDNumberOfIdEntries;
      } IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

      这个结构每一个实体对应资源树的一个分支节点,其中NumberOfNamedEntry代表这个节点的子节点中靠名字标识的子节点的个数,NumberOfIdEntries代表这个节点的子节点中靠ID标识的子节点的个数,这两个数之和即为该节点全部叶子节点的个数。


      typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
      union {
      struct {
      DWORD NameOffset:31;
      DWORD NameIsString:1;
      };
      DWORD   Name;
      WORDId;
      };
      union {
      DWORD   OffsetToData;
      struct {
      DWORD   OffsetToDirectory:31;
      DWORD   DataIsDirectory:1;
      };
      };
      }IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

      这个结构的实体对应树的每个节点本身,其本质上就是两个DWORD成员变量,第一个变量表示该节点的名称,第二个变量表示其对应数据的位置。具体使用方式后面会有介绍。

      资源
      我们可以通过如下代码获得资源树根节点。

      PIMAGE_RESOURCE_DIRECTORY pImageResourceDirectory =
      (PIMAGE_RESOURCE_DIRECTORY)(((char
      *)pFileImage)+pResourceHeader->PointerToRawData);

      资源树的第一层分支代表各种资源类型,每一类资源分出一棵子树,不同子树根节点对应的成员变量Name的值不同,代表不同类型的资源,具体含义参见Winnt.h,不再赘述了。我们可以使用如下代码遍历资源树的第一层。


      PIMAGE_RESOURCE_DIRECTORY_ENTRY pResourceEntry =
      (IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pImageResourceDirectory +
      sizeof (IMAGE_RESOURCE_DIRECTORY));
      for(int i=0; i< (pImageResourceDirectory->NumberOfIdEntries +
      pImageResourceDirectory->NumberOfNamedEntries); i++,pResourceEntry++)
      { //所有资源
      CString strName;
      switch(pResourceEntry->Name)
      {
      case 1:
      {
      strName.Format("光标");
      break;
      }
      ……重复代码省略……
      default:
      {
      strName.Format("Unknow :%u",pResourceEntry->Name);
      break;
      }
      }
      }

      资源树的第二层对应本类资源的全部资源,例如PE文件光标资源有3个,则根节点下对应光标资源的子树第一层就有三个分支。我们可以通过如下代码遍历各个子树。


      PIMAGE_RESOURCE_DIRECTORY pResourceBranch = (IMAGE_RESOURCE_DIRECTORY *)
      ((char *) pImageResourceDirectory + pResourceEntry->OffsetToDirectory);
      PIMAGE_RESOURCE_DIRECTORY_ENTRY pFirstFloorResourceEntry =
      (IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pResourceBranch +
      sizeof(IMAGE_RESOURCE_DIRECTORY));
      for(int k=0; k<(pResourceBranch->NumberOfIdEntries +
      pResourceBranch->NumberOfNamedEntries); k++,pFirstFloorResourceEntry++)
      {
      ……通过pFirstFloorResourceEntry访问被遍历的二层子树
      }

      该层子树主要存储资源的名称,资源可以按照两种方式导入:名称导入或者ID导入。如果资源采用ID导入,则其成员变量Name直接就代表ID值;如使用名称导入,则Name变量为一个相对于资源段首的偏移量,指向一个IMAGE_RESOURCE_DIR_STRING_U结构,这个结构里保存着WCHAR格式存储的资源名称,下面代码将尝试解析该项。


      CString strName;
      if(pFirstFloorResourceEntry->NameIsString ==0)
      {
      strName.Format("ID :%u",pFirstFloorResourceEntry->Name);
      }
      else
      {
      PIMAGE_RESOURCE_DIR_STRING_U pImageResourceDirString =
      (PIMAGE_RESOURCE_DIR_STRING_U)((char
      *)pImageResourceDirectory+pFirstFloorResourceEntry->NameOffset);
      WCHAR wsBuffer[128];
      memset(wsBuffer,0,sizeof(WCHAR)*128);
      memcpy(wsBuffer,pImageResourceDirString->NameString,sizeof(WCHAR)*pImageResourceDirString->Length);

      USES_CONVERSION;
      char *p =W2A(wsBuffer);
      strName.Format("Name :%s",p);
      }

      资源树的第三层不再分支,每一个资源对应一个下层节点,存储其语言ID,具体语言ID值的含义请参考winnt.h,下面给出读取语言ID的代码。

      PIMAGE_RESOURCE_DIRECTORY pResourceLeaf = (IMAGE_RESOURCE_DIRECTORY *)
      ((char *) pImageResourceDirectory +
      pFirstFloorResourceEntry->OffsetToDirectory );
      PIMAGE_RESOURCE_DIRECTORY_ENTRY pResourceLeafEntry =
      (IMAGE_RESOURCE_DIRECTORY_ENTRY *) ((char *) pResourceLeaf +
      sizeof(IMAGE_RESOURCE_DIRECTORY));
      CString Tmp;
      if(pResourceLeafEntry->Id<=0xfff)
      {
      Tmp.Format("语言ID: 0x0%X",pResourceLeafEntry->Id);
      }
      else
      {
      Tmp.Format("语言ID: 0x%X",pResourceLeafEntry->Id);
      }

      资源树的最后叶子节点是一个特殊的结构:IMAGE_RESOURCE_DATA_ENTRY,其定义如下:

      typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
      DWORD   OffsetToData;
      DWORD   Size;
      DWORD   CodePage;
      DWORD   Reserved;
      } IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;

      这样,我们就可以根据这个结构中的变量获得该资源PE文件中的偏移量和大小,注意这个偏移量是一个虚拟地址,下面代码获得每个资源的大小和相对文件头部的偏移量。


      PIMAGE_RESOURCE_DATA_ENTRY pResourceData = (IMAGE_RESOURCE_DATA_ENTRY *)
      ((char *) pImageResourceDirectory + pResourceLeafEntry->OffsetToData );
      //资源入口结构
      Tmp.Format("偏移量:%u" ,
      pResourceData->OffsetToData-pResourceHeader->VirtualAddress);
      Tmp.Format("资源大小:%u",pResourceData->Size);
      结束语
      至此,整个PE文件资源段就分析完了,基于本文对应的示例程序,大家可以写一个PE文件资源抽取器,或者写一个PE文件资源修改器。如果你对本文有什么问题到黑防论坛或者发信和交流。最后祝大家玩得愉快。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值