在PE结构中最重要的就是区块表和数据目录表,上节已经说明了如何解析区块表,下面就是数据目录表,在数据目录表中一般只关心导入表,导出表和资源这几个部分,但是资源实在是太复杂了,而且在一般的病毒木马中也不会存在资源,所以在这个工具中只是简单的解析了一下导出表和导出表。这节主要说明导入表,下节来说导出表。

RVA到fRva的转化

RVA转化为fRva主要是通过某个数据在内存中的相对偏移地址找到其在文件中的相对偏移地址,在对某个程序进行逆向时,如果找到关键的那个变量或者那句指令,我根据变量或者代码指令在内存中的RVA找到它在文件中的偏移,就可以找到它的位置,修改它可能就可以破解某个程序。废话不多说,直接上代码:

DWORD CPeFileInfo::RVA2fOffset(DWORD dwRVA, DWORD dwImageBase)
{
InitSectionTable();
vector<IMAGE_SECTION_HEADER>::iterator it;
for (it = m_SectionTable.begin(); it != m_SectionTable.end(); it++)
{
if (dwRVA >= (DWORD)(it->VirtualAddress) &&
dwRVA <= (DWORD)((DWORD)(it->VirtualAddress) + it->Misc.VirtualSize)
)
{
break;
}
} if (it == m_SectionTable.end())
{
return -1;
} return (DWORD)(dwRVA - (DWORD)(it->VirtualAddress) + (DWORD)(it->PointerToRawData) + dwImageBase);
}

系统在将PE文件加载到内存中时,PE中的区块是按页的方式对齐的,也就是说同一节中的内容在一页内存中的排列方式与在文件中的排列方式相同,所以这里利用这一关系就可以根据RVA推算出它在文件中的偏移,即:fRva - Roffset = Rva - Voffset ,其中fRva是某个成员在文件中的偏移,Roffset是区块在文件中的偏移,Voffset是该区块在内存中的偏移。

上述代码就是利用这个原理来计算的,代码中存在一个循环,VirtualAddress 和 VirtualSize分别代表这个区块在内存中的起始地址和这个区块所占内存的大小,当这个RVA大于起始地址,小于起始地址 + 区块大小也就说明这个RVA是处在这个区块中,这样我们就找到RVA所在区块,用RVA - 区块起始地址就得到它在区块中的偏移,这个偏移加上区块在文件中的首地址,得到的就是RVA对应的在文件中的偏移,只要知道文件的起始地址就可以知道它在文件中的详细位置了。

获取数据目录表的信息

数据目录表的信息主要存储在PE头结构中的OptionHeader中,回顾一下它的定义:

typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
// WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
DWORD BaseOfData; //
// NT additional fields.
// DWORD ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
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;

这个结构的最后一个结构是数据目录表的数组,而NumberOfRvaAndSizes表示数据目录表中元素的个数,一般都是8,而IMAGE_DATA_DIRECTORY 结构的定义如下:

typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

第一个是指向某个具体的表结构的RVA,第二个是这个表结构的大小,在这个解析器中,主要显示这两项,同时为了方便在文件中查看,我们新加了一项,就是它在文件中的偏移

在这个解析器的代码中,我们定义了一个结构来存储这些信息

struct IMAGE_DATA_DIRECTORY_INFO
{
PVOID pVirtualAddress;
PVOID pFileOffset;
DWORD dwVirtualSize;
};

在类中定义了一个该结构的vector结构,同时定义一个InitDataDirectoryTable函数来初始化这个结构

void CPeFileInfo::InitDataDirectoryTable()
{
if (!m_DataDirectoryTable.empty())
{
//先清空之前的内容
m_DataDirectoryTable.clear();
} PIMAGE_SECTION_HEADER pSectionHeader = GetSectionHeader();
PIMAGE_OPTIONAL_HEADER pOptionalHeader = GetOptionalHeader();
PIMAGE_DATA_DIRECTORY pDataHeader = pOptionalHeader->DataDirectory; IMAGE_DATA_DIRECTORY_INFO dataInfo;
for (int i = 0; i < IMAGE_NUMBEROF_DIRECTORY_ENTRIES; i++)
{
dataInfo.pVirtualAddress = (PVOID)(pDataHeader[i].VirtualAddress);
dataInfo.dwVirtualSize = pDataHeader[i].Size;
dataInfo.pFileOffset = (PVOID)RVA2fOffset((DWORD)(dataInfo.pVirtualAddress), 0); //这里调用这个函数计算它的偏移所以这里假定文件的起始地址为0
m_DataDirectoryTable.push_back(dataInfo);
}
}

