【警告】本文要介紹的技巧很有趣,在某些情境可以巧妙解決刁鑽問題,令人拍案叫絕。
但對開發來說這類手法如同雙面刃,帶有嚴重副作用,常導致程式邏輯不易理解且難以維護。想像一下,接手程式的人追了三天三夜,百思不得其解,最後才發現有人他 X 的偷改某個 System.* 方法的傳回值,下一秒就算沒有抄起棒球棍,也會拿出稻草人跟大頭針。
遇問題時應優先考量其他正規解法(改用自訂 API、繼承改造、Fork 改寫...),此等非常手段宜謹慎使用。

在討論將 Playwright 程式部署問題時,由於每支程式需自帶一份 68MB .playwright 資料夾,內容重複頗浪費空間。但 Playwright 程式庫不提供參數自訂 .playwright 路徑,無法多支程式共用一份。若要解決,要嘛 Fork Microsoft.Playwright 專案寫個可傳參數版本,要嘛用點駭客手法,強迫 Playwright 程式庫共用路徑。

前者沒什麼技術含量也不有趣,於是我情不自禁研究起怎麼用駭客技巧解決,結果發現好東西。先來看一段展示:

如上圖,施展某段邪惡魔法後,原本該輸出亂數的 Random.Next(),居然傳回 1, 2, 3, 4, 5。

我們都知道 .NET 程式會編譯成通用中間語言 CIL (過去稱為 MSIL),再由 JIT 編譯器轉成機器碼交給 CPU 執行。

System.Reflection.Emit 命名空間有一套 API 可動態生成 CIL 程式碼, 透過一些特殊技巧將 Random.Next() 導向我們寫的 CIL 函式,我們就能接管 Random.Next() 換上自訂邏輯。概念不難,但實作細節很多,而且還要懂 IL 語言才能成功。

於是網路上有高手將這些操作包成簡單易用的程式庫,讓一般開發者不懂 IL 也能輕鬆改掉 .NET 系統 API 或外部程式庫方法。其中有個 Harmony 開源程式庫,得到近 3.8K 顆星星,還有蠻完整的文件網站, 有 System.Reflection 基礎的開發者應能很快上手。

Harmony 可以在執行階段對 .NET 方式進行修補(Patch)、置換或加料(Decorate),運作概念如下:

五個切入方式

  1. Prefix
    在原始方法執行前執行,可以修改參數、設定結果、跳過原始方法、設定自訂狀態變數給 Postfix 使用
  2. Postfix
    在原始方法執行後執行
  3. Transpiler
    接收原始方法的 IL Code,加工產生新的 IL Code。要處理 IL Code 難度較高但彈性最大,通常是 Prefix/Postfix 無法處理才上場。
  4. Finalizer
    處理 Exception
  5. Reverse Path
    在你的方法中呼叫原始方法

在一般情況下用 Prefix 及 Postfix 就能滿足需求,回到一開始偷改 Random.Next() 範例,來看看 HackRandomNextApi() 是怎麼做的:

internal class Demo
{
    public static void HackRandomNextApi()
    {
        // 建立 Harmony 物件,傳入反向順序網域名稱作為識別,供後續管理之用
        var harmony = new HarmonyLib.Harmony("net.darkthread.blog.harmony.example");
        // 對 Random.Next() 方法進行修補,加上 Prefix 方法
        harmony.Patch(
            typeof(Random).GetMethod(nameof(Random.Next), BindingFlags.Instance | BindingFlags.Public, new Type[] {}),
            prefix: new HarmonyLib.HarmonyMethod(typeof(Demo).GetMethod(nameof(MyRandomNext),
                BindingFlags.Static | BindingFlags.NonPublic))
        );
        // 可使用 AccessTools Helper 簡化寫法
        // https://harmony.pardeike.net/articles/utilities.html
        /*
        harmony.Patch(
            AccessTools.Method(typeof(Random), nameof(Random.Next), new Type[] { }),
            prefix: new HarmonyLib.HarmonyMethod(AccessTools.Method(typeof(Demo), nameof(MyRandomNext)))
        );
        */

        // 或使用 [HarmonyPatch(typeof(Math))] [HarmonyPatch(nameof(Math.Max))] 
        // https://harmony.pardeike.net/articles/annotations.html
    }
    static int i = 1;
    static bool MyRandomNext(ref int __result)
    {
        // __result 為原始方法的回傳值
        __result = i++;
        // 傳回 false 不執行原始方法
        return false;
    }
}

