返回介绍

19.3 使用 SDK 编写处理器模块

发布于 2024-10-11 21:05:48 字数 35719 浏览 0 评论 0 收藏 0

在讨论如何创建处理器模块之前,必须指出,有关处理器模块的文档资料非常少。除了浏览 SDK 包含文件和 SDK 提供的处理器模块源代码外,SDK 的 readme.txt 文件是唯一一个说明如何创建处理器模块的文件,其中有几条提示位于 Description of Processor modules(处理器模块说明)标题下。

需要澄清的是,虽然 README 文件提及的处理器模块中一些特定的文件名好像是一成不变的,实际上并非如此。但是,它们确实是 SDK 包含的示例所使用的文件名,也是这些示例包含的构建脚本提到的文件名。在创建处理器模块时,你可以使用任何你想要的文件名,只要对构建脚本进行相应更新即可。

提及特定处理器文件一般是为了说明:处理器模块主要由 3 个逻辑组件构成:分析器、指令模拟器和输出生成器。在下面创建 Python 处理器模块的过程中,我们将讨论这些功能组件的用途。

<SDKDIR>/module 目录中保存有几个示例处理器。你需要浏览(如果需要)的一个较为简单的处理器为 z8 处理器。其他处理器的复杂程度根据它们的指令集以及它们是否承担加载任务而各有不同。如果你正考虑编写自己的处理器模块,其中一种方法(Ilfak 在 README 文件中推荐过)是复制一个现有的处理器模块,并根据需要修改。这时,你可能需要找到逻辑结构(不一定是处理器体系结构)与你的模块最为相似的处理器模块。

19.3.1 processor_t 结构体

与插件和加载器一样,处理器模块只导出一个项目。对处理器而言,这个项目必定是一个名为 LPHprocessor_t 结构体。如果你包含<SDKDIR>/module/idaidp.hpp 文件(这个文件又包含处理器模块所需的其他许多 SDK 头文件),IDA 将自动导出这个结构体。编写处理器模块之所以非常困难,其中一个原因是 processor_t 结构体包含 56 个必须初始化的字段,其中 26 个字段是函数指针,并且有 1 个字段是一个指向数组的指针,这个数组由一个或多个结构体指针构成,每个指针指向一种包含 59 个需要初始化的字段的结构体( asm_t )。很简单,是吗?构建处理器模块面临的一大难题与初始化全部必需的静态数据有关,由于每个数据结构中都包含大量字段,这个过程很容易出错。这也是 Ilfak 建议你在创建新处理器时使用一个现有的处理器作为基础的原因之一。

由于这些数据结构非常复杂,我们并不打算枚举每一个可能的字段及其用法。我们将重点讨论主要的字段。有关每个结构体中的这些字段及其他字段的详细信息,请参阅 idp.hpp 文件。我们讨论各种 processor_t 字段的顺序,并不是 processor_t 声明它们的顺序。

19.3.2 LPH 结构体的基本初始化

在深入分析处理器模块的行为之前,需要注意一些静态数据要求。在构建反汇编模块时,你需要创建一个汇编语言助记符列表,日后你需要为目标处理器识别这些助记符。这个列表采用 instruc_t (在 idp.hpp 中定义)结构体数组的形式创建,通常保存在一个名为 ins.cpp 的文件中。如下所示, instruc_t 是一个简单的结构体,它有两方面的用途:为指令助记符提供一个表查找,描述每条指令的一些基本特点。

struct instruc_t {  
  const char *name;  //instruction mnemonic  
  ulong feature;     //bitwise OR of CF_xxx flags defined in idp.hpp  
};

feature 字段用于说明各种行为,如指令是否读取或写入它的操作数,指令执行时程序该如何继续(默认、跳转、调用)。 CF_xxx 中的 CF 代表 典型特征 (canonical feature )。基本上,feature 字段用于实现控制流和交叉引用概念。下面是一些有趣的典型特征标志。

  • CF_STOP 。这条指令并不将控制权转交给下一条指令。绝对跳转和函数返回指令即属于这类指令。

  • CF_CHGn 。这条指令修改操作数 n ,这里的 n 在 1 与 6 之间。

  • CF_USEn 。这条指令使用操作数 n ,这里的 n 在 1 与 6 之间。 USE 指“读取”或“引用”(但不是修改,参见 CF_CHGn )一个内存位置。

  • CF_CALL 。这条指令调用一个函数。

指令不需要按任何特殊的顺序列出。具体来说,你没有必要根据与指令有关的二进制操作码对指令排序,这个数组中的指令与有效的二进制操作码之间也不需要建立一一对应的关系。示例指令数组的前几行和后几行如下所示:

    instruc_t Instructions[] = {  
       {"STOP_CODE", CF_STOP},   /* 0 */  
       {"POP_TOP", 0},           /* 1 */  
       {"ROT_TWO", 0},           /* 2 */  
       {"ROT_THREE", 0},         /* 3 */  
       {"DUP_TOP", 0},           /* 4 */  
       {"ROT_FOUR", 0},          /* 5 */  
➊     {NULL, 0},                /* 6 */  
       ...  
       {"CALL_FUNCTION_VAR_KW", CF_CALL}, /* 142 */  
       {"SETUP_WITH", 0},                 /* 143 */  
       {"EXTENDED_ARG", 0},               /* 145 */  
       {"SET_ADD", 0},                    /* 146 */  
       {"MAP_ADD", 0}                     /* 147 */  
   };

在我们的例子中,由于 Python 字节码非常简单,我们将在指令与字节码之间保持一一对应的关系。需要注意的是,为了保持这种关系,如果操作码没有定义,如这里的操作码 6(➊),有些指令记录必须充当填充符。

通常,ins.hpp 文件定义了一组相关的枚举常量,说明了整数和指令之间的对应关系,如下所示:

enum python_opcodes {  
   STOP_CODE = 0,  
   POP_TOP = 1,    //remove top item on stack  
   ROT_TWO = 2,    //exchange top two items on stack  
   ROT_THREE = 3,  //move top item below the 2nd and 3rd items  
   DUP_TOP = 4,    //duplicate the top item on the stack  
   ROT_FOUR = 5,   //move top item below the 2nd, 3rd, and 4th items  
   NOP = 9,        //no operation  
   ...  
   CALL_FUNCTION_VAR_KW = 142,  
   SETUP_WITH = 143,  
   EXTENDED_ARG = 145,  
   SET_ADD = 146,  
   MAP_ADD = 147,  
   PYTHON_LAST = 148  
};

