用Go語言寫一個Windows的外掛(上)

亂入的Zeal發表於2017-07-19

本人在一家網際網路金融公司上班,對於一家網際網路金融公司,最基本的功能就是客戶入金和出金,而出金的穩定性是很重要的,出金不暢容易導致投資人恐慌,本文講的是出金,出金介面我們對接的是招商銀行的銀企直聯絡統,那麼銀企直連繫統是一個什麼樣的程式呢?

image.png

沒錯,這個程式是執行在Windows上的,並且需要插入USBKey才能正常工作,這就意味著,不能簡單的使用命令列進行運維管理。

看到這裡,做運維的同學的內心應該和我一樣是崩潰的。。

image.png

跟大家解釋一下,這個服務是做什麼的,大家可以把這個程式當成是我們的業務系統和招商銀行溝通的信使,所有出金操作、查詢操作都是通過這個信使來完成。

由於各種未知的原因,比如網路不穩定,或者USBKey插入時間過長產生了一些莫名其妙的錯誤,那麼就需要人工去重啟一下服務或重新登入一下賬號,而且,這個工作有時候是在夜間操作的,這相當於要24小時待命啊,雖然故障頻率不高,但這根弦始終是崩著的,這簡直就是在破壞我的幸福美好生活啊。

image.png

這種體力活的事情,我堅決不能幹,所以一定要交給別人幹。

image.png

別想多了,【別人】也只能是個外掛而已,誰都不喜歡幹這種人肉體力活。

所以憑藉著我18歲那年的開發經驗,腦子裡想到了 Windows 的訊息模型,使用 SendMessage 給對應的窗體控制元件控制程式碼傳送特定的事件不就搞定了麼,異常自動重啟使用 CreateProcess 不就行了嗎?

天真的我腦子裡已經充滿了 SendMessage 的語句

LRESULT WINAPI SendMessage(
  _In_ HWND   hWnd,
  _In_ UINT   Msg,
  _In_ WPARAM wParam,
  _In_ LPARAM lParam
);

有木有很熟悉的樣子,驚不驚喜,開不開心?是不是感覺傳送鍵盤點選事件、滑鼠點選事件就OK了?

後面會講到,其實還需要很多工作才能完成一個比較完善可用的外掛軟體,SendMessage 基本上只能解決一部分問題

然而當我想完這些程式碼後,感覺還是太麻煩,因為按鍵精靈這類軟體就能解決,為什麼還要自己親自操刀?不過最終放棄了這種念頭,因為這是一個很重要的服務,說不定在未來會掌握好 幾千個億 的資金命運,如果安裝了不明軟體,資金安全如何得以保障???絕對不能這麼草草的做這種決定,所以還是決定老老實實的擼程式碼了。。。

用什麼語言是個問題,在Windows上可以使用 C++ , C# 系列,而且C#我記得有一個automation框架可以完成類似的操作,不過本人最近這3年一直在使用 golang,前兩種語言目前也只是偶爾用用的節奏,所以基本處於手生的狀態,而 golang 本身也支援使用 syscall 來呼叫 windowsDLL(動態連結庫),所以果斷使用 golang, 因為這個外掛大部分的WinAPI都在 user32.dllkernel32.dll 裡,我們只需要能載入這幾個DLL 就可以呼叫強大的 WinAPI

image.png

大家可以使用 PE Explorer 檢視一個DLL有哪些輸出函式

var (
    moduser32 = syscall.NewLazyDLL("user32.dll")
    procSendMessage = moduser32.NewProc("SendMessageW")
    procPostMessage = moduser32.NewProc("PostMessageW")
)

func SendMessage(hwnd HWND, msg uint32, wParam, lParam uintptr) uintptr {
    ret, _, _ := procSendMessage.Call(
        uintptr(hwnd),
        uintptr(msg),
        wParam,
        lParam)
    return ret
}

大家可以看到,在這裡我們使用的是SendMessageW,而不是SendMessageA,因為go語言底層呼叫DLL介面時,傳入的是utf16,看看下面的程式碼就明白了

func SetWindowText(hwnd HWND, text string) {
    procSetWindowText.Call(
        uintptr(hwnd),
        uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(text))))
}

這是一個設定窗體標題的API,第一個引數是窗體控制程式碼,第二個引數大家可以看到,是將go語言的字串轉換成UTF16格式,並獲取其指標。

另外值得注意的是,如果我們編譯出來的程式是32位的,那麼儘量不要用來作為64位程式的外掛,因為有很多複雜一點的功能無法實現,後續會提到這個部分,銀企直連 這個服務是32位的,因此我們的go語言也是安裝的32位的,同時為了更好的編譯測試,我的虛擬機器裝的是 Win2008 R2 32位 作業系統

那麼我們應該如何向一個窗體傳送訊息呢?能不能先做實驗,不寫程式碼呢?答案是肯定的,我們先請出我們的神器,Spy++

image.png

將瞄準器拖拽到具體的視窗上,就會得到視窗的控制程式碼,我們可以通過 FindWindowW 或 EnumChildWindows 來實現相同的功能

銀企直連正常工作需要兩個步驟

  1. 啟動HTTP服務監聽
  2. 登入

我們先看看啟動HTTP監聽按鈕

image.png

我們使用spy++抓到了這個ToolBar的控制程式碼

image.png

然後用 spy++ 向第一個按鈕傳送滑鼠點選事件,那麼就可以開啟監聽了

image.png

點選動作在Windows訊息來看,是分為兩個動作,一個是 WM_LBUTTONDOWN 而另一個是 WM_LBUTTONUP ,所以我們需要傳送兩次事件,當完成這兩次傳送後,我們可以看到下面的介面

image.png