上述代码比较简单,获得了OptionHeader结构的指针后直接找到DataDirectory的地址,就可以得到数组的首地址,然后在循环中依次遍历这个数组就可以得到各项的内容,对于文件中的偏移直接调用之前写的那个转化函数即可

导入表的解析

导入的dll的信息的获取

导入表在数据目录表的第1项,所以我们只需要区数据目录表数组中的第一个元素,从中就可以得到它的RVA,然后调用RVA到文件偏移的转化函数就可以在文件中找到它的位置,在代码中也是这样做的

PIMAGE_IMPORT_DESCRIPTOR CPeFileInfo::GetImportDescriptor()
{
//由于这个表中保存的是RVA,要在文件中遍历,需要转为在文件中的偏移
PVOID pImportRVA = m_DataDirectoryTable[1].pVirtualAddress;
//在读取这些数据的时候,是从内存中读取的,从内存中读取时,需要考虑文件被加载到内存中的基址
return (PIMAGE_IMPORT_DESCRIPTOR)RVA2fOffset((DWORD)pImportRVA, (DWORD)pImageBase);
}

导入表在Windows中的定义如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 一般给0
DWORD OriginalFirstThunk; //指向first thunk,IMAGE_THUNK_DATA,该 thunk 拥有 Hint 和 Function name 的地址。这个IMAGE_THUNK_DATA在后面解析具体函数时会用到
};
DWORD TimeDateStamp; //忽略,很少使用它
DWORD ForwarderChain; //忽略,很少使用它
DWORD Name; //这个dll的名称
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;

这个结构是以一个数组的形式存储在对应的位置,所以说我们只要找到第一个结构的位置就可以找到剩余的位置,但是这个数组的个数事先并不知道,数组的最后一个元素都为0,所以在遍历到对应的都为0 的成员是就到了它的尾部,根据这个我们定义了一个函数,来判断它是否到达数组尾部,当不在尾部时将数组成员取出来,存储到事先定义的vector中。

void CImportDlg::InitImportTable()
{
//获取导入函数表在文件中的偏移
PIMAGE_IMPORT_DESCRIPTOR pImportTable = m_pPeFileInfo->GetImportDescriptor();
if (NULL != pImportTable)
{
int i = 0;
while (!IsEndOfTable(&pImportTable[i]))
{
m_ImportTable.push_back(pImportTable[i]);
i++;
}
}
} BOOL CImportDlg::IsEndOfTable(PIMAGE_IMPORT_DESCRIPTOR pImportTable)
{
//是否到达表的尾部,这个表中没有给出总共有多少项,需要自己判断
//判断条件是最后一项的所有内容都是null
if (0 == pImportTable->OriginalFirstThunk &&
0 == pImportTable->TimeDateStamp &&
0 == pImportTable->ForwarderChain &&
0 == pImportTable->Name &&
0 == pImportTable->FirstThunk)
{
return TRUE;
} return FALSE;
}

在显示时需要注意两点:

1. name属性保存的是ASCII码形式的字符串,如果我们程序使用Unicode编码,需要进行对应的转化。

2 . 要将时间戳转化为我们常见的时分秒的格式。

下面是显示这些信息的部分代码:

        //根据Name成员中的RVA推算出其在文件中的偏移
char *pName = (char*)m_pPeFileInfo->RVA2fOffset(it->Name, (DWORD)(m_pPeFileInfo->pImageBase));
if (NULL == pName || -1 == (int)pName)
{
m_ImportList.InsertItem(i, _T("-"));
}else
{
#ifdef UNICODE
//如果是UNICODE字符串,那么需要进行转化
WCHAR wszName[256] = _T("");
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pName, strlen(pName), wszName, 256);
m_ImportList.InsertItem(i, wszName);
#else
m_ImportList.InsertItem(i, pName);
#endif
}
//显示时间戳
tm p;
errno_t err1;
err1 = gmtime_s(&p,(time_t*)&it->TimeDateStamp);
TCHAR s[100] = {0};
_tcsftime (s, sizeof(s) / sizeof(TCHAR), _T("%Y-%m-%d %H:%M:%S"), &p);
m_ImportList.SetItemText(i, 1, s);

导入dll中的函数信息

dll中函数的信息需要使用之前的FirstThunk来获取,其实OriginalFirstThunk与FirstThunk指向的是同一个结构,都是指向一个IMAGE_THUNK_DATA STRUC的结构,这个结构的定义如下:

typedef struct _IMAGE_THUNK_DATA32 {
union {
DWORD ForwarderString;
DWORD Function;
DWORD Ordinal;
DWORD AddressOfData;
} u1;
} IMAGE_THUNK_DATA32;

这个结构是一个公用体,它其实只占4个字节,当 它的最高位为 1时,表示函数以序号方式输入,这时候低 31位被看作一个函数序号。 当 它的最高位为 0时,表示函数以字符串类型的函数名方式输入,这时双字的值是一个 RVA,指向一个 IMAGE_IMPORT_BY_NAME 结构。 这个结构的定义如下:

typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
BYTE Name[1];
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

为什么要用两个指针指向同一个结构呢?这个跟dll的加载有关,由OriginalFirstThunk指向的结构是一个固定的值,不会被重写的值,一般它里面保存的是函数的名称,而由FirstThunk 保存的结构一般是由PE解析器进行重写,PE 装载器首先搜索 OriginalFirstThunk ,找到之后加载程序迭代搜索数组中的每个指针,找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址,然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口,也就是说此时的FirstThunk 不再指向这个INAGE_IMPORT_BY_NAME结构,而是真实的函数的RVA。因此我们称为输入地址表(IAT)。所以在解析这个PE文件时一般使用OriginalFirstThunk这个成员来获取dll中的函数信息,因为需要获取函数名称。

void CImportDlg::ShowFunctionInfoByDllIndex(int nIndex)
{
IMAGE_IMPORT_DESCRIPTOR ImageDesc = m_ImportTable[nIndex];
//获取到对应项所指向的IMAGE_THUNK_DATA的指针
PIMAGE_THUNK_DATA pThunkData = (PIMAGE_THUNK_DATA)m_pPeFileInfo->RVA2fOffset(ImageDesc.OriginalFirstThunk, (DWORD)m_pPeFileInfo->pImageBase);
CString strInfo = _T("");
int i = 0;
if (NULL == pThunkData || 0xffffffff == (int)pThunkData)
{
return;
}
//这个结构数组以0结尾
while (0 != pThunkData->u1.AddressOfData)
{
//当它的最高位为0时表示函数以字符串类型的函数名方式输入,此时才解析这个信息得到函数名
strInfo.Format(_T("%08x"), pThunkData);
m_TunkList.InsertItem(i, strInfo); strInfo.Format(_T("%08x"), pThunkData->u1.AddressOfData);
m_TunkList.SetItemText(i, 1, strInfo); if (0 == (pThunkData->u1.AddressOfData & 0x80000000))
{
PIMAGE_IMPORT_BY_NAME pIibn= (PIMAGE_IMPORT_BY_NAME)m_pPeFileInfo->RVA2fOffset(pThunkData->u1.AddressOfData, (DWORD)m_pPeFileInfo->pImageBase);
//name 这个域保存的是函数名的第一个字符,所以它的地址就是函数名字符串的地址
char *pszName = (char*)&(pIibn->Name);
#ifdef UNICODE
WCHAR wszName[256] = _T("");
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pszName, strlen(pszName), wszName, 256);
m_TunkList.SetItemText(i, 2, wszName);
#else
m_TunkList.SetItemText(i, 3, pszName);
#endif strInfo.Format(_T("%04x"), pIibn->Hint);
m_TunkList.SetItemText(i, 3, strInfo);
}
else
{
m_TunkList.SetItemText(i, 2, _T("-"));
m_TunkList.SetItemText(i, 3, _T("-"));
}
pThunkData++;
}
}

上面的代码主要用来解析对应dll中的函数。

在上面的代码中,根据用户点击鼠标的序号得到对应的dll项的结构信息,根据OriginalFirstThunk中保存的RVA找到结构IMAGE_THUNK_DATA对应的地址,得到地址后利用0x80000000这个值对它的最高位进行判断,如果为0,那么就可以获得函数名称,在获得名称时,也是需要注意函数名称在Unicode环境下需要转化。在这段代码中主要显示了函数的Thunk的rva,这个rva转化后对应的值,函数名,以及里面的Hint

导出表的解析

一般的exe文件不存在导出表,只有在dll中存在导出表。

导出表中主要存储的是一个序号和对应的函数名,序数是指定DLL 中某个函数的16位数字,在所指向的DLL 文件中是独一无二的。 导出表在数据目录表的第0个元素。导出表的结构如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics; //属性信息
DWORD TimeDateStamp; //生成日期
WORD MajorVersion; //主版本号
WORD MinorVersion; //副版本号
DWORD Name; //dll的名称
DWORD Base; //导出函数序号的起始值
DWORD NumberOfFunctions; //文件中包含的导出函数的总数。
DWORD NumberOfNames; //文件中命名函数的总数,这个一般与上面的那个总数相同
DWORD AddressOfFunctions; //指向导出函数地址的RVA
DWORD AddressOfNames; //指向导出函数名字的RVA
DWORD AddressOfNameOrdinals; //指向导出函数序号的RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