这里选择为每一条指令显式赋值,这既是为了表述清楚,也因为指令序列中留有空白(由于使用了真正的 Python 操作码作为指令索引)。这里还添加了另一个常量( PYTHON_LAST ),以便于你找到列表的结尾。了解指令及相关整数之间的对应关系后,我们就拥有了足够的信息,可以初始化 LPH (我们的全局 processor_t )的 3 个字段。这 3 个字段为:

int instruc_start;   // integer code of the first instruction  
  int instruc_end;     // integer code of the last instruction + 1  
  instruc_t *instruc;  // array of instructions

我们必须分别使用 STOP_CODEPYTHON_LASTInstructions 初始化这些字段。这些字段共同使处理器模块能够迅速查询反汇编代码清单中任何指令的助记符。

对大多数处理器模块而言,我们还需要定义一组寄存器名称以及一组枚举常量,以引用它们。如果要编写 x86 处理器模块,可以首先编写以下代码。在这里,为了简化,我们仅限于基本的 x86 寄存器集合:

static char *RegNames[] = {  
   "eax", "ebx", "ecx", "edx", "edi", "esi", "ebp", "esp  
   "ax", "bx", "cx", "dx", "di", "si", "bp", "sp",  
   "al", "ah", "bl", "bh",  "cl", "ch", "dl", "dh",  
   "cs", "ds", "es", "fs", "gs"  
};

RegNames 数组通常在文件 reg.cpp 中声明。这个文件也是样本处理器模块声明 LPH 的地方,它可以静态声明 RegNames 。寄存器枚举在一个头文件中声明,通常这个文件以处理器名称命名(这里可能为 x86.hpp),如下所示:

enum x86_regs {  
   r_eax, r_ebx, r_ecx, r_edx, r_edi, r_esi, r_ebp, r_esp,  
   r_ax, r_bx, r_cx, r_dx, r_di, r_si, r_bp, r_sp,  
   r_al, r_ah, r_bl, r_bh,  r_cl, r_ch, r_dl, r_dh,  
   r_cs, r_ds, r_es, r_fs, r_gs  
};

一定要维护寄存器名称数组与相关联的常量集合之间的正确对应关系。在格式化指令操作数时,寄存器名称数组与枚举的寄存器常量共同作用,使处理器模块能够迅速查询寄存器名称。这两个数据声明用于初始化 LPH 中的其他字段:

int   regsNum;            // total number of registers  
  char  **regNames;         // array of register names

通常,这两个字段分别使用 qnumber(RegNames)RegNames 进行初始化,这里的 qnumber 是一个宏,它在 pro.h 中定义,用于计算一个静态分配的数组的元素数量。

无论是否使用段寄存器,IDA 处理器模块总是需要指定与段寄存器有关的信息。由于 x86 使用了段寄存器,前面的例子配置起来相当简单。段寄存器使用 processor_t 中的下列字段配置:

➊ // Segment register information (use virtual CS and DS registers if  
  // your processor doesn't have segment registers):  
    int   regFirstSreg;        // number of first segment register  
    int   regLastSreg;         // number of last segment register  
    int   segreg_size;         // size of a segment register in bytes  

➋ // If your processor does not use segment registers, You should define  
   // 2 virtual segment registers for CS and DS.  
   // Let's call them rVcs and rVds.  
     int   regCodeSreg;         // number of CS register  
     int   regDataSreg;         // number of DS register

要初始化我们假定的 x86 处理器模块,需要按如下顺序对前面的 5 个字段进行初始化:

r_cs, r_gs, 2, r_cs, r_ds

请注意➊和➋处有关段寄存器的注释。即使处理器并不使用段寄存器,IDA 总是需要有关段寄存器的信息。回到 Python 示例,在设置寄存器对应关系时,我们几乎没有什么工作要做,因为 Python 解释器采用的是一个基于栈的体系结构,它并不使用寄存器,但我们仍然需要处理段寄存器问题。典型的处理方法是虚构最小的一组段寄存器的名称和枚举常量值(代码和数据)。基本上,我们虚构段寄存器,只是因为 IDA 需要它们。但是,即使 IDA 需要它们,我们绝没有义务使用它们,因此,在处理器模块中,我们完全忽略它们。对于 Python 处理器,我们做如下处理:

//in reg.cpp  
static char *RegNames = { "cs", "ds" };  

//in python.hpp  
enum py_registers { rVcs, rVds };

声明就绪后,我们可以回过头来使用下面的值初始化 LPH 中的相应字段:

rVcs, rVds, 0, rVcs, rVds

在开始执行 Python 处理器的任何行为之前,都应花一些时间了解与初始化 LPH 结构体有关的一些简单知识。 processor_t 的前 5 个字段如下所示:

int version; // should be IDP_INTERFACE_VERSION  
int id;     // IDP id, a PLFM_xxx value or self assigned > 0x8000  
ulong flag; // Processor features, bitwise OR of PR_xxx values  
int cnbits; // Number of bits in a byte for code segments (usually 8)  
int dnbits; // Number of bits in a byte for data segments (usually 8)

这里的 version 字段看起来有些眼熟,因为插件和加载器模块也使用了这个字段。对于自定义处理器模块来说, id 字段必须是一个大于 0x8000 的、自分配的值。 flag 字段以在 idp.hpp 中定义的 PR_ xxx 标志组合描述处理器模块的各种特点。对于 Python 处理器,我们仅指定 PR_RNAMESOKPRN_DEC ,前者允许将寄存器名称用作位置名称(因为我们没有寄存器,这不会造成问题),后者将默认的数字显示格式设置为十进制。剩下的两个字段 cnbitsdnbits 分别设置为 8 。

19.3.3 分析器

现在,我们已经在 LPH 结构体中填入了足够的信息,可以开始考虑处理器模块将要执行的第一个组件——分析器。在处理器模块示例中,分析器通常由 ana.cpp 文件中的一个名为 ana (你可以使用任何你喜欢的名称)的函数实现。这个函数的原型非常简单,如下所示:

int idaapi ana(void); //analyze one instruction and return the instruction length

你必须用一个指向分析器函数的指针初始化 LPH 对象的 u_ana 成员。分析器的工作包括分析单条指令,用与指令有关的信息填充全局变量 cmd ,返回指令的长度。分析器不得对数据库进行任何修改。