沒錯,其實這裡是一個坑,啟動監聽還不好好啟動,非得彈出一個訊息框,同時伴隨著的是spy++卡死了,為什麼呢? 因為我們使用的是SendMessage,這是一個同步的過程,因為出現了訊息框,所以spy++還未收到返回訊息,所以就卡死了。當我們點選完 確認 按鈕後就可以恢復了,當然我們也可以使用 PostMessage ,不過這個介面只適合不在乎執行結果的情況下執行。

好了,這裡我們出現了第一個坑:有彈窗,我們的外掛需要自動識別,並且能夠自動關閉彈窗。

image.png

OK, 我們繼續,我們該開始登陸了

image.png

剛才我們 SendMessage 裡的WPARAM是1,那麼,這個按鈕是4

image.png

繼續使用 spy++ 傳送訊息

image.png

模擬完傳送,整個人一下子就不好了,因為這個按鈕根本就沒有反應,後面的兩個引數你也不知道到底傳什麼好,就在陷入了整個困局的時候,發現我們其實可以通過快捷鍵 ctrl+b 完成監聽, ctrl+i 進入登入介面

image.png

此時未插入USBKey

所以,我們需要使用另外一個API: SendInput, 包括後面的密碼輸入,也一樣要使用這個API

我們看一下這個API的定義

UINT WINAPI SendInput(
  _In_ UINT    nInputs, // 按鍵數量
  _In_ LPINPUT pInputs, // 按鍵內容陣列
  _In_ int     cbSize // 陣列內容結構體的尺寸
);

看上去很心塞,一堆引數。

image.png

由於本文講解的是調研篇,我們此處假設SendInput可以完成快捷鍵的按鍵模擬,密碼輸入的按鍵模擬,實際上這個API確實是可以工作的,因為這個介面是真實的模擬鍵盤輸入,不針對某個視窗控制程式碼。

接下來我們會迎來第二個坑,如果USBKey正常工作,那麼使用者名稱裡的的內容是自動填寫好的,如圖:

image.png

這個使用者名稱是從USBKey裡讀出來的,讀取是需要時間的,因此我們可以在這裡不停的向這個文字框傳送WM_GETTEXT 訊息,拿到使用者名稱,如果使用者名稱是預期的資料,我們就認為此時USBKey是正常工作的,否則如果長時間使用者名稱未成功載入,則說明USBKey工作異常,應該傳送報警資訊。

image.png

image.png

image.png

我們大概會得到如下幾類錯誤

  • 密碼錯誤
  • 通訊故障
  • USBKey有問題

對於密碼錯誤這個問題,我們的外掛應該立即停止工作,因為密碼輸入次數超過限制,USBKey將會鎖定,公司出金服務就掛了。。。。

image.png

為什麼會密碼輸入錯誤呢?因為很有可能在自動輸入時,被其他程式干擾了一下 我們在程式碼中會盡量用 SetForegroundWindow 讓視窗保持在最前面,成為啟用狀態

那麼對於通訊故障,解決的辦法就只能是重新嘗試了

剩下的問題,我個人認為發出報警,人工處理一下會比較合適。

此時迎來兩個新問題, 1. 我們如何知道訊息框裡的內容是什麼 2. 我們如何知道外掛登入成功了呢?

對於第一個問題,我們可以通過 EnumChildWindows 來遍歷這個訊息框的孩子控制程式碼,然後通過 GetWindowText 就可以知道是什麼內容了。

我們重點來討論第二個問題

此處有兩種解法:

  1. 向招行發起查詢請求,如果能查詢到資料,說明登入成功
  2. 檢查登陸資訊裡的內容

image.png

登陸資訊列表

為了提升難度,我們選擇方案2

image.png

這種方法是比較困難的,有困難,我們要解決,沒有困難我們也要創造困難來解決。。。。

為什麼難呢?

因為我們沒辦法通過SendMessage 傳送 WM_GETTEXT 事件獲取內容,但是我們可以通過 LVM_GETITEMTEXT 來獲取 listview 的列表內容

BUT..... 跨程式這麼拿是拿不到的,同時,不同位數的程式,也是拿不到資料的。

如何解決?

我們需要使用API VirtualAllocEx 向銀企直聯程式申請一塊記憶體空間,用於我們的外掛程式和銀企直聯進行資料溝通,當我們傳送 LVM_GETITEMTEXT 訊息之前,我們需要把引數資訊寫到這個記憶體塊裡,然後再使用SendMessage,ListView的資料會寫到這個記憶體塊,最後我們通過 ReadProcessMemory 來讀取獲取到列表的資料

這裡就是為什麼32位不能讀64位程式的內容的原因了,雖然我們可以使用WriteProcessMemoryReadProcessMemory 來寫入和讀取程式記憶體裡的資料,但是由於通過這種機制進行互動,指標大小是不同的,通過SendMessage指令雖然能執行成功,但是回寫的資料內容會跑飛。

image.png

箭頭代表資料流向,所有的API呼叫都是在外掛這邊完成的

整個流程大概就是這樣的,我們需要藉助遠端程式的記憶體塊來做資料互動,但最後切記一定要使用VirtualFreeEx 釋放掉不用的記憶體塊。

此處應該有總結:
  1. 使用模擬鍵盤的方法開啟監聽和進入到登入介面而非SendMessage
  2. 通過遠端申請記憶體塊的方式獲取登入結果內容
  3. 需要判斷彈出訊息框的內容,用以判斷是否有異常,同時需要關閉這些訊息視窗

到此為止,關鍵的技術內容我們已經調研完了,下一篇內容我們會講如何使用go語言實現一個真正可用的外掛。 我們先來預覽幾個外掛的截圖吧:

外掛工作中.....

image.png

當發生穩定性異常時,會通過bearychat的Incoming服務傳送報警

image.png

image.png

相關文章