由愛到痛
有道云筆記是個好東西,在認識它之前,我一直使用Windows記事本來保存網上摘抄的文檔資料和學習心得體會。某天朋友推薦了有道云筆記,我安裝后就不可收拾的愛上了它。那種感覺,就好比一夜之間手扶拖拉機換成了奧迪Q7,從此駛上了碼字界的康莊大道。
可就在我對它的愛如火如荼的進行中時,一件痛心疾首的事情發(fā)生了。

宋體,是我鐘愛的字體,而有道云筆記鐘愛的字體則是微軟雅黑。就是那么一個興趣愛好的不同,使我們之間產生了矛盾,并不斷被激化,最終影響到了工作和生活,以至于之后一度要和它分手。
問題是這樣的,如上圖所示,當我正襟危坐的打開有道云筆記開始寫點東西的時候,我先將字體設置為宋體,然后開始打字。當我打下了“現(xiàn)在是宋體”這幾個字后,接下來我需要從網上摘抄一句話,于是便從Chrome中的網頁上復制出了“我走過最長的路就是你的套路”這段話,然后到有道云筆記中粘貼。我想要的效果是,粘貼后,讓這句話和當前的字體格式一致,于是便使用純文本粘貼,沒想到,無論是先點右鍵,再點純文本粘貼,還是Ctrl+Shift+V,粘貼后,這段文字一定會變成微軟雅黑?。∵@完全是一個BUG,并不是我的用法有問題,在Word中根本就不會這樣。雖然微軟雅黑是微軟的小兒子,但是微軟絕不會讓自己的大兒子Word干出這種溺愛的事情。
至于直接粘貼,效果如同第二行,帶顏色帶下劃線,和網頁中的格式字體一樣,更不是我想要的結果。我就是單純的想把網頁上拷貝下來的文字,粘貼為宋體,就有那么難嗎?技術文章和筆記的寫作,往往需要頻繁引用和拷貝網上的各種文檔、代碼片段,現(xiàn)在有道云筆記的這種情況,讓我完全無法寫下去。要么就妥協(xié),和宋體說拜拜,通篇文字使用微軟雅黑來寫,要么還是妥協(xié),每次粘貼后,再拉選這段文字,改為宋體。不得不說,這樣使用體驗,讓人很心累。
我們談談吧
都說溝通是解決問題的最好方式,于是我便嘗試和有道云筆記溝通一下這個問題。于是我在有道云筆記反饋頁面上反饋了該BUG。

