前言
在PyCon China 2018 杭州站分享過 Python 源碼加密 ,講述了如何通過修改 Python 解釋器達到加解密 Python 代碼的目的。然而因爲筆者拖延症發作,一直沒有及時整理成文字版,現在終于戰勝了它,才有了本文。
本文將首先介紹下現有源碼加密方案的思路、方法、優點與不足,進而介紹如何通過定制 Python 解釋器來達到更好地加解密源碼的目的。
現有加密方案
由于 Python 的動態特性和開源特點,導致 Python 代碼很難做到很好的加密。社區中的一些聲音認爲這樣的限制是事實,應該通過法律手段而不是加密源碼達到商業保護的目的;而還有一些聲音則是不論如何都希望能有一種手段來加密。于是乎,人們想出了各種或加密、或混淆的方案,借此來達到保護源碼的目的。
常見的源碼保護手段有如下幾種:
.pyc py2exe Cython
下面來簡單說說這些方案。
發行 .pyc 文件
思路
大家都知道,Python 解釋器在執行代碼的過程中會首先生成 .pyc 文件,然後解釋執行 .pyc 文件中的內容。當然了,Python 解釋器也能夠直接執行 .pyc 文件。而 .pyc 文件是二進制文件,無法直接看出源碼內容。如果發行代碼到客戶環境時都是 .pyc 而非 .py 文件的話,那豈不是能達到保護 Python 代碼的目的?
方法
把 .py 文件編譯爲 .pyc 文件,是件非常輕松地事情,可不需要把所有代碼跑一遍,然後去撈生成的 .pyc 文件。
事實上,Python 標准庫中提供了一個名爲 compileall 的庫,可以輕松地進行編譯。
執行如下命令能夠將遍曆 <src> 目錄下的所有 .py 文件,將之編譯爲 .pyc 文件:
python -m compileall <src>然後刪除 <src> 目錄下所有 .py 文件就可以打包發布了:
$ find <src> -name ‘*.py’ -type f -print -exec rm {} \;
優點
- 簡單方便,提高了一點源碼破解門檻
- 平台兼容性好, .py 能在哪裏運行, .pyc 就能在哪裏運行
不足
.pyc
python-uncompyle6 就是這樣一款反編譯工具,效果出衆。
執行如下命令,即可將 .pyc 文件反編譯爲 .py 文件:
$ uncompyle6 *compiled-python-file-pyc-or-pyo*
代碼混淆
如果代碼被混淆到一定程度,連作者看著都費勁的話,是不是也能達到保護源碼的目的呢?
思路
既然我們的目的是混淆,就是通過一系列的轉換,讓代碼逐漸不那麽讓人容易明白,那就可以這樣下手:
- 移除注釋和文檔。沒有這些說明,在一些關鍵邏輯上就沒那麽容易明白了。
- 改變縮進。完美的縮進看著才舒服,如果縮進忽長忽短,看著也一定鬧心。
- 在tokens中間加入一定空格。這就和改變縮進的效果差不多。
- 重命名函數、類、變量。命名直接影響了可讀性,亂七八糟的名字可是閱讀理解的一大障礙。
- 在空白行插入無效代碼。這就是障眼法,用無關代碼來打亂閱讀節奏。
方法
方法一:使用 oxyry 進行混淆
http://pyob.oxyry.com/ 是一個在線混淆 Python 代碼的網站,使用它可以方便地進行混淆。
假定我們有這樣一段 Python 代碼,涉及到了類、函數、參數等內容:
# coding: utf-8
class A(object): “”” Description “””
def __init__(self, x, y, default=None): self.z = x + y self.default = default def name(self): return ‘No Name’
def always(): return True
num = 1 a = A(num, 999, 100) a.name() always()
經過 Oxyry 的混淆,得到如下代碼:
class A (object ):#line:4 “”#line:7 def __init__ (O0O0O0OO00OO000O0 ,OO0O0OOOO0000O0OO ,OO0OO00O00OO00OOO ,OO000OOO0O000OOO0 =None ):#line:9 O0O0O0OO00OO000O0 .z =OO0O0OOOO0000O0OO +OO0OO00O00OO00OOO #line:10 O0O0O0OO00OO000O0 .default =OO000OOO0O000OOO0 #line:11 def name (O000O0O0O00O0O0OO ):#line:13 return ‘No Name’#line:14 def always ():#line:17 return True #line:18 num =1 #line:21 a =A (num ,999 ,100 )#line:22 a .name ()#line:23 always ()
混淆後的代碼主要在注釋、參數名稱和空格上做了些調整,稍微帶來了點閱讀上的障礙。
方法二:使用 pyobfuscate 庫進行混淆
pyobfuscate 算是一個頗具年頭的 Python 代碼混淆庫了,但卻是“老當益壯”了。
對上述同樣一段 Python 代碼,經 pyobfuscate 混淆後效果如下:
# coding: utf-8 if 64 – 64: i11iIiiIii if 65 – 65: O0 / iIii1I11I1II1 % OoooooooOO – i1IIi class o0OO00 ( object ) : if 78 – 78: i11i . oOooOoO0Oo0O if 10 – 10: IIiI1I11i11 if 54 – 54: i11iIi1 – oOo0O0Ooo if 2 – 2: o0 * i1 * ii1IiI1i % OOooOOo / I11i / Ii1I def __init__ ( self , x , y , default = None ) : self . z = x + y self . default = default if 48 – 48: iII111i % IiII + I1Ii111 / ooOoO0o * Ii1I def name ( self ) : return ‘No Name’ if 46 – 46: ooOoO0o * I11i – OoooooooOO if 30 – 30: o0 – O0 % o0 – OoooooooOO * O0 * OoooooooOO def Oo0o ( ) : return True if 60 – 60: i1 + I1Ii111 – I11i / i1IIi if 40 – 40: oOooOoO0Oo0O / O0 % ooOoO0o + O0 * i1IIi I1Ii11I1Ii1i = 1 Ooo = o0OO00 ( I1Ii11I1Ii1i , 999 , 100 ) Ooo . name ( ) Oo0o ( ) # dd678faae9ac167bc83abf78e5cb2f3f0688d3a3
相比于方法一,方法二的效果看起來更好些。除了類和函數進行了重命名、加入了一些空格,最明顯的是插入了若幹段無關的代碼,變得更加難讀了。
優點
- 簡單方便,提高了一點源碼破解門檻
- 兼容性好,只要源碼邏輯能做到兼容,混淆代碼亦能
不足
- 只能對單個文件混淆,無法做到多個互相有聯系的源碼文件的聯動混淆
- 代碼結構未發生變化,也能獲取字節碼,破解難度不大
使用 py2exe
思路
py2exe 是一款將 Python 腳本轉換爲 Windows 平台上的可執行文件的工具。其原理是將源碼編譯爲 .pyc 文件,加之必要的依賴文件,一起打包成一個可執行文件。
如果最終發行由 py2exe 打包出的二進制文件,那豈不是達到了保護源碼的目的?
方法
使用 py2exe 進行打包的步驟較爲簡便。
- 編寫入口文件。本示例中取名爲 hello.py :
print ‘Hello World’
- 編寫 setup.py :
from distutils.core import setup import py2exe
setup(console=[‘hello.py’])
- 生成可執行文件
python setup.py py2exe
生成的可執行文件位于 dist\hello.exe 。
優點
- 能夠直接打包成 exe,方便分發和執行
- 破解門檻比 .pyc 更高一些
不足
.pyc
使用 Cython
思路
雖說 Cython 的主要目的是帶來性能的提升,但是基于它的原理:將 .py / .pyx 編譯爲 .c 文件,再將 .c 文件編譯爲 .so (Unix) 或 .pyd (Windows),其帶來的另一個好處就是難以破解。
方法
使用 Cython 進行開發的步驟也不複雜。
- 編寫文件 hello.pyx 或 hello.py :
def hello(): print(‘hello’)
- 編寫 setup.py :
from distutils.core import setup from Cython.Build import cythonize
setup(name=’Hello World app’, ext_modules=cythonize(‘hello.pyx’))
- 編譯爲 .c ,再進一步編譯爲 .so 或 .pyd :
python setup.py build_ext –inplace
執行 python -c “from hello import hello;hello()” 即可直接引用生成的二進制文件中的 hello() 函數。
優點
- 生成的二進制 .so 或 .pyd 文件難以破解
- 同時帶來了性能提升
不足
- 兼容性稍差,對于不同版本的操作系統,可能需要重新編譯
- 雖然支持大多數 Python 代碼,但如果一旦發現部分代碼不支持,完善成本較高
定制 Python 解釋器
考慮前文所述的幾個方案,均是從源碼的加工入手,或多或少都有些不足。假設我們從解釋器的改造入手,會不會能夠更好的保護代碼呢?
由于發行商業 Python 程序到客戶環境時通常會包含一個 Python 解釋器,如果改造解釋器能解決源碼保護的問題,那麽也是可選的一條路。
假定我們有一個算法,能夠加密原始的 Python 代碼,這些加密後代碼隨發行程序一起,可被任何人看到,卻難以破解。另一方面,有一個定制好的 Python 解釋器,它能夠解密這些被加密的代碼,然後解釋執行。而由于 Python 解釋器本身是二進制文件,人們也就無法從解釋器中獲取解密的關鍵數據。從而達到了保護源碼的目的。
要實現上述的設想,我們首先需要掌握基本的加解密算法,其次探究 Python 執行代碼的方式從而了解在何處進行加解密,最後禁用字節碼用以防止通過 .pyc 反編譯。
加解密算法
對稱密鑰加密算法
對稱密鑰加密(Symmetric-key algorithm)又稱爲對稱加密、私鑰加密、共享密鑰加密,是密碼學中的一類加密算法。這類算法在加密和解密時使用相同的密鑰,或是使用兩個可以簡單地相互推算的密鑰。
對稱加密算法的特點是算法公開、計算量小、加密速度快、加密效率高。
常見的對稱加密算法有:DES、3DES、AES、Blowfish、IDEA、RC5、RC6 等。
對稱密鑰加解密過程如下:
明文通過公鑰加密成密文,密文通過與公鑰對應的私鑰解密爲明文。
通過 openssl 工具,我們能夠方便選擇非對稱加密算法進行加解密。下面我們以 RSA 算法爲例,介紹其用法。
生成私鑰、公鑰
# 輔以 AES-128 算法,生成 2048 比特長度的私鑰 $ openssl genrsa -aes128 -out private.pem 2048
# 根據私鑰來生成公鑰 $ openssl rsa -in private.pem -outform PEM -pubout -out public.pem
RSA 加密
# 使用公鑰進行加密 openssl rsautl -encrypt -in passwd.txt -inkey public.pem -pubin -out enpasswd.txt
RSA 解密
# 使用私鑰進行解密 openssl rsautl -decrypt -in enpasswd.txt -inkey private.pem -out passwd.txt
基于加密算法實現源碼保護
對稱加密適合加密源碼文件,而非對稱加密適合加密密鑰。如果將兩者結合,就能達到加解密源碼的目的。
在構建環境進行加密
我們發行出去安裝包中,源碼應該是被加密過的,那麽就需要在構建階段對源碼進行加密。加密的過程如下:
- Python 解釋器執行加密代碼時需要被傳入指示加密密鑰的參數,通過這個參數,解釋器獲取到了加密密鑰
- Python 解釋器使用內置的私鑰,對該加密密鑰進行非對稱解密,得到原始密鑰
- Python 解釋器使用原始密鑰對加密代碼進行對稱解密,得到原始代碼
- Python 解釋器執行這段原始代碼
可以看到,通過改造構建環節、定制 Python 解釋器的執行過程,便可以實現保護源碼的目的。改造構建環節是容易的,但是如何定制 Python 解釋器呢?我們需要深入了解解釋器執行腳本和模塊的方式,才能在特定的入口進行控制。
腳本、模塊的執行與解密
執行 Python 代碼的幾種方式
爲了找到 Python 解釋器執行 Python 代碼時的所有入口,我們需要首先執行 Python 解釋器都能以怎樣的方式執行代碼。
直接運行腳本
python test.py
直接運行語句
python -c “print ‘hello'”
直接運行模塊
python -m test
導入、重載模塊
python >>> import test # 導入模塊 >>> reload(test) # 重載模塊
直接運行語句的方式接收的就是明文的代碼,我們也無需對這種方式做額外處理。
直接運行模塊和 導入、重載模塊 這兩種方式在流程上是殊途同歸的,所以接下來會一起來看。
因此我們將分兩種情況:運行腳本和加載模塊來進一步探究各自的過程和解密方式。
運行腳本時解密
運行腳本的過程
Python 解釋器在運行腳本時的代碼調用邏輯如下:
main WinMain [Modules/python.c] [PC/WinMain.c] \ / \ / \ / \ / \ / Py_Main [Moduls/main.c]
Python 解釋器運行腳本的入口函數因操作系統而異,在 Linux/Unix 系統上,主入口函數是 Modules/python.c 中的 main 函數,在 Windows系統上,則是 PC/WinMain.c 中的 WinMain 函數。不過這兩個函數最終都會調用 Moduls/main.c 中的 Py_Main 函數。
我們不妨來看看 Py_Main 函數中的相關邏輯:
[Modules/Main.c] ————————————–
int Py_Main(int argc, char **argv) { if (command) { // 處理 python -c <command> } else if (module) { // 處理 python -m <module> } else { // 處理 python <file> … fp = fopen(filename, “r”); … } }
處理 <command> 和 <module> 的部分我們暫且先不管,在處理文件(通過直接運行腳本的方式)的邏輯中,可以看到解釋打開了文件,獲得了文件指針。那麽如果我們把這裏的 fopen 換成是自定義的 decrypt_open 函數,這個函數用來打開一個加密文件,然後進行解密,並返回一個文件指針,這個指針指向解密後的文件。那麽,不就可以實現解密腳本的目的了嗎?
自定義 decrypt_open
我們不妨新增一個 Modules/crypt.c 文件,用來存放一些自定義的加解密函數。
decrypt_open 函數大概實現如下:
[Modules/crypt.c] ————————————–
/* 以解密方式打開文件 */ FILE * decrypt_open(const char *filename, const char *mode) { int plainlen = -1; char *plaintext = NULL; FILE *fp = NULL;
if (aes_passwd == NULL) fp = fopen(filename, “r”); else { plainlen = aes_decrypt(filename, aes_passwd, &plaintext); // 如果無法解密,返回源文件描述符 if (plainlen < 0) fp = fopen(filename, “r”); // 否則,轉換爲內存文件描述符 else fp = fmemopen(plaintext, plainlen, “r”); } return fp; }
這裏的 aes_passwd 是一個全局變量,代表對稱加密算法中的密鑰。我們暫時假定已經獲取該密鑰了,後文會說明如何獲得。而 aes_decrypt 是自定義的一個使用AES算法進行對稱解密的函數,限于篇幅,此函數的實現不再貼出。
decrypt_open 邏輯如下:
- 判斷是否獲得了對稱密鑰,如果沒獲得,直接打開該文件並返回文件指針
- 如果獲得了,則嘗試使用對稱算法進行解密如果解密失敗,可能就是一段非加密的腳本,直接打開該文件並返回文件指針如果解密成功,我們通過解密後的內容創建一個內存文件對象,並返回該文件指針
實現了上述這些函數後,我們就能夠實現在直接運行腳本時,解密執行被加密代碼的目的。
加載模塊時解密
加載模塊的過程
加載模塊的邏輯主要實現在 Python/import.c 文件中,其過程如下:
Py_Main [Moduls/main.c] | builtin___import__ RunModule | | PyImport_ImportModuleLevel <—-┐ PyImport_ImportModule | | | import_module_level └——- PyImport_Import | load_next builtin_reload | | import_submodule PyImport_ReloadModule | | find_module <—————————┘
- 通過 python -m <module> 的方式來加載模塊時,其入口函數是 Py_Main 函數
- 通過 import <module> 的方式來加載模塊時,其入口函數是 builtin___import__ 函數
- 通過 reload(<module>) 的方式來加載模塊時,其入口函數是 builtin_reload 函數
但不論是哪種方式,最終都會調用 find_module 函數,我們看看這個函數中是否暗藏乾坤呢?
[Python/import.c] ————————————–
static struct filedescr * find_module(char *fullname, char *subname, PyObject *path, char *buf, size_t buflen, FILE **p_fp, PyObject **p_loader) { … fp = fopen(buf, filemode); … }
我們在 find_module 函數中找到了打開文件的邏輯,如果直接改成前文實現的 decrypt_open ,豈不是就能達成加載模塊時解密的目的了?
總體思路是這樣的,但有個細節需要注意, buf 不一定就是 .py 文件,也可能是 .pyc 文件,我們只對 .py 文件做改動,則可以這麽寫:
[Python/import.c] ————————————–
static struct filedescr * find_module(char *fullname, char *subname, PyObject *path, char *buf, size_t buflen, FILE **p_fp, PyObject **p_loader) { … if (fdp->type == PY_SOURCE) { fp = decrypt_open(buf, filemode); } else { fp = fopen(buf, filemode); } … }
經過上述改動,就實現了加載模塊時解密的目的了。
支持指定密鑰文件
前文中還留有一個待解決的問題:我們一開始是假定解釋器已獲取到了密鑰內容並存放在了全局變量 aes_passwd 中,那麽密鑰內容怎麽獲取呢?
我們需要 Python 解釋器能支持一個新的參數選項,通過它來指定已加密的密鑰文件,然後再通過非對稱算法進行解密,得到 aes_passed 。
假定這個參數選項是 -k <filename> ,則可使用如 python -k enpasswd.txt 的方式來告知解釋器加密密鑰的文件路徑。其實現如下:
[Modules/main.c] ————————————–
/* 命令行選項,注意k:是新增的內容 */ #define BASE_OPTS “3bBc:dEhiJk:m:OQ:RsStuUvVW:xX?” … /* Long usage message, split into parts < 512 bytes */ static char *usage_1 = “\ … -k key : decrypt source file by using key file\n\ … “; … int Py_Main(int argc, char **argv) { … char *keyfilename = NULL; … while ((c = _PyOS_GetOpt(argc, argv, PROGRAM_OPTS)) != EOF) { … case ‘k’: keyfilename = (char *)malloc(strlen(_PyOS_optarg) + 1); if (keyfilename == NULL) Py_FatalError( “not enough memory to copy -k argument”); strcpy(keyfilename, _PyOS_optarg); keyfilename[strlen(_PyOS_optarg)] = ‘\0’; break; … } … if (keyfilename != NULL) { int passwdlen; char *passwd = NULL;
passwdlen = rsa_decrypt(keyfilename, &passwd); set_aes_passwd(passwd); if (passwdlen < 0) { fprintf(stderr, “%s: parsing key file ‘%s’ error\n”, argv[0], keyfilename); free(keyfilename); return 2; } else { free(keyfilename); } } … }
其邏輯如下:
- k: 中的 k 表示支持 -k 選項; : 表示選項後跟一個參數,即這裏的已加密密鑰文件的路徑
- 解釋器在處理到 -k 參數時,獲取其後所跟的文件路徑,記錄在 keyfilename 中
- 使用自定義的 rsa_decrypt 函數(限于篇幅,不列出如何實現的邏輯)對已加密密鑰文件進行非對稱解密,獲得密鑰的原始內容
- 將該密鑰內容寫入到 aes_passwd 中
由此,通過顯示地指定已加密密鑰文件,解釋器獲得了原始密鑰,進而通過該密鑰解密已加密代碼,再執行原始代碼。但是,這裏面還潛藏著一個 風險 :執行代碼的過程中會生成 .pyc 文件,通過它反編譯出的 .py 文件是未加密的。換句話說,惡意用戶可以通過這種手段繞過限制。所以,我們需要 禁用字節碼
禁用字節碼
不生成 .pyc 文件
首先要做的就是不生成 .pyc 文件,這樣,惡意用戶就沒法直接根據 .pyc 文件來得到源碼。
我們知道,通過 -B 選項可以告知 Python 解釋器不生成 .pyc 文件。既然定制的 Python 解釋器就不生成 .pyc 我們幹脆禁用這個選項:
[Modules/main.c] ————————————–
/* 命令行選項,注意移除了B */ #define BASE_OPTS “3bc:dEhiJm:OQ:RsStuUvVW:xX?” … /* Long usage message, split into parts < 512 bytes */ static char *usage_1 = “\ … //-B : don’t write .py[co] files on import; also PYTHONDONTWRITEBYTECODE=x\n\ … “; … int Py_Main(int argc, char **argv) { … // 不生成 py[co] Py_DontWriteBytecodeFlag++; … }
除此以外,Python 解釋器還會從環境變量中獲取是否不生成 .pyc 文件,因此也需要做處理:
[Python/pythonrun.c] ————————————–
void Py_InitializeEx(int install_sigs) { … f ((p = Py_GETENV(“PYTHONDEBUG”)) && *p != ‘\0’) Py_DebugFlag = add_flag(Py_DebugFlag, p); if ((p = Py_GETENV(“PYTHONVERBOSE”)) && *p != ‘\0’) Py_VerboseFlag = add_flag(Py_VerboseFlag, p); if ((p = Py_GETENV(“PYTHONOPTIMIZE”)) && *p != ‘\0’) Py_OptimizeFlag = add_flag(Py_OptimizeFlag, p); // 移除對 PYTHONDONTWRITEBYTECODE 的處理 if ((p = Py_GETENV(“PYTHONDONTWRITEBYTECODE”)) && *p != ‘\0’) Py_DontWriteBytecodeFlag = add_flag(Py_DontWriteBytecodeFlag, p); … }
禁止訪問字節碼對象 co_code
僅僅是不生成 .pyc 文件還是不夠的,惡意用戶已然可以訪問對象的 co_code 屬性來獲取字節碼,進而通過反編譯的手段獲取到源碼。因此,我們也需要禁止用戶訪問字節碼對象:
[Objects/codeobject.c] ————————————–
static PyMemberDef code_memberlist[] = { {“co_argcount”, T_INT, OFF(co_argcount), READONLY}, {“co_nlocals”, T_INT, OFF(co_nlocals), READONLY}, {“co_stacksize”,T_INT, OFF(co_stacksize), READONLY}, {“co_flags”, T_INT, OFF(co_flags), READONLY}, // {“co_code”, T_OBJECT, OFF(co_code), READONLY}, {“co_consts”, T_OBJECT, OFF(co_consts), READONLY}, {“co_names”, T_OBJECT, OFF(co_names), READONLY}, {“co_varnames”, T_OBJECT, OFF(co_varnames), READONLY}, {“co_freevars”, T_OBJECT, OFF(co_freevars), READONLY}, {“co_cellvars”, T_OBJECT, OFF(co_cellvars), READONLY}, {“co_filename”, T_OBJECT, OFF(co_filename), READONLY}, {“co_name”, T_OBJECT, OFF(co_name), READONLY}, {“co_firstlineno”, T_INT, OFF(co_firstlineno), READONLY}, {“co_lnotab”, T_OBJECT, OFF(co_lnotab), READONLY}, {NULL} /* Sentinel */ };
到此,一個定制的 Python 解釋器完成了。
演示
運行腳本
通過 -k 選項執行已加密密鑰文件,Python 解釋器可以運行已加密和未加密的 Python 文件。
禁用字節碼
通過禁用字節碼,我們達到以下效果:
.pyc
調試
加密的代碼也是允許調試的,但是輸出的代碼內容會是加密的,這正是我們所期望的。