吳 芳
(陜西省紡織科學研究所,西安710038)
C/C++等編程語言開發的程序被編譯成本機可執行的二進制代碼。編譯器用內存地址代替了程序中的變量名稱、方法名稱等信息。因此對這些本地二進制代碼進行反編譯從而得到源代碼是非常困難,甚至是不可能的。
Java是一種跨平臺的解釋型語言。Java編譯工具將Java源代碼編譯成為class文件(即Java字節碼),由 Java 虛擬機(Java Virtual Machine,JVM)負責對class文件進行解釋執行。與本地目標代碼不同,class文件中仍然保留了方法名稱、變量名稱,并且通過這些名稱來訪問變量和方法,這些符號往往帶有許多語義信息。因此,對class文件進行反編譯就顯得比較容易。目前,市場上有許多Java的反編譯工具,有免費的,也有商用的,還有的是開放源代碼的。這些工具都能夠從class文件生成高質量源代碼。所以,如何保護Java程序就變成了一個非常重要的挑戰。
目前主要有如下幾種Java字節碼保護技術:隔離Java程序、字節碼混淆、轉換本地代碼、數字水印、自定義類加載器等。
隔離Java程序是指將關鍵的class文件放在服務器端,客戶端通過訪問服務器的相關接口來獲得服務,而不是直接訪問class文件。這樣黑客就沒有辦法反編譯 class文件[1]。HTTP、Web Service、RPC等標準和協議都能夠支持通過接口提供服務。但是有很多應用都不適合這種保護方式,例如對于單機運行的程序就無法隔離Java程序。
字節碼混淆主要是通過將定義的類、變量、方法和包的名字改為無意義的字符串、使用非法的字符代替變量符號和在軟件中添加一些無關的指令或永遠執行不到的指令[2]等手段來增加反編譯和對反編譯后源代碼閱讀的難度。但這種方法并不能真正阻止反編譯,而且不論是開源的或是商用的混淆工具都有一定的規律可循,如果掌握這些規律,在反編譯時仍然可以得到一定質量的源代碼。
轉換本地代碼即將Java程序像C/C++程序一樣編譯成本機可執行的二進制代碼,目前TowerJ、jexegen、JET、JOVE、JToEXE 等工具都能做到這一點。但是這樣做使得Java程序失去其跨平臺的特性,而且這種技術目前并不十分成熟,因此不適用于大型應用程序。
數字水印技術是在class文件中嵌入以數字水印形式存在的開發者簽名[3]。數字水印技術并不能阻止反編譯,但是能在確認某些程序是否屬于剽竊時提供有效的證據。
自定義類加載器是指首先將class文件進行加密處理,然后自己編寫一個Java類裝載器(即繼承java.lang.ClassLoader并重載其 loadClass方法)在class文件裝載時再進行解密處理。這種方法的缺點在于雖然經過加密的class文件無法被反編譯,但自定義的類加載器本身卻不能防止被反編譯。因此,加密過的class文件仍然是不安全的。
另外,一些商用的專業數據加密軟件也能提供對Java字節碼的保護,如HASP、CodeMeter等,其原理是對Java程序和java.exe都進行加殼處理,在Java程序運行時需要連接加密狗進行解密。這種方法雖然安全性較高但是對于不使用java.exe來運行的Java程序(如Eclipse平臺使用的是javaw.exe)則無能為力。因此通用性較差。
通過第一節對Java字節碼保護現狀的分析可以得出結論:要找到一種更好的Java字節碼保護方法則必須滿足以下3個條件:
·能夠有效防止反編譯,使得Java程序的安全級別相當于本地應用程序。
·保持Java程序的跨平臺特性。
·不修改JVM。
將class文件作為數據文件進行加密是比較容易的(關于數據加密的算法很多,在此不再贅述),實現了這一步即可阻止對class文件的直接反編譯。但問題的關鍵在于何時以何種方式將加密過的class文件安全地解密?首先來分析一下JVM的類加載機制。
Java語言是一種動態性的解釋型語言。Java編譯器將每個類和接口都編譯成一個單獨的class文件,這些文件對于JVM來說就是一個個可以動態加載的單元,這些文件只有在需要使用時才會被加載。當指定程序運行的時候,JVM就將class文件按照需求和一定的規則加載到JVM的內存,并組織成為一個完整的Java應用程序。
JVM初始化類加載器的過程如圖1所示。