变量 cmdinsn_t 对象的一个全局实例。在 ua.hpp 中定义的 insn_t 类用于描述数据库中的单条指令。它的声明如下所示:

     class insn_t {  
     public:  
       ea_t cs; // Current segment base paragraph. Set by kernel  
       ea_t ip; // Virtual address of instruction (within segment). Set by kernel  
       ea_t ea; // Linear address of the instruction. Set by kernel  
➊     uint16 itype; // instruction enum value (not opcode!). Proc sets this in ana  
➋     uint16 size;  // Size of instruction in bytes. Proc sets this in ana  
       union {       // processor dependent field. Proc may set this  
         uint16 auxpref;  
         struct {  
           uchar low;  
           uchar high;  
         } auxpref_chars;  
       };  
       char segpref;     // processor dependent field.  Proc may set this  
       char insnpref;    // processor dependent field.  Proc may set this  
➌     op_t Operands[6]; // instruction operand info.  Proc sets this in  
       char flags;       // instruction flags.  Proc may set this  
     };

在调用分析器函数之前,IDA 内核(IDA 的核心)会使用指令的分段的线性地址填充 cmd 对象的前 3 个字段。之后,再由分析器填充其他字段。需要分析器填充的主要字段为 itype (➊)、 size (➋)和 Operands (➌)。 itype 字段必须设置为前面讨论的一个枚举指令类型值。 size 字段必须设置为指令的总大小,并且应用作指令的返回值。如果无法解析指令,分析器应返回 0。最后,一条指令最多只能有 6 个操作数,分析器应填充与指令使用的每个操作数有关的信息。

分析器函数通常使用一个分支语句来实现。第一步,分析器通常会从指令流中请求一个或几个(取决于处理器)字节,并将它们作为分支测试变量。SDK 提供特殊的函数供分析器使用,以从指令流中获取字节。这些函数如下所示:

//read one byte from current instruction location  
uchar ua_next_byte(void);  
//read two bytes from current instruction location  
ushort ua_next_word(void);  
//read four bytes from current instruction location  
ulong ua_next_long(void);  
//read eight bytes from current instruction location  
ulonglong ua_next_qword(void);

其中的 current instruction location (当前指令位置)是 cmd.ip 文件中的初始值。每次调用一个 ua_next_xxx 函数,都会产生一个副作用,即 cmd.size 的大小会根据被调用的 ua_next_xxx 函数所请求的字节数量(1、2、4 或 8)递增。获取的字节必须充分解码,以在 itype 字段中分配适当的指令类型枚举值,决定指令所需的任何操作数的数量和类型,然后决定指令的总长度。在解码的过程中,需要用到其他指令字节,直到从指令流中获取整条指令。只要你使用 ua_next_xxx 函数, cmd.size 将自动更新,因而你不必跟踪你已经从给定指令中获取了多少个字节。从宏观角度看,分析器有点类似于现有 CPU 所使用的指令提取和指令解码阶段。在现实生活中,对使用固定长度的指令的处理器进行指令解码会更加容易,RISC 体系结构即是如此。而对使用可变长度的指令的处理器进行指令解码则会更加困难,如 x86 处理器。

使用获取到的字节,分析器必须为指令使用的每一个操作数初始化 cmd.Operands 数组中的一个元素。指令操作数使用在 ua.hpp 中定义的 op_t 类的实例表示,如下所示:

class op_t {  
 public:  
   char n;  // number of operand (0,1,2).  Kernel sets this do not change!  
   optype_t type; // type of operand.  Set in ana, See ua.hpp for values  

   // offset of operand relative to instruction start  
   char offb;  //Proc sets this in ana, set to 0 if unknown  
   // offset to second part of operand (if present) relative to instruction  
 start  

   char offo;  //Proc sets this in ana, set to 0 if unknown  
   uchar flags; //Proc sets this in ana.  See ua.hpp for possible values  

   char dtyp; // Specifies operand datatype. Set in ana. See ua.hpp for values 

   // The following unions keep other information about the operand  
   union {  
     uint16 reg;    // number of register for type o_reg  
     uint16 phrase; // number of register phrase for types o_phrase and o_displ  
                    // define numbers of phrases as you like  
   };  

   union {          // value of operand for type o_imm or  
     uval_t value;  // outer displacement (o_displ+OF_OUTER_DISP)  
     struct {       // Convenience access to halves of value  
        uint16 low;  
        uint16 high;  
     } value_shorts;
   };  

   union {   // virtual address pointed or used by the operand  
    ea_t addr;  // for types (o_mem,o_displ,o_far,o_near)  
    struct {    // Convenience access to halves of addr  
        uint16 low;  
        uint16 high;  
    } addr_shorts;  
  };  

  //Processor dependent fields, use them as you like.  Set in ana
  union {  
    ea_t specval;  
    struct {  
        uint16 low;  
        uint16 high;  
    } specval_shorts;  
  };  
  char specflag1, specflag2, specflag3, specflag4;  
};

要配置一个操作数,首先需要将操作数的 type 字段设置为在 ua.hpp 中定义的一个枚举 optype_t 常量。操作数的 type 描述了操作数数据的来源和目标。换句话说, type 字段大致描述操作数所采用的寻址模式。操作数类型包括 o_reg、o_memo_imm ,它们分别表示操作数是一个寄存器的内容、一个在编译时获知的内存地址和指令中的即时数据。

dtype 字段指定操作数数据的大小。这个字段应设置为 ua.hpp 文件指定的一个 dt_xxx 值。示例值包括用于 8 位数据的 dt_type 、用于 16 位数据的 dt_word 和用于 32 位数据的 dt_dword

下面的 x86 指令说明了一些主要的操作数数据类型与常用的操作数之间的对应关系:

mov  eax, 0x31337          ; o_reg(dt_dword), o_imm(dt_dword)  
push word ptr [ebp - 12]   ; o_displ(dt_word)  
mov [0x08049130], bl       ; o_mem(dt_byte), o_reg(dt_byte)  
movzx eax, ax              ; o_reg(dt_dword), o_reg(dt_word)  
ret                        ; o_void(dt_void)

op_t 中各种联合的使用方式由 type 字段的值确定。例如,如果一个操作数的类型为 o_imm ,则即时数据值应存储在 value 字段中;如果操作数的类型为 o_reg ,则寄存器编号(根据一组枚举的寄存器常量)应存储在 reg 字段中。有关指令的每一条信息的详细存储位置,请参阅 ua.hpp 文件。

