inline hook 原理&教程

2021年5月24日

  • <1> inline hook 是什么
  • <2> inline hook 基本原理
  • <3> inline hook 跳板函数
  • <4> inline hook 线程安全
  • <5> inline hook 推荐库
  • <6> thiscall hook的方法
  • <7> hook现有进程的其他事项

<1> inline hook 是什么

当我们想要拦截现有运行中的进程内某个现有的汇编函数体,最常用的办法就是 inline hook

它可以在权限允许内,通过修改程序运行中的内存代码段汇编,以达到拦截任何函数的目的,包括系统api(只限非内核态的函数体,要hook内核函数需要进内核态),以及程序内部现有的任何函数体。

比如想拦截系统APICreateFileW的调用,修改原调用参数并继续执行CreateFileW原函数逻辑,获得返回值,或者直接拦截返回NULL失败,或者拦截程序本身代码汇编的函数体,用 inline hook都可以做到。

<2>inline hook 基本原理

在windows下,程序执行的时候会把dll和exe的代码段 text 以及其他数据整理后加载进内存,以顺序排列在指定的虚拟内存空间内。

xx.exe, 0xc80000
abseil_dll.dll, 0x6ca0000
AcLayers.dll, 0x7b9d0000
AddrSearch.dll, 0x46a0000
advapi32.dll, 0x75b80000
advapi32.dll.mui, 0x201b0000
Advertisement.dll, 0x1e560000
AdvVideoDev.dll, 0x7c930000
AFBase.dll, 0x3a80000
AFCtrl.dll, 0x7c9b0000
AFUtil.dll, 0x7c2b0000
AppCenter.dll, 0x7b9b0000
AppFramework.dll, 0x796f0000
...

其中,dll或者exe的内存空间首地址,被称为基地址,此时xx.exe的基地址就是0xc80000。

既然在内存内,那就意味着一个exe或者dll的代码段 text或者其他段的运行时数据是可能被修改的?

是的,windows下可以使用VirtualProtect函数,修改虚拟内存地址块的保护属性,标记为可读写

请看下面的代码


<此代码只适用32位程序>

#include <Windows.h>
#include <iostream>

//构造了一个 参数 与原CreateFileA一样的的函数

HANDLE __stdcall MY_CreateFileA(
	LPCSTR lpFileName,
	DWORD dwDesiredAccess,
	DWORD dwShareMode,
	LPSECURITY_ATTRIBUTES lpSecurityAttributes,
	DWORD dwCreationDisposition,
	DWORD dwFlagsAndAttributes,
	HANDLE hTemplateFile) {

	printf("MY_CreateFileA: %s n", lpFileName);
	return NULL;
}

int main()
{

	HANDLE hFile;
	printf("第一次调用CreateFileA n");
	hFile = CreateFileA(
		"abc.txt",
		GENERIC_WRITE,
		0,
		NULL,
		CREATE_ALWAYS,
		FILE_ATTRIBUTE_NORMAL,
		NULL);

	if (hFile == NULL) {
		printf("hFile==NULLn");
	}
	else if (hFile != INVALID_HANDLE_VALUE) {
		printf("CreateFileA successn");
		CloseHandle(hFile);
	}


	//原CreateFileA的函数内存地址 或者直接用&CreateFileA
	char* target = (char*)GetProcAddress(GetModuleHandleA("Kernel32.dll"), "CreateFileA");

	//MY_CreateFileA的内存地址
	char* detour = (char*)&MY_CreateFileA;


	DWORD  oldProtect;

	//修改CreateFileA的内存地址块 5个大小为可读写
	if (!VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect)) {
		printf("VirtualProtect falsen");
		return 0;
	}

	//0xe9为汇编代码jmp的二进制值
	unsigned char jmp_0xe9 = 0xE9;

	//这是一个jmp用的偏移地址,从CreateFileA位置跳转到MY_CreateFileA
	unsigned int jmp_addr = detour - (target + 5);

	//将target 原CreateFileA 的内存前五个 改写,覆盖为一个jmp指令,和一个jmp需要的偏移地址,刚好五个字节大小
	memcpy(target, &jmp_0xe9, 1);
	memcpy(target + 1, &jmp_addr, 4);

	//恢复原内存保护属性
	VirtualProtect(target, 5, oldProtect, &oldProtect);

	printf("第二次调用CreateFileA n");
	hFile = CreateFileA(
		"abc.txt",
		GENERIC_WRITE,
		0,
		NULL,
		CREATE_ALWAYS,
		FILE_ATTRIBUTE_NORMAL,
		NULL);

	if (hFile == NULL) {
		printf("hFile==NULLn");
	}
	else if (hFile != INVALID_HANDLE_VALUE) {
		CloseHandle(hFile);
	}

	std::cout << "Hello World!n";
}

