作者: 我是小三 博客: http://www.cnblogs.com/2014asm/ 由於時間和水平有限,本文會存在諸多不足,希望得到您的及時反饋與指正,多謝! 工具環境: windwos10、IDEA 目錄 : 為什么需要保護?保護后性能如何? 市面上常見的解決方案 整體加密保護方案架構 class文件格式與反匯編引擎淺析 LLVM IR介紹 技術實現細節分析 總結
0x00:為什么需要保護?保護后性能如何?
1.為什么須要保護?
由於Java的指令集比較簡單而通用,較容易得出程序的語義信息,Java編譯后的Jar包和Class文件,可以輕而易舉的使用反編譯工具(如JD-GUI)進行反編譯,拿到源碼。
目前,市場上有許多Java的反編譯工具,有免費的,也有商業使用的,還有的是開放源代碼的。這些工具的反編譯速度和效果都非常不錯。好的反編譯軟件,能夠反編譯出非常接近源代碼的程序。因此,通過反編譯器,黑客能夠對這些程序進行更改,或者復用其中的程序,核心算法被使用等。因此,如何保護Java程序不被反編譯,是非常重要的一個問題。
2.保護后性能如何?
由於java跨平台的需求,在某些情況下須要使用jvm虛擬機來解釋執行編譯后的 java字節碼,可能很多人會擔心在此基礎上再做一層保護會使得程序變得更低效。其實這個主要與加密方案有關,我們這個方案的原理是將java字節碼轉換成對應平台的二制代碼。去掉了jvm虛擬機,將程序直接運行在真實的CPU上,這樣反而會提高原本的程序執行效率。
0x01:市面上常見的加密保護方案
由於Java字節碼的抽象級別較高,使得它較容易被反編譯,因此Java代碼反編譯要比其他開發語言更容易實現,並且反編譯的代碼經過優化后幾乎可與源代碼相媲美。為了避免出這種情況,保護軟件知識產權的目的,就出現了各種各樣的加密保護方式。
1.遠程調用Java程序
最簡單的方法就是讓用戶不能夠拿到Jar程序,這種方法是最根本的方法,具體實現有多種方式。例如,開發人員可以將關鍵的jar放在服務器端,客戶端通過訪問服務器的相關接口來獲得服務,而不是直接本地調用jar文件。這樣黑客就沒有辦法反編譯Class文件。目前,通過接口提供服務的標准和協議也越來越多,例如 HTTP、Web Service、RPC等。但是有很多應用都不適合這種保護方式,例如單機運行的程序或者須要很大網絡流量的程序就無法遠程調用Java程序。這種保護方式如圖1所示。
圖1
2.自定義ClassLoader
為了防止Class文件被直接反編譯,許多開發人員將一些關鍵算法的Class文件進行加密,在使用這些class被加載之前,程序首先需要對這些類進行解密,而后再將這些類裝載到JVM當中。大致流程如圖2。
圖2
這種方法首先需要對編寫代碼對類文件進行加密,然后自己編寫類加載器解密加載,在往JAVA虛擬機導入class文件的同時進行class文件的解密,這種方式存在被內存dump的風險。
3.代碼混淆
代碼混淆是對Class文件進行重新組織和處理,使得處理后的代碼與處理前代碼完成相同的功能(語義)。但是反編譯后得出的代碼是非常難懂、晦澀的,因此反編譯人員很難得出程序的真正語義。但是也只是增加了分析時間,被混淆的代碼仍然可能被破解的風險。
4.轉換成本地代碼
將程序轉換成本地代碼也是一種防止反編譯的有效方法。因為本地代碼往往難以被反編譯。開發人員可以選擇將整個應用程序轉換成本地代碼,也可以選擇關鍵模塊轉換。如果僅僅轉換關鍵部分模塊,Java程序在使用這些模塊時,需要使用JNI技術進行調用。
當然,在使用這種技術保護Java程序的同時,也犧牲了Java的跨平台特性。對於不同的平台,我們需要維護不同版本的本地代碼,這將加重軟件支持和維護的工作。不過對於一些關鍵的模塊,有時這種方案往往是必要的。
為了保證這些本地代碼分析難度,我們可以通過對這些代碼進行二進制混淆或部分VM,加大分析難度。我們本次也是使用這種方式。
0x02:整體加密保護方案架構
整體方案主要通過解析class文件並且轉化成一種和平台無關的中間語言。最后通過調用相關JNI方法。這種保護方式如圖3。
圖3
以上就是整體的加密保護框架。
0x03:class文件格式與反匯編引擎淺析
1.class文件格式
寫過java程序的都知道,java代碼運行,需要先編譯成class文件,再經由JVM加載來解釋執行。那class文件中,都存放了哪些信息,JVM又是如何通過加載這些信息來執行我們的java代碼的。反編譯工具又是如何還原其代碼的呢?通過了解class文件,有助於我們理解實現機制。
class文件結構如下,u2、u4分別代表2、4個字節長度:
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
其中:magic:魔數,占用4個字節,固定值為“0xCAFEBABE”,用於標識這是一個class文件。
minor_version、major_version:class文件次、主版本號,分別占用2個字節,一般高版本的JVM能夠加載低
methods_count:該類或接口擁有的方法數。
methods:列表每一下為method_info數據,method_info結構如下:
method_info { u2 access_flags; u2 name_index; u2 descriptor_index; u2 attributes_count; attribute_info attributes[attributes_count]; }
上面這個結構是我們比較關心的,會定位到方法指令,在轉換IR過程中占比較重。其它的字段都是按照以上格式進行解析。
下面是部分解析代碼:
public Method(DataInputStream dis, ConstantPool cp) throws IOException, InvalidConstantPoolIndex { mf = new MethodFormatter(); /* Parsing access and property modifier */ accessFlags = dis.readUnsignedShort(); accessModifier = mf.setModifier(accessFlags); /* Parsing the method name */ nameIndex = dis.readUnsignedShort(); cpEntry = cp.getEntry(nameIndex); if (cpEntry instanceof ConstantUtf8) { constantUtf8 = (ConstantUtf8) cpEntry; methodName = new String(constantUtf8.getBytes()); //if (!methodName.equals("<init>")) methodName += "()"; // enable () front of method except <init>. uncomment if want } /* Parsing the method descriptor */ descriptorIndex = dis.readUnsignedShort(); cpEntry = cp.getEntry(descriptorIndex); if (cpEntry instanceof ConstantUtf8) { constantUtf8 = (ConstantUtf8) cpEntry; descriptor = constantUtf8.getBytes(); /* Parse return type & parameters */ returnType = mf.parseReturnType(descriptor); parameters = mf.parseParameters(descriptor); } /* Parsing the method attributes */ attributesCount = dis.readUnsignedShort(); attributes = new Attribute[attributesCount]; for (int i = 0; i < attributesCount; i++) { attributes[i] = new Attribute(dis, cp); } }
2.ASM反匯編引擎
ASM是一個Java字節碼操控框架。它能被用來動態生成類或者增強既有類的功能。ASM是Java中比較流行的用來讀寫字節碼的類庫,用來基於字節碼層面對代碼進行分析和轉換。在讀寫的過程中可以加入自定義的邏輯以增強或修改原來已編譯好的字節碼功能。
ASM關鍵類與接口:
在ASM的核心實現中,它主要有以下幾個類、接口(在org.objectweb.asm包中):
ClassReader類:字節碼的讀取與分析引擎。它采用類似SAX的事件讀取機制,每當有事件發生時,調用注冊的ClassVisitor、AnnotationVisitor、FieldVisitor、MethodVisitor做相應的處理。
ClassVisitor接口:定義在讀取Class字節碼時會觸發的事件,如類頭解析完成、注解解析、字段解析、方法解析等。
AnnotationVisitor接口:定義在解析注解時會觸發的事件,如解析到一個基本值類型的注解、enum值類型的注解、Array值類型的注解、注解值類型的注解等。
FieldVisitor接口:定義在解析字段時觸發的事件,如解析到字段上的注解、解析到字段相關的屬性等。
MethodVisitor接口:定義在解析方法時觸發的事件,如方法上的注解、屬性、代碼等。
ClassWriter類:
它實現了ClassVisitor接口,用於拼接字節碼。
AnnotationWriter類:它實現了AnnotationVisitor接口,用於拼接注解相關字節碼。
FieldWriter類:它實現了FieldVisitor接口,用於拼接字段相關字節碼。
MethodWriter類:它實現了MethodVisitor接口,用於拼接方法相關字節碼。
ClassReader是ASM中最核心的實現,它用於讀取並解析Class字節碼。整體關系如圖4所示:
圖4
0x04:LLVM IR介紹
llvm(low level virtual machine)是一個開源編譯器框架,llvm有一個表達形式很好的IR語言,高度模塊化的結構,因此它可以作為多種語言的后端,提供與編程語言無關的優化和針對多種CPU的代碼生成功能。
如圖5所示:
圖5
好處是不同的前端后端使用統一的LLVM IR ,如果需要支持新的編程語言或者新的設備平台,只需要開發對應的前端和后端即可。同時基於LLVM IR 我們可以將我們的java字節碼轉換成IR可用不同的后端進行編譯到不同平台運行。
0x05:技術實現細節分析
1.讀取Jar包中的class文件進行轉換IR。
InputStream is = new FileInputStream(fn); ClassReader cr = new ClassReader(is); cr.accept(sc, 0);
調用ClassReader讀取並解析Class字節碼,分發到accept去處理邏輯,部分代碼。
public void accept(final ClassVisitor classVisitor, final int parsingOptions) { accept(classVisitor, new Attribute[0], parsingOptions); // Visit the fields and methods. int fieldsCount = readUnsignedShort(currentOffset); currentOffset += 2; while (fieldsCount-- > 0) { currentOffset = readField(classVisitor, context, currentOffset); } int methodsCount = readUnsignedShort(currentOffset); currentOffset += 2; while (methodsCount-- > 0) { currentOffset = readMethod(classVisitor, context, currentOffset);//讀取方法 } }
讀取方法指令,部分代碼如下:
private int readMethod( final ClassVisitor classVisitor, final Context context, final int methodInfoOffset) { // Visit the non standard attributes. while (attributes != null) { // Copy and reset the nextAttribute field so that it can also be used in MethodWriter. Attribute nextAttribute = attributes.nextAttribute; attributes.nextAttribute = null; methodVisitor.visitAttribute(attributes); attributes = nextAttribute; } // Visit the Code attribute. if (codeOffset != 0) { methodVisitor.visitCode(); readCode(methodVisitor, context, codeOffset); } }
根據不同的指令,做不同的IR轉換,部分代碼如下:
private void readCode( final MethodVisitor methodVisitor, final Context context, final int codeOffset) { while (currentOffset < bytecodeEndOffset) { final int bytecodeOffset = currentOffset - bytecodeStartOffset; final int opcode = classBuffer[currentOffset] & 0xFF; switch (opcode) { case Constants.SIPUSH: methodVisitor.visitIntInsn(opcode, readShort(currentOffset + 1)); currentOffset += 3; break; } }
根據指令(opcode)轉換成對誚的LLVM IR,部分代碼如下:
public void visitIntInsn(final int opcode, final int operand) { if (mv != null) { mv.visitIntInsn(opcode, operand); } } public void visitVarInsn(int opcode, int slot) { switch (opcode) { case Opcodes.ILOAD: // 21 case Opcodes.LLOAD: // 22 case Opcodes.FLOAD: // 23 case Opcodes.DLOAD: // 24 case Opcodes.ALOAD: // 25 { LocalVar lv = this.vars.get(slot); String type = Util.javaSignature2irType(this.cv.getStatistics().getResolver(), lv.signature); String s = stack.push(type); out.comment(type + "load " + slot); out.add(s + " = load " + type + ", " + type + "* %" + lv.name); } break; case Opcodes.ISTORE: // 54 case Opcodes.LSTORE: // 55 case Opcodes.FSTORE: // 56 case Opcodes.DSTORE: // 57 case Opcodes.ASTORE: // 58 { LocalVar lv = this.vars.get(slot); String type = Util.javaSignature2irType(this.cv.getStatistics().getResolver(), lv.signature); StackValue value = stack.pop(); value = out.castP1ToP2(stack, value, type); out.comment(type + "store " + slot); out.add("store " + value.fullName() + ", " + type + "* %" + lv.name); } break; default: } } private List<String> strings = new ArrayList<String>(); public void add(String str) { strings.add(str); } public void addImm(Object value, String type, RuntimeStack stack) { String immname = "%_imm_" + tmp; add(immname + " = alloca " + type); add("store " + type + " " + floatToString(value) + ", " + type + "* " + immname); String sv = stack.push(type); add(sv + " = load " + type + ", " + type + "* " + immname); tmp++; }
循環讀取指令進行轉換成相應的IR,轉換前后簡單示例如下:
//轉換前java代碼: public static void main() { long a = System.currentTimeMillis(); Test test = new Test(); test.in = 9; linux.glibc.put(test.in); linux.glibc.put(System.currentTimeMillis() - a); linux.glibc.put(sl); linux.glibc.put(singleton.ln); } //轉換后對應的IR define void @test_Test_main() { %stack0 = call i64 @java_lang_System_currentTimeMillis() %__tmp0 = call i8* @malloc(i32 ptrtoint (%test_Test* getelementptr (%test_Test* null, i32 1) to i32)) %stack1 = bitcast i8* %__tmp0 to %test_Test* call void @java_lang_Object__init_(%test_Test* %stack1) %__tmp0.i = getelementptr %test_Test* %stack1, i32 0, i32 0 store i32 1, i32* %__tmp0.i %__tmp1.i = getelementptr %test_Test* %stack1, i32 0, i32 1 store i32 127, i32* %__tmp1.i %__tmp2.i = getelementptr %test_Test* %stack1, i32 0, i32 2 store i32 100, i32* %__tmp2.i %__tmp3.i = getelementptr %test_Test* %stack1, i32 0, i32 3 store i32 142, i32* %__tmp3.i %__tmp4.i = getelementptr %test_Test* %stack1, i32 0, i32 4 store i64 42, i64* %__tmp4.i %__tmp5.i = getelementptr %test_Test* %stack1, i32 0, i32 5 store float 0x42F6A8F600000000, float* %__tmp5.i %__tmp6.i = getelementptr %test_Test* %stack1, i32 0, i32 6 store double 5.300000e-01, double* %__tmp6.i %stack16.i = load i32* %__tmp3.i call void @linux_glibc_put_I(i32 %stack16.i) store i32 9, i32* %__tmp3.i call void @linux_glibc_put_I(i32 9) %stack8 = call i64 @java_lang_System_currentTimeMillis() %stack10 = sub i64 %stack8, %stack0 call void @linux_glibc_put_J(i64 %stack10) %stack11.b = load i1* @test_Test_sl.b %stack11 = select i1 %stack11.b, i64 1111, i64 0 call void @linux_glibc_put_J(i64 %stack11) %stack12 = load %test_Test** @test_Test_singleton %__tmp5 = getelementptr %test_Test* %stack12, i32 0, i32 4 %stack13 = load i64* %__tmp5 call void @linux_glibc_put_J(i64 %stack13) ret void }
2.編譯運行
將上面轉換好的LLVM IR編譯成對應的平台機器指令,如圖6所示:
圖6
通過IDA將編譯的后的class代碼反編譯,已經被編譯成X64平台的指令,圖7所示:
圖 7
成功運行,后續還要將對外提供的方法封裝在JNI接口中進行打包調用。
0x06:總結
沒有絕對的安全,只能說,這種加密方案,使逆向和破解都更難,相對於上面介紹的幾種保護方案,這種方案在反調式、反Dump、抗逆向方面擴展能力會比較強,比如:指令混淆,字符串加密,VM等(關於VM可以參考文章:https://www.cnblogs.com/2014asm/p/6534897.html),沒有最好的方案,只有最適合的方案。
這種方案是失去了java原來跨平台的特性,還有一點不足的地方就是對GC的支持不好,所以這也是將來須要重點更進的地方。
但是好在現在LLVM可以很好的支持不同平台的最終代碼生成。
歡迎關注公眾號 :