请注意, op_t 中没有字段描述操作数是否被用作数据来源或目标。实际上,这不是分析器的任务。指令名称数组中指定的典型标志将在后阶段用于决定具体如何使用操作数。

insn_t 类和 op_t 类中有几个字段被描述为“是取决于处理器的”,这表示你可以将这些字段用于任何目的。通常,这些字段用于存储这些类中的其他字段不适于存储的信息。“取决于处理器”的字段也是一种向处理器的后阶段传递信息的便捷机制,使这些阶段不需要重复分析器的工作。

讨论完与分析器有关的所有基本规则后,我们可以开始着手为 Python 字节码创建一个最小的分析器。Python 字节码非常简单。Python 操作码长为 1 个字节。小于 90 的操作码没有操作数,而大于或等于 90 的操作码拥有一个 2 字节的操作数。我们创建的基本分析器如下所示:

#define HAVE_ARGUMENT 90  
int idaapi py_ana(void) {  
   cmd.itype = ua_next_byte();    //opcodes ARE itypes for us (updates cmd.size)  
   if (cmd.itype >= PYTHON_LAST) return 0;             //invalid instruction  
   if (Instructions[cmd.itype].name == NULL) return 0; //invalid instruction  
   if (cmd.itype &lt HAVE_ARGUMENT) { //no operands  
      cmd.Op1.type = o_void;      //Op1 is a macro for Operand[0] (see ua.hpp)  
      cmd.Op1.dtyp = dt_void;  
   }  
   else {   //instruction must have two bytes worth of operand data  
      if (flags[cmd.itype] & (HAS_JREL | HAS_JABS)) {  
         cmd.Op1.type = o_near;  //operand refers to a code location  
      }  
      else {  
         cmd.Op1.type = o_mem;   //operand refers to memory (sort of)  
      }  
      cmd.Op1.offb = 1;          //operand offset is 1 byte into instruction  
      cmd.Op1.dtyp = dt_dword;   //No sizes in python so we just pick something  

      cmd.Op1.value = ua_next_word(); //fetch the operand word (updates cmd.size)  
      cmd.auxpref = flags[cmd.itype]; //save flags for later stages  

      if (flags[cmd.itype] & HAS_JREL) {  
         //compute relative jump target  
         cmd.Op1.addr = cmd.ea + cmd.size + cmd.Op1.value;  
      }  
      else if (flags[cmd.itype] & HAS_JABS) {  
         cmd.Op1.addr = cmd.Op1.value;  //save absolute address  
      }  
      else if (flags[cmd.itype] & HAS_CALL) {  
         //target of call is on the stack in Python, the operand indicates  
         //how many arguments are on the stack, save these for later stages  
         cmd.Op1.specflag1 = cmd.Op1.value & 0xFF;         //positional parms  
         cmd.Op1.specflag2 = (cmd.Op1.value >> 8) & 0xFF;  //keyword parms  
      }  
   }  
   return cmd.size;  
}

对 Python 处理器模块来说,我们为每条指令创建了另外一个标志数组,用于补充(有时候是复制)每条指令的“典型特征”。我们定义了 HAS_JREL、HAS_JABSHAS_CALL 标志,供 flags 数组使用。我们使用这些标志指出一个指令操作数表示一个相对跳转偏移量还是一个绝对跳转目标,或者是函数调用栈说明。如果不深入分析 Python 解释器的操作,我们很难解释分析阶段的每一个细节,因此,利用前面代码中的注释,基于分析器的工作是解析单条指令,我们将分析器的功能总结为如下内容。

  1. 分析器从指令流中获得下一个指令字节,并决定该字节是否是一个有效的 Python 操作码。

  2. 如果该指令没有操作数,则将 cmd.Operand[0] (cmd.Op1 )初始化为 o_void

  3. 如果该指令有一个操作数,则初始化 cmd.Operand[0] 以反映该操作数的类型。几个特定于处理器的字段用于将信息转发到处理器模块的后续阶段。

  4. 向调用方返回指令的长度。

可以肯定,指令集越复杂,分析器阶段就越复杂。但是,总体而言,任何分析器的行为通常都会包括以下内容。

  1. 从指令流中读取足够的字节,以确定指令是否有效,并将指令与一个指令类型枚举常量对应起来,然后将这个常量保存在 cmd.itype 中。这项操作通常由一个大的分支语句执行,以对指令操作码进行分类。

  2. 读取所需的其他字节,以正确确定指令所需的操作数的数量、这些操作数使用的寻址模式以及构成每个操作数(寄存器和即时数据)的组件。这些数据用于填充 cmd.Operands 数组的元素。这项操作由一个单独的操作数解码函数执行。

  3. 返回指令及其操作数的总长度。

严格来讲,解析一条指令后,IDA 将拥有足够的信息,能够生成该指令的汇编语言代码。为了生成交叉引用,促进递归下降过程,并监控程序栈指针的行为,IDA 必须获得关于每条指令的其他信息。这是 IDA 处理器模块模拟器阶段的任务。

19.3.4 模拟器

分析器阶段分析单条指令的结构,而模拟器阶段则分析单条指令的行为。在 IDA 处理器模块中,模拟器通常由 emu.cpp 文件中的 emu (你可以使用任何你喜欢的名称)函数实现。和 ana 函数一样,这个函数的原型非常简单,如下所示:

int idaapi emu(void); //emulate one instruction

根据 idp.hpp 文件, emu 函数应返回被模拟的指令的长度,但是,绝大多数的样本模拟器似乎返回的都是 1。

你必须使用一个指向你的模拟器函数的指针初始化 LPH 对象的 u_emu 成员。到调用 emu 时, cmd 已经被分析器初始化。模拟器的主要作用是基于 cmd 描述的指令的行为创建代码和数据交叉引用。模拟器还用于跟踪栈指针的变化,并根据观察到的对函数栈帧的访问创建局部变量。与分析器不同,模拟器可以更改数据库。

通常,确定一条指令是否会创建交叉引用,需要检查该指令的“典型特征”,以及指令操作数的 type 字段。下面是一个每条指令最多包含两个操作数的指令集的基本模拟器函数(典型的 SDK 示例):

void TouchArg(op_t &op, int isRead);  //Processor author writes this  

