與其重寫協定,不如直接呼叫它#

當你已經透過逆向工程(第 6 章)搞懂協定的細節,很快就會意識到:如果能直接重用那份執行碼,就根本不必自己實作協定

最理想的情況是拿得到原始碼——專案本身開源,或實作用的是 Python 這類直譯式語言——那你可以重新編譯或直接把程式碼用在自己的應用裡。但當程式碼已經被編譯成二進位執行檔,選項就少得多了。

託管語言平台(managed language platform)如 .NET 與 Java 是最容易重用的,因為編譯後的程式碼裡有定義良好的 metadata 結構,新的應用程式可以直接對內部的類別與方法編譯。相對地,C/C++ 這類非託管(unmanaged)平台的編譯器不保證二進位檔裡的任何元件能被外部順利呼叫。

良好的 metadata 還帶來另一項能力:反射(reflection)——應用程式可以晚期繫結(late binding)執行碼,在執行期檢視資料並呼叫任意方法。

託管語言雖然多半能被反編譯(decompile),但反編譯不一定方便,尤其面對混淆過(obfuscated)的應用程式時,混淆會讓反編譯難以產出可用的原始碼——這時直接呼叫既有執行碼,往往比還原原始碼划算。

接下來會分別說明在 .NET 與 Java(你最可能遇到的兩個平台)中呼叫目標程式碼的模式與技巧,最後再看非託管執行檔。

重用 .NET 應用程式的程式碼#

如第 6 章所述,.NET 應用程式由一或多個組件(assembly)構成,可能是執行檔(.exe)或函式庫(.dll)。對重用而言兩者沒有差別,方法一樣能呼叫。

可見性範圍決定你能不能直接編譯#

能不能直接對組件編譯,取決於你要用的型別的可見性範圍(visibility scope)。最重要的三種是:

  • public:組件外的所有呼叫端都能存取。
  • private:範圍限縮在目前的型別內(例如 public 類別裡的 private 類別)。
  • internal:只有同一個組件內的呼叫端能存取,在組件內部表現得像 public,但外部無法對它編譯。

確認要用的型別與成員全部是 public 之後,編譯時加上組件參照即可。用 IDE 的話直接在專案裡加參照;用 Mono 或 Windows .NET framework 在命令列編譯的話,對 CSC 或 MCS 指定 -reference:<FILEPATH> 選項。

可見性範圍的完整範例
public class PublicClass
{
    private class PrivateClass
    {
        public PrivatePublicMethod() {}
    }
    internal class InternalClass
    {
        public void InternalPublicMethod() {}
    }
    private void PrivateMethod() {}
    internal void InternalMethod() {}
    public void PublicMethod() {}
}

這段程式碼共定義三個類別:一個 public、一個 private、一個 internal。對這個組件編譯時,只有 PublicClass 與它的 PublicMethod() 能被直接存取,存取其他任何型別或成員都會產生編譯錯誤。

值得注意的是 PrivatePublicMethod()InternalPublicMethod() 都宣告為 public,卻依然無法從外部呼叫——因為它們被包在 PrivateClassInternalClass 的範圍裡。

使用反射 API#

如果不是所有型別與成員都是 public,就得動用 .NET framework 的反射 API。它們大多位於 System.Reflection 命名空間,例外是 Type 類別,它在 System 之下。

類別名稱說明
System.Type代表組件中的單一型別,可存取其成員資訊
System.Reflection.Assembly載入與檢視組件,並列舉可用的型別
System.Reflection.MethodInfo代表型別中的一個方法
System.Reflection.FieldInfo代表型別中的一個欄位
System.Reflection.PropertyInfo代表型別中的一個屬性
System.Reflection.ConstructorInfo代表類別的建構式

動手之前得先載入組件,用 Assembly 類別的 Load()LoadFrom():前者接受組件名稱(識別碼),並假設組件檔案與呼叫端應用程式在同一位置;後者接受組件檔案的路徑,多數情況直接用它就好。

Assembly asm = Assembly.LoadFrom(@"c:\path\to\assembly.exe");
Type type = asm.GetType("ChatProgram.Connection");

型別名稱一律是含命名空間的完整名稱,各段以句點分隔。上例取的是 ChatProgram 命名空間裡的 Connection 類別。