执行结果输出为

第一次调用CreateFileA
CreateFileA success
第二次调用CreateFileA
MY_CreateFileA: abc.txt
hFile==NULL
Hello World!

可以很明显的看到,第二次调用CreateFileA被拦截到MY_CreateFileA,并成功获取到了原调用参数!

这其中的原理就是,修改了CreateFileA前五个字节的汇编,覆盖为jmp 0x 0x 0x 0x, 四字节的0x 0x 0x 0xMY_CreateFileA的函数地址!

[---原函数---]
[[ jmp 0x 0x 0x 0x ]-被修改的函数---](头五个字节汇编被覆盖)

当执行CreateFileA,就会执行jmp 指令,跳转到给定的函数地址,所以就跳转到了MY_CreateFileA

0xE9 jmp的汇编之后的四个字节,是偏移的地址,通用公式为:要跳转的地址-(jmp下一行汇编的地址),即detour - (target + 5)

上面的代码完成了32位下对某一个函数的简单拦截。

思考几个问题?

1:为什么要一个参数和调用方式与原函数一样参数一样调用方式MY_CreateFileA函数?

答:因为退栈的原则,CreateFileAstdcall调用,参数全是push压栈,且,清理栈还原esp值责任也全在目标函数CreateFileA!所以,有一个参数与CreateFileA一样的函数,MY_CreateFileA正常退栈esp值,才能保证正常esp值,否则函数执行完,esp值并没有还原到 call CreateFileA之前的状态,造成程序错乱异常甚至崩溃!

2:为什么直接jmpMY_CreateFileA能获取到参数?

答:因为call之后跳转到CreateFileA中途只有一个jmp,栈是传参后的原样没有改变,MY_CreateFileACreateFileA的调用方式都是__stdcall,所以MY_CreateFileA去获取参数的时候,就能获取到原本的传参。

3:MY_CreateFileA执行完成为什么会成功跳转到原来的调用代码?

答:原本执行的过程是 call CreateFileA -> 在CreateFileA函数结束retcallret是成对工作的,call 会压栈一个ip地址,而ret会退栈一个值,并跳转到这个值地址,从而回到call的下一行汇编。当覆盖原CreateFileA前五个字节为jmp,并没有改变栈的状态,所以跳转到MY_CreateFileA,不仅能够获取到原本的参数,而且ret同样能跳转到原来call CreateFileA的代码ip位置。

思考这些汇编实现问题很重要,如果不能想通,就需要去补习函数调用增栈退栈和传参的实现原理。

现在已经完成了对某一个函数的拦截,那么如何成功的调用原函数呢?

<3>inline hook 跳板函数

CreateFileA已被破坏了,因为前5个字节的汇编代码都被覆盖了。无法正常调用!

如何才能正常的调用原函数?

可以尝试这样

<此代码只适用于32位>

#include <Windows.h>
#include <iostream>

char backups_asm[5];

int __stdcall MY_MessageBoxA(
	HWND hWnd,
	LPCSTR lpText,
	LPCSTR lpCaption,
	UINT uType);

//拦截,并备份
void Intercept()
{
	char* target = (char*)&MessageBoxA;
	char* detour = (char*)&MY_MessageBoxA;

	DWORD  oldProtect;
	VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);

	//----------------备份原来的5个字节------------------------------
	memcpy(backups_asm, target, 5);
	//----------------备份原来的5个字节------------------------------

	unsigned char jmp_0xe9 = 0xE9;//jmp
	unsigned int jmp_addr = detour - (target + 5);//jmp 地址

	//覆盖原函数5个字节为 jmp
	memcpy(target, &jmp_0xe9, 1);
	memcpy(target + 1, &jmp_addr, 4);

	VirtualProtect(target, 5, oldProtect, &oldProtect);
}