沒過兩天,于2016年9月26日,有道云筆記客服就給我發(fā)來了郵件,說這是一個已知的問題,將在后續(xù)版本中進行修復。我心里頓時有了一絲愉悅,覺得網易的這波辦事效率,還是可以的。于是我便在等待的過程中,繼續(xù)用麻煩的辦法,將復制來的文字一句一句改成宋體。
但接下來結果,讓我從期待變成了失望,最終演變成了憤怒。
2016年10月24日,時隔近一個月,總算等來了有道云筆記的更新,而且這次是大更新,版本號直接從4.12變成了5.0。我滿心歡喜的下載更新了新版本,然而測試結果卻給了我當頭一棒,這個微軟雅黑的BUG依然存在。
我不灰心,我是一只打不死的小強,我繼續(xù)反饋,不過與上次反饋不同,這次反饋后并沒有收到郵件回復,我不放心,除了在網頁上反饋,我還給其之前回復我的郵箱發(fā)送了一封郵件。
2016年12月13日,在苦苦的等待與煎熬中,終于又迎來了有道云筆記的一次更新,版本號從5.0變成了5.5。依然是滿懷期待的下載更新,然而又吃了當頭一棒,微軟雅黑的BUG依然存在。
在接來下的日子里,我不停在問題反饋頁面、郵箱、在線客服三種渠道上反復反饋該BUG,希望能引起重視,因為這真的很影響使用。
結局是悲催的,盡管后來又有一些更新,但這個BUG,直到2017年4月22日的今天,依然沒有修復。
不放棄不拋棄
在這期間多次想過和它分手,但是試用了其它同類產品,如為知筆記、印象筆記,都有讓人不如意的地方。依然繼續(xù)用有道云筆記,每次復制粘貼弄的想發(fā)火的時候,都對自己說,咬咬牙,再忍一忍,說不定明天會更好呢。而且不知不覺在有道上積累了大量的文章,想搬遷也不容易了。
很久以前我一直覺得,不要試圖去改變一個人,要么改變你自己,要么離開。
直到我有了生命中的第一個女朋友,她是一個漂亮、可愛,充滿陽光的女孩子,和她在一起每天都對生活充滿希望,看著她走在我前面蹦蹦噠噠開心的樣子,一切煩惱都煙消云散。
盡管我們的興趣愛好有很大不同,性格上也有一些差異,盡管我們身上都有彼此討厭的一些缺點。但我一直記著她說的那句話:
沒有天生合適的兩個人,需要的是彼此包容理解與改變。
是的,我們既要為彼此做出改變,也要幫助對方塑造一個更好的自己,這樣不是很好么。
現(xiàn)在的我不會輕易說離開。
停止抱怨,冷靜分析
抱怨是解決不了問題的,既然要做出改變,就要靜下心來分析問題根源所在,并尋找解決方案。
在之前的測試中發(fā)現(xiàn),無論粘貼的來源帶不帶格式,只要粘貼為純文本,一定會變成微軟雅黑。說明粘貼為純文本的功能代碼上出現(xiàn)了BUG。一個簡單的思路是使用OD跟進去調試,找到改字體的代碼,在粘貼為純文本時,跳過改字體相關代碼的調用。如何在OD中找到粘貼為純文本功能的代碼,首先想到的是既然要粘貼,有道云筆記肯定會去讀剪貼板,而Windows中讀剪貼板的API是GetClipboardData,只需在OD中對該API下斷點很容易就可以找到粘貼為純文本的實現(xiàn)代碼。不過反匯編代碼看起來實在太頭疼,本著能偷懶就偷懶的思想,還是應該優(yōu)先尋求非逆向的實現(xiàn)方案。
思考一下有沒有什么變通的方法實現(xiàn)我要的效果,實際上我想要的效果就是,無論哪里來的內容,統(tǒng)統(tǒng)給我粘貼為純文本,不要亂改我設置好的字體,我設置的是什么字體格式,粘貼后的文本字體格式就保持和當前上下文一致。
既然有道云筆記的粘貼為純文本功能有BUG,那么直接使用粘貼功能,能不能實現(xiàn)我要的效果呢?
當然是可以的,而且更方便,直接按Ctrl+V就行了,不用按Ctrl+Shift+V,但是有個前提,就是粘貼來源本來就是純文本。
可是我的粘貼來源都是直接從網頁上復制的,怎么可能不帶格式呢?基本都不是純文本吧。
當然可以,只是要進行一個額外操作,先把網頁上復制的內容粘貼到Windows記事本里,然后再復制一遍,再粘貼到有道云筆記里。這樣文本在Windows記事本里過了一遍,格式就丟掉了。
好想法,那么只要編寫一個小程序,監(jiān)聽剪貼板,一旦發(fā)現(xiàn)我從網頁上復制了帶格式的新內容,就對其進行處理,去掉格式,這樣我在有道云筆記中Ctrl+V的時候,就是純文本了。
這個思路可以是實現(xiàn)我要的效果,但是會影響到其它軟件,比如你想帶格式粘貼到Word中時怎么辦?而且這樣一來你這臺電腦上,再也無法復制粘貼帶格式的文本了,嚴重影響其它軟件的使用。剪貼板不是你一個人的,電腦上其它軟件也要用,不能亂改剪貼板的內容。
是的,不能影響全局,剪貼板是大家的。那么我有沒有辦法只讓有道云筆記這個軟件讀剪貼板的時候,永遠讀到的都是不帶格式的純文本的內容,這樣Ctrl+V就是純文本了。而其它如Word的軟件是正常的,剪貼板里是什么就讀到什么。
當然可以,使用API HOOK就可以實現(xiàn),Hook住有道云筆記讀剪貼板的API,改掉內容就行了。
這么搞好像游戲外掛一樣,注入DLL、API HOOK、改內存之類的操作,讓我想到了變速齒輪,它就是Hook了獲取時間相關的API,給目標程序提供了錯誤的時間,讓目標程序以為世界都變快了。
是的,善意的謊言讓它的世界更美好。
思路已定,那簡單了,直接祭出大殺器API Monitor,簡單粗暴,快速有效。直接分析有道云筆記在粘貼為純文本時調用了哪些WindowsAPI,設置過濾器只關注和剪貼板相關的API,分析如下:
當粘貼帶格式的文本到有道云筆記時:

