如何保护你的 Python 代码—— 定制 Python 解释器
0 前言
考慮前文所述的幾個方案,均是從源碼的加工入手,或多或少都有些不足。假設我們從解釋器的改造入手,會不會能夠更好的保護代碼呢?
由于發行商業 Python 程序到客戶環境時通常會包含一個 Python 解釋器,如果改造解釋器能解決源碼保護的問題,那么也是可選的一條路。
假定我們有一個算法,能夠加密原始的 Python 代碼,這些加密后代碼隨發行程序一起,可被任何人看到,卻難以破解。另一方面,有一個定制好的 Python 解釋器,它能夠解密這些被加密的代碼,然后解釋執行。而由于 Python 解釋器本身是二進制文件,人們也就無法從解釋器中獲取解密的關鍵數據。從而達到了保護源碼的目的。
要實現上述的設想,我們首先需要掌握基本的加解密算法,其次探究 Python 執行代碼的方式從而了解在何處進行加解密,最后禁用字節碼用以防止通過 .pyc 反編譯。
1 加解密算法
1.1 對稱密鑰加密算法
對稱密鑰加密(Symmetric-key algorithm)又稱為對稱加密、私鑰加密、共享密鑰加密,是密碼學中的一類加密算法。這類算法在加密和解密時使用相同的密鑰,或是使用兩個可以簡單地相互推算的密鑰。
對稱加密算法的特點是算法公開、計算量小、加密速度快、加密效率高。
常見的對稱加密算法有:DES、3DES、AES、Blowfish、IDEA、RC5、RC6 等。
對稱密鑰加解密過程如下:
明文通過密鑰加密成密文,密文也可通過相同的密鑰解密為明文。
通過 openssl 工具,我們能夠方便選擇對稱加密算法進行加解密。下面我們以 AES 算法為例,介紹其用法。
AES 加密
# 指定密碼進行對稱加密 $ openssl enc -aes-128-cbc -in test.py -out entest.py -pass pass:123456# 指定文件進行對稱加密 $ openssl enc -aes-128-cbc -in test.py -out entest.py -pass file:passwd.txt# 指定環境變量進行對稱加密 $ openssl enc -aes-128-cbc -in test.py -out entest.py -pass env:passwd AES 解密# 指定密碼進行對稱解密 $ openssl enc -aes-128-cbc -d -in entest.py -out test.py -pass pass:123456# 指定文件進行對稱解密 $ openssl enc -aes-128-cbc -d -in entest.py -out test.py -pass file:passwd.txt# 指定環境變量進行對稱解密 $ openssl enc -aes-128-cbc -d -in entest.py -out test.py -pass env:passwd1.2 非對稱密鑰加密算法
密鑰加密(英語:public-key cryptography,又譯為公開密鑰加密),也稱為非對稱加密(asymmetric cryptography),一種密碼學算法類型,在這種密碼學方法中,需要一對密鑰,一個是私鑰,另一個則是公鑰。這兩個密鑰是數學相關,用某用戶公鑰加密后所得的信息,只能用該用戶的私鑰才能解密。
非對稱加密算法的特點是算法強度復雜、安全性依賴于算法與密鑰但是由于其算法復雜,而使得加密解密速度沒有對稱加密解密的速度快。
常見的對稱加密算法有:RSA、Elgamal、背包算法、Rabin、D-H、ECC 等。
非對稱密鑰加解密過程如下:
明文通過公鑰加密成密文,密文通過與公鑰對應的私鑰解密為明文。
通過 openssl 工具,我們能夠方便選擇非對稱加密算法進行加解密。下面我們以 RSA 算法為例,介紹其用法。
生成私鑰、公鑰
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' # 輔以 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.txt2 基于加密算法實現源碼保護
對稱加密適合加密源碼文件,而非對稱加密適合加密密鑰。如果將兩者結合,就能達到加解密源碼的目的。
2.1 在構建環境進行加密
我們發行出去安裝包中,源碼應該是被加密過的,那么就需要在構建階段對源碼進行加密。加密的過程如下:
-
隨機生成一個密鑰。這個密鑰實際上是一個用于對稱加密的密碼。
-
使用該密鑰對源代碼進行對稱加密,生成加密后的代碼。
-
使用公鑰(生成方法見 非對稱密鑰加密算法)對該密鑰進行非對稱加密,生成加密后的密鑰。
不論是加密后的代碼還是加密后的密鑰,都會放在安裝包中。它們能夠被用戶看到,卻無法被破譯。而 Python 解釋器該如何執行加密后的代碼呢?
2.2 Python 解釋器進行解密
假定我們發行的 Python 解釋器中內置了與公鑰相對應的私鑰,有了它就有了解密的可能。而由于 Python 解釋器本身是二進制文件,所以不需要擔心內置的私鑰會被看到。解密的過程如下:
-
Python 解釋器執行加密代碼時需要被傳入指示加密密鑰的參數,通過這個參數,解釋器獲取到了加密密鑰
-
Python 解釋器使用內置的私鑰,對該加密密鑰進行非對稱解密,得到原始密鑰
-
Python 解釋器使用原始密鑰對加密代碼進行對稱解密,得到原始代碼
-
Python 解釋器執行這段原始代碼
可以看到,通過改造構建環節、定制 Python 解釋器的執行過程,便可以實現保護源碼的目的。改造構建環節是容易的,但是如何定制 Python 解釋器呢?我們需要深入了解解釋器執行腳本和模塊的方式,才能在特定的入口進行控制。
3 腳本、模塊的執行與解密
3.1 執行 Python 代碼的幾種方式
為了找到 Python 解釋器執行 Python 代碼時的所有入口,我們需要首先執行 Python 解釋器都能以怎樣的方式執行代碼。
直接運行腳本
python test.py直接運行語句
python -c "print 'hello'"直接運行模塊
python -m test導入、重載模塊
python >>> import test # 導入模塊 >>> reload(test) # 重載模塊直接運行語句 的方式接收的就是明文的代碼,我們也無需對這種方式做額外處理。直接運行模塊和導入、重載模塊這兩種方式在流程上是殊途同歸的,所以接下來會一起來看。因此我們將分兩種情況:運行腳本和加載模塊來進一步探究各自的過程和解密方式。
3.2 運行腳本時解密
運行腳本的過程 Python 解釋器在運行腳本時的代碼調用邏輯如下:
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");...} }處理和的部分我們暫且先不管,在處理文件(通過直接運行腳本的方式)的邏輯中,可以看到解釋打開了文件,獲得了文件指針。那么如果我們把這里的 fopen 換成是自定義的 decrypt_open 函數,這個函數用來打開一個加密文件,然后進行解密,并返回一個文件指針,這個指針指向解密后的文件。那么,不就可以實現解密腳本的目的了嗎?
自定義 decrypt_open 我們不妨新增一個 Modules/crypt.c 文件,用來存放一些自定義的加解密函數。
decrypt_open 函數大概實現如下:
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' [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");// 否則,轉換為內存文件描述符elsefp = fmemopen(plaintext, plainlen, "r");}return fp; }這里的 aes_passwd 是一個全局變量,代表對稱加密算法中的密鑰。我們暫時假定已經獲取該密鑰了,后文會說明如何獲得。而 aes_decrypt 是自定義的一個使用AES算法進行對稱解密的函數,限于篇幅,此函數的實現不再貼出。
decrypt_open 邏輯如下:- 判斷是否獲得了對稱密鑰,如果沒獲得,直接打開該文件并返回文件指針 - 如果獲得了,則嘗試使用對稱算法進行解密 - 如果解密失敗,可能就是一段非加密的腳本,直接打開該文件并返回文件指針 - 如果解密成功,我們通過解密后的內容創建一個內存文件對象,并返回該文件指針
實現了上述這些函數后,我們就能夠實現在直接運行腳本時,解密執行被加密代碼的目的。
3.3 加載模塊時解密
加載模塊的過程 加載模塊的邏輯主要實現在 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 的方式來加載模塊時,其入口函數是 Py_Main 函數
-
通過 import 的方式來加載模塊時,其入口函數是 builtin___import__ 函數
-
通過 reload() 的方式來加載模塊時,其入口函數是 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);}... }經過上述改動,就實現了加載模塊時解密的目的了。
3.4 支持指定密鑰文件
前文中還留有一個待解決的問題:我們一開始是假定解釋器已獲取到了密鑰內容并存放在了全局變量 aes_passwd 中,那么密鑰內容怎么獲取呢?
我們需要 Python 解釋器能支持一個新的參數選項,通過它來指定已加密的密鑰文件,然后再通過非對稱算法進行解密,得到 aes_passed。假定這個參數選項是 -k ,則可使用如 python -k enpasswd.txt 的方式來告知解釋器加密密鑰的文件路徑。其實現如下:
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' [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 文件是未加密的。換句話說,惡意用戶可以通過這種手段繞過限制。所以,我們需要 禁用字節碼。
4 禁用字節碼
4.1 不生成 .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);... }4.2 禁止訪問字節碼對象 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 解釋器完成了。
5 演示
5.1 運行腳本
通過 -k 選項執行已加密密鑰文件,Python 解釋器可以運行已加密和未加密的 Python 文件。
5.2 加載模塊
可以通過 -m 的方式加載已加密和未加密的模塊,也可以通過 import 的方式來加載已加密和未加密的模塊。
5.3 禁用字節碼
通過禁用字節碼,我們達到以下效果:- 不會生成 .pyc 文件 - 可以訪問函數的 func_code - 無法訪問代碼對象的 co_code,即本示例中的 f.func_code.co_code - 無法使用dis模塊來獲取字節碼
5.4 異常堆棧信息
盡管代碼是加密的,但是不會影響異常時的堆棧信息。
5.5 調試
加密的代碼也是允許調試的,但是輸出的代碼內容會是加密的,這正是我們所期望的。
6 思考
-
如何防止通過內存操作的方式找到對象的 co_code?
-
如何進一步提升私鑰被逆向工程探知的難度?
-
如何能在調試并希望看到源碼的時候看到?
總結
以上是生活随笔為你收集整理的如何保护你的 Python 代码—— 定制 Python 解释器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python:如何仅用递归函数和栈操作逆
- 下一篇: python爬虫:做一个界面爬虫小软件