那巢狀在其他類別裡的類別怎麼辦?C# 裡父子類別同樣以句點書寫,容易混淆。反射框架用加號+)區分兩者:ChatProgram.Connection 是命名空間 ChatProgram 下的 Connection 類別,而 ChatProgram+Connection 表示的是類別 ChatProgram 裡的子類別 Connection

建立實例與呼叫方法#

取得 Type 之後,可以呼叫 GetConstructor 手動處理建構式,但在簡單情境下有更省事的做法:用內建的 System.ActivatorCreateInstance() 接受要建立的型別,以及一個布林值表示「建構式是否為非 public」。若建構式是 internal,要傳 true 才找得到正確的建構式。

Type type = asm.GetType("ChatProgram.Connection");
object conn = Activator.CreateInstance(type, true);

方法則透過 TypeGetMethod() 取得 MethodInfo(找不到時回傳 null),再用 Invoke() 執行——第一個參數是要執行的物件實例,第二個是參數陣列。

呼叫方法時有兩個常見的坑:

坑一:最單純的 GetMethod() 只會找 public 方法。要呼叫非 public 的多載,就得使用接受 BindingFlags 列舉值的多載版本。

旗標名稱說明
BindingFlags.Public查找 public 成員
BindingFlags.NonPublic查找非 public 成員(internal 或 private)
BindingFlags.Instance查找只能在類別實例上使用的成員
BindingFlags.Static查找不需實例、可靜態存取的成員

坑二:方法有多載時會拋出 AmbiguousMatchException。如果一個類別同時有 Send(byte[])Send(string),反射 API 不知道你要哪一個,於是誰也不回傳、直接丟例外。解法是使用另一個 GetMethod() 多載,明確指定參數型別。

為什麼 Connect() 有兩個多載卻不會出錯,Send() 卻會?因為前者的兩個多載可見性不同,binding flags 已經先把它們區隔開了:查找 public 的 Connect() 時,反射 API 根本不會去看非 public 的那個多載。可見性只是「碰巧」幫你消歧義,遇到同可見性的多載就失效了。

以反射操作 Connection 類別的完整流程

假設目標組件裡有這樣一個 internal 類別:

internal class Connection
{
    internal Connection() {}

    public void Connect(string hostname)
    {
        Connect(hostname, 12345);
    }

    private void Connect(string hostname, int port)
    {
        // Implementation...
    }

    public void Send(byte[] packet)
    {
        // Implementation...
    }

    public void Send(string packet)
    {
        // Implementation...
    }

    public byte[] Receive()
    {
        // Implementation...
    }
}

建立實例(非 public 的無參數建構式,第二個參數傳 true):

Type type = asm.GetType("ChatProgram.Connection");
object conn = Activator.CreateInstance(type, true);

呼叫 public 的 Connect()

MethodInfo connect_method = type.GetMethod("Connect");
connect_method.Invoke(conn, new object[] { "host.badgers.com" });

呼叫 private 的 Connect()(才能指定任意 TCP 埠):因為要找的是「可在實例上呼叫的非 public 方法」,兩個旗標都要給。

MethodInfo connect_method = type.GetMethod("Connect",
                            BindingFlags.NonPublic | BindingFlags.Instance);
connect_method.Invoke(conn, new object[] { "host.badgers.com", 9999 });

呼叫有多載的 Send():直接用 type.GetMethod("Send") 會得到

System.Reflection.AmbiguousMatchException: Ambiguous match found.
   at System.RuntimeType.GetMethodImpl(...)
   at System.Type.GetMethod(String name)
   at Program.Main(String[] args)

改用指定參數型別的多載,挑出接受 string 的那個:

MethodInfo send_method = type.GetMethod("Send", new Type[] { typeof(string) });
send_method.Invoke(conn, new object[] { "data" });

呼叫 Receive():它是 public 且沒有多載,也不收參數,Invoke() 傳空陣列或 null 都可以。由於 Invoke() 回傳 object,要轉型才能取用位元組。

MethodInfo recv_method = type.GetMethod("Receive");
byte[] packet = (byte[])recv_method.Invoke(conn, null);

重用 Java 應用程式的程式碼#