當粘貼不帶格式的純文本到有道云筆記時:

一經對比,很快就能找出不同之處。有道云筆記注冊了名為"HTML Format"的剪貼板格式,實際上這是一種使用HTML表示富文本的通用格式,從瀏覽器中拷貝出來的文本正是這種格式。
對比兩次粘貼,當粘貼帶格式的文本時,有道云筆記詢問操作系統(tǒng)關于剪貼板的內容:
RegisterClipbardFormatW("HTML Format")
我要注冊"HTML Format"這種格式
49381
注冊好了,ID是49381,拿去吧
IsClipboardFormatAvailable(49381)
現(xiàn)在剪貼板里面的東西是“HTML Format”這種格式嗎?
TRUE
是的
GetClipboardData(49381)
我要獲取剪貼板里“HTML Format”這種格式的內容
0x0d42bd18
好的,獲取了,存在這個地址處了
當粘貼不帶格式的純文本時,有道云筆記是這樣和操作系統(tǒng)對話的:
RegisterClipbardFormatW("HTML Format")
我要注冊"HTML Format"這種格式
49381
注冊好了,ID是49381,拿去吧
IsClipboardFormatAvailable(49381)
現(xiàn)在剪貼板里面的東西是“HTML Format”這種格式嗎?
FALSE
不是
IsClipboardFormatAvailable(CF_TEXT)
那好吧,那現(xiàn)在剪貼板里面的東西是CF_TEXT(純文本)這種格式嗎?
TRUE
是的
GetClipboardData(CF_UNICODETEXT)
那好吧,我要獲取剪貼板里的純文本內容,以CF_UNICODETEXT(Unicode文本)形式給我
0x0d3d01d8
好的,獲取了,存在這個地址處了
區(qū)別在于:
當剪貼板中是帶格式的文本時,IsClipboardFormatAvailable(49381)返回了TRUE
當剪貼板中是不帶格式的純文本時,IsClipboardFormatAvailable(49381)返回了FALSE
那好辦!我們只需要寫一個DLL,注入到有道云筆記進程中,Hook IsClipboardFormatAvailable這個API,當有道云筆記詢問是不是“HTML Format”這種格式時,我們就用于告訴它,不是!!這樣一來,它永遠都只會去獲取純文本,從而,我們Ctrl+V粘貼到有道云筆記中的文本,永遠都是純文本!
是的,但最好讓用戶可以控制,設置一個開關,當開啟時,會改變有道云筆記,讓它讀剪貼板讀到的永遠是純文本,當關閉開關時,一切恢復正常,帶格式的就是帶格式,粘貼后依然帶格式。讓用戶自主選擇更棒,因為像我這樣的用戶,基本上永遠都只會粘貼為純文本,網頁上拷貝過來的格式,幾乎都要去掉的,否則怎么融入到我文章上下文中,但是Ctrl+Shift+V用起來很不順手(何況目前還有微軟雅黑的BUG),只用Ctrl+V多方便。
是的,我們可以在注入的DLL的DllMain中啟動一個線程,使用RegisterHotKey注冊一個熱鍵,比如Ctrl+Q,然后啟動消息循環(huán)來接收WM_HOTKEY消息,啟用或關閉API Hook來實現(xiàn)上述的開關。
行動
明確本次行動的目標:
- 修復純文本粘貼就變成微軟雅黑字體的BUG
- 增加功能,加一個開關,開啟后粘貼的內容永遠是純文本,不管是從哪里復制來的
思路有了,解決問題的辦法也想出來了,只差行動了,我們不能做思想上的巨人,行動上矮子,既然是男人,說干就干!準備好趁手的工具,直接開車!
DLL注入方式使用遠程線程注入,這種方式比較經典、簡單。
API HOOK技術使用IAT HOOK,這種Hook方式多線程下穩(wěn)定可靠。API HOOK庫我選擇的是《Windows核心編程》的隨書示例代碼中的CAPIHook。也可以使用強大的WinAPIOverride或微軟的Detours等。當然手寫Inline Hook也是可以的,代碼超簡短,由于有道云筆記訪問剪貼板時不存在多線程并發(fā)訪問情況,Inline Hook也是沒有問題的。
新建一個Win32動態(tài)庫項目取名YNotePatch,關鍵代碼如下:
#include <windows.h>
#include "APIHook.h"
static UINT g_format = 0;
static bool g_switch = true;
UINT __stdcall My_RegisterClipboardFormatW(LPCWSTR lpszFormat)
{
UINT ret = RegisterClipboardFormat(lpszFormat);
if (wcscmp(lpszFormat, L"HTML Format") == 0)
g_format = ret;
return ret;
}
BOOL __stdcall My_IsClipboardFormatAvailable(UINT format)
{
BOOL ret = IsClipboardFormatAvailable(format);
if (g_switch && format == g_format)
ret = FALSE;
return ret;
}
void WorkThread(void *param)
{
const UINT Q_KEY = 0x51;
if (!RegisterHotKey(NULL, GlobalAddAtom(L"MyHotKey"), MOD_CONTROL | MOD_NOREPEAT, Q_KEY))
return;
MSG msg = { 0 };
while (GetMessage(&msg, NULL, 0, 0) != 0)
{
if (msg.message == WM_HOTKEY)
{
if (g_switch)
g_switch = false;
else
g_switch = true;
}
}
}
CAPIHook hooker_RegisterClipboardFormatW("User32.dll", "RegisterClipboardFormatW", reinterpret_cast<PROC>(My_RegisterClipboardFormatW));
CAPIHook hooker_IsClipboardFormatAvailable("User32.dll", "IsClipboardFormatAvailable", reinterpret_cast<PROC>(My_IsClipboardFormatAvailable));
新建一個Win32應用程序項目取名YNoteStarter,寫一個EXE作為啟動器,用于啟動有道云筆記主程序后注入DLL:
#include "YNoteStarter.h"
#include <windows.h>
#include <TlHelp32.h>
#include <tchar.h>
#include <string>
using std::wstring;
DWORD StartProcess(const wstring &app_name, const wstring &cmd)
{
STARTUPINFO start_info = { sizeof(start_info) };
PROCESS_INFORMATION process_info = { 0 };
if (!CreateProcess(app_name.c_str(), (LPWSTR)cmd.c_str(), NULL, NULL, FALSE, NULL, NULL, NULL, &start_info, &process_info))
return 0;
WaitForInputIdle(process_info.hProcess, INFINITE);
CloseHandle(process_info.hThread);
CloseHandle(process_info.hProcess);
return process_info.dwProcessId;
}
bool InjectModule(DWORD process_id, const wstring &module_name)
{
//獲取要注入的模塊絕對路徑
wchar_t self_path[MAX_PATH + 1] = { 0 };
GetModuleFileName(NULL, self_path, MAX_PATH);
wcsrchr(self_path, L'\\');
wstring inject_module_path = self_path;
size_t last_backslash = inject_module_path.rfind(L'\\');
if (last_backslash == wstring::npos)
return false;
inject_module_path = inject_module_path.substr(0, last_backslash + 1);
inject_module_path += module_name;
if (_waccess(inject_module_path.c_str(), 0) != 0)
return false;
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, FALSE, process_id);
if (process == NULL)
return false;
//在目標進程中分配內存并寫入待注入模塊的路徑
int mem_size = (inject_module_path.length() + 1) * sizeof(wchar_t);
void *module_name_buffer = VirtualAllocEx(process, NULL, mem_size, MEM_COMMIT, PAGE_READWRITE);
if (module_name_buffer == NULL)
{
CloseHandle(process);
return false;
}
if (!WriteProcessMemory(process, module_name_buffer, inject_module_path.c_str(), mem_size, NULL))
{
VirtualFreeEx(process, module_name_buffer, mem_size, MEM_RELEASE);
CloseHandle(process);
return false;
}
//創(chuàng)建遠程線程
HMODULE kernel_module = GetModuleHandle(L"kernel32.dll");
LPTHREAD_START_ROUTINE start_function_addr = reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(kernel_module, "LoadLibraryW"));
HANDLE remote_thread = CreateRemoteThread(process, NULL, 0, start_function_addr, module_name_buffer, 0, NULL);
if (remote_thread == NULL)
{
VirtualFreeEx(process, module_name_buffer, mem_size, MEM_RELEASE);
CloseHandle(process);
return false;
}
WaitForSingleObject(remote_thread, INFINITE);
CloseHandle(remote_thread);
VirtualFreeEx(process, module_name_buffer, mem_size, MEM_RELEASE);
CloseHandle(process);
return true;
}
int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
//奇葩的有道,不帶show參數(shù)就會重啟自身進程
DWORD process_id = StartProcess(L"YoudaoNote.exe", L" show");
if (process_id == 0)
{
MessageBox(NULL, L"無法啟動YoudaoNote.exe", L"提示", MB_OK);
return 1;
}
if (!InjectModule(process_id, L"YNotePatch.dll"))
{
MessageBox(NULL, L"無法注入YNotePatch.dll", L"提示", MB_OK);
return 2;
}
return 0;
}
訪問 Github https://github.com/charlessimonyi/YNotePatch 查看src和bin
總結
是的,在沒有程序源碼的情況下要給一個已經編譯好的Native程序修復BUG,增加、修改功能往往就是這么做的。把我們的代碼編譯成DLL注入進去執(zhí)行,這種方式稱為打內存補丁。也可以在目標進程中分配內存,直接用WriteProcessMemory把機器碼寫進去讓它執(zhí)行,也可以把整個DLL復制到這塊內存中,不過需要處理導入表和重定位,比較麻煩。當然也可以打文件補丁,直接修改它的PE文件,不過只改幾行指令還好,如果要大量注入代碼,也是比較麻煩的,而且萬一目標EXE有加殼有壓縮或者有完整性校驗,就走不通了。注入DLL其實是最簡單的,注入后我們就可以在它的進程空間內為所欲為,動它的窗口,攔截和修改它的窗口消息,改內存,改變量的值,改目標代碼的跳轉流程,替換目標代碼,配合VirtualProtect,沒有什么是不能動的。當然最大難點還是在于該改什么,什么能改什么不能改,改什么才能實現(xiàn)想要的效果,需要花時間慢慢分析。
新生活
至此,總算可以舒服的使用有道云筆記了,使用YNoteStarter啟動有道云筆記,任何文本內容,不管從哪里復制來的,Ctrl+V后都是純文本,實在是爽哉。使用過程中按Ctrl+Q關閉補丁,恢復本色,帶格式的文本粘貼后就是帶格式的。
好景不長
可是好景不長,沒過幾天有道云筆記的富文本編輯器就被我拋棄了,果斷擁抱Markdown。
?
?
?
?
?
本文由CharlesSimonyi發(fā)表于CSDN博客:http://blog.csdn.net/CharlesSimonyi/article/details/70344604轉載請注明出處