下面对这3个RVA进行详细说明:

AddressOfFunctions,这个RVA指向的是一个双字数组,数组中的每一项是一个RVA 值,存储的是所有导出函数的入口地址,数组的元素个数等于NumberOfFunctions

AddressOfNames:这个RVA指向一个包含所有导出函数名称的表的指针,这个地址表是一个双字数组,数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames 字段的值

AddressOfNameOrdinals:指向一个字型数组(注意这里不是双字)存储的是对应函数的地址,假如现在我们使用函数GetProcAddress在dll中导出一个函数A,它会根据这个函数名称在名称的表中查找,假设它找到的是函数名称表中的第x项与之相同,那么它会在AddressOfNameOrdinals表中查找第x项得到函数的序号,最后根据这个序号在AddressOfFunctions中找到对应的函数地址。

void CExportInfoDlg::ShowFunctionInfo()
{
if (NULL == m_pPeFileInfo)
{
return;
}
PIMAGE_EXPORT_DIRECTORY pExportTable = m_pPeFileInfo->GetExportDeirectory();
PDWORD pAddressOfFunc = (PDWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfFunctions, (DWORD)m_pPeFileInfo->pImageBase);
PWORD pOriginals = (PWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfNameOrdinals, (DWORD)m_pPeFileInfo->pImageBase);
PDWORD pFuncName = (PDWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfNames, (DWORD)m_pPeFileInfo->pImageBase);
int nCount = pExportTable->NumberOfFunctions;
CString strInfo = _T("");
if (pAddressOfFunc == NULL || (int)pAddressOfFunc == -1 ||
pOriginals == NULL || (int)pOriginals == -1 ||
pFuncName == NULL || (int)pFuncName == -1)
{
return;
}
for (int i = 0; i < nCount; i++)
{
//导出序号等于base + 在数组中的索引(pOriginals数组保存的值)
if (pOriginals[i] > nCount)
{
//这个索引值无效
strInfo = _T("-");
}else
{
strInfo.Format(_T("%04x"), pOriginals[i] + pExportTable->Base);
}
m_FuncInfoList.InsertItem(i, strInfo); strInfo.Format(_T("%08x"), pAddressOfFunc[pOriginals[i]]);
m_FuncInfoList.SetItemText(i, 2, strInfo); char *pszName = (char*)m_pPeFileInfo->RVA2fOffset(pFuncName[i], (DWORD)m_pPeFileInfo->pImageBase);
#ifdef UNICODE
WCHAR wszName[256] = _T("");
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pszName, strlen(pszName), wszName, 256);
strInfo = wszName;
#else
strInfo = pszName;
#endif
m_FuncInfoList.SetItemText(i, 1, strInfo);
}
}

上面的代码描述了这个过程。

在代码中首先获取了导出函数表的数据,根据数据中的三个RVA获取它们在文件中的真实地址。首先在名称表中遍历所有函数名称,然后在对应的序号表中找到对应的序号,我在这个解析器中显示出的序号与Windows显示给外界的序号相同,但是在pe文件内部,在进行寻址时使用的是这个序号 - base的值,寻址时使用的是减去base后的值作为元素的位置。pAddressOfFunc[pOriginals[i]] 这句首先找到它在序号表中的序号值,然后根据这个序号在地址表中找到它的地址,在这得到的只是一个RVA地址,如果想得到具体的地址,还需要加上在内存或者文件的起始地址