int idaapi emu() {  
   ulong feature = cmd.get_canon_feature(); //get the instruction's CF_xxx flags  

   if (feature & CF_USE1) TouchArg(cmd.Op1, 1);  
   if (feature & CF_USE2) TouchArg(cmd.Op2, 1);  

   if (feature & CF_CHG1) TouchArg(cmd.Op1, 0);  
   if (feature & CF_CHG2) TouchArg(cmd.Op2, 0);  

   if ((feature & CF_STOP) == 0) { //instruction doesn't stop  
      //add code cross ref to next sequential instruction  
      ua_add_cref(0, cmd.ea + cmd.size, fl_F);  
   }  
   return 1;  
}

对于每个指令操作数,前面的函数检查指令的“典型特征”,以确定是否应生成任何交叉引用。在这个例子中,一个名为 TouchArg 的函数检查每一个操作数,以确定应生成什么类型的交叉引用,并处理正确生成交叉引用的细节。由模拟器生成交叉引用时,你应使用在 ua.hpp (而不是在 xref.hpp)中声明的交叉引用创建函数。下面的简单指南用于确定生成什么类型的交叉引用。

  • 如果操作数类型为 o_imm ,则操作为读取( isRead 为真),且操作数的数值为一个指针,并创建一个偏移量引用。确定一个操作数是否为指针,需要调用 isOff 函数,如 isOff(uFlag, op.n) 。使用 ua_add_off_drefs 添加一个偏移量交叉引用,如 ua_add_off_ drefs(op, dr_0) ;。

  • 如果操作数类型为 o_displ 且操作数的数值是一个指针,则根据需要创建一个读取或写入类型的偏移量交叉引用,如 ua_add_off_drefs(op, isRead ? dr_R : dr_W) ; 。

  • 如果操作数类型为 o_mem ,则根据需要使用 ua_add_dref 添加一个读取或写入类型的数据交叉引用,如 ua_add_dref(op.offb, op.addr, isRead ? dr_R : dr_W);

  • 如果操作数类型为 o_near ,则根据需要使用 ua_add_cref 添加一个跳转或调用交叉引用,如 ua_add_cref(op.offb, op.addr, feature & CF_CALL ? fl_CN : fl_JN) ; 。

模拟器还负责报告栈指针寄存器的行为。模拟器应通过 add_auto_stkpnt 2 函数告诉 IDA :一条指令更改了栈指针的值。 add_auto_stkpnt 2 函数的原型如下所示:

bool add_auto_stkpnt2(func_t *pfn, ea_t ea, sval_t delta);

pfn 指针应指向包含被模拟地址的函数。如果 pfn 为 NULL,它将由 IDA 自动决定。 ea 参数应指定更改栈指针的指令的结束地址(通常为 cmd.ea+cmd.size ), delta 参数应用于指定栈指针变大或缩小的字节数。如果栈变大(如执行 push 指令后),则使用负增量;如果栈缩小(如执行 pop 指令后),就使用正增量。使用 push 操作对栈指针进行简单的 4 字节调整,其模拟代码如下:

if (cmd.itype == X86_push) {  
   add_auto_stkpnt2(NULL, cmd.ea + cmd.size, -4);  
}

为了准确记录栈指针的行为,模拟器应能够识别和模拟更改栈指针的所有指令,而不仅仅是简单的 pushpop 指令。如果一个函数通过从栈指针中减去一个常量值来分配它的局部变量,这时跟踪栈指针可能会更加复杂,如下所示:

//handle cases such as:  sub  esp, 48h  
if (cmd.itype == X86_sub && cmd.Op1.type == o_reg  
    && cmd.Op1.reg == r_esp && cmd.Op2.type == o_imm) {  
   add_auto_stkpnt2(NULL, cmd.ea + cmd.size, -cmd.Op2.value);  
}

因为各 CPU 体系结构之间存在巨大的差异,IDA (或任何其他类似的程序)不可能考虑到操作数的每一种构成,以及指令引用其他指令或数据的每一种方式。因此,关于如何构建模拟器模块,并没有精确的指南。要想构建满足你需求的模拟器,你需要仔细阅读现有的处理器模块源代码,并进行大量的试验。

示例 Python 处理器的模拟器如下所示:

int idaapi py_emu(void) {  
   //We can only resolve target addresses for relative jumps  
   if (cmd.auxpref & HAS_JREL) { //test the flags set by the analyzer  
      ua_add_cref(cmd.Op1.offb, cmd.Op1.addr, fl_JN);  
   }  
   //Add the sequential flow as long as CF_STOP is not set  
   if((cmd.get_canon_feature() & CF_STOP) == 0) {  
      //cmd.ea + cmd.size computes the address of the next instruction  
      ua_add_cref(0, cmd.ea + cmd.size, fl_F);  
   }  
   return 1;  
}

由于 Python 解释器所使用的体系结构,我们能够生成的交叉引用的类型受到很大的限制。在 Python 字节码中,并没有数据项内存地址的概念,每条指令的绝对地址只能通过解析编译后的 Python (.pyc )文件所包含的元信息才能确定。数据项要么存储在表中,并通过索引值引用,要么存储在程序栈上,不能直接引用。同样,虽然我们能够直接从指令操作数中读取数据项索引值,但是,除非我们解析.pyc 文件中包含的其他元信息,否则无法获知保存这些数据的表的结构。在我们的处理器中,只能计算出相对跳转指令的目标,以及下一条指令的地址,是因为它们的位置与当前指令的地址有关。实际上,我们的处理器只有更详细地了解文件结构,才能提供更加完善的反汇编代码清单。我们将在 19.6 节中讨论这个限制。

因为相同的原因,我们选择在 Python 处理器中不跟踪栈指针的行为。这主要是因为 IDA 只处理在函数范围内发生的栈指针变化,而目前我们并没有办法识别 Python 代码中的函数边界。如果我们想进行栈指针跟踪,应该记住的是,作为一种基于栈的体系结构,几乎每一条 Python 指令都会以某种方式修改栈。在这种情况下,为了简单确定每条指令更改了多少个栈指针,一个较为容易的方法是为每条 Python 指令定义一个数值数组,并在这些数值中包含每条指令修改栈的总次数。然后,在每次模拟指令时,将这些总次数用于调用 add_auto_stkpnt2 函数。

只要模拟器已经添加了它能生成的所有交叉引用,并且对数据库进行了它认为必要的其他修改后,你就可以开始生成输出了。在下一节中,我们将讨论如何使用输出器生成 IDA 的反汇编代码清单。