圖1 JVM類加載器的初始化
JVM被激活后,會產生第一個類加載器BootstrapClassLoader,BootstrapClassLoader 是采用 C++語言編寫的本地代碼。BootstrapClassLoader首先加載Launcher.java之中的 ExtClassLoader,并設定其Parent為 null,代表其父加載器為 BootstrapClass-Loader。然后 BootstrapClassLoader再要求加載Launcher.java之中的AppClassLoader,并設定其Parent為之前產生的ExtClassLoader實例。在這三個類加載器中 BootstrapClassLoader和 ExtClassLoader用來加載JVM的系統類,AppClassLoader用來加載用戶自己的類。AppClassLoader首先通過其findclass方法查找需要加載的class文件,然后調用defineclass方法將class文件的內容轉換成Class對象。AppClassLoader的defineclass方法最終調用的是其超類ClassLoader中的本地方法defineclass1。defineclass1方法的第二個參數就是class文件的內容。
通過上述分析,如果有一種Hook機制能夠將JVM調用本地方法defineclass1的事件截獲,并在Hook函數中利用本地方法實現對class文件內容的解密操作,然后將解密后的字節碼傳遞給JVM,這樣就能使解密過程僅在內存中進行,從而實現對加密后的class文件的安全解密。
JNI的全稱是 Java Native Interface[4](Java 本地接口)。JNI是JDK的一部分,用于為Java提供本地代碼接口。JNI使得運行在JVM虛擬機上的Java代碼能夠操作使用其它語言編寫的應用程序和庫,如C/C++以及匯編語言等。JNI在Java程序和C/C++程序間的調用原理如圖2所示。