int __stdcall MY_MessageBoxA(
	HWND hWnd,
	LPCSTR lpText,
	LPCSTR lpCaption,
	UINT uType)
{
	printf("MY_MessageBoxA: %s n", lpText);

	//------在这里通过备份恢复原函数------

	char* target = (char*)&MessageBoxA;
	DWORD  oldProtect;
	VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);

	//----------------恢复原来的5个字节------------------------------
	memcpy(target, backups_asm, 5);
	//----------------恢复原来的5个字节------------------------------

	VirtualProtect(target, 5, oldProtect, &oldProtect);

	//继续调用原函数
	return MessageBoxA(hWnd, lpText, lpCaption, uType);
}

int main()
{
	Intercept();
	MessageBoxA(NULL, "this my text!", "title", MB_OK);
	std::cout << "end n";
}

输出:

MY_MessageBoxA: this my text!
end

可以看到,MY_MessageBoxA已拦截到,同时也正确执行了MessageBoxA原函数。

但这种方式很别扭,每次都需要进行memcpy,不停改变代码段汇编,并不是线程安全的,不太实用。

有一种更好的办法,那就是创建一个跳板函数。

步骤如下,在虚拟内存内开辟一块可被执行的内存,将原函数的前5个字节复制到这里,然后在尾部再加上一个往原函数地址jmp,接着逻辑继续执行,如果执行到这个地址,那么先会执行备份的5个字节,然后jmp到原来函数的逻辑,就能成功调用原函数了。

开辟的新可执行内存空间 跳板函数()
{
备份复制原函数的5字节汇编
jmp 到原函数5字节之后位置
}

但这里有一个问题,汇编指令集不是一直为5个字节大小,有各种长度的,如果贸然只备份5个字节,可能会切断原本有的汇编指令,从而无法完整执行正常的代码段,造成程序崩溃。

所以,这里这里在备份原函数的时候,需要读取汇编指令,从而备份的一个大于5字节的完整汇编段

还有一个问题,当备份汇编字节的汇编有各种跳转指令的时候,copy到跳板函数内存区域,这些偏移地址也要进行修改。 比如原函数 (0xe9)jmp 0x 0x 0x 0x相对跳转指令,复制到跳板函数的时候就需要重新计算跳转偏移。
需要进行修改的跳转指令大概有这些,call jmp jcc 等,当然如果是0xFF 0x25 jmp这样的绝对跳转,地址是不用变的。

如何获得完整的汇编指令大小和值,可以通过开源代码实现,hde32_disasmhde64_disasm

创建跳板Trampoline函数示例如下

<此代码只适用于32位>

#include <Windows.h>
#include <iostream>

#include "./hde/hde32.h"
#include <cassert>

typedef HANDLE(__stdcall* FN_PTR_CreateFileA)(
	LPCSTR lpFileName,
	DWORD dwDesiredAccess,
	DWORD dwShareMode,
	LPSECURITY_ATTRIBUTES lpSecurityAttributes,
	DWORD dwCreationDisposition,
	DWORD dwFlagsAndAttributes,
	HANDLE hTemplateFile);

FN_PTR_CreateFileA CreateFileATrampoline = NULL;

HANDLE __stdcall MY_CreateFileA(
	LPCSTR lpFileName,
	DWORD dwDesiredAccess,
	DWORD dwShareMode,
	LPSECURITY_ATTRIBUTES lpSecurityAttributes,
	DWORD dwCreationDisposition,
	DWORD dwFlagsAndAttributes,
	HANDLE hTemplateFile) {

	printf("MY_CreateFileA: %s n", lpFileName);

	//执行原函数 跳板函数
	return CreateFileATrampoline(lpFileName,
		dwDesiredAccess,
		dwShareMode,
		lpSecurityAttributes,
		dwCreationDisposition,
		dwFlagsAndAttributes,
		hTemplateFile);
}

//将target前面第一条汇编改成 jmp跳转到detour,所需5个字节
void Hook(char* target, char* detour)
{

	DWORD  oldProtect;
	VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);

	unsigned char jmp_0xe9 = 0xE9;//jmp
	unsigned int jmp_addr = detour - (target + 5);//jmp 地址

	memcpy(target, &jmp_0xe9, 1);
	memcpy(target + 1, &jmp_addr, 4);

	VirtualProtect(target, 5, oldProtect, &oldProtect);
}