19.3.5 输出器

输出器的作用是根据 cmd 全局变量的指示,将一条经过反汇编的指令输出到 IDA 窗口中。在 IDA 处理器模块中,输出器通常由 out.cpp 文件中的 out (你可以使用任何你喜欢的名称)函数实现。与 anaemu 函数一样,这个函数的原型非常简单,如下所示:

void idaapi out(void); //output a single disassembled instruction

你必须使用一个指向输出函数的指针初始化 LPH 对象的 u_out 成员。到调用 out 时, cmd 已经被分析器初始化。输出函数不得以任何形式修改数据库。你还需要创建一个帮助函数,专门用于格式化和输出一个指令操作数。通常,这个函数名为 outop,LPH 的 u_outop 成员即指向这个函数。 out 函数不能直接调用 outop 函数。每次需要打印反汇编行的操作数部分时,你应当调用 out_one_operand 函数。数据输出操作通常由 cpu_data 函数处理,并由 LPH 对象的 d_out 成员指定。在 Python 处理器中,这个函数叫做 python_data

反汇编代码清单中的输出行由几个组件构成,如前缀、名称标签、助记符、操作数,可能还包括注释。IDA 内核负责显示其中一些组件(如前缀、注释和交叉引用),而其他组件则由处理器的输出器负责显示。一些用于生成输出行组件的函数在 ua.hpp 文件的以下标题下声明:

//--------------------------------------------------------------------------  
//      I D P   H E L P E R   F U N C T I O N S  -  O U T P U T  
//--------------------------------------------------------------------------

使用在输出缓冲区中插入特殊颜色标签的函数,你可以给每个输出行的各个部分添加颜色。其他用于生成输出行的函数在 lines.hpp 文件中声明。

IDA 并未使用可以直接在其中写入内容的基于控制台的输出模型,而是采用一种基于缓冲区的输出方案,使用这种方案,你必须在一个字符缓冲区中写入一行显示文本,然后要求 IDA 显示这个缓冲区。生成一个输出行的基本过程如下所示。

  1. 调用 init_output_buffer(char *buf, size_t bufsize) (在 ua.hpp 中声明)初始化你的输出缓冲区。

  2. 利用在 ua.hpp 中声明的缓冲区输出函数,通过添加经过初始化的缓冲区生成一行内容。这些函数大多会自动写入上一步中指定的目标缓冲区,因此,你通常不需要向这些函数显式传递一个缓冲区。这些函数通常叫做 out_xxxOutXxx

  3. 调用 term_output_buffer () 终止输出缓冲区,为发送给 IDA 内核并显示出来做好准备。

  4. 使用 MakeLineprintf_line (均在 lines.hpp 中声明)将输出缓冲区发送给内核。

注意,通常 init_output_buffer、term_out_bufferMakeLine 仅在 out 函数中调用。一般情况下, outop 函数会使用经 out 初始化的当前输出缓冲区,因而不需要初始化它自己的输出缓冲区。

严格来讲,只要你不介意要完全控制生成缓冲区的整个过程,并放弃使用 ua.hpp 文件提供的便捷函数,你就可以略过上面前 4 个步骤中的缓冲区操作,直接调用 MakeLine 函数。除了为生成的输出假设一个默认目标(通过 init_out_buffer 指定),许多便捷函数自动采用 cmd 变量的当前值。ua.hpp 文件提供的一些有用的便捷函数如下所示。

  • OutMnem(int width, char * suffix )。在一个至少有 width 字符的字段中输出与 cmd.itype 对应的助记符,并附加指定的后缀。在助记符后至少打印一个空格。默认的宽度为 8,默认的后缀为 NULL。操作数大小修饰符可能需要使用后缀值,如下面的 x86 助记符: movsb、movswmovsd

  • out_one_operand(int n) 。调用处理器的 outop 函数打印 cmd.Operands[n]

  • out_snprintf(const char * format , …)。在当前输出缓冲区后附加格式化文本。

  • OutValue(op_t &op, int outflags )。输出一个操作数的常量字段。这个函数根据 outflags 的值输出 op.valueop.addr 。请参见 ua.hpp 了解 outflags 的意义,它的默认值为 0。这个函数只能从 outop 中调用。

  • out_symbol(char c) 。使用当前的标点符号( COLOR_SYMBOL ,在 lines.hpp 中定义)输出给定的字符。这个函数主要用于输出操作数中的句法元素(因而由 outop 调用),如逗号和括号。

  • out_line(char *str, color_t color) 。以给定的 color 将给定的字符串附加到当前输出缓冲区后面。颜色在 lines.hpp 中定义。注意,这个函数根本不会输出一行数据。这个函数最好叫做 out_str

  • OutLine(char *str) 。作用与 out_line 相同,但不使用颜色。

  • out_register(char *str) 。使用当前的寄存器颜色( COLOR_REG )输出给定的字符串。

  • out_tagon(color_t tag) 。在输出缓冲区中插入一个“打开颜色”标签。随后输出的缓冲区将以给定的颜色显示,直到遇到“关闭颜色”标签。

  • out_tagoff(color_t tag) 。在输出缓冲区中插入“关闭颜色”标签。

请参阅 ua.hpp 文件了解其他可用于构建输出器的输出函数。

一个从 ua.hpp 文件遗漏的输出功能是输出寄存器名称。在分析阶段,根据操作数使用的寻址模式,寄存器的编号被存储在操作数的 regphrase 字段中。由于许多操作数使用寄存器,因此最好有一个函数能够根据给定的寄存器编号迅速输出一个寄存器字符串。下面的函数提供了这样的基本功能:

//with the following we can do things like: OutReg(op.reg);  
void OutReg(int regnum) {  
   out_register(ph.regNames[regnum]);  //use regnum to index register names array  
}

IDA 仅在必要时调用 out 函数,例如一个地址出现在一个 IDA 窗口,或一个反汇编行的某些部分被重新格式化时。每次 out 被调用,它都会根据需要输出多行数据,以表示在 cmd 全局变量中描述的指令。为此, out 通常会一次或多次调用 MakeLine (或 printf_line )。多数情况下,输出一行(因此只需调用 MakeLine 一次)数据就够了。

如果需要多行数据来描述一条指令,你绝不能在输出缓冲区中添加换行符,尝试一次生成几个数据行。相反,你应该多次调用 MakeLine ,以输出各行数据。 MakeLine 的原型如下所示:

