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