//创建跳板函数,备份至少5字节汇编指令的完整汇编,并在末尾补充跳转到原函数
void* CreateTrampoline(char* target)
{
	DWORD  oldProtect;
	VirtualProtect(target, 5, PAGE_EXECUTE_READWRITE, &oldProtect);

	UINT  asm_size;
	hde32s hs;
	UINT  trampoline_size = 0;
	char* hde_target = target;

	//开辟一个足够大的空间 +5,是因为末尾还需要jmp指令,jmp到原函数
	char* TrampolineMem = (char*)VirtualAlloc(NULL, trampoline_size + 10 + 5,
		MEM_COMMIT, PAGE_EXECUTE_READWRITE);
	if (TrampolineMem == NULL) {
		printf("VirtualAlloc Error n");
		exit(0);
	}

	do {
		//通过hde32_disasm 读取下一条完整汇编
		asm_size = hde32_disasm(hde_target, &hs);

		//备份一条汇编
		memcpy(TrampolineMem + trampoline_size, hde_target, asm_size);

		//当汇编指令是以下的相对偏移指令,需要重新修改偏移跳转值,此代码!未实现完整!!!
		if (hs.opcode == 0xE8) {
			printf("CALL n");
			assert(false);
		}
		else if ((hs.opcode & 0xFD) == 0xE9) {
			printf("JMP (EB or E9) n");
			if (hs.opcode == 0xe9) {
				uint32_t* jmp_addr = (uint32_t*)(TrampolineMem + trampoline_size + asm_size - 4);

				*jmp_addr = *jmp_addr + (hde_target + asm_size) - (TrampolineMem + trampoline_size + asm_size);

				printf("0xe9 地址修复 n");
			}
			else
				assert(false);

		}
		else if ((hs.opcode & 0xF0) == 0x70
			|| (hs.opcode & 0xFC) == 0xE0
			|| (hs.opcode2 & 0xF0) == 0x80) {
			printf(" Jcc n");
			assert(false);
		}

		trampoline_size += asm_size;
		hde_target += asm_size;


	} while (trampoline_size < 5);//至少备份5个字节的汇编


	//已将大于5字节的汇编备份到TrampolineMem,然后在TrampolineMem末尾加jmp
	unsigned char jmp_0xe9 = 0xE9;//jmp
	unsigned int jmp_addr = (target + trampoline_size) - (TrampolineMem + trampoline_size + 5);//jmp 到原函数

	memcpy(TrampolineMem + trampoline_size, &jmp_0xe9, 1);
	memcpy(TrampolineMem + trampoline_size + 1, &jmp_addr, 4);

	VirtualProtect(target, 5, oldProtect, &oldProtect);

	return TrampolineMem;
};

typedef int(*FN_add)(int a, int b);
FN_add old_add = NULL;
int add(int a, int b)
{
	return a + b + 100;
}

int my_add(int a, int b)
{
	printf("add %d %d n", a, b);
	return old_add(a + 1, b);
}

void main()
{

	//先在hook破坏原函数前创建跳板函数
	old_add = (FN_add)CreateTrampoline((char*)&add);
	Hook((char*)&add, (char*)&my_add);
	int x = add(1, 2);
	printf("add(1, 2) x : %d n", x);


	//先在hook破坏原函数前创建跳板函数
	CreateFileATrampoline = (FN_PTR_CreateFileA)CreateTrampoline((char*)&CreateFileA);
	Hook((char*)&CreateFileA, (char*)&MY_CreateFileA);
	HANDLE hFile;
	hFile = CreateFileA("abc.txt",
		GENERIC_WRITE,
		0,
		NULL,
		CREATE_ALWAYS,
		FILE_ATTRIBUTE_NORMAL,
		NULL);

	if (hFile != INVALID_HANDLE_VALUE) {
		printf("CreateFileA successn");
		CloseHandle(hFile);
	}

	printf("end n");
}


输出:

JMP (EB or E9)
0xe9 地址修复
add 1 2
add(1, 2) x : 104

MY_CreateFileA: abc.txt
CreateFileA success
end

<4>inline hook 线程安全

知道了hook原理和创建跳板函数的大概流程,但现在还有一个问题,那就是线程安全

