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==NULL\n");
}
else if (hFile != INVALID_HANDLE_VALUE) {
printf("CreateFileA success\n");
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 false\n");
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==NULL\n");
}
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 0x
為MY_CreateFileA
的函數地址!
[---原函數---]
[[ jmp 0x 0x 0x 0x ]-被修改的函數---](頭五個字節匯編被覆蓋)
當執行CreateFileA
,就會執行jmp 指令
,跳轉到給定的函數地址,所以就跳轉到了MY_CreateFileA
。
0xE9
jmp的匯編之后的四個字節,是偏移的地址,通用公式為:要跳轉的地址-(jmp下一行匯編的地址)
,即detour - (target + 5)
。
上面的代碼完成了32位
下對某一個函數的簡單攔截。
思考幾個問題?
1:為什么要一個參數和調用方式與原函數一樣參數一樣調用方式
的MY_CreateFileA
函數?
答:因為退棧的原則,CreateFileA
是stdcall
調用,參數全是push壓棧,且,清理棧還原esp
值責任也全在目標函數CreateFileA
!所以,有一個參數與CreateFileA
一樣的函數,MY_CreateFileA
正常退棧esp
值,才能保證正常esp值,否則函數執行完,esp
值並沒有還原到 call CreateFileA
之前的狀態,造成程序錯亂異常甚至崩潰!
2:為什么直接jmp
到MY_CreateFileA
能獲取到參數?
答:因為call之后跳轉到CreateFileA
中途只有一個jmp
,棧是傳參后的原樣沒有改變,MY_CreateFileA
和CreateFileA
的調用方式都是__stdcall
,所以MY_CreateFileA
去獲取參數的時候,就能獲取到原本的傳參。
3:MY_CreateFileA
執行完成為什么會成功跳轉到原來的調用代碼?
答:原本執行的過程是 call CreateFileA
-> 在CreateFileA
函數結束ret
。call
與 ret
是成對工作的,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_disasm
或 hde64_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 success\n");
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
,很大不同,原因在於vc
的thiscall
會固定this
參數在ecx
寄存器。
如果構建一個thiscall or cdecl fake(void* this,...args)
行不行呢?那肯定不行,因為this
是ecx寄存器
傳參,這里的this
卻是thiscall
用棧傳參(64位用寄存器
),所以不能用c++語法的方式去寫代碼,要用匯編的角度去思考。
而且thiscall
不能標注在普通非成員函數的方法上,所以最好去創建一個類成員函數的指針
,當調用類成員函數的指針
,就會自己處理this
ecx
函數參數
的相關流程。
class TestA
{
public:
TestA() {}
~TestA() {}
public:
// 這是需要hook的函數
void ClassMemberFunction(void* arg)
{
printf("%s this = %p arg = %p\n", __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 = %p\n", __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 = %p\n", &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) 【轉載請注明出處】。