bool MakeLine(const char *contents, int indent = -1);

indent 值为-1 表示使用默认缩进,它是在 Options▶General 对话框的 Disassembly 部分指定的 inf.indent 的当前值。当反汇编代码清单中的一条指令(数据)跨越几行时, indent 参数还有其他意义。在一条多行指令中,缩进为-1 的行表示这一行为该指令的“最重要”的行。请参考 lines.hpp 文件中 printf_line 函数的注释,了解在这种情况下如何使用 indent 的其他信息。

到现在为止,我们一直回避讨论注释。与名称和交叉引用一样,注释也由 IDA 内核处理。但是,你可以控制注释在多行指令的哪一行显示。在某种程度上,注释的显示由在 lines.hpp 中声明的全局变量 gl_comm 控制。关于 gl_comm ,需要注意的是,除非 gl_comm 被设置为 1 ,否则注释根本不会显示。如果 gl_comm 设置为 0 ,即使用户输入 1 并且在 OptionsGeneral 设置中启用注释,注释仍然不会在你生成的输出后面显示。问题是, gl_comm 的默认值为 0 ,因此,如果你希望用户在使用你的处理器模块时看到注释,你需要在某个时候将它设置为 1 。 out 函数生成多行数据时,如果你希望用户输入的注释在除第一行输出以外的行中显示,那么,你需要控制 gl_comm

了解了构建输出器的重点内容后,下面是示例 Python 处理器的 out 函数:

void py_out(void) {  
   char str[MAXSTR];  //MAXSTR is an IDA define from pro.h  
   init_output_buffer(str, sizeof(str));  
   OutMnem(12);       //first we output the mnemonic  
   if(cmd.Op1.type != o_void) {  //then there is an argument to print  
      out_one_operand(0);  
   }  
   term_output_buffer();  
   gl_comm = 1;      //we want comments!  
   MakeLine(str);    //output the line with default indentation  
}

这个函数以一种非常简单的方式处理一个反汇编行的各个组件。如果 Python 指令包含两个操作数,我们可以使用 out_symbol 输出一个逗号,然后再次调用 out_one_operand 输出第二个操作数。多数情况下, outop 函数都比 out 函数更加复杂,因为操作数的结构通常要比指令的宏观结构更加复杂。执行 outop 函数的常见方法是使用一个分支语句测试操作数的 type 字段的值,并对操作数进行相应的格式化。

在 Python 示例中,我们被迫使用一个非常简单的 outop 函数,因为多数情况下,我们都缺乏将整数操作数转换成其他更易懂的数据所需的信息。 outop 函数的实现过程如下所示,我们仅仅对比较和相对跳转进行了特殊处理:

char *compare_ops[] = {  
    "<", "<=", "==", "!=", ">", ">=",  
    "in", "not in", "is", "is not", "exception match"  
};  

bool idaapi py_outop(op_t& x) {  
   if (cmd.itype == COMPARE_OP) {  
      //For comparisons, the argument indicates the type of comparison to be  
      //performed.  Print a symbolic representation of the comparison rather  
      //than a number.  
      if (x.value  qnumber(compare_ops)) {  
         OutLine(compare_ops[x.value]);  
      }  
    else {  
      OutLine("BAD OPERAND");  
    }  
   }  
   else if (cmd.auxpref & HAS_JREL) {  
      //we don't test for x.type == o_near here because we need to distinguish  
      //between relative jumps and absolute jumps.  In our case, HAS_JREL  
      //implies o_near  
      out_name_expr(x, x.addr, x.addr);  
   }  
   else {  //otherwise just print the operand value  
      OutValue(x);  
   }  
   return true;  
}

除了经过反汇编的指令外,反汇编代码清单中通常还包括应表示为数据的字节。在输出阶段,数据显示由 LPH 对象的 d_out 成员处理。内核调用 d_out 函数来显示任何不属于指令的字节,不管这些字节的数据类型是未知,还是已经被用户或模拟器格式化成数据。 d_out 的原型如下:

void idaapi d_out(ea_t ea);   //format data at the specified address

d_out 函数应检查与 ea 参数指定的地址有关的标志,并以所生成的汇编语言生成数据的相应表示形式。你必须为所有处理器模块指定这个函数。SDK 以 intel_data 函数的形式提供了这个函数的大致实现,但它不可能满足你的特殊要求。在 Python 示例中,其实很少需要格式化静态数据,因为我们没有办法找到这类数据的位置。举例来说,以下面这种方式应用这个函数:

void idaapi python_data(ea_t ea) {  
   char obuf[256];  
   init_output_buffer(obuf, sizeof(obuf));  
   flags_t flags = get_flags_novalue(ea);  //get the flags for address ea  
   if (isWord(flags)) {  //output a word declaration  
      out_snprintf("%s %xh", ash.a_word ? ash.a_word : "", get_word(ea));  
   }  
   else if (isDwrd(flags)) {  //output a dword declaration  
      out_snprintf("%s %xh", ash.a_dword ? ash.a_dword : "", get_long(ea));  
   }  
   else { //we default to byte declarations in all other cases  
      int val = get_byte(ea);  
      char ch = ' ';  
      if (val >= 0x20 && val = 0x7E) {  
         ch = val;  
      }  
      out_snprintf("%s %02xh   ; %c", ash.a_byte ? ash.a_byte : "", val, ch);  
   }  
   term_output_buffer();  
   gl_comm = 1;  
   MakeLine(obuf);  
}

bytes.hpp 中声明了一些函数,它们用于访问和测试与数据库中的任何地址有关的标志。在这个例子中,标志经过测试,以确定地址表示的是字还是双字,并使用当前汇编器模块中适当的数据声明关键字生成相应的输出。全局变量 ashasm_t 结构体的一个实例,该结构体描述反汇编代码清单所使用的汇编器语法的特点。如果希望生成更加复杂的数据显示,如数组,我们将需要更多信息。

19.3.6 处理器通知

在第 17 章中,我们提到,插件能够使用 hook_to_notification_point 函数“钩住”各种通知消息。通过“钩住”通知,插件能够获知数据库中发生的各种操作。处理器模块也采用通知消息的概念,但处理器通知的实现方式与插件通知的实现方式稍有不同。

所有处理器模块都应设置一个指针,指向 LPH 对象的 notify 字段中的一个通知函数。 notify 函数的原型如下所示:

int idaapi notify(idp_notify msgid, ...);  //notify processor with a given msg

notify 函数是一个参数可变的函数,它接收一个通知代码以及一个特定于通知代码的参数列表,其中列表中的参数数量可变。请参阅 idp.hpp 文件,了解完整的处理器通知代码。通知消息既适用于简单的操作,如加载( init )和卸载( term )处理器,也适用于复杂的通知,如创建的代码或数据、添加或删除的函数、添加或删除的段。idp.hpp 文件中还指定了与每个通知代码有关的参数列表。在分析 notify 函数的示例之前,我们先来看在 SDK 的一些样本处理器模块中发现的下列注释:

// A well-behaving processor module should call invoke_callbacks()  
// in its notify() function. If invoke_callbacks function returns 0,  
// then the processor module should process the notification itself.  
// Otherwise the code should be returned to the caller.

为了确保所有已经“钩住”处理器通知的模块都能够得到通知,必须调用 invoke _ callbacks 函数。这使得内核将给定的通知消息传播给所有注册的回调函数。Python 处理器中使用的 notify 函数如下所示:

static int idaapi notify(processor_t::idp_notify msgid, ...) {  
   va_list va;  
   va_start(va, msgid);   //setup args list  
   int result = invoke_callbacks(HT_IDP, msgid, va);  
   if (result == 0) {  
      result = 1;             //default success  
      switch(msgid) {  
         case processor_t::init:  
            inf.mf = 0;       //ensure little endian!  
            break;  
         case processor_t::make_data: {  
            ea_t ea = va_arg(va, ea_t);  
            flags_t flags = va_arg(va, flags_t);  
            tid_t tid = va_arg(va, tid_t);  
            asize_t len = va_arg(va, asize_t);  
            if (len > 4) { //our d_out can only handle byte, word, dword  
               result = 0; //disallow big data  
            }  
            break;  
         }  
      }  
   }  
   va_end(va);  
   return result;  
}

notify 函数仅处理两个通知代码: initmake_data 。处理 init 通知是为了迫使内核以小端方式处理数据。 inf.mf 标志(多数情况下为第一个标志)指出内核使用的字节顺序值(0 表示小端,1 表示大端)。任何时候如果要将字节转换成数据,则发送 make_data 通知。在上面的例子中, d_out 函数只能处理字节、字和双字,因此,该函数测试所创建数据的大小,并驳回任何大于 4 个字节的代码。

19.3.7 其他 processor_t 成员

在结束讨论处理器模块的创建时,我们至少需要提及 LPH 对象中的其他几个字段。如前所述,这个结构体中有大量函数指针。如果你仔细阅读 idp.hpp 文件中的 processor_t 结构体定义,你会发现,有时候你完全可以将一些函数指针设置为 NULL,而且内核不会调用它们。有理由认为,你需要为 processor_t 所需的所有其他函数提供实现。总地来说,如果你不知道该如何做,可以用一个空白的存根函数蒙混过关。在 Python 处理器中,NULL 是否为有效值并不清楚,我们对函数指针进行的初始化如下所示(请参阅 idp.hpp 了解每个函数的行为)。

  • header ,在示例中指向空函数。

  • footer ,在示例中指向空函数。

  • segstart ,在示例中指向空函数。

  • segend ,在示例中指向空函数。

  • is_far_jump ,在示例中设置为 NULL。

  • ranslate ,在示例中设置为 NULL。

  • realcvt ,指向 ieee.h 中的 ieee_realcvt

  • is_switch ,在示例中设置为 NULL。

  • extract_address ,在示例中指向一个返回(BADADDR 1)的函数。

  • is_sp_based ,在示例中设置为 NULL。

  • create_func_frame ,在示例中设置为 NULL。

  • get_frame_retsize ,在示例中设置为 NULL。

  • u_outspec ,在示例中设置为 NULL。

  • set_idp_options ,在示例中设置为 NULL。

除这些函数指针以外,还有下面 3 个数据成员需要注意。

  • shnames ,一个以 NULL 结束的字符指针数组,这些指针指向与处理器有关的短名称(不超过 9 个字符,如 python )。用一个 NULL 指针结束该数组。

  • lnames ,一个以 NULL 结束的字符指针数组,这些指针指向与处理器有关的长名称(如 Python 2.4 byte code)。这个数组的元素数量应与 shnames 数组的元素数量相同。

  • asms ,一个以 NULL 结束的指针数据,这里的指针指向目标汇编器( asm_t )结构体。

shnameslnames 数组指定可以被当前处理器模块处理的所有处理器类型的名称。用户可以在 Options▶General 对话框的 Analysis 选项卡中选择替代的处理器,如图 19-1 所示。

图 19-1 选择替代的处理器和汇编器

支持多处理器的处理器模块应处理 processor_t.newprc 通知,以获知有关处理器变更的通知。

asm_t 结构体用于描述汇编语言的一些语法要素,如十六进制数、字符串和字符分隔符的格式,以及汇编语言常用的各种关键字。 asms 字段允许某一个处理器模块生成各种不同风格的汇编语言。支持多个汇编器的处理器模块应处理 processor_t.newasm 通知,以获知有关处理器变更的通知。

最终,我们的简单 Python 处理器的完整版本能够生成下面的代码:

ROM:00156                 LOAD_CONST 12  
ROM:00159                 COMPARE_OP ==  
ROM:00162                 JUMP_IF_FALSE loc_182  
ROM:00165                 POP_TOP  
ROM:00166                 LOAD_NAME 4  
ROM:00169                 LOAD_ATTR 10  
ROM:00172                 LOAD_NAME 5  
ROM:00175                 CALL_FUNCTION 1  
ROM:00178                 POP_TOP  
ROM:00179                 JUMP_FORWARD loc_183  
ROM:00182 # ----------------------------------------------------------  
ROM:00182 loc_182:                           # CODE XREF: ROM:00162j  
ROM:00182                 POP_TOP  
ROM:00183  
ROM:00183 loc_183:                           # CODE XREF: ROM:00179j  
ROM:00183                 LOAD_CONST 0  
ROM:00186                 RETURN_VALUE

虽然我们可以生成比上面的代码揭示更多信息的 Python 反汇编代码清单,但是我们需要了解更多与.pyc 文件格式有关的信息。读者可以在本书的网站上找到一个功能更加强大的 Python 处理器模块。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文