PE解析器的编写(四)——数据目录表的解析的更多相关文章

  1. PE解析器的编写(一)——总体说明

    之前自己学习了PE文件的格式,后来自己写了个PE文件的解析器,这段时间工作上刚好要用到它,老板需要能查看某个exe中加载的dll的一个工具,我在使用之前自己写的这个东西的时候,发现很多东西都忘记了,所 ...

  2. 非标准的xml解析器的C++实现:二、解析器的基本构造:语法表

    解析器的目的:一次从头到尾的文本遍历,文本数据 转换为 xml节点数据. 这其实是全世界所有编程语言编译或者转换为虚拟代码的基础,学会这种方法,发明一种编程语言其实只是时间问题,当然了,时间也是世界上 ...

  3. Python 之父的解析器系列之三:生成一个 PEG 解析器

    原题 | Generating a PEG Parser 作者 | Guido van Rossum(Python之父) 译者 | 豌豆花下猫("Python猫"公众号作者) 声明 ...

  4. 非标准的xml解析器的C++实现:三、解析器的初步实现

    如同我之前的一篇文章说的那样,我没有支持DTD与命名空间, 当前实现出来的解析器,只能与xmlhttp对比,因为chrome浏览器解析大文档有bug,至于其他人实现的,我就不一一测试了,既然都决定自己 ...

  5. PE解析器的编写(三)——区块表的解析

    PE文件中所有节的属性都被定义在节表中,节表由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构用来描述一个节,结构的排列顺序和它们描述的节在文件中的排列顺序是一致的. 具有相同属 ...

  6. PE文件解析器的编写(二)——PE文件头的解析

    之前在学习PE文件格式的时候,是通过自己查看各个结构,自己一步步计算各个成员在结构中的偏移,然后在计算出其在文件中的偏移,从而找到各个结构的值,但是在使用C语言编写这个工具的时候,就比这个方便的多,只 ...

  7. Spring MVC-视图解析器(View Resolverr)-XML视图解析器(Xml View Resolver)示例(转载实践)

    以下内容翻译自:https://www.tutorialspoint.com/springmvc/springmvc_xmlviewresolver.htm 说明:示例基于Spring MVC 4.1 ...

  8. SpringMVC 视图和视图解析器&表单标签

    视图和视图解析器 请求处理方法执行完成后,最终返回一个 ModelAndView 对象.对于那些返回 String,View 或 ModeMap 等类型的处理方法,Spring MVC 也会在内部将它 ...

  9. Python爬虫(十四)_BeautifulSoup4 解析器

    CSS选择器:BeautifulSoup4 和lxml一样,Beautiful Soup也是一个HTML/XML的解析器,主要的功能也是如何解析和提取HTML/XML数据. lxml只会局部遍历,而B ...

随机推荐

  1. JS排序

    冒泡排序 https://sort.hust.cc/1.bubbleSort.html 选择排序 https://sort.hust.cc/2.selectionSort.html 插入排序 http ...

  2. OVS 总体架构、源码结构及数据流程全面解析

    在前文「从 Bridge 到 OVS」中,我们已经对 OVS 进行了一番探索.本文决定从 OVS 的整体架构到各个组件都进行一个详细的介绍. OVS 架构 OVS 是产品级的虚拟交换机,大量应用在生产 ...

  3. ASP.NET Core 使用 Hangfire 定时任务

    定时任务组件,除了 Hangfire 外,还有一个 Quarz.NET,不过 Hangfire .NET Core 支持的会更好些. ASP.NET Core 使用 Hangfire 很简单,首先,N ...

  4. jQuery里使用setinterval

    如果第一个参数是一个已写好的函数而不是匿名代码块,一定不要加引号,直接var ** = setinterval{myFunction ,500},只能这样,加括号会直接只调用一次,自然不行,加引号和括 ...

  5. 三.RabbitMQ之异步消息队列(Work Queue)

    上一篇文章简要介绍了RabbitMQ的基本知识点,并且写了一个简单的发送和接收消息的demo.这一篇文章继续介绍关于Work Queue(工作队列)方面的知识点,用于实现多个工作进程的分发式任务. 一 ...

  6. Android开发——使用LitePal开源数据库

    前言:之前使用Android内置的数据库,感觉一大堆SQL语句,一不小心就错了,很难受,学习了这个LItePal的开源数据库,瞬间觉得Android内置的数据库简直是垃圾般的存在 LitePal Gi ...

  7. Java零碎总结

    获取当前类运行的根目录(即classpath,如bin.classes.AppName等)的方式有: 1.Thread.currentThread().getContextClassLoader(). ...

  8. [array] leetcode - 35. Search Insert Position - Easy

    leetcode - 35. Search Insert Position - Easy descrition Given a sorted array and a target value, ret ...

  9. Java I/O---输入与输出

    编程语言的I/O类库中常使用流这个抽象概念, 它代表任何有能力产出数据的数据源对象或者是有能力接收数据的接收端对象. "流" 屏蔽了实际的I/O设备中处理数据的细节.Java类库中 ...

  10. Wincc flexable的画面浏览切换组态

    1.新建项目和6个画面 2.双击导航控件设置,选择默认设置 3.使用画面浏览编辑器编辑画面层次切换关系,拖拽画面到编辑器中进行关系连接 4.保运并运行