Java 與 .NET 相當接近,主要差別在於 Java 沒有組件(assembly)的概念:每個類別各自是一個 .class 檔,雖然可以打包成 JAR(Java Archive),那也只是便利設施。因此 Java 沒有「只有同組件才能存取」的 internal 類別,但有一個類似的機制——package-private 範圍的類別,只有同一個 package 裡的類別能存取(.NET 把 package 稱為 namespace)。

這個特性帶來一個好用的後果:只要把自己的程式碼宣告在同一個 package 裡,就能隨意存取那些 package 範圍的類別與成員。寫一個 bridge 類別,把建立實例的動作包起來,再編進自己的應用程式即可。

既有的 class 或 JAR 檔則透過 classpath 指定,通常是對 Java 編譯器或執行檔傳 -classpath 參數。

若需要改用反射,Java 的核心反射型別與 .NET 幾乎一一對應:.NET 的 Type 對應 Java 的 ClassMethodInfo 對應 Method,依此類推。

類別名稱說明
java.lang.Class代表單一類別,可存取其成員
java.lang.reflect.Method代表型別中的一個方法
java.lang.reflect.Field代表型別中的一個欄位
java.lang.reflect.Constructor代表類別的建構式

Java 有一個 .NET 沒有的陷阱:任何非 public 的成員(建構式、欄位或方法)在使用前,都必須先呼叫 setAccessible(true)。忘了這一步,後續的 newInstance() 會直接拋例外。

Java:bridge 類別與反射的完整範例

bridge 類別PackageClass 是目標函式庫裡的 package-private 類別,BridgeClass 則是你自己編進應用程式、宣告在同一個 package 的橋接類別。

// Package-private (PackageClass.java)
package com.example;

class PackageClass {
    PackageClass() {
    }

    PackageClass(String arg) {
    }

    @Override
    public String toString() {
        return "In Package";
    }
}

// Bridge class (BridgeClass.java)
package com.example;

public class BridgeClass {
    public static Object create() {
        return new PackageClass();
    }
}

用反射取得類別物件,以 Class.forName() 依名稱查找:

Class c = Class.forName("com.example.PackageClass");
System.out.println(c);

建立實例:public 且有無參數建構式的類別可以直接用 ClassnewInstance(),但這對 package-private 類別行不通。改用 getDeclaredConstructor() 取得 Constructor,並傳入 Class 物件清單來選出參數型別相符的那個建構式。

Constructor con = c.getDeclaredConstructor(String.class);
con.setAccessible(true);
Object obj = con.newInstance("Hello");

非託管執行檔與 Python ctypes#

在多數非託管執行檔裡呼叫任意程式碼比託管平台困難得多。你確實可以拿到某個內部函式的指標並呼叫它,但這麼做有相當機率讓你的應用程式當掉。比較可行的是:當實作被明確地透過動態函式庫對外公開時,去呼叫它。

Linux、macOS 與 Windows 都支援動態函式庫,各自稱為 object file(.so)、dynamic library(.dylib)與 dynamic link library(.dll)。Python 內建的 ctypes 函式庫提供了一套大致通用的方式把它們載入記憶體,並用一致的語法描述如何呼叫匯出的函式。

載入函式庫#

from ctypes import *
# On Linux
lib = cdll.LoadLibrary("./lib.so")
# On macOS
#lib = cdll.LoadLibrary("lib.dylib")
# On Windows
#lib = cdll.LoadLibrary("lib.dll")
# Or we can do the following on Windows
#lib = cdll.lib

lib.say_hello()

在 Linux 上載入函式庫必須指定路徑:Linux 預設不會把目前目錄放進函式庫搜尋順序,寫成 lib.so 會失敗。macOS 與 Windows 則沒有這個限制;Windows 上甚至可以在 cdll 後面直接寫函式庫名稱,它會自動補上 .dll 副檔名並載入。

把腳本載入 Python shell(例如執行 execfile("listing8-23.py"))就會看到 Hello;保持互動式工作階段開著,接下來的呼叫都在同一個 session 裡進行。

呼叫比較複雜的函式#

ctypes 會盡可能從你傳入的 Python 參數自動推斷函式參數型別,而且永遠假設回傳型別是 C 的 int。這對 add_numbers(1, 2) 這種簡單呼叫沒問題,但更複雜的函式就得明確指定 ctypes 命名空間裡的資料型別。