圖2 JNI在Java程序和C/C++程序間的調用原理
通過JNI中提供的接口,本地代碼可以與JVM進行交互,其中有一個名為RegisterNatives的接口,這個接口可以注冊與某個類的方法關聯的本地代碼。
JVMTI的全稱是Java Virtual Machine Tool Interface[5](Java 虛擬機工具接口),它提供了一種監視JVM狀態及控制JVM執行的方法:JVMTI客戶端(又稱為代理或Agent)。JVMTI客戶端能夠從JVM監聽到感興趣的事件。用戶可以通過JVM的-agentlib參數向JVM注冊JVMTI客戶端。下面對所要涉及的JVMTI函數和事件做簡要說明。
·Agent_OnLoad、Agent_OnUnload函數:JVM 與代理交互的入口和出口函數。
·SetEventNotificationMode函數:該函數可以向JVM注冊要監聽的事件。
·SetEventCallbacks函數:該函數可以為用SetEventNotificationMode注冊過的監聽事件指定Hook函數。
·VMInit事件:JVM的初始化事件,這個事件的發生表示JVM初始化的完成。在該事件完成后,JVMTI客戶端就可以得到JNI和JVMTI環境。
通過對 JNI和 JVMTI的分析可知只要采用JVMTI技術編寫合適的JVMTI客戶端就能夠監聽到class文件的加載事件;然后向該事件掛接一個采用JNI技術編寫事件的回調函數,在回調函數中對字節碼進行解密處理,這樣就可以在JVM加載字節碼的時候利用本地代碼將其解密。
由于JVMTI客戶端和JNI回調函數都是本地代碼,并且解密的動作在JVM的內存中完成,不在本地保留解密后的字節碼,因此解密動作的安全級別與本地代碼相當。
由于JNI和JVMTI都是JVM提供的機制,與平臺無關,即JVMTI客戶端和 JNI回調函數在支持JVM的平臺上都可以實現,所以這種方法能夠保持Java程序的跨平臺特性。
當JVM加載一個JVMTI客戶端時會自動調用Agent_OnLoad函數,如果在這個函數中向JVM注冊VMInit事件及回調函數,那么JVM初始化后JVMTI客戶端就會監聽到VMInit事件并進入該事件的回調函數。在回調函數中利用JNI提供的RegisterNatives函數將ClassLoader.defineclass1方法注冊為自定義的一個代理函數,這樣就可以攔截 JVM對ClassLoader.defineclass1方法的調用。當JVM在為了生成某個類的Class對象而調用ClassLoader.defineclass1方法時,它會調用自定義的代理函數,在代理函數中實現對字節碼的解密。
JVMTI客戶端和JNI回調函數在支持JVM的平臺上都可以實現,這里僅以Windows平臺和Linux平臺為例說明。
在Windows平臺上JVMTI客戶端以動態鏈接庫的形式出現。支持創建動態鏈接庫的開發工具很多,這里以Microsoft Visual C++為例說明。
首先創建一個動態鏈接庫項目,并將JDK中JVMTI和 JNI相關的頭文件(jvmti.h、jni.h、jni_md.h)引入到項目中以建立支持JVMTI和JNI的編程環境;然后創建Agent_OnLoad函數,在該函數中通過GetEnv獲取JVMTI環境并向JVM預定VMInit事件的通知并設置VMInit事件的回調函數,在回調函數中利用RegisterNatives將自定義的本地代理方法注冊到ClassLoader.defineclass1上;最后完成自定義的本地代理方法,在該方法中實現對字節碼的解密。解密后的字節碼存儲在JVM的內存中,此時再調用java.dll中的_Java_java_lang_ClassLoader_define-Class1@32方法將解密后的字節碼轉換為Class對象并將這個對象作為Agent_OnLoad的返回值返回給JVM。
在運行加密過的class文件時,必須在JVM的運行參數中通過-agentlib參數指定JVMTI客戶端。
在windows平臺的源代碼基礎上做如下修改即可實現JVMTI客戶端向Linux平臺的移植:
(1)由于在Windows平臺從動態鏈接庫中調用函數需要LoadLibrary、GetProcAddress和FreeLibrary三個庫函數,而g++編譯器不支持這三個庫函數,所以需要將windows.h頭文件改為dlfcn.h頭文件,然后分別使用dlopen、dlsym和dlclose這三個庫函數,具體實現和Windows平臺類似。
(2)利用g++編譯代碼時,需要使用shared參數,這樣將代碼編譯成動態鏈接庫,利用-o libXXX.so輸出代理庫,其中輸出名稱的后綴為lib和.so,XXX為自定義名稱。
(3)在程序運行前,必須使用 set命令設置L-D_LIBRARY_PATH變量為代理庫的路徑。
和Windows平臺一樣,在運行加密過的class文件時,必須在JVM的運行參數中通過-agentlib參數指定JVMTI客戶端。
基于JVMTI的Java字節碼保護技術及其實現方案在保持Java程序跨平臺特性和不修改JVM的前提下達到了有效防止反編譯的目標。當然這種技術并非無懈可擊,采用極端的方式仍然可以對JVMTI客戶端程序進行破解,破解的難度取決于JVMTI客戶端采用的加密算法的強度。但正如Alex Kalinovsky所說[6]:成功的保護機制的關鍵是讓它復雜到95%的典型用戶很難破解,并迫使其余5%有經驗的破解者花費大量的時間來破譯。換句話來說,目標是讓購買許可比花費在破解保護上的成本顯得更加劃算。
該方案在JVMTI客戶端程序對JVM類加載器執行效率的影響方面沒有給出詳盡的分析或數據,但可以肯定的是這種影響是存在的,其影響程度主要取決于解密算法的時間復雜度。這個問題值得相關工程技術人員進一步研究和探討。
[1] 歐陽辰.如何保護Java程序[EB/OL].2004-12-02.http://www.infosecurity.org.cn/article/websec/java/index.html.
[2] 陳剛.基于封裝包的Java源代碼安全保護[J].電子信息與對抗技術,2006,21(3):45-48.
[3] 陳晗,趙軼群,繆亞波.Java字節碼的水印嵌入[J].微型電腦應用,2004,20(20):56-58.
[4] Sun Microsystems.Java Native Interface Specification[EB/OL].http://java.sun.com/javase/6/docs/technotes/guides/jni/spec/niTOC.html.2007-06-20.
[5] Sun Microsystems.JVM(TM)Tool Interface 1.1.102[EB/OL].2007-06-20.http://java.sun.com/javase/6/docs/platform/jvmti/jvmti.html.
[6] Alex Kalinovsky,著.透視 JAVA[M].劉凌,周哲海,譯.北京:清華大學出版社,2005.