呼叫 Patch() 指定要修改的方法,原始方法、Prefix、Postfix 都需要傳入 MethodInfo 當參數,AccessTools Helper 可簡化 Reflection 取得 MethodInfo 的寫法。除了透過 Reflection 取 MethodInfo,Harmony 也支援 或使用 [HarmonyPatch(typeof(Random))] [HarmonyPatch(nameof(Random.Next))] 或 [HarmonyPatch(typeof(Math), nameof(Math.Max))] 等標註方式自動對映。
參考

Prefix 部分可透過 ref int __result 參數修改回傳值,傳回 false 則可跳過不執行原始方法。

夠簡單吧!

下面是另一種應用方式,我們讓 Prefix、Postfix 搭配運作。像是在 Prefix 建立 Stopwatch 開始計時,並將其當成參數傳給 Postfix 計算執行時間;另外,Prefix 也可以偷改傳入參數,以下是一個範例,同時加入 Prefix 及 Postfix,二者用 Stopwatch 計算 File.WriteAllText() 執行時間,Prefix 還會將原本要寫入 "d:\" 的檔案重導到 "d:\redirect" 目錄下。

static void Main(string[] args)
{
    Action<string> printTitle = (msg) => {
        Console.ForegroundColor = ConsoleColor.Yellow;
        Console.WriteLine(msg);
        Console.ResetColor();
    };
    var harmony = new Harmony("net.darkthread.blog.harmony.example");       

    printTitle("Prefix & Postfix Example");
    harmony.Patch(
        original: AccessTools.Method(typeof(File), nameof(File.WriteAllText), new Type[]
        {
            typeof(string), typeof(string)
        }),
        prefix: new HarmonyMethod(AccessTools.Method(typeof(Program), nameof(BeforeWriteFile))),
        postfix: new HarmonyMethod(AccessTools.Method(typeof(Program), nameof(AfterWriteFile)))
    );
    File.WriteAllText("d:\\short.txt", "Hello World");
    File.WriteAllText("d:\\long.txt", new string('A', 1024*1024*16));
}

private static void BeforeWriteFile(ref string path, string contents, out Stopwatch __state)
{
    // 宣告自訂狀態變數給 Postfix 使用
    __state = Stopwatch.StartNew();
    // 示範竄改參數,要修改的參數宣告加上 ref
    var dir = Path.GetDirectoryName(path);
    if (dir == "d:\\")
    {
        Directory.CreateDirectory("d:\\redirect");
        path = Path.Combine("d:\\redirect", Path.GetFileName(path));
    }
}

static void AfterWriteFile(string path, string contents, Stopwatch __state)
{
    __state.Stop();
    Console.WriteLine($"File.WriteAllBytes({path}, {contents.Length:n0} chars) took {__state.ElapsedTicks:n0} ticks");
}

原本要寫入 d:\ 的檔案被放到 d:\redirect\ 下,並會計算寫入耗費的時間:

是不是很像黑魔法?它的確像黑魔法,真要動用記得明顯標示妥善提醒,否則就是將接手者推入火坑,小心造業損陰德。

不過,修改底層屬較進階的技巧,精巧但也偏脆弱,常會因一些條件失敗(例如:目前不支援 .NET 7,且不適用 Interop 非 IL Body 方法、內容過短 Inlined 化的方法、泛型相關方法... 等),錯誤訊息又不直覺,要偵錯頗有難度。故 Harmony 雖已降低使用門檻,但不能算是傻瓜型工具,能不能用它玩出新花樣也看緣份吧。

Introduction the library to patching and replacing .NET method in runtime - Harmony library.


Comments

# by Victor Tseng

黑暗大終於要一頭栽進資安之坑了嗎?

# by Jimmy

這是某種惡搞前公司的新手法嗎.jpg

Post a comment