当hook时候,hook流程正在覆盖目标函数起始的几个汇编字节的时候,如果有其他线程正在执行这个函数,也恰好正在执行起始位置,贸然覆盖汇编代码,可能会造成程序崩溃!

解决方法:在附加hook的时候,暂停当前进程内 除当前线程外的其他所有线程,再继续执行附加hook的逻辑,附加hook完成之后,判断其他所有线程的eip,就是执行的代码地址,是否为目标函数的前几个覆盖的字节,如果是,需要把eip重新设置到跳板函数对应的位置。最后重新启动其他所有线程。

多线程下安全的 attach hook 步骤

1> 遍历当前进程的所有线程,暂停当前线程以外的所有线程。
(遍历所有线程可以用TH32CS_SNAPTHREAD,暂停线程函数为SuspendThread。)

2>执行attach hook流程。
(覆盖目标函数头jmp到detour函数。创建跳板函数。)

3>判断其他所有线程的执行的代码ip地址,如果正在执行目标函数,且恰好正在执行起始几个覆盖的汇编,则将此线程的ip地址重新设置到trampoline跳板函数对应的地址。
(获取线程ip地址的函数为GetThreadContext,32eip 64rip,重新设置为SetThreadContext)

4>恢复其他所有线程。
(ResumeThread)

卸载hook也需要线程安全,同样需要在detach hook前后暂停和恢复线程。但缺无法准确判断一个线程ip是不是正在执行hook代码段,因为有可能detour正在执行其他另外的函数。所以这是一个问题。只能尽可能的保证线程安全,稍晚一点释放跳板函数。

<5>inline hook 推荐库

要实现整个hook的流程,保证通用性和稳定性,这是一项不小的工作量,有两个推荐的开源库。

Detours

微软开源的库,支持x86/x64,arm。针对windows api适配得很好,同样也可以hook普通函数。有比较好的稳定性。

https://github.com/microsoft/Detours

使用流程&问题

	DetourRestoreAfterWith();
	DetourTransactionBegin();
	DetourSetIgnoreTooSmall(TRUE);

	DetourUpdateThread() 将某个线程加入休眠队列

	-----------开始执行-----------
	DetourAttachEx 创建hook

	DetourDetach 移除分离hook
	----------------------

	DetourTransactionCommit() 提交hook 并且会恢复由DetourUpdateThread休眠的线程

这里有个问题需要注意,

	DetourUpdateThread函数内部代码

    // Silently (and safely) drop any attempt to suspend our own thread.
    if (hThread == GetCurrentThread()) {
        return NO_ERROR;
    }

GetCurrentThread是一个伪句柄,由TH32CS_SNAPTHREAD得到的线程id再OpenThread,和这个伪句柄是对不上的。

所以你需要自己在外部过滤掉当前线程,不能把由OpenThread的当前线程HANDLE传进去!!也许这是一个bug吧。

	CreateToolhelp32Snapshot TH32CS_SNAPTHREAD 
	这里得到的线程是当前操作系统所有的线程!!无论传不传进程id都是。

	HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
	DWORD currentthreadid = GetCurrentThreadId();
	DWORD processid = GetCurrentProcessId();
    if (hSnapshot != INVALID_HANDLE_VALUE)
    {
        THREADENTRY32 te;
        te.dwSize = sizeof(THREADENTRY32);
        if (Thread32First(hSnapshot, &te))
        {
            do
            {
			if (processid == thread_entry32.th32OwnerProcessID
				&& currentthreadid != thread_entry32.th32ThreadID) {
					既是当前进程,又不是当前线程
					调用DetourUpdateThread(OpenThread);
				}
               
            } while (Thread32Next(hSnapshot, &te));
        }
        CloseHandle(hSnapshot);
    }

再推荐另外一个库,使用更便捷,api也简单易懂。不需要解决这个当前线程伪句柄的问题。

minhook 同样支持线程安全。支持x86/x64。个人推荐。

https://github.com/TsudaKageyu/minhook

MH_Initialize()

MH_CreateHook()

//启用hook,这里内部会暂停其它线程和恢复线程
MH_EnableHook()

MH_DisableHook()

MH_RemoveHook();

MH_Uninitialize()

当hook数量比较多的时候,最好用MH_ALL_HOOKS
MH_EnableHook(MH_ALL_HOOKS);
MH_DisableHook(MH_ALL_HOOKS);
这样不用每次单独一个hook执行MH_EnableHook都会暂停和恢复线程。

例子

#include "MinHook.h"
#if defined _M_X64
#pragma comment(lib, "libMinHook.x64.lib")
#elif defined _M_IX86
#pragma comment(lib, "libMinHook.x86.lib")
#endif

#include <iostream>

int Function(int x)
{
	x++;
	std::cout << "real function" << std::endl;
	return x;
}

typedef int (*FUNCTION)(int x);
FUNCTION fpFunction = NULL;

int DetourFunction(int x)
{
	x--;
	std::cout << "fake function" << std::endl;
	return x;
}

int main()
{
	int value = 0;
	if (MH_Initialize() != MH_OK) {
		return 1;
	}

	if (MH_CreateHook(&Function, &DetourFunction,
		reinterpret_cast<LPVOID*>(&fpFunction)) != MH_OK) {
		return 1;
	}

	if (MH_EnableHook(&Function) != MH_OK) {
		return 1;
	}

	value = Function(123);

	if (MH_DisableHook(&Function) != MH_OK) {
		return 1;
	}

	value = Function(123);

	if (MH_Uninitialize() != MH_OK) {
		return 1;
	}

	return 0;
}

<6> thiscall hook的方法

关于thiscall,就是c++的成员函数的调用方式,thiscall__cdecl, __stdcall,很大不同,原因在于vcthiscall会固定this参数在ecx寄存器。

如果构建一个thiscall or cdecl fake(void* this,...args)行不行呢?那肯定不行,因为thisecx寄存器传参,这里的this却是thiscall用栈传参(64位用寄存器),所以不能用c++语法的方式去写代码,要用汇编的角度去思考。

而且thiscall不能标注在普通非成员函数的方法上,所以最好去创建一个类成员函数的指针,当调用类成员函数的指针,就会自己处理this ecx 函数参数的相关流程。

class TestA
{
public:
	TestA() {}
	~TestA() {}

public:
	// 这是需要hook的函数
	void ClassMemberFunction(void* arg)
	{
		printf("%s  this = %p arg = %pn", __FUNCTION__, this, arg);
	}
};

class FakerClass;
typedef void(__thiscall* mfunc)(FakerClass*, void*);
mfunc org_mfunc = nullptr;

struct FakerClass
{
	// 这是拦截的伪函数
	void Mfunc(void* arg)
	{
		printf("%s  this = %p arg = %pn", __FUNCTION__, this, arg);
		//调用原函数
		org_mfunc(this, arg);
	}
};

int main(int argc, char** argv)
{
	MH_Initialize();

	//asMETHOD .ptr.f.func 的作用是获得成员函数的函数地址,当然你可以用其他方法去做

	auto f = asMETHOD(TestA, ClassMemberFunction);
	auto ff = asMETHOD(FakerClass, Mfunc);

	auto s = MH_CreateHook(f.ptr.f.func, ff.ptr.f.func,
		(void**)&org_mfunc);

	if (s == MH_OK){
		MH_EnableHook(MH_ALL_HOOKS);
	}

	TestA t;
	void* arg = (void*)0x88888;
	printf("t = %pn", &t);
	t.ClassMemberFunction(arg);

	return getchar();
}

t = 00F3FCEB
FakerClass::Mfunc  this = 00F3FCEB arg = 00088888
TestA::ClassMemberFunction  this = 00F3FCEB arg = 00088888

<7> hook现有进程的其他事项

当你需要对运行中的目标进程进行hook,你可能先需要知道目标函数的地址,这个地址应该是rva地址,就是相对于当前模块的偏移地址,rva地址+模块基地址=最终的函数在内存中的地址,因为模块在内存中的位置有可能每次启动都不一样,所以偏移地址+当前基地址才是正确的做法。

你还需要注入逻辑,把自己的代码dll,注入到目标程序,否则无法方便操作,当前篇幅不涉及。

<完> 2021年5月26日 qq: base64(MTcxMjgzNjQ0) 【转载请注明出处】。

内容来源于网络如有侵权请私信删除

文章来源: 博客园

原文链接: https://www.cnblogs.com/luconsole/p/14813573.html

你还没有登录,请先登录注册
  • 还没有人评论,欢迎说说您的想法!

相关课程

3960 0元 限免
5671 0元 限免