Python ctypes對應的原生 C 型別
c_char, c_wcharchar, wchar_t
c_byte, c_ubytechar, unsigned char
c_short, c_ushortshort, unsigned short
c_int, c_uintint, unsigned int
c_long, c_ulonglong, unsigned long
c_longlong, c_ulonglonglong long, unsigned long long(通常是 64 位元)
c_float, c_doublefloat, double
c_char_p, c_wchar_pchar*, wchar_t*(NUL 結尾字串)
c_void_pvoid*(泛用指標)

四個關鍵手法:

  • 指定回傳型別:把資料型別指派給 lib.名稱.restype
  • 指定參數型別:把資料型別陣列指派給 argtypes
  • 以指標傳遞參數:用 byref 輔助函式。
  • 傳遞結構:定義一個繼承 Structure 的類別,設定 _fields_ 屬性後以 byref 傳入。

沒有設定 restypeargtypes 的呼叫不會報錯,只會安靜地給你錯誤的結果。例如回傳字串指標的函式在設定 restype 前印出的是像 -1686370079 這樣的整數;而 add_longs(0x100000000, 1) 在設定 argtypes 前會得到 1,設定後才是正確的 4294967297

範例 C 函式庫 lib.c 與各種呼叫方式

範例函式庫(本節所有呼叫都以它為對象):

#include <stdio.h>
#include <wchar.h>

void say_hello(void) {
  printf("Hello\n");
}

void say_string(const char* str) {
  printf("%s\n", str);
}

void say_unicode_string(const wchar_t* ustr) {
  printf("%ls\n", ustr);
}

const char* get_hello(void) {
  return "Hello from C";
}

int add_numbers(int a, int b) {
  return a + b;
}

long add_longs(long a, long b) {
  return a + b;
}

void add_numbers_result(int a, int b, int* c) {
  *c = a + b;
}

struct SimpleStruct
{
   const char* str;
   int num;
};

void say_struct(const struct SimpleStruct* s) {
  printf("%s %d\n", s->str, s->num);
}

在 Linux 上安裝 GCC 之類的 C 編譯器後,用以下指令編出共享函式庫 lib.so

gcc -shared -fPIC -o lib.so lib.c

簡單呼叫(ctypes 自動推斷參數):

print lib.add_numbers(1, 2)
>>> 3
lib.say_string("Hello from Python");
>>> Hello from Python

指定回傳型別(回傳字串指標的函式):

# Before setting return type
print lib.get_hello()
>>> -1686370079
# After setting return type
lib.get_hello.restype = c_char_p
print lib.get_hello()
>>> Hello from C

指定參數型別

# Before argtypes
lib.add_longs.restype = c_long
print lib.add_longs(0x100000000, 1)
>>> 1
# After argtypes
lib.add_longs.argtypes = [c_long, c_long]
print lib.add_longs(0x100000000, 1)
>>> 4294967297

以指標傳遞參數(結果透過 int 指標回傳):

i = c_int()
lib.add_numbers_result(1, 2, byref(i))
print i.value
>>> 3

傳遞結構

class SimpleStruct(Structure):
  _fields_ = [("str", c_char_p),
              ("num", c_int)]

s = SimpleStruct()
s.str = "Hello from Struct"
s.num = 100
lib.say_struct(byref(s))
>>> Hello from Struct 100

Windows 上的呼叫慣例#

如第 6 章所述,Windows API 呼叫可以採用多種呼叫慣例(calling convention),最常見的是 stdcall 與 cdecl。在 ctypes 裡:

  • 使用 cdll 時,所有呼叫都假設函式是 cdecl
  • 使用 windll 時,預設則是 stdcall

如果一個 DLL 同時匯出 cdecl 與 stdcall 方法,你可以按需要混用 cdllwindll。(此處關於在 Windows 上呼叫非託管函式庫的說明針對 32 位元 Windows。)

用 ctypes 呼叫非託管程式碼還有許多更複雜的場景,例如傳遞字串值或呼叫 C++ 函式。網路上有不少詳盡的資源,本節的目的只是給你足夠的基礎,讓你有興趣繼續深入。