轉載:http://blog.csdn.net/ir0nf1st/article/details/61650984
<0x00> 前言
Python開發者常常面臨這樣一個難題,即如何保護代碼中的技術秘密。筆者嘗試過的一些Python代碼保護工具要么難以有效實現該目標,要么有效但是有着不可忽視的缺點。最近筆者也遇到了這個問題,在難以找到一個有效解決方案的情況下,不得不自行開發了一個字節碼混淆器。本文首先對常見的Python代碼保護機制以及幾個比較容易獲得的Python代碼保護工具進行了簡單的分析,然后展示了通過字節碼混淆來保護Python代碼的技術原理。
<0x01> 源碼混淆
筆者嘗試過兩個源碼混淆工具。一個是pyminifier,另一個是提供在線源碼混淆服務的http://pyob.oxyry.com/。這兩個工具的工作方法類似,他們對類名/函數名/變量名進行重新命名,pyminifier甚至能夠對部分Python常量進行擾亂(如True/False/None),然而代碼的邏輯與控制流並沒有被改變。閱讀被混淆過的源碼對於讀者的眼睛來說是一種摧殘,也帶來了理解上的困難,但是簡單的改名甚至無法對抗基本的文本查找與替換。源碼混淆如果只在名字替換上下功夫,要實現代碼保護無異於緣木求魚。筆者認為:源碼混淆要實現代碼保護,則必須提取目標程序的抽象語法樹(Abstract Syntax Tree)並對語法樹進行修改,再根據修改后的語法樹生成新的源碼。然而這么做的工作量不會比實現一個編譯器來得更少。這篇英文文章更深入的介紹了基於AST分析的Python源碼混淆方法,有興趣的讀者可參考。
以下是pyminifier試用結果,讀者可以評估一下名字替換是否可以有效保護源碼。http://pyob.oryry.com提供的服務要比pyminifier來得更簡單,這里就不提供試用效果了。
混淆前的樣例代碼:
class SampleClass: def __init__(self): self.data = None def method1SampleClass(self, arg): self.data = arg def function_with_if(arg): if arg == True: pass else: pass def function_with_if1(arg=True): if arg == True: print('True') else: print('False') def function_with_if2(): if True: print('True') else: print('False') def function_with_try_except1(): try: data = 1/0 except: print('Constructed Control Flow') def function_with_try_except2(): try: pass print('Constructed Control Flow') except: pass global_var1, global_var2, global_var3 pass a = SampleClass() a.method1SampleClass() function_with_if(False) function_with_if1() del global_var1, global_var2, global_var3
混淆后的代碼:
class N: y=None T=True H=False def __init__(P): P.data=y def b(P,R): P.data=R def x(R): if R==T: pass else: pass def L(arg=T): if arg==T: print('True') else: print('False') def I(): if T: print('True') else: print('False') def d(): try: n=1/0 except: print('Constructed Control Flow') def E(): try: pass print('Constructed Control Flow') except: pass global_var1,global_var2,global_var3 pass a=N() a.method1SampleClass() x(H) L() del global_var1,global_var2,global_var3 # Created by pyminifier (https://github.com/liftoff/pyminifier)
值得一提的是pyminifier在對None/True/False進行擾亂的時候似乎有一個bug。
class N: y=None T=True H=False def __init__(P): P.data=y def b(P,R): P.data=R def x(R): if R==T: pass else: pass
其中變量y/T/H的作用域在class N內部,然而下面的函數並不是class N的方法,函數中對T/H的引用超出了其作用域。
def L(arg=T): if arg==T: print('True') else: print('False') def I(): if T: print('True') else: print('False')
如果讀者使用pyminifier,需要注意這個問題。
<0x02> 將Python代碼打包為可執行文件
py2exe, PyInstaller將Python代碼以及Python運行環境(如Python解釋器,應用依賴的標准模塊等)打包為可執行文件,這樣你的Python代碼就可以在一個沒有事先安裝Python的目標機器上運行。py2exe將Python代碼及其依賴文件打包成一個zip包,解壓后你會發現所有文件都在那等着被反編譯。PyInstaller比py2exe更安全一些,它支持對Python代碼進行AES加密,然而明文的AES密鑰也被存儲在打包文件中。
另外一個選擇是Cython。這是是一個將Python擴展到C的模塊,開發者可以在Python中直接使用類似於C的語法進行開發,或者間接使用C語言進行開發。開發者開發的C模塊可以被Python代碼調用,同時該C模塊在運行環境上是native binary code(x86 Windows平台上就是x86_PE格式,ARM Linux平台則為 arm_elf,arm_eabi或者其他)。一定程度上native binary code對逆向工程者提出了更高的技術要求,增加了逆向工程的難度,從而實現了對開發者代碼的保護。但是本質上來說,它保護的不過是開發者的C代碼,而不是Python代碼。而使用Cython的缺點也是顯而易見的,C語言開發難度要顯著高於Python,C語言開發的模塊也導致整個軟件喪失了跨平台的特性。
如果你不在意Cython帶來的缺點,使用Cython來保護你的C代碼不失為一個好的選擇。
<0x03> 使用私有Python Bytecode指令集
對於同一個版本的Python,Python編譯器、解釋器、反匯編器以及反編譯器都使用同樣的Bytecode指令集。不同版本的Python則使用不同的Bytecode指令集,這也是為何Python 2.X編譯器產生的pyc文件無法被Python 3.x解釋器執行的原因之一。
如果使用私有的Bytecode指令集,那么通常的Python反匯編器和反編譯器無法工作在由你私有Python編譯器產生的pyc文件上,也相當於保護了你的Python代碼。這么做的代價是你的Python應用只能在你的私有Python解釋器上運行。
<0x04> 字節碼混淆
字節碼混淆可以非常容易的欺騙通常的反匯編器和反編譯器,同時不影響代碼的正常執行。下面這個例子展示了如何欺騙Uncompyle6反編譯器以及dis反匯編器:
#一個簡單的Python應用 sample1.py print 'Hello World'
對其進行編譯:
python -m py_compile sample1.py
對編譯后的sample1.pyc使用Python內置dis模塊反匯編:
>>> import marshal,dis >>> fd = open('sample1.pyc', 'rb') >>> fd.seek(8) >>> sample1_code_obj = marshal.load(fd) >>> fd.close() >>> dis.dis(sample1_code_obj) 1 0 LOAD_CONST 0 ('Hello World') 3 PRINT_ITEM 4 PRINT_NEWLINE 5 LOAD_CONST 1 (None) 8 RETURN_VALUE >>>
以上的匯編代碼筆者肉眼反匯編的結果如下:
0 LOAD_CONST 0 ('Hello World') #加載co_consts[0]到棧頂,co_consts[0]存儲着常量字符串'Hello World' 3 PRINT_ITEM #打印棧頂到sys.stdout,即print 'Hello World' 4 PRINT_NEWLINE #打印新行到sys.stdout,此指令因print語句而由編譯器自動生成 5 LOAD_CONST 1 (None) #加載co_consts[1]到棧頂,co_consts[1]存儲着None 8 RETURN_VALUE #將棧頂返回給調用者,此兩條指令為編譯器自動生成
現在我們修改sample1.pyc,在程序入口增加一條絕對跳轉指令(可以使用UltraEdit 16進制插入功能修改pyc文件,”JUMP_ABSOLUTE 3”在Python 2.7中對應的字節碼為 0x71 0x03 0x00。修改code string內容的同時應修改code string的長度,此處增加了一個3字節指令),使用內置dis模塊反匯編的結果如下:
1 0 JUMP_ABSOLUTE 3 #自行添加 >> 3 LOAD_CONST 0 ('Hello World') 6 PRINT_ITEM 7 PRINT_NEWLINE 8 LOAD_CONST 1 (None) 11 RETURN_VALUE
如果讀者對匯編代碼有一定認識,就會明白此處的絕對跳轉對Python虛擬機執行此程序基本沒有影響(除了增加一個指令執行周期),然而這個絕對跳轉將成功欺騙反編譯器。使用Uncompyle6反編譯的結果如下:
<<< Error: Decompiling stopped due to <class 'uncompyle6.semantics.pysource.ParserError'>
- 1
如果一個pyc文件無法被反編譯,初級的破解者可能就會止步於此了,但對於有經驗的工程師來說這還遠遠不夠。同樣的,我們還要讓通常的反匯編器也無法工作才行。按下面的匯編代碼繼續加工上面的sample1.pyc。
| 1 0 JUMP_ABSOLUTE [71 06 00] 6 | 3 LOAD_CONST [64 FF FF] 65535 (FAKE!) | >> 6 LOAD_CONST [64 00 00] 0 (Hello World) | 9 PRINT_ITEM [47 -- --] | 10 PRINT_NEWLINE [48 -- --] | 11 LOAD_CONST [64 01 00] 1 (None) | 14 RETURN_VALUE [53 -- --]
以上第二條指令的意思是加載code object常量表的第65535項到棧頂。在上述sample1.pyc中,常量表的長度為2,下標65535已超出常量表的范圍,所以這是條非法指令。但由於第一條絕對跳轉的存在,第二條指令永遠都不會被執行。通常的反匯編器如dis會盡全力列舉有用的信息,但並不能理解實際執行的控制流,當反匯編器嘗試反匯編第二條指令時,會試着去讀取code object常量表的第65535項並且拋出一個’tuple index out of range’的意外。Python內置dis模塊的出錯信息如下:
>>> fd = open('sample1.pyc', 'rb') >>> fd.seek(8) >>> import marshal,dis >>> sample1_code_obj = marshal.load(fd) >>> dis.dis(sample1_code_obj) 1 0 JUMP_ABSOLUTE 6 3 LOAD_CONST 65535 Traceback (most recent call last): File "<stdin>", line 1, in <module> File "C:\Python27\lib\dis.py", line 43, in dis disassemble(x) File "C:\Python27\lib\dis.py", line 96, in disassemble print '(' + repr(co.co_consts[oparg]) + ')', IndexError: tuple index out of range >>>
現在Uncompyle6和dis都被欺騙了,代碼得到了有效的保護。
<0x05> 更多的字節碼混淆技術
<0x05 0x01>虛假分支
開發者可以故意構造復雜的分支結構,然而通過預置條件來實現僅覆蓋特定分支,可以有效的浪費手動逆向者的時間與精力,即便逆向者使用控制流分析軟件也無濟於事。
#flag可以是一些計算的結果 #或者是隱藏在某處的預置常量 #也可以是某次函數調用的返回值 if flag is condition: normal_processing() else useless_but_complicated_obfuscating_code() or_even_invalid_code()
try: some_processing() raise_exeception = __import__('module_does_not_exist') #上面的調用將拋出一個'ImportError'的意外,控制流將轉向except分支 useless_but_complicated_obfuscating_code() except: continue_normal_processing()
try: some_processing() raise_exeception = __import__('sys').non_exist_function() #上面的調用將拋出一個'AttributeError'的意外,控制流將轉向except分支 useless_but_complicated_obfuscating_code() except: continue_normal_processing()
try: some_processing() raise_exeception = 1/0 #上面的語句將拋出一個'ZeroDivisionError'的意外,控制流將轉向except分支 useless_but_complicated_obfuscating_code() except: continue_normal_processing()
<0x05 0x02>重疊指令
重疊指令(Overlapping Instruction)在有變長指令的CISC機器(如X86)上有廣泛應用。以x86匯編舉例說明重疊指令:
#例1單重疊指令 00: EB 01 jmp 3 02: 68 c3 90 90 90 push 0x909090c3 #例1實際執行 00: EB 01 jmp 3 03: C3 retn
#例2多重疊指令 00: EB02 jmp 4 02: 69846A40682C104000EB02 imul eax, [edx + ebp*2 + 0102C6840], 0x002EB0040 #例2實際執行 00: EB02 jmp 4 04: 6A40 push 040 06: 682C104000 push 0x40102C 0B: EB02 jmp 0xF
#例3跳轉至自身 00: EBFF jmp 1 02: C0C300 rol bl, 0 #例3實際執行 00: EBFF jmp 1 01: FFC0 inc eax 03: C3 retn
與單一跳轉指令相比,重疊指令是在跳轉基礎上進一步混淆控制流的技術手段,可以有效對抗逆向者。Python字節碼類似於RISC指令(如ARM),其指令長度要么是三字節要么是一字節,但任然可以構造重疊指令:
#例1 Python單重疊指令 0 JUMP_ABSOLUTE [71 05 00] 5 3 PRINT_ITEM [47 -- --] 4 LOAD_CONST [64 64 01] 356 7 STOP_CODE [00 -- --] #例1 實際執行 0 JUMP_ABSOLUTE [71 05 00] 5 5 LOAD_CONST [64 01 00] 1
#例2 Python多重疊指令 0 EXTENDED_ARG [91 00 64] 3 EXTENDED_ARG [91 00 53] 6 JUMP_ABSOLUTE [71 02 00] #例2 實際執行 0 EXTENDED_ARG [91 00 64] 3 EXTENDED_ARG [91 00 53] 6 JUMP_ABSOLUTE [71 02 00] 2 LOAD_CONST [64 91 00] 5 RETURN_VALUE [53 -- --]
<0x06>對抗手動逆向工程
以上展示的是欺騙機器(反編譯器和反匯編器)的技術,但是並不存在一種技術可以欺騙人類。對於願意進行手動逆向的人來說,唯一可行的手段是增加其逆向的難度和時間成本。引入更復雜的控制流可以略微增加逆向的難度,但也不會太多, 有經驗的破解者通常會對你的代碼使用控制流分析軟件。
代碼擾亂可以在對抗人類的路上走得更遠一些。真正的應用代碼可以被加密存儲在pyc文件的一個或者多個字符串常量中,程序執行時首先有一段解擾代碼對加密存儲的應用代碼進行解擾,然后真正的應用代碼被執行。精心設計的擾碼算法可以對抗破解者靜態分析你的應用代碼。下面是一個簡單的代碼擾亂例子。
仍以上面的sample1.pyc為例,對其進行加擾:
>>> fd = open('sample1.pyc', 'rb') >>> fd.seek(8) >>> import marshal >>> co = marshal.load(fd) >>> fd.close() >>> code_string = marshal.dumps(co) >>> scrambled_code = code_string.encode('zlib').encode('base64') >>> print scrambled_code eJxLZoACRiB2AOJifiBRyMaQ8v9/CgODu0cKI0OwBhNIghtIeKTm5OQrhOcX5aT4aYC0oRHFXCAi MbcgJ9VIr6CyhAPItcnNTynNSbUD2VACUgQAIHcTlg==
將加擾后的代碼串拷貝到下面的descramble.py中
scrambled_code_string='eJxLZoACRiB2AOJifiBRyMaQ8v9/CgODu0cKI0OwBhNIghtIeKTm5OQrhOcX5aT4aYC0oRHFXCAiMbcgJ9VIr6CyhAPItcnNTynNSbUD2VACUgQAIHcTlg==' exec __import__('marshal').loads(scrambled_code_string.decode('base64').decode('zlib'))
執行descrmble.py
>python descramble.py Hello World
不要在意這個簡單的加擾算法,本例只是展示加擾的概念。
<0x07>后記
字節碼混淆(匯編混淆)在x86平台上早已廣泛應用,並不是什么新技術,除了應用在Python上,其他使用字節碼/匯編代碼的編程語言應該都可以采用同樣的原理進行代碼保護。