如何保護你的 Python 代碼 (二)—— 定製 Python 解釋器

轉載自:Prodesire博客

0 前言

考慮前文所述的幾個方案,均是從源碼的加工入手,或多或少都有些不足。假設我們從解釋器的改造入手,會不會能夠更好的保護代碼呢?

由於發行商業 Python 程序到客戶環境時通常會包含一個 Python 解釋器,如果改造解釋器能解決源碼保護的問題,那麼也是可選的一條路。

假定我們有一個算法,能夠加密原始的 Python 代碼,這些加密後代碼隨發行程序一起,可被任何人看到,卻難以破解。另一方面,有一個定製好的 Python 解釋器,它能夠解密這些被加密的代碼,然後解釋執行。而由於 Python 解釋器本身是二進制文件,人們也就無法從解釋器中獲取解密的關鍵數據。從而達到了保護源碼的目的。

要實現上述的設想,我們首先需要掌握基本的加解密算法,其次探究 Python 執行代碼的方式從而瞭解在何處進行加解密,最後禁用字節碼用以防止通過 .pyc 反編譯。

1 加解密算法

1.1 對稱密鑰加密算法

對稱密鑰加密(Symmetric-key algorithm)又稱爲對稱加密、私鑰加密、共享密鑰加密,是密碼學中的一類加密算法。這類算法在加密和解密時使用相同的密鑰,或是使用兩個可以簡單地相互推算的密鑰。

對稱加密算法的特點是算法公開、計算量小、加密速度快、加密效率高。

常見的對稱加密算法有:DES、3DES、AES、Blowfish、IDEA、RC5、RC6 等。

對稱密鑰加解密過程如下:

v2-a24f27ed494c8ec09aa8bea2bb77a54d_720w.jpg

明文通過密鑰加密成密文,密文也可通過相同的密鑰解密爲明文。

通過 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 等。

非對稱密鑰加解密過程如下:

v2-6a569c39b573d1a6410a87cc0e3bc48b_720w.jpg

明文通過公鑰加密成密文,密文通過與公鑰對應的私鑰解密爲明文。

通過 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 在構建環境進行加密

我們發行出去安裝包中,源碼應該是被加密過的,那麼就需要在構建階段對源碼進行加密。加密的過程如下:

v2-82eba5b4ffa3972ee79d6c555f9837d2_720w.jpg

  1. 隨機生成一個密鑰。這個密鑰實際上是一個用於對稱加密的密碼。

  2. 使用該密鑰對源代碼進行對稱加密,生成加密後的代碼。

  3. 使用公鑰(生成方法見 非對稱密鑰加密算法)對該密鑰進行非對稱加密,生成加密後的密鑰。

不論是加密後的代碼還是加密後的密鑰,都會放在安裝包中。它們能夠被用戶看到,卻無法被破譯。而 Python 解釋器該如何執行加密後的代碼呢?

2.2 Python 解釋器進行解密

假定我們發行的 Python 解釋器中內置了與公鑰相對應的私鑰,有了它就有了解密的可能。而由於 Python 解釋器本身是二進制文件,所以不需要擔心內置的私鑰會被看到。解密的過程如下:

v2-1336e4c5ad4ad03fc9761f7054bb300b_720w.jpg

  1. Python 解釋器執行加密代碼時需要被傳入指示加密密鑰的參數,通過這個參數,解釋器獲取到了加密密鑰

  2. Python 解釋器使用內置的私鑰,對該加密密鑰進行非對稱解密,得到原始密鑰

  3. Python 解釋器使用原始密鑰對加密代碼進行對稱解密,得到原始代碼

  4. 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 文件。

v2-d93ab2e713f1ea64b73d98f0908e1a0c_b.jpg

5.2 加載模塊

可以通過 -m <module> 的方式加載已加密和未加密的模塊,也可以通過 import <module> 的方式來加載已加密和未加密的模塊。

v2-f6577d5eecb8f99caaf6100c7bea9b77_b.jpg

5.3 禁用字節碼

通過禁用字節碼,我們達到以下效果: - 不會生成 .pyc 文件 - 可以訪問函數的 func_code - 無法訪問代碼對象的 co_code,即本示例中的 f.func_code.co_code - 無法使用dis模塊來獲取字節碼

v2-f513382e667449eb9992374129bc2674_b.jpg

5.4 異常堆棧信息

儘管代碼是加密的,但是不會影響異常時的堆棧信息。

v2-e044c049d1a3a6e5838b018899989413_b.jpg

5.5 調試

加密的代碼也是允許調試的,但是輸出的代碼內容會是加密的,這正是我們所期望的。

v2-4a4a06223f17cebc9d36f53ff25cc9b2_b.jpg

6 思考

  1. 如何防止通過內存操作的方式找到對象的co_code?

  2. 如何進一步提升私鑰被逆向工程探知的難度?

  3. 如何能在調試並希望看到源碼的時候看到?


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章