“鼠標屏幕取詞”技術是在電子字典中得到廣泛地應用的,如四通利方和金山詞霸等軟件,這個技術看似簡單,其實在WINDOWS系統中實現卻是非常復雜的,總的來說有兩種實現方式:
??? 第一種:采用截獲對部分GDI的API調用來實現,如TextOut,TextOutA等。
??? 第二種:對每個設備上下文(DC)做一分Copy,并跟蹤所有修改上下文(DC)的操作。??????
?
?? 第二種方法更強大,但兼容性不好,而第一種方法使用的截獲WindowsAPI的調用,這項技術的強大可能遠遠超出了您的想象,毫不夸張的說,利用WindowsAPI攔截技術,你可以改造整個操作系統,事實上很多外掛式Windows中文平臺就是這么實現的!而這項技術也正是這篇文章的主題。
??? 截WindowsAPI的調用,具體的說來也可以分為兩種方法:
??? 第一種方法通過直接改寫WinAPI 在內存中的映像,嵌入匯編代碼,使之被調用時跳轉到指定的地址運行來截獲;第二種方法則改寫IAT(Import Address Table 輸入地址表),重定向WinAPI函數的調用來實現對WinAPI的截獲。
??? 第一種方法的實現較為繁瑣,而且在Win95、98下面更有難度,這是因為雖然微軟說WIN16的API只是為了兼容性才保留下來,程序員應該盡可能地調用32位的API,實際上根本就不是這樣!WIN 9X內部的大部分32位API經過變換調用了同名的16位API,也就是說我們需要在攔截的函數中嵌入16位匯編代碼!
??? 我們將要介紹的是第二種攔截方法,這種方法在Win95、98和NT下面運行都比較穩定,兼容性較好。由于需要用到關于Windows虛擬內存的管理、打破進程邊界墻、向應用程序的進程空間中注入代碼、PE(Portable Executable)文件格式和IAT(輸入地址表)等較底層的知識,所以我們先對涉及到的這些知識大概地做一個介紹,最后會給出攔截部分的關鍵代碼。
????? 先說Windows虛擬內存的管理。Windows9X給每一個進程分配了4GB的地址空間,對于NT來說,這個數字是2GB,系統保留了2GB到 4GB之間的地址空間禁止進程訪問,而在Win9X中,2GB到4GB這部分虛擬地址空間實際上是由所有的WIN32進程所共享的,這部分地址空間加載了共享Win32 DLL、內存映射文件和VXD、內存管理器和文件系統碼,Win9X中這部分對于每一個進程都是可見的,這也是Win9X操作系統不夠健壯的原因。Win9X中為16位操作系統保留了0到4MB的地址空間,而在4MB到2GB之間也就是Win32進程私有的地址空間,由于 每個進程的地址空間都是相對獨立的,也就是說,如果程序想截獲其它進程中的API調用,就必須打破進程邊界墻,向其它的進程中注入截獲API調用的代碼,這項工作我們交給鉤子函數(SetWindowsHookEx)來完成,關于如何創建一個包含系統鉤子的動態鏈接庫,《電腦高手雜志》在第?期已經有過專題介紹了,這里就不贅述了。所有系統鉤子的函數必須要在動態庫里,這樣的話,當進程隱式或顯式調用一個動態庫里的函數時,系統會把這個動態庫映射到這個進程的虛擬地址空間里,這使得DLL成為進程的一部分,以這個進程的身份執行,使用這個進程的堆棧,也就是說動態鏈接庫中的代碼被鉤子函數注入了其它GUI進程的地址空間(非GUI進程,鉤子函數就無能為力了),
當包含鉤子的DLL注入其它進程后,就可以取得映射到這個進程虛擬內存里的各個模塊(EXE和DLL)的基地址,
如:HMODULE hmodule=GetModuleHandle(“Mypro.exe”);
在MFC程序中,我們可以用AfxGetInstanceHandle()函數來得到模塊的基地址。EXE和DLL被映射到虛擬內存空間的什么地方是由它們的基地址決定的。它們的基地址是在鏈接時由鏈接器決定的。當你新建一個Win32工程時,VC++鏈接器使用缺省的基地址0x00400000。可以通過鏈接器的BASE選項改變模塊的基地址。EXE通常被映射到虛擬內存的0x00400000處,DLL也隨之有不同的基地址,通常被映射到不同進程
的相同的虛擬地址空間處。
系統將EXE和DLL原封不動映射到虛擬內存空間中,它們在內存中的結構與磁盤上的靜態文件結構是一樣的。即PE (Portable Executable) 文件格式。我們得到了進程模塊的基地址以后,就可以根據PE文件的格式窮舉這個模塊的IMAGE_IMPORT_DESCRIPTOR數組,看看進程空間中是否引入了我們需要截獲的函數所在的動態鏈接庫,比如需要截獲“TextOutA”,就必須檢查“Gdi32.dll”是否被引入了。說到這里,我們有必要介紹一下PE文件的格式,如右圖,這是PE文件格式的大致框圖,最前面是文件頭,我們不必理會,從PE File Optional Header后面開始,就是文件中各個段的說明,說明后面才是真正的段數據,而實際上我們關心的只有一個段,那就是“.idata”段,這個段中包含了所有的引入函數信息,還有IAT(Import Address Table)的RVA(Relative Virtual Address)地址。
說到這里,截獲WindowsAPI的整個原理就要真相大白了。實際上所有進程對給定的API函數的調用總是通過PE文件的一個地方來轉移的,這就是一個該模塊(可以是EXE或DLL)的“.idata”段中的IAT輸入地址表(Import Address Table)。在那里有所有本模塊調用的其它DLL的函數名及地址。對其它DLL的函數調用實際上只是跳轉到輸入地址表,由輸入地址表再跳轉到DLL真正的函數入口。
具體來說,我們將通過IMAGE_IMPORT_DESCRIPTOR數組來訪問“.idata”段中引入的DLL的信息,然后通過IMAGE_THUNK_DATA數組來針對一個被引入的DLL訪問該DLL中被引入的每個函數的信息,找到我們需要截獲的函數的跳轉地址,然后改成我們自己的函數的地址……具體的做法在后面的關鍵代碼中會有詳細的講解。
?? 講了這么多原理,現在讓我們回到“鼠標屏幕取詞”的專題上來。除了API函數的截獲,要實現“鼠標屏幕取詞”,還需要做一些其它的工作,簡單的說來,可以把一個完整的取詞過程歸納成以下幾個步驟:
1. 安裝鼠標鉤子,通過鉤子函數獲得鼠標消息。
使用到的API函數:SetWindowsHookEx
2. 得到鼠標的當前位置,向鼠標下的窗口發重畫消息,讓它調用系統函數重畫窗口。
???? 使用到的API函數:WindowFromPoint,ScreenToClient,InvalidateRect
3. 截獲對系統函數的調用,取得參數,也就是我們要取的詞。
對于大多數的Windows應用程序來說,如果要取詞,我們需要截獲的是“Gdi32.dll”中的“TextOutA”函數。
我們先仿照TextOutA函數寫一個自己的MyTextOutA函數,如:
BOOL WINAPI MyTextOutA(HDC hdc, int nXStart, int nYStart, LPCSTR lpszString,int cbString)
{
?????? // 這里進行輸出lpszString的處理
?????????? // 然后調用正版的TextOutA函數
}
把這個函數放在安裝了鉤子的動態連接庫中,然后調用我們最后給出的HookImportFunction函數來截獲進程
對TextOutA函數的調用,跳轉到我們的MyTextOutA函數,完成對輸出字符串的捕捉。HookImportFunction的
用法:
?HOOKFUNCDESC hd;
?PROC???????? pOrigFuns;
?hd.szFunc="TextOutA";
?hd.pProc=(PROC)MyTextOutA;
?HookImportFunction (AfxGetInstanceHandle(),"gdi32.dll",&hd,pOrigFuns);
下面給出了HookImportFunction的源代碼,相信詳盡的注釋一定不會讓您覺得理解截獲到底是怎么實現的
很難,Ok,Let’s Go:
///////////////////////////////////////////// Begin ///////////////////////////////////////////////////////////////
#include <crtdbg.h>
// 這里定義了一個產生指針的宏
#define MakePtr(cast, ptr, AddValue) (cast)((DWORD)(ptr)+(DWORD)(AddValue))
// 定義了HOOKFUNCDESC結構,我們用這個結構作為參數傳給HookImportFunction函數
typedef struct tag_HOOKFUNCDESC
{
? LPCSTR szFunc; // The name of the function to hook.
? PROC pProc;??? // The procedure to blast in.
} HOOKFUNCDESC , * LPHOOKFUNCDESC;
// 這個函數監測當前系統是否是WindowNT
BOOL IsNT();
// 這個函數得到hModule -- 即我們需要截獲的函數所在的DLL模塊的引入描述符(import descriptor)
PIMAGE_IMPORT_DESCRIPTOR GetNamedImportDescriptor(HMODULE hModule, LPCSTR szImportModule);
// 我們的主函數
BOOL HookImportFunction(HMODULE hModule, LPCSTR szImportModule,
???????????????????????? LPHOOKFUNCDESC paHookFunc, PROC* paOrigFuncs)
{
/////////////////////// 下面的代碼檢測參數的有效性 ////////////////////////////
?_ASSERT(szImportModule);
?_ASSERT(!IsBadReadPtr(paHookFunc, sizeof(HOOKFUNCDESC)));
#ifdef _DEBUG
?if (paOrigFuncs) _ASSERT(!IsBadWritePtr(paOrigFuncs, sizeof(PROC)));
?_ASSERT(paHookFunc.szFunc);
?_ASSERT(*paHookFunc.szFunc != '\0');
??????? _ASSERT(!IsBadCodePtr(paHookFunc.pProc));
#endif
?if ((szImportModule == NULL) || (IsBadReadPtr(paHookFunc, sizeof(HOOKFUNCDESC))))
?{
??_ASSERT(FALSE);
??SetLastErrorEx(ERROR_INVALID_PARAMETER, SLE_ERROR);
??return FALSE;
?}
//////////////////////////////////////////////////////////////////////////////
?// 監測當前模塊是否是在2GB虛擬內存空間之上
?// 這部分的地址內存是屬于Win32進程共享的
?if (!IsNT() && ((DWORD)hModule >= 0x80000000))
?{
??_ASSERT(FALSE);
??SetLastErrorEx(ERROR_INVALID_HANDLE, SLE_ERROR);
??return FALSE;
?}
??? ?// 清零
?if (paOrigFuncs) memset(paOrigFuncs, NULL, sizeof(PROC));
?// 調用GetNamedImportDescriptor()函數,來得到hModule -- 即我們需要
?// 截獲的函數所在的DLL模塊的引入描述符(import descriptor)
?PIMAGE_IMPORT_DESCRIPTOR pImportDesc = GetNamedImportDescriptor(hModule, szImportModule);
?if (pImportDesc == NULL)
?return FALSE; // 若為空,則模塊未被當前進程所引入
?//? 從DLL模塊中得到原始的THUNK信息,因為pImportDesc->FirstThunk數組中的原始信息已經
?//? 在應用程序引入該DLL時覆蓋上了所有的引入信息,所以我們需要通過取得pImportDesc->OriginalFirstThunk
?//? 指針來訪問引入函數名等信息
?PIMAGE_THUNK_DATA pOrigThunk = MakePtr(PIMAGE_THUNK_DATA, hModule,
?????????????????????????????????????????????? pImportDesc->OriginalFirstThunk);
?
?//? 從pImportDesc->FirstThunk得到IMAGE_THUNK_DATA數組的指針,由于這里在DLL被引入時已經填充了
?//? 所有的引入信息,所以真正的截獲實際上正是在這里進行的
?PIMAGE_THUNK_DATA pRealThunk = MakePtr(PIMAGE_THUNK_DATA, hModule, pImportDesc->FirstThunk);
?//? 窮舉IMAGE_THUNK_DATA數組,尋找我們需要截獲的函數,這是最關鍵的部分!
?while (pOrigThunk->u1.Function)
?{
??// 只尋找那些按函數名而不是序號引入的函數
??if (IMAGE_ORDINAL_FLAG != (pOrigThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG))
??{
???// 得到引入函數的函數名
???PIMAGE_IMPORT_BY_NAME pByName = MakePtr(PIMAGE_IMPORT_BY_NAME, hModule,
?????????????? pOrigThunk->u1.AddressOfData);
???// 如果函數名以NULL開始,跳過,繼續下一個函數??
???if ('\0' == pByName->Name[0])
????continue;
???// bDoHook用來檢查是否截獲成功
???BOOL bDoHook = FALSE;
???// 檢查是否當前函數是我們需要截獲的函數
???if ((paHookFunc.szFunc[0] == pByName->Name[0]) &&
????(strcmpi(paHookFunc.szFunc, (char*)pByName->Name) == 0))
???{
????// 找到了!
????if (paHookFunc.pProc)
????bDoHook = TRUE;
???}
???if (bDoHook)
???{
????// 我們已經找到了所要截獲的函數,那么就開始動手吧
????// 首先要做的是改變這一塊虛擬內存的內存保護狀態,讓我們可以自由存取
????MEMORY_BASIC_INFORMATION mbi_thunk;
????VirtualQuery(pRealThunk, &mbi_thunk, sizeof(MEMORY_BASIC_INFORMATION));
????_ASSERT(VirtualProtect(mbi_thunk.BaseAddress, mbi_thunk.RegionSize,
??????????????????????? PAGE_READWRITE, &mbi_thunk.Protect));
????// 保存我們所要截獲的函數的正確跳轉地址
????if (paOrigFuncs)
????? paOrigFuncs = (PROC)pRealThunk->u1.Function;
????// 將IMAGE_THUNK_DATA數組中的函數跳轉地址改寫為我們自己的函數地址!
????// 以后所有進程對這個系統函數的所有調用都將成為對我們自己編寫的函數的調用
????pRealThunk->u1.Function = (PDWORD)paHookFunc.pProc;
????// 操作完畢!將這一塊虛擬內存改回原來的保護狀態
????DWORD dwOldProtect;
????_ASSERT(VirtualProtect(mbi_thunk.BaseAddress, mbi_thunk.RegionSize,
??????????????????????? mbi_thunk.Protect, &dwOldProtect));
????SetLastError(ERROR_SUCCESS);
????return TRUE;
???}
??}
??// 訪問IMAGE_THUNK_DATA數組中的下一個元素
??pOrigThunk++;
??pRealThunk++;
?}
?return TRUE;
}
// GetNamedImportDescriptor函數的實現
PIMAGE_IMPORT_DESCRIPTOR GetNamedImportDescriptor(HMODULE hModule, LPCSTR szImportModule)
{
?// 檢測參數
?_ASSERT(szImportModule);
?_ASSERT(hModule);
?if ((szImportModule == NULL) || (hModule == NULL))
?{
??_ASSERT(FALSE);
??SetLastErrorEx(ERROR_INVALID_PARAMETER, SLE_ERROR);
??return NULL;
?}
?// 得到Dos文件頭
?PIMAGE_DOS_HEADER pDOSHeader = (PIMAGE_DOS_HEADER) hModule;
?// 檢測是否MZ文件頭
?if (IsBadReadPtr(pDOSHeader, sizeof(IMAGE_DOS_HEADER)) ||
??(pDOSHeader->e_magic != IMAGE_DOS_SIGNATURE))
?{
??_ASSERT(FALSE);
??SetLastErrorEx(ERROR_INVALID_PARAMETER, SLE_ERROR);
??return NULL;
?}
?// 取得PE文件頭
?PIMAGE_NT_HEADERS pNTHeader = MakePtr(PIMAGE_NT_HEADERS, pDOSHeader, pDOSHeader->e_lfanew);
?// 檢測是否PE映像文件
?if (IsBadReadPtr(pNTHeader, sizeof(IMAGE_NT_HEADERS)) ||
?? (pNTHeader->Signature != IMAGE_NT_SIGNATURE))
?{
??_ASSERT(FALSE);
??SetLastErrorEx(ERROR_INVALID_PARAMETER, SLE_ERROR);
??return NULL;
?}
?// 檢查PE文件的引入段(即 .idata section)
?if (pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress == 0)
??return NULL;
?// 得到引入段(即 .idata section)的指針
?PIMAGE_IMPORT_DESCRIPTOR pImportDesc = MakePtr(PIMAGE_IMPORT_DESCRIPTOR, pDOSHeader,
??pNTHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
?// 窮舉PIMAGE_IMPORT_DESCRIPTOR數組尋找我們需要截獲的函數所在的模塊
?while (pImportDesc->Name)
?{
??PSTR szCurrMod = MakePtr(PSTR, pDOSHeader, pImportDesc->Name);
??if (stricmp(szCurrMod, szImportModule) == 0)
????? break; // 找到!中斷循環
??// 下一個元素
??pImportDesc++;
?}
?// 如果沒有找到,說明我們尋找的模塊沒有被當前的進程所引入!
?if (pImportDesc->Name == NULL)
??return NULL;
?// 返回函數所找到的模塊描述符(import descriptor)
?return pImportDesc;
}
// IsNT()函數的實現
BOOL IsNT()
{
?OSVERSIONINFO stOSVI;
?memset(&stOSVI, NULL, sizeof(OSVERSIONINFO));
?stOSVI.dwOSVersionInfoSize = sizeof(OSVERSIONINFO);
?BOOL bRet = GetVersionEx(&stOSVI);
?_ASSERT(TRUE == bRet);
?if (FALSE == bRet) return FALSE;
?return (VER_PLATFORM_WIN32_NT == stOSVI.dwPlatformId);
}
/////////////////////////////////////////////// End //////////////////////////////////////////////////////////////////////
?? 不知道在這篇文章問世之前,有多少朋友嘗試過去實現“鼠標屏幕取詞”這項充滿了挑戰的技術,也只有嘗試過的朋友才能體會到其間的不易,尤其在探索API函數的截獲時,手頭的幾篇資料沒有一篇是涉及到關鍵代碼的,重要的地方都是一筆代過,MSDN更是顯得蒼白而無力,也不知道除了IMAGE_IMPORT_DESCRIPTOR和IMAGE_THUNK_DATA,微軟還隱藏了多少秘密,好在硬著頭皮還是把它給攻克了,希望這篇文章對大家能有所幫助。