一、序言
對大多數(shù)的Windows開發(fā)者來說,如何在Win32系統(tǒng)中對API函數(shù)的調(diào)用進行攔截一直是項極富挑戰(zhàn)性的課題,因為這將是對你所掌握的計算機知識較為全面的考驗,尤其是一些在如今使用RAD進行軟件開發(fā)時并不常用的知識,這包括了操作系統(tǒng)原理、匯編語言甚至是機器指令(聽上去真是有點恐怖,不過這是事實)。
當前廣泛使用的Windows操作系統(tǒng)中,像Win 9x和Win NT/2K,都提供了一種比較穩(wěn)健的機制來使得各個進程的內(nèi)存地址空間之間是相互獨立,也就是說一個進程中的某個有效的內(nèi)存地址對另一個進程來說是無意義的,這種內(nèi)存保護措施大大增加了系統(tǒng)的穩(wěn)定性。不過,這也使得進行系統(tǒng)級的API攔截的工作的難度也大大加大了。
當然,我這里所指的是比較文雅的攔截方式,通過修改可執(zhí)行文件在內(nèi)存中的映像中有關代碼,實現(xiàn)對API調(diào)用的動態(tài)攔截;而不是采用比較暴力的方式,直接對可執(zhí)行文件的磁盤存儲中機器代碼進行改寫。
二、API鉤子系統(tǒng)一般框架
通常,我們把攔截API的調(diào)用的這個過程稱為是安裝一個API鉤子(API Hook)。一個API鉤子基本是由兩個模塊組成:一個是鉤子服務器(Hook Server)模塊,一般為EXE的形式;一個是鉤子驅(qū)動器(Hook Driver)模塊,一般為DLL的形式。
鉤子服務器主要負責向目標進程注入鉤子驅(qū)動器,使得鉤子驅(qū)動器運行在目標進程的地址空間中,這是關鍵的第一步,而鉤子驅(qū)動器則負責實際的API攔截處理工作,以便在我們所關心的API函數(shù)調(diào)用的之前或之后能做一些我們所希望的工作。一個比較常見的API鉤子的例子就是一些實時翻譯軟件(像金山詞霸)中必備的的功能:屏幕抓詞。它主要是對一些Win32 API中的GDI函數(shù)進行了攔截,獲取它們的輸入?yún)?shù)中的字符串,然后在自己的窗口中顯示出來。
針對上述關于API鉤子的兩個部分,有以下兩點需要我們重點考慮的: 選用何種DLL注入技術,以及采用何種API攔截機制。
三、注入技術的選用
由于在Win32系統(tǒng)中各個進程的地址是互相獨立的,因此我們無法在一個進程中對另一個進程的代碼進行有效的修改,但如果你要完成API鉤子的工作又必須如此。因此,我們必須采取某種獨特的手段,使得API鉤子(準確的說是鉤子驅(qū)動器)能夠成為目標進程中的一部分,才有較大的可能來對目標進程數(shù)據(jù)和代碼進行有控制的修改。
通常可采用的幾種注入方式:
1.利用注冊表
如果我們準備攔截的進程連接了User32.dll,也就是使用了User32.dll中的API(一般圖形界面的應用程序都是符合這個條件),那么就可以簡單把你的鉤子驅(qū)動器DLL的名字作為值添加在下面注冊表的鍵下: HKEY_LOCAL_MACHINE\Software\Microsoft\WindowsNT\CurrentVersion\Windows\AppInit_DLLs 值的形式可以為單個DLL的文件名,或者是一組DLL的文件名,相鄰的名稱之間用逗號或空格間隔。所有由該值標識的DLL將在符合條件的應用程序啟動的時候裝載。這是一個操作系統(tǒng)內(nèi)建的機制,相對其他方式來說危險性較小,但它也有一些比較明顯的缺點:該方法僅適用于NT/2K操作系統(tǒng),顯然看看鍵的名稱就可以明白;如果需要激活或停止鉤子的注入,只有重新啟動Windows,這個就似乎太不方便了;最后一點也很顯然,不能用此方法向沒有使用 User32.dll的應用程序注入DLL,例如控制臺應用程序等。另外,不管是否為你所希望,鉤子DLL將注入每一個GUI應用程序,這將導致整個系統(tǒng)性能的下降!
2.建立系統(tǒng)范圍的Windows鉤子
要向某個進程注入DLL,一個十分普遍也是比較簡單的方法就是建立在標準的 Windows鉤子的基礎上。Windows鉤子一般是在DLL中實現(xiàn)的,這是一個全局性的Windows鉤子的基本要求,這也很符合我們的需要。當我們成功地調(diào)用SetWindowsHookEx函數(shù)之后,便在系統(tǒng)中安裝了某種類型的消息鉤子,這個鉤子可以是針對某個進程,也可以是針對系統(tǒng)中的所有進程。一旦某個進程中產(chǎn)生了該類型的消息,操作系統(tǒng)會自動把該鉤子所在的DLL映像到該進程的地址空間中,從而使得消息回調(diào)函數(shù)(在 SetWindowsHookEx的參數(shù)中指定)能夠?qū)Υ讼⑦M行適當?shù)奶幚恚谶@里,我們所感興趣的當然不是對消息進行什么處理,因此在消息回調(diào)函數(shù)中只需把消息鉤子向后傳遞就可以了,但是我們所需的DLL已經(jīng)成功地注入了目標進程的地址空間,從而可以完成后續(xù)工作。
我們知道,不同的進程之間是不能直接共享數(shù)據(jù)的,因為它們活動在不同的地址空間中。但在Windows鉤子DLL中,有一些數(shù)據(jù),例如Windows鉤子句柄HHook,這是由SetWindowsHookEx函數(shù)返回值得到的,并且作為參數(shù)將在CallNextHookEx函數(shù)和 UnhookWindoesHookEx函數(shù)中使用,顯然使用SetWindowsHookEx函數(shù)的進程和使用CallNextHookEx函數(shù)的進程一般不會是同一個進程,因此我們必須能夠使句柄在所有的地址空間中都是有效的有意義的,也就是說,它的值必須必須在這些鉤子DLL所掛鉤的進程之間是共享的。為了達到這個目的,我們就應該把它存儲在一個共享的數(shù)據(jù)區(qū)域中。
在VC++中我們可以采用預編譯指令#pragma data_seg在DLL文件中創(chuàng)建一個新的段,并且在DEF文件中把該段的屬性設置為“shared”,這樣就建立了一個共享數(shù)據(jù)段。對于使用 Delphi的人來說就沒有這么幸運了:沒有類似的比較簡單的方法(或許是有的,但我沒有找到)。不過我們還是可以利用內(nèi)存映像技術來申請使用一塊各進程可以共享的內(nèi)存區(qū)域,主要是利用了CreateFileMapping和MapViewOfFile這兩個函數(shù),這倒是一個通用的方法,適合所有的開發(fā)語言,只要它能直接或間接的使用Windows的API。
在Borland的BCB中有一個指令#pragma codeseg與VC++中的#pragma data_seg指令有點類似,應該也能起到一樣的作用,但我試了一下,沒有沒有效果,而且BCB的聯(lián)機幫助中對此也提到的不多,不知怎樣才能正確的使用(或許是另外一個指令,呵呵)。
一旦鉤子DLL加載進入目標進程的地址空間后,在我們調(diào)用UnHookWindowsHookEx函數(shù)之前是無法使它停止工作的,除非目標進程關閉。
這種DLL注入方式有兩個優(yōu)點: 這種機制在Win 9x/Me和Win NT/2K中都是得到支持的,預計在以后的版本中也將得到支持;鉤子DLL可以在不需要的時候,可由我們主動的調(diào)用 UnHookWindowsHookEx來卸載,比起使用注冊表的機制來說方便了許多。盡管這是一種相當簡潔明了的方法,但它也有一些顯而易見的缺點:首先值得我們注意的是,Windows鉤子將會降低整個系統(tǒng)的性能,因為它額外增加了系統(tǒng)在消息處理方面的時間;其次,只有當目標進程準備接受某種消息時,鉤子所在的DLL才會被系統(tǒng)映射到該進程的地址空間中,鉤子才能真正開始發(fā)揮作用,因此如果我們要對某些進程的整個生命周期內(nèi)的API調(diào)用情況進行監(jiān)控,用這種方法顯然會遺漏某些API的調(diào)用 。
3.使用 CreateRemoteThread函數(shù)
在我看來這是一個相當棒的方法,然而不幸的是,CreateRemoteThread這個函數(shù)只能在Win NT/2K系統(tǒng)中才得到支持,雖然在Win 9x中這個API也能被安全的調(diào)用而不出錯,但它除了返回一個空值之外什么也不做。該注入過程也十分簡單:我們知道,任何一個進程都可以使用 LoadLibrary來動態(tài)地加載一個DLL。但問題是,我們?nèi)绾巫屇繕诉M程(可能正在運行中)在我們的控制下來加載我們的鉤子DLL(也就是鉤子驅(qū)動器)呢?有一個API函數(shù)CreateRemoteThread,通過它可在一個進程中可建立并運行一個遠程的線程--這個好像和注入沒什么關系嘛?往下看!
調(diào)用該API需要指定一個線程函數(shù)指針作為參數(shù),該線程函數(shù)的原型如下: Function ThreadProc(lpParam: Pointer): DWORD,我們再來看一下LoadLibrary的函數(shù)原型: Function LoadLibrary(lpFileName: PChar): HModule。發(fā)現(xiàn)了吧!這兩個函數(shù)原型幾乎是一樣的(其實返回值是否相同關系不大,因為我們是無法得到遠程線程函數(shù)的返回值的),這種類似使得我們可以把直接把LoadLibrary當做線程函數(shù)來使用,從而在目標進程中加載鉤子DLL。
與此類似,當我們需要卸載鉤子DLL時,也可以 FreeLibrary作為線程函數(shù)來使用,在目標進程中卸載鉤子DLL,一切看來是十分的簡潔方便。通過調(diào)用GetProcAddress函數(shù),我們可以得到LoadLibrary函數(shù)的地址。由于LoadLibrary是Kernel32中的函數(shù),而這個系統(tǒng)DLL的映射地址對每一個進程來說都是相同的,因此LoadLibrary函數(shù)的地址也是如此。這點將確保我們能把該函數(shù)的地址作為一個有效的參數(shù)傳遞給CreateRemoteThread使用。 FreeLibrary也是一樣的。
AddrOfLoadLibrary := GetProcAddress(GetModuleHandle(‘Kernel32.dll’), ‘LoadLibrary’);
HRemoteThread := CreateRemoteThread(HTargetProcess, nil, 0, AddrOfLoadLibrary, HookDllName, 0, nil);
要使用CreateRemoteThread,我們需要目標進程的句柄作為參數(shù)。當我們用OpenProcess函數(shù)來得到進程的句柄時,通常是希望對此進程有全權的存取操作,也就是以PROCESS_ALL_ACCESS為標志打開進程。但對于一些系統(tǒng)級的進程,直接這樣顯然是不行的,只能返回一個的空句柄(值為零)。為此,我們必須把自己設置為擁有調(diào)試級的特權,這樣將具有最大的存取權限,從而使得我們能對這些系統(tǒng)級的進程也可以進行一些必要的操作。
4.通過BHO來注入DLL
有時,我們想要注入DLL的對象僅僅是Internet Explorer,很幸運,Windows操作系統(tǒng)為我們提供了一個簡單的歸檔方法(這保證了它的可靠性!)―― 利用Browser Helper Objects(BHO)。一個BHO是一個在 DLL中實現(xiàn)的COM對象,它主要實現(xiàn)了一個IObjectWithSite接口,而每當IE運行時,它會自動加載所有實現(xiàn)了該接口的COM對象。
四、攔截機制
在鉤子應用的系統(tǒng)級別方面,有兩類API攔截的機制――內(nèi)核級的攔截和用戶級的攔截。內(nèi)核級的鉤子主要是通過一個內(nèi)核模式的驅(qū)動程序來實現(xiàn),顯然它的功能應該最為強大,能捕捉到系統(tǒng)活動的任何細節(jié),但難度也較大,不在本文的探討范圍之內(nèi)(尤其對我這個使用Delphi的人來說,還沒涉足這塊領域,因此也無法探討,呵呵)。
而用戶級的鉤子則通常是在普通的DLL中實現(xiàn)整個API的攔截工作,這才是此次重點關注的。攔截API函數(shù)的調(diào)用,一般可有以下幾種方法:
1. 代理DLL(特洛伊木馬
一個容易想到的可行的方法是用一個同名的DLL去替換原先那個輸出我們準備攔截的API所在的DLL。當然代理DLL也要和原來的一樣,輸出所有函數(shù)。但如果想到DLL中可能輸出了上百個函數(shù),我們就應該明白這種方法的效率是不高的,估計是要累死人的。另外,我們還不得不考慮DLL的版本問題,很是麻煩。
2.改寫執(zhí)行代碼
有許多攔截的方法是基于可執(zhí)行代碼的改寫,其中一個就是改變在CALL指令中使用的函數(shù)地址,這種方法有些難度,也比較容易出錯。它的基本思路是檢索出在內(nèi)存中所有你所要攔截的API的CALL指令,然后把原先的地址改成為你自己提供的函數(shù)的地址。
另外一種代碼改寫的方法的實現(xiàn)方法更為復雜,它的主要的實現(xiàn)步驟是先找到原先的API函數(shù)的地址,然后把該函數(shù)開始的幾個字節(jié)用一個JMP指令代替(有時還不得不改用一個INT指令),從而使得對該API函數(shù)的調(diào)用能夠轉(zhuǎn)向我們自己的函數(shù)調(diào)用。實現(xiàn)這種方法要牽涉到一系列壓棧和出棧這樣的較底層的操作,顯然對我們的匯編語言和操作系統(tǒng)底層方面的知識是一種考驗。這個方法倒和很多文件型病毒的感染機制相類似。
3.以調(diào)試器的身份進行攔截
另一個可選的方法是在目標函數(shù)中安置一個調(diào)試斷點,使得進程運行到此處就進入調(diào)試狀態(tài)。然而這樣一些問題也隨之而來,其中較主要的是調(diào)試異常的產(chǎn)生將把進程中所有的線程都掛起。它也需要一個額外的調(diào)試模塊來處理所有的異常,整個進程將一直在調(diào)試狀態(tài)下運行,直至它運行結(jié)束。
4.改寫PE文件的輸入地址表
這種方法主要得益于現(xiàn)如今Windows系統(tǒng)中所使用的可執(zhí)行文件(包括EXE文件和DLL文件)的良好結(jié)構――PE文件格式(Portable Executable File Format),因此它相當穩(wěn)健,又簡單易行。要理解這種方法是如何運作的,首先你得對PE文件格式有所理解。
一個PE文件的結(jié)構大致如下所示: 一般PE文件一開始是一段DOS程序,當你的程序在不支持Windows的環(huán)境中運行時,它就會顯示“This Program cannot be run in DOS mode”這樣的警告語句;接著這個DOS文件頭,就開始真正的PE文件內(nèi)容了,首先是一段稱為“IMAGE_NT_HEADER”的數(shù)據(jù),其中是許多關于整個PE文件的消息,在這段數(shù)據(jù)的尾端是一個稱為Data Directory的數(shù)據(jù)表,通過它能快速定位一些PE文件中段(section)的地址;在這段數(shù)據(jù)之后,則是一個 “IMAGE_SECTION_HEADER”的列表,其中的每一項都詳細描述了后面一個段的相關信息;接著它就是PE文件中最主要的段數(shù)據(jù)了,執(zhí)行代碼、數(shù)據(jù)和資源等等信息就分別存放在這些段中。
在所有的這些段里,有一個被稱為“.idata”的段(輸入數(shù)據(jù)段)值得我們?nèi)プ⒁猓摱沃邪恍┍环Q為輸入地址表(IAT,Import Address Table)的數(shù)據(jù)列表,每個用隱式方式加載的API所在的DLL都有一個IAT與之對應,同時一個API的地址也與IAT中一項相對應。當一個應用程序加載到內(nèi)存中后,針對每一個API函數(shù)調(diào)用,相應的產(chǎn)生如下的匯編指令:
JMP DWORD PTR [XXXXXXXX]
如果在VC++中使用了_delcspec(import),那么相應的指令就成為:
CALL DWORD PTR [XXXXXXXX]。
不管怎樣,上述方括號中的總是一個地址,指向了輸入地址表中一個項,是一個DWORD,而正是這個DWORD才是API函數(shù)在內(nèi)存中的真正地址。因此我們要想攔截一個API的調(diào)用,只要簡單的把那個DWORD改為我們自己的函數(shù)的地址,那么所有關于這個API的調(diào)用將轉(zhuǎn)到我們自己的函數(shù)中去,攔截工作也就宣告順利的成功了。這里要注意的是,自定義的函數(shù)的調(diào)用約定應該是API的調(diào)用約定,也就是stdcall,而Delphi中默認的調(diào)用約定是 register,它們在參數(shù)的傳遞方法等方面存在著較大的區(qū)別。
另外,自定義的函數(shù)的參數(shù)形式一般來講和原先的API函數(shù)是相同的,不過這也不是必須的,而且這樣的話在有些時候也會出現(xiàn)一些問題,我在后面將會提到。因此要攔截API的調(diào)用,首先我們就要得到相應的IAT的地址。系統(tǒng)把一個進程模塊加載到內(nèi)存中,其實就是把PE文件幾乎是原封不動的映射到進程的地址空間中去,而模塊句柄HModule實際上就是模塊映像在內(nèi)存中的地址, PE文件中一些數(shù)據(jù)項的地址,都是相對于這個地址的偏移量,因此被稱為相對虛擬地址(RVA,Relative Virtual Address)。
于是我們就可以從HModule開始,經(jīng)過一系列的地址偏移而得到IAT的地址。不過我這里有一個簡單的方法,它使用了一個現(xiàn)有的API函數(shù) ImageDirectoryEntryToData,它幫助我們在定位IAT時能少走幾步,省得把偏移地址弄錯了,走上彎路。不過純粹使用RVA從 HModule開始來定位IAT的地址其實并不麻煩,而且這樣還更有助于我們對PE文件的結(jié)構的了解。上面提到的那個API函數(shù)是在 DbgHelp.dll中輸出的(這是從Win 2K才開始有的,在這之前是由ImageHlp.dll提供的),有關這個函數(shù)的詳細介紹可參見MSDN。
在找到IAT之后,我們只需在其中遍歷,找到我們需要的API地址,然后用我們自己的函數(shù)地址去覆蓋它,下面給出一段對應的源碼:
procedure RedirectApiCall; var ImportDesc:PIMAGE_IMPORT_DESCRIPTOR; FirstThunk:PIMAGE_THUNK_DATA32; szWORD;
begin
//得到一個輸入描述結(jié)構列表的首地址,每個DLL都對應一個這樣的結(jié)構
ImportDesc:=ImageDirectoryEntryToData(Pointer(HTargetModule), true, IMAGE_DIRECTORY_ENTRY_IMPORT, sz);
while Pointer(ImportDesc.Name)<>nil do
begin //判斷是否是所需的DLL輸入描述
if StrIComp(PChar(DllName),PChar(HTargetModule+ImportDesc.Name))=0 then begin
//得到IAT的首地址
FirstThunk:=PIMAGE_THUNK_DATA32(HTargetModule+ImportDesc.FirstThunk);
while FirstThunk.Func<>nil do
begin
if FirstThunk.Func=OldAddressOfAPI then
begin
//找到了匹配的API地址 ……
//改寫API的地址
break;
end;
Inc(FirstThunk);
end;
end;
Inc(ImportDesc);
end;
end;
最后有一點要指出,如果我們手工執(zhí)行鉤子DLL的退出目標進程,那么在退出前應該把函數(shù)調(diào)用地址改回原先的地址,也就是API的真正地址,因為一旦你的 DLL退出了,改寫的新的地址將指向一個毫無意義的內(nèi)存區(qū)域,如果此時目標進程再使用這個函數(shù)顯然會出現(xiàn)一個非法操作。
五、替換函數(shù)的編寫
前面關鍵的兩步做完了,一個API鉤子基本上也就完成了。不過還有一些相關的東西需要我們研究一番的,包括怎樣做一個替換函數(shù)。 下面是一個做替換函數(shù)的步驟: 首先,不失一般性,我們先假設有這樣的一個API函數(shù),它的原型如下:
function SomeAPI(param1: Pchar;param2: Integer): DWORD;
接著再建立一個與之有相同參數(shù)和返回值的函數(shù)類型:
type FuncType= function (param1: Pchar;param2: Integer): DWORD;
然后我們把SomeAPI函數(shù)的地址存放在OldAddress指針中。接著我們就可以著手寫替換函數(shù)的代碼了:
function DummyFunc(param1: Pchar;param2: Integer): DWORD; begin ……
//做一些調(diào)用前的操作
//調(diào)用被替換的函數(shù),當然也可以不調(diào)用
result := FuncType(OldAddress) (param1 , param2);
//做一些調(diào)用后的操作
end;
我們再把這個函數(shù)的地址保存到NewAddress中,接著用這地址覆蓋掉原先API的地址。這樣當目標進程調(diào)用該API的時候,實際上是調(diào)用了我們自己的函數(shù),在其中我們可以做一些操作,然后在調(diào)用原先的API函數(shù),結(jié)果就像什么也沒發(fā)生過一樣。當然,我們也可以改變輸入?yún)?shù)的值,甚至是屏蔽調(diào)這個 API函數(shù)的調(diào)用。
盡管上述方法是可行的,但有一個明顯的不足:這種替換函數(shù)的制作方法不具有通用性,只能針對少量的函數(shù)。如果只有幾個 API要攔截,那么只需照上述說的重復做幾次就行了。但如果有各種各樣的API要處理,它們的參數(shù)個數(shù)和類型以及返回值的類型是各不相同的,仍然采用這種方法就太沒效率了。
的確是的,上面給出的只是一個最簡單最容易想到的方法,只是一個替換函數(shù)的基本構架。正如我前面所提到的,替換函數(shù)的與原先的API函數(shù)的參數(shù)類型不必相同,一般的我們可以設計一個沒有調(diào)用參數(shù)也沒有返回值的函數(shù),通過一定的技巧,使它能適應各種各樣的API函數(shù)調(diào)用,不過這得要求你對匯編語言有一定的了解。
首先,我們來看一下執(zhí)行到一個函數(shù)體內(nèi)前的系統(tǒng)堆棧情況(這里函數(shù)的調(diào)用方式為 stdcall),函數(shù)的調(diào)用參數(shù)是按照從右到左的順序壓入堆棧的(堆棧是由高端向低端發(fā)展的),同時還壓入了一個函數(shù)返回地址。在進入函數(shù)之前,ESP 正指向返回地址。因此,我們只要從ESP+4開始就可以取得這個函數(shù)的調(diào)用參數(shù)了,每取一個參數(shù)遞增4。另外,當從函數(shù)中返回時,一般在EAX中存放函數(shù)的返回值。
了解了上述知識,我們就可以設計如下的一個比較通用的替換函數(shù),它利用了Delphi的內(nèi)嵌式匯編語言的特性。
Procedure DummyFunc;
asm add esp,4 mov eax,esp//得到第一個參數(shù)
mov eax,esp+4//得到第二個參數(shù) ……
//做一些處理,這里要保證esp在這之后恢復原樣
call OldAddress //調(diào)用原先的API函數(shù) ……
//做一些其它的事情
end;
當然,這個替換函數(shù)還是比較簡單的,你可以在其中調(diào)用一些純粹用OP語言寫的函數(shù)或過程,去完成一些更復雜的操作(要是都用匯編來完成,那可得把你忙死了),不過應該把這些函數(shù)的調(diào)用方式統(tǒng)一設置為stdcall方式,這使它們只利用堆棧來傳遞參數(shù),因此你也只需時刻掌握好堆棧的變化情況就行了。如果你直接把上述匯編代碼所對應的機器指令存放在一個字節(jié)數(shù)組中,然后把數(shù)組的地址當作函數(shù)地址來使用,效果是一樣的。
六、后記
做一個 API鉤子的確是件不容易的事情,尤其對我這個使用Delphi的人來說,為了解決某個問題,經(jīng)常在OP、C++和匯編語言的資料中東查西找,在程序調(diào)試中還不時的發(fā)生一些意想不到的事情,弄的自己是手忙腳亂。不過,好歹總算做出了一個API鉤子的雛形,還是令自己十分的高興,對計算機系統(tǒng)方面的知識也掌握了不少,受益非淺。當初在寫這篇文章之前,我只是想翻譯一篇從網(wǎng)上Down下來的英文資料(網(wǎng)址為www.codeproject.com ,文章名叫“API Hook Revealed”,示例源代碼是用VC++寫的,這里不得不佩服老外的水平,文章寫得很有深度,而且每個細節(jié)都講的十分詳細)。
PS:多年以前曾在 Win 2K/XP 用 Delphi 6.0 中實現(xiàn)個基本的框架,也成功的作了攔截工作,可惜隨著我剛買的硬盤的夭折而灰飛煙滅了,可惡的JS!如今也再無當年的興致與激情了,僅剩這篇小記,載著我曾有的夢想。