轉載自:Prodesire博客
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:passwd
1.2 非對稱密鑰加密算法
密鑰加密(英語:public-key cryptography,又譯爲公開密鑰加密),也稱爲非對稱加密(asymmetric cryptography),一種密碼學算法類型,在這種密碼學方法中,需要一對密鑰,一個是私鑰,另一個則是公鑰。這兩個密鑰是數學相關,用某用戶公鑰加密後所得的信息,只能用該用戶的私鑰才能解密。
非對稱加密算法的特點是算法強度複雜、安全性依賴於算法與密鑰但是由於其算法複雜,而使得加密解密速度沒有對稱加密解密的速度快。
常見的對稱加密算法有:RSA、Elgamal、揹包算法、Rabin、D-H、ECC 等。
非對稱密鑰加解密過程如下:
明文通過公鑰加密成密文,密文通過與公鑰對應的私鑰解密爲明文。
通過 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
2 基於加密算法實現源碼保護
對稱加密適合加密源碼文件,而非對稱加密適合加密密鑰。如果將兩者結合,就能達到加解密源碼的目的。
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 解釋器在運行腳本時的代碼調用邏輯如下:
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]--------------------------------------intPy_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
邏輯如下: - 判斷是否獲得了對稱密鑰,如果沒獲得,直接打開該文件並返回文件指針 - 如果獲得了,則嘗試使用對稱算法進行解密 - 如果解密失敗,可能就是一段非加密的腳本,直接打開該文件並返回文件指針 - 如果解密成功,我們通過解密後的內容創建一個內存文件對象,並返回該文件指針
實現了上述這些函數後,我們就能夠實現在直接運行腳本時,解密執行被加密代碼的目的。
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 <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); } ...}
經過上述改動,就實現了加載模塊時解密的目的了。
3.4 支持指定密鑰文件
前文中還留有一個待解決的問題:我們一開始是假定解釋器已獲取到了密鑰內容並存放在了全局變量 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\...";...intPy_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\...";...intPy_Main(int argc, char **argv){ ... // 不生成 py[co] Py_DontWriteBytecodeFlag++; ...}
除此以外,Python 解釋器還會從環境變量中獲取是否不生成 .pyc
文件,因此也需要做處理:
[Python/pythonrun.c]--------------------------------------voidPy_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 <module>
的方式加載已加密和未加密的模塊,也可以通過 import <module>
的方式來加載已加密和未加密的模塊。
5.3 禁用字節碼
通過禁用字節碼,我們達到以下效果: - 不會生成 .pyc
文件 - 可以訪問函數的 func_code - 無法訪問代碼對象的 co_code,即本示例中的 f.func_code.co_code - 無法使用dis模塊來獲取字節碼
5.4 異常堆棧信息
儘管代碼是加密的,但是不會影響異常時的堆棧信息。
5.5 調試
加密的代碼也是允許調試的,但是輸出的代碼內容會是加密的,這正是我們所期望的。
6 思考
如何防止通過內存操作的方式找到對象的co_code?
如何進一步提升私鑰被逆向工程探知的難度?
如何能在調試並希望看到源碼的時候看到?