Java反编译器的剖析
簡單地說,反編譯器嘗試將源代碼轉換為目標代碼。 但是有很多有趣的復雜性-Java源代碼是結構化的; 字節碼當然不是。 而且,轉換不是一對一的:兩個不同的Java程序可能會產生相同的字節碼。 我們需要應用啟發式方法,以合理地近似原始來源。
(微小的)字節碼刷新器
為了了解反編譯器的工作原理,有必要了解字節碼的基礎知識。 如果您已經熟悉字節碼,請隨時跳到下一部分。
JVM是基于堆棧的計算機 (與基于寄存器的計算機相對),這意味著指令在評估堆棧上運行。 可以從堆棧彈出操作數,執行各種操作,然后將結果推回堆棧以進行進一步評估。 請考慮以下方法:
public static int plus(int a, int b) {int c = a + b;return c; }注意:本文顯示的所有字節碼都是從javap輸出的,例如javap -c -p MyClass 。
public static int plus(int, int);Code:stack=2, locals=3, arguments=20: iload_0 // load ‘x’ from slot 0, push onto stack1: iload_1 // load ‘y’ from slot 1, push onto stack2: iadd // pop 2 integers, add them together, and push the result3: istore_2 // pop the result, store as ‘sum’ in slot 24: iload_2 // load ‘sum’ from slot 2, push onto stack5: ireturn // return the integer at the top of the stack(為清楚起見添加了注釋。)
方法的局部變量(包括該方法的參數)存儲在JVM所謂的局部變量數組中 。 為了簡潔起見,我們將存儲在局部變量數組中位置#x的值(或引用)稱為“插槽#x”(請參閱JVM規范§3.6.1 )。
對于實例方法,插槽#0中的值始終是this指針。 然后從左到右依次是方法參數,然后是方法中聲明的所有局部變量。 在上面的示例中,該方法是靜態的,因此沒有this指針。 插槽#0保留參數x ,插槽#1保留參數y 。 局部變量sum駐留在插槽#2中。
有趣的是,每種方法都具有最大堆棧大小和最大局部變量存儲量,這兩者都是在編譯時確定的。
從這里立即顯而易見的一件事是您最初可能不會想到的,那就是編譯器沒有嘗試優化代碼。 實際上, javac幾乎從不發出優化的字節碼。 這有很多好處,包括在大多數位置設置斷點的能力:如果我們要消除冗余的加載/存儲操作,我們將失去該能力。 因此,大多數繁重的工作都是在運行時由即時(JIT)編譯器執行的。
反編譯
因此,如何獲取基于堆棧的非結構化字節代碼并將其轉換回結構化Java代碼? 第一步通常是擺脫操作數堆棧,我們可以通過將堆棧值映射到變量并插入適當的加載和存儲操作來做到這一點。
“堆棧變量”僅分配一次,并且消耗一次。 您可能會注意到,這將導致很多冗余變量-稍后再介紹! 反編譯器也可以將字節碼減少為一個甚至更簡單的指令集,但是在此我們不考慮。
我們將使用符號s0 (等)表示堆棧變量 ,使用v0表示原始字節碼中引用的真實局部變量(并存儲在插槽中)。
| 0 1個 2 3 4 5 | iload_0 iload_1 我加 istore_2 iload_2 我回來 | s0 = v0 s1 = v1 s2 = s0 + s1 v2 = s2 s3 = v2 返回s3 | v2 = v0 + v1 返回v2 |
通過將標識符分配給每個推入或彈出的值,我們可以從字節碼轉換為變量 ,例如, iadd彈出兩個操作數以進行加和推結果。
然后,我們應用一種稱為復制傳播的技術來消除一些冗余變量。 復制傳播是一種內聯形式,其中只要對轉換有效,就可以簡單地將對變量的引用替換為分配的值。
我們所說的“有效”是什么意思? 好吧,這里有一些重要的限制。 考慮以下:
0: s0 = v1 1: v1 = s4 2: v2 = s0 <-- s0 cannot be replaced with v1在這里,如果我們用v0替換s0 ,則行為將發生變化,因為v0的值在分配s0之后但被消耗之前發生變化。 為避免這些復雜性,我們僅使用復制傳播將內聯變量分配給恰好一次分配的變量。
強制執行的一種方式可能是追蹤所有門店非堆棧變量,即,我們知道, v1在#0分配V1 0,并且還V1 1在#2。 由于對v1有多個分配,因此我們無法執行復制傳播。
但是,我們最初的示例沒有這種復雜性,最終我們得到了一個簡潔明了的結果:
v2 = v0 + v1 return v2另外:恢復變量名
如果將變量簡化為字節碼中的插槽引用,那么如何恢復原始變量名? 有可能我們做不到。 為了改善調試體驗,每種方法的字節碼可能包括一個稱為局部變量表的特殊部分。 對于原始源中的每個變量,都存在一個條目,用于指定名稱,插槽號和名稱所適用的字節碼范圍。 通過包含-v選項,可以將該表(以及其他有用的元數據)包含在javap反匯編中。 對于上面的plus()方法,該表如下所示:
Start Length Slot Name Signature 0 6 0 a I 0 6 1 b I 4 2 2 c I在這里,我們看到v2指的是原始變量' c ',其字節碼偏移量為#4-5。
對于已編譯而沒有局部變量表的類(或被混淆器剝離的類),我們必須生成自己的名稱。 有許多策略可以做到這一點。 一個聰明的實現可能會看一看如何將變量用于適當名稱的提示。
堆棧分析
在前面的示例中,我們可以保證在任何給定點上哪個值位于堆棧的頂部,因此可以命名為s0 , s1 ,依此類推。
到目前為止,處理變量非常簡單,因為我們僅探討了具有單個代碼路徑的方法。 在現實世界的應用程序中,大多數方法都不會那么適應。 每次在方法中添加循環或條件時,都會增加調用者可能采用的路徑數量。 考慮我們先前示例的修改版本:
public static int plus(boolean t, int a, int b) {int c = t ? a : b;return c; }現在,我們有了控制流程來使事情復雜化。 如果嘗試執行與以前相同的任務,則會遇到問題。
| 0 1個 4 5 8 9 10 11 | iload_0 ifeq 8 iload_1 轉到9 iload_2 istore_3 iload_3 我回來 | s0 = v0 如果(s0 == 0)轉到#8 s1 = v1 轉到#9 s2 = v2 v3 = {s1,s2} s4 = v3 返回s4 |
我們需要更加聰明地分配堆棧標識符。 單獨考慮每條指令不再足夠了。 我們需要跟蹤堆棧在任何給定位置的外觀,因為我們可能會采用多種路徑到達該位置。
當我們檢查#9 ,我們看到istore_3彈出一個值,但是該值有兩個來源:它可能起源于#5或#8 。 堆棧頂部#9可能是s1或s2 ,這取決于我們分別來自#5還是#8 。 因此,我們需要將它們視為相同的變量-我們將它們合并,并且對s1或s2所有引用都將成為對明確變量s{1,2}引用。 進行“重新標記”后,我們可以安全地執行復制傳播。
| 0 1個 4 5 8 9 10 11 | s0 = v0 如果(s0 == 0)轉到#8 s {1,2} = v1 轉到#9 s {1,2} =:v2 v3 = s {1,2} s4 = v3 返回s4 | 如果(v0 == 0)轉到#8 s {1,2} = v1 轉到#9 s {1,2} = v2 v3 = s {1,2}返回v3 |
注意條件分支在#1 :如果s0的值為零,我們跳轉到else塊; else ,跳轉到else塊。 否則,我們將沿著當前路徑繼續。 有趣的是,與原始來源相比,測試條件被否定了。
我們現在已經涵蓋了足夠的內容,可以深入……
條件表達式
在這一點上,我們可以確定我們的代碼可以使用三元運算符( ?: :)進行建模:我們有一個條件,每個分支對同一堆棧變量s {1,2}都有一個賦值,此后兩個路徑會聚。
一旦確定了這種模式,就可以立即將三元數向上滾動。
| 0 1個 4 5 8 9 10 11 | 如果(v0 == 0)轉到#8 s {1,2} = v1 轉到9 s {1,2} = v2 v3 = s {1,2}返回v3 | v3 = v0!= 0 v1:v2 返回v3 |
請注意,作為轉換的一部分,我們否定了#9的條件。 事實證明, javac否定條件的方式是相當可預測的,因此,如果將條件翻轉回去,我們可以更緊密地匹配原始源。
除了–但是類型是什么?
在處理堆棧值時,JVM使用的類型系統比Java源代碼更簡單。 具體來說, boolean , char和short值使用與int值相同的指令進行操作。 因此,比較v0 != 0可以解釋為:
v0 != false ? v1 : v2…要么:
v0 != 0 ? v1 : v2…甚至:
v0 != false ? v1 == true : v2 == true…等等!
但是,在這種情況下,我們很幸運地知道v0的確切類型,因為它包含在方法描述符中 :
descriptor: (ZII)Iflags: ACC_PUBLIC, ACC_STATIC這告訴我們方法簽名的形式為:
public static int plus(boolean, int, int)我們還可以推斷v3應該是一個int (而不是boolean ),因為它被用作返回值,并且描述符告訴我們返回類型。 然后我們剩下:
v3 = v0 ? v1 : v2 return v3v0一句,如果v0是局部變量(而不是形式參數),那么我們可能不知道它表示boolean值而不是int 。 還記得我們前面提到的局部變量表,它告訴我們原始變量名嗎? 它還包含有關變量類型的信息 ,因此,如果將編譯器配置為發出調試信息,我們可以在該表中查找類型提示。 還有另一個類似的表,稱為LocalVariableTypeTable ,其中包含類似的信息。 主要區別在于LocalVariableTypeTable可能包含有關泛型類型的詳細信息,而LocalVariableTable無法。 值得注意的是,這些表是未經驗證的元數據,因此它們不一定是可信賴的 。 一個特別狡猾的混淆器可能會選擇用謊言填充這些表,并且生成的字節碼仍然有效! 自行決定使用它們。
短路運算符(
public static boolean fn(boolean a, boolean b, boolean c){return a || b && c; }怎么會更簡單? 不幸的是,字節碼有點麻煩……
| 0 1個 4 5 8 9 12 13 16 17 | iload_0 ifne#12 iload_1 ifeq#16 iload_2 ifeq#16 iconst_1 轉到#17 iconst_0 我回來 | s0 = v0 如果(s0!= 0)轉到#12 s1 = v1 如果(s1 == 0)轉到#16 s2 = v2 如果(s2 == 0)轉到#16 s3 = 1 轉到17 s4 = 0 返回s {3,4} | 如果(v0!= 0)轉到#12如果(v1 == 0)轉到#16 如果(v2 == 0)轉到#16 |
#17的ireturn指令可能會返回s3或s4 ,這取決于所采用的路徑。 我們如上所述對它們進行別名處理,然后執行復制傳播以消除s0 , s1和s2 。
我們在#1 , #5和#7處擁有三個連續的條件。 如前所述,條件分支會跳轉或掉入下一條指令。
上面的字節碼包含遵循特定且非常有用的模式的條件分支序列:
| T1: if (c1) goto L1if (c2) goto L2 L1:...Becomesif (!c1 && c2) goto L2 L1:... | T1:if (c1) goto L2if (c2) goto L2 L1:...Becomesif (c1 || c2) goto L2 L1:... |
如果我們考慮以上相鄰條件對,則#1...#5不符合以下任何一個模式,但是#5...#9是條件析取( || ),因此我們應用適當的變換:
1: if (v0 != 0) goto #125: if (v1 == 0 || v2 == 0) goto #16 12: s{3,4} = 1 13: goto #17 16: s{3,4} = 0 17: return s{3,4}請注意,我們應用的每個轉換都可能創造機會執行其他轉換。 在這種情況下,應用|| transform重組了我們的條件,現在#1...#5符合&&模式! 因此,我們可以通過將這些行合并為單個條件分支來進一步簡化該方法:
1: if (v0 == 0 && (v1 == 0 || v2 == 0)) goto #16 12: s{3,4} = 1 13: goto #17 16: s{3,4} = 0 17: return s{3,4}這看起來很熟悉嗎? 它應該:該字節碼現在符合我們前面介紹的三元( ?: :)運算符模式。 我們可以將#1...#16簡化為單個表達式,然后使用復制傳播將s{3,4}內聯到#17的return語句中:
return (v0 == 0 && (v1 == 0 || v2 == 0)) ? 0 : 1;使用前面描述的方法描述符和局部變量類型表,我們可以推斷出將該表達式簡化為的所有必要類型:
return (v0 == false && (v1 == false || v2 == false)) ? false : true;好吧,這當然比我們最初的反編譯更簡潔,但仍然很麻煩。 讓我們看看我們能做些什么。 我們可以先折疊x和!x比較,例如x == true和x == false 。 我們也可以通過減少x ? false : true來消除三元運算符x ? false : true x ? false : true與簡單表達式!x為x ? false : true 。
return !(!v0 && (!v1 || !v2));更好,但是仍然很少。 如果您還記得高中離散數學,那么可以看到De Morgan定理可以在這里應用:
!(a || b) --> (!a) && (!b)!(a && b) --> (!a) || (!b)因此:
return ! ( !v0 && ( !v1 || !v2 ) )…成為:
return ( v0 || !(!v1 || !v2 ) )…最終:
return ( v0 || (v1 && v2) )歡呼!
處理方法調用
我們已經看到了一種方法的外觀:在locals數組中將參數“到達”。 若要調用方法,必須將參數壓入堆棧,對于例如方法,此參數必須緊跟this指針。 正如您所期望的那樣,以字節碼調用方法:
push arg_0push arg_1 invokevirtual METHODREF我們在上面指定了invokevirtual ,這是用于調用大多數實例方法的指令。 JVM實際上有一些用于方法調用的指令,每個指令具有不同的語義:
對于反編譯器編寫器,重要的細節是該類的常量池包含任何調用方法的細節,包括其參數的數量和類型以及其返回類型。 在調用程序類中記錄此信息,可以使運行時驗證運行時是否存在預期的方法,并且該方法符合預期的簽名。 如果目標方法在第三方代碼中,并且其簽名發生了更改,則任何嘗試調用舊版本的代碼都將引發錯誤(與產生未定義行為相反)。
回到上面的示例, invokevirtual操作碼的存在告訴我們目標方法是實例方法 ,因此需要this指針作為隱式第一個參數。 常量池中的METHODREF告訴我們該方法具有一個形式參數,因此我們知道除了目標實例指針外,還需要從堆棧中彈出一個參數。 然后,我們可以將代碼重寫為:
arg_0.METHODREF(arg_1)當然,字節碼并不總是那么友好。 不需要將堆棧參數整齊地推入堆棧,一個接一個。 例如,如果參數之一是三元表達式,則將存在需要獨立轉換的中間加載,存儲和分支指令。 混淆器可能將方法重寫為特別復雜的指令序列。 一個好的反編譯器將需要足夠靈活,以處理超出本文范圍的許多有趣的極端情況。
不僅限于此……
到目前為止,我們僅限于分析單個代碼序列,首先是一系列簡單的指令,然后進行轉換以生成更熟悉的高級構造。 如果您認為這似乎過于簡單,那么您是正確的。 Java是一種高度結構化的語言,具有諸如范圍和塊之類的概念以及更復雜的控制流機制。 為了處理諸如if/else塊和循環之類的構造,我們需要對代碼進行更嚴格的分析,并特別注意可能采用的各種路徑。 這稱為控制流分析 。
我們首先將代碼分解為可以保證從頭到尾執行的連續塊。 這些稱為基本塊 ,我們通過沿可能跳轉到另一個塊的位置以及可能成為跳轉目標的任何指令劃分指令列表來構造它們。
然后,我們通過在塊之間創建代表所有可能分支的邊來建立控制流程圖 (CFG)。 注意,這些邊緣可能不是顯式分支。 包含可能引發異常的指令的塊將連接到它們各自的異常處理程序。 我們不會詳細介紹CFG的構造,但是需要一些高級知識來理解我們如何使用這些圖形來檢測諸如循環之類的代碼構造。
控制流程圖的示例。
我們最感興趣的控制流程圖是控制關系 :
- 如果到N所有路徑都經過D則稱節點D 支配另一個節點N 所有節點都占主導地位。 如果D和N是不同的節點,則說D 嚴格支配 N
- 如果D嚴格支配N 且不嚴格支配其他嚴格支配N節點,則可以說D 立即支配 N
- 支配者樹是節點樹,其中每個節點的子節點都是其立即支配的節點。
- D的支配性邊界是所有節點N的集合,以使D支配N的直接前輩,但不嚴格支配N 換句話說,這是D的優勢結束的節點集。
基本循環和控制流程
考慮以下Java方法:
public static void fn(int n) {for (int i = 0; i < n; ++i) {System.out.println(i);} }…及其拆卸:
0: iconst_01: istore_12: iload_13: iload_04: if_icmpge 207: getstatic #2 // System.out:PrintStream 10: iload_1 11: invokevirtual #3 // PrintStream.println:(I)V 14: iinc 1, 1 17: goto 2 20: return讓我們應用上面討論的內容,首先通過引入堆棧變量,然后執行復制傳播,將其轉換為更具可讀性的形式。
| 0 1個 2 3 4 7 10 11 14 17 20 | iconst_0 istore_1 iload_1 iload_0 if_icmpge 20 靜態#2 iload_1 invokevirtual#3 1號,1號 轉到2 返回 | s0 = 0 v1 = s0 s2 = v1 s3 = v0 如果(s2> = s3)轉到20 s4 = System.out s5 = v1 s4.println(s5) v1 = v1 + 1 轉到2 返回 | 如果(v1> = v0)轉到20,則v1 = 0 System.out.println(v1) |
注意在#4處的條件分支和在#17處的goto如何創建邏輯循環。 通過查看控制流程圖,我們可以更容易地看到這一點:
從圖中可以明顯看出,我們有一個整潔的循環,其邊緣從goto到條件分支。 在這種情況下,條件分支稱為循環頭 ,可以將其定義為具有形成循環后向邊緣的控制者。 循環頭控制著循環體內的所有節點。
我們可以通過尋找形成循環的后邊緣來確定條件是否為循環頭,但是我們該怎么做呢? 一個簡單的解決方案是測試條件節點是否在其自己的優勢邊界中。 一旦知道了循環頭,就必須找出要拉入循環主體的節點。 我們可以通過查找由標頭控制的所有節點來做到這一點。 在偽代碼中,我們的算法如下所示:
findDominatedNodes(header)q := new Queue()r := new Set()q.enqueue(header)while (not q.empty())n := q.dequeue()if (header.dominates(n))r.add(n)for (s in n.successors())q.enqueue(n)return r一旦弄清楚了循環體,就可以將代碼轉換成循環。 請記住,我們的循環頭可能是一個條件跳出循環,在這種情況下,我們需要否定的條件。
v1 = 0 while (v1 < v0) {System.out.println(v1)v1 = v1 + 1 } return瞧,我們有一個簡單的前提條件循環! 大多數循環(包括while , for和for-each )都編譯為相同的基本模式,我們將其視為簡單的while循環。 無法確定程序員最初編寫的是哪種循環,但是for和for-each遵循我們可以尋找的非常具體的模式。 我們將不進行詳細介紹,但是如果您查看上面的while循環,則可以看到原始的for循環的初始值設定項( v1 = 0 )在循環之前,并已插入其迭代器( v1 = v1 + 1 )。在循環主體的末尾。 我們將把它作為一種練習來思考何時以及如何將while循環轉換for或for-each 。 考慮如何調整邏輯以檢測條件后循環( do/while )也很有趣。
我們可以應用類似的技術來反編譯if/else語句。 字節碼模式非常簡單:
begin:iftrue(!condition) goto #else// `if` block begins here...goto #endelse:// `else` block begins here...end:// end of `if/else`在這里,我們使用iftrue作為表示任何條件分支的偽指令:測試條件,如果條件通過,則分支; 否則,請繼續。 我們知道if塊從條件后面的指令開始, else塊從條件的跳轉目標開始。 查找那些塊的內容就像查找由那些起始節點所控制的節點一樣簡單,我們可以使用與上述相同的算法來完成。
現在,我們已經介紹了基本的控制流機制,盡管還有其他一些機制(例如,異常處理程序和子例程),但是它們不在本文的介紹范圍之內。
包起來
編寫反編譯器絕非易事,而經驗很容易轉化為一本書的材料價值,甚至可能是一系列書籍! 顯然,我們無法在單個博客文章中介紹所有內容,并且如果我們嘗試過,您可能不想閱讀。 我們希望通過接觸最常見的結構(邏輯運算符,條件和基本控制流程),使您對反編譯器開發世界有一個有趣的了解。
Lee Benfield是CFR Java反編譯器的作者。
Mike Strobel是Java反編譯器和元編程框架Procyon的作者。
現在去寫你自己的! :)
翻譯自: https://www.javacodegeeks.com/2013/12/anatomy-of-a-java-decompiler.html
總結
以上是生活随笔為你收集整理的Java反编译器的剖析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: s选取高光快捷键(选取高光快捷键mac)
- 下一篇: Windows10系统玩游戏时如何关闭输