一文讓你搞懂 Python 的 pyc 文件

pyc 文件的觸發

上一篇文章我們介紹了字節碼,當時提到,py 文件在執行的時候會先被編譯成 PyCodeObject 對象,並且該對象還會被保存到 pyc 文件中。

但不幸的是,事實並不總是這樣,有時當我們運行一個簡單的程序時,並沒有產生 pyc 文件。因此我們猜測:有些 Python 程序只是臨時完成一些瑣碎的工作,這樣的程序僅僅只會運行一次,然後就不會再使用了,因此也就沒有保存至 pyc 文件的必要。

如果我們在代碼中加上了一個 import abc 這樣的語句,再執行你就會發現 Python 爲 abc.py 生成了 pyc 文件,這就說明 import 會觸發 pyc 的生成。

實際上,在運行過程中,如果碰到 import abc 這樣的語句,那麼 Python 會在設定好的 path 中尋找 abc.pyc 或者 abc.pyd 文件。如果沒有這些文件,而是隻發現了 abc.py,那麼會先將 abc.py 編譯成 PyCodeObject,然後寫入到 pyc 文件中。

接下來,再對 abc.pyc 進行 import 動作。對的,並不是編譯成 PyCodeObject 對象之後就直接使用。而是先寫到 pyc 文件裏,然後再將 pyc 文件裏面的 PyCodeObject 對象重新在內存中複製出來。

當然啦,觸發 pyc 文件生成不僅可以通過 import,還可以通過 py_compile 模塊手動生成。比如當前有一個 tools.py:

a = 1
b = "你好啊"

如何將其編譯成 pyc 呢?

import py_compile

py_compile.compile("tools.py")

查看當前目錄的 pycache 目錄,會發現 pyc 已經生成了。

然後 py 文件名. cpython - 版本號. pyc 爲編譯之後的 pyc 文件名。

pyc 文件的導入

如果有一個現成的 pyc 文件,我們要如何導入它呢?

from importlib.machinery import SourcelessFileLoader

tools = SourcelessFileLoader(
    "tools""__pycache__/tools.cpython-38.pyc"
).load_module()

print(tools.a)  # 1
print(tools.b)  # 你好啊

以上我們就成功手動導入了 pyc 文件。

pyc 文件包含的內容

pyc 文件在創建的時候都會往裏面寫入哪些內容呢?

1. magic number

這是 Python 定義的一個整數值,不同版本的 Python 會定義不同的 magic number,這個值是爲了保證 Python 能夠加載正確的 pyc。

比如 Python3.7 不會加載 3.6 版本的 pyc,因爲 Python 在加載 pyc 文件的時候會首先檢測該 pyc 的 magic number。如果和自身的 magic number 不一致,則拒絕加載。

2. pyc 文件的寫入時間

這個很好理解,在加載 pyc 之前會先比較源代碼的最後修改時間和 pyc 文件的寫入時間。如果 pyc 文件的寫入時間比源代碼的修改時間要早,說明在生成 pyc 之後,源代碼被修改了,那麼會重新編譯並寫入 pyc,而反之則會直接加載已存在的 pyc。

3. py 文件的大小

py 文件的大小也會被記錄在 pyc 文件中。

4. PyCodeObject 對象

編譯之後的 PyCodeObject 對象,這個不用說了,肯定是要存儲的,並且是序列化之後再存儲。

因此 pyc 文件的結構如下:

注意:以上是 Python 3.7+ 的 pyc 文件結構,如果版本低於 3.7,那麼開頭沒有 4 個 \x00。我們實際驗證一下:

import struct
from importlib.util import MAGIC_NUMBER
from datetime import datetime

with open("__pycache__/tools.cpython-38.pyc""rb") as f:
    data = f.read()

# 0 ~ 4 字節是 MAGIC NUMBER
print(data[: 4])  # b'U\r\r\n'
print(MAGIC_NUMBER)  # b'U\r\r\n'

# 4 ~ 8 字節是 4 個 \x00
print(data[4: 8])  # b'\x00\x00\x00\x00'

# 8 ~ 12 字節是 pyc 的寫入時間(小端存儲),一個時間戳
ts = struct.unpack("<I", data[8: 12])[0]
print(ts)  # 1671001724
print(
    datetime.fromtimestamp(ts)
)  # 2022-12-14 20:32:23

# 12 ~ 16 字節是 py 文件的大小
print(
    struct.unpack("<I", data[12: 16])[0]
)  # 21

結果和我們分析的一樣,因此對於任何一個 pyc 文件來說,前 16 字節是固定的(如果 Python 低於 3.7,那麼前 12 個字節是固定的)。

16 個字節往後就是 PyCodeObject 對象,並且是序列化之後的,因爲該對象顯然無法直接存在文件中。

import marshal

with open("__pycache__/tools.cpython-38.pyc""rb") as f:
    data = f.read()

# 通過 marshal.loads 可以反序列化
# marshal.dumps 則表示序列化
code = marshal.loads(data[16:])
# 此時就拿到了 py 文件編譯之後的 PyCodeObject
print(code)
"""
<code object <module> at 0x..., file "tools.py", line 1>
"""
# 查看常量池
print(code.co_consts)  # (1, '你好啊', None)

# 符號表
print(code.co_names)  # ('a', 'b')

問題來了,既然我們可以根據 pyc 文件反推出 PyCodeObject,那麼能否手動構建 PyCodeObject 然後生成 pyc 呢?來試一下。

a = 1
b = 2
c = 3

上述代碼編譯之後的結果,就是我們要構建的 PyCodeObject。

from importlib.util import MAGIC_NUMBER
import struct
import time
from types import CodeType
import marshal
from opcode import opmap

HEADER = MAGIC_NUMBER + b"\x00" * 4
# 時間隨便寫
HEADER += struct.pack("<I", int(time.time()))
# 大小隨便寫
HEADER += struct.pack("<I", 30)

# 構建 PyCodeObject
code = CodeType(
    0,                # co_argcount
    0,                # co_posonlyargcount
    0,                # co_kwonlyargcount
    3,                # co_nlocals
    1,                # co_stacksize
    0,                # co_flags

    bytes([
        # a = 1 分爲兩步
        # 第一步:先通過 LOAD_CONST 將常量加載進來
        # 因此指令是 LOAD_CONST,然後參數是 0
        # 表示加載常量池中索引爲 0 的常量
        opmap["LOAD_CONST"], 0,
        # 第二步:通過 STORE_NAME 將常量和符號綁定起來
        # 參數是 0,表示和符號表中索引爲 0 的符號進行綁定
        opmap["STORE_NAME"], 0,
        # b = 2
        opmap["LOAD_CONST"], 1,
        opmap["STORE_NAME"], 1,
        # c = 3
        opmap["LOAD_CONST"], 2,
        opmap["STORE_NAME"], 2,
        # 結尾要 LOAD 一個 None,然後返回
        opmap["LOAD_CONST"], 3,
        opmap["RETURN_VALUE"]
    ]),               # co_code

    (1, 2, 3, None),  # co_consts
    ("a""b""c"),  # co_names
    (),               # co_varnames
    "build_pyc.py",   # co_filename
    "<module>",       # co_name
    1,                # co_firstlineno
    b"",              # co_lnotab
    (),               # freevars
    ()                # cellvars
)

# pyc 文件內容
pyc_content = HEADER + marshal.dumps(code)
# 生成 pyc 文件
with open("build_pyc.pyc""wb") as f:
    f.write(pyc_content)

# 然後加載生成的 pyc 文件
from importlib.machinery import SourcelessFileLoader
mod = SourcelessFileLoader(
    "build_pyc""build_pyc.pyc"
).load_module()

print(mod)  # <module 'build_pyc' from 'build_pyc.pyc'>
print(mod.a)  # 1
print(mod.b)  # 2
print(mod.c)  # 3

怎麼樣,是不是很有趣呢?

pyc 文件的寫入

下面通過源碼來查看 pyc 文件的寫入過程,既然要寫入,那麼肯定要有文件句柄。

//位置:Python/marshal.c

//FILE是 C 自帶的文件句柄
//可以把WFILE看成是FILE的包裝
typedef struct {
    FILE *fp;  //文件句柄
    //下面的字段在寫入信息的時候會看到
    int error;  
    int depth;
    PyObject *str;
    char *ptr;
    char *end;
    char *buf;
    _Py_hashtable_t *hashtable;
    int version;
} WFILE;

首先是寫入 magic number、創建時間和文件大小,它們會調用 PyMarshal_WriteLongToFile 函數進行寫入:

void
PyMarshal_WriteLongToFile(long x, FILE *fp, int version)
{  
    //magic number、創建時間和文件大小,只是一個整數
    //在寫入的時候,使用char [4]來保存
    char buf[4];
    //聲明一個WFILE類型變量wf
    WFILE wf;
    //內存初始化
    memset(&wf, 0, sizeof(wf));
    //初始化內部成員
    wf.fp = fp;
    wf.ptr = wf.buf = buf;
    wf.end = wf.ptr + sizeof(buf);
    wf.error = WFERR_OK;
    wf.version = version;
    //調用w_long將x、也就是版本信息或者時間寫到wf裏面去
    w_long(x, &wf);
    //刷到磁盤上
    w_flush(&wf);
}

所以該函數只是初始化了一個 WFILE 對象,真正寫入則是調用的 w_long。

static void
w_long(long x, WFILE *p)
{
    w_byte((char)( x      & 0xff), p);
    w_byte((char)((x>> 8) & 0xff), p);
    w_byte((char)((x>>16) & 0xff), p);
    w_byte((char)((x>>24) & 0xff), p);
}

w_long 則是調用 w_byte 將 x 逐個字節地寫到文件裏面去。

而寫入 PyCodeObject 對象則是調用 PyMarshal_WriteObjectToFile,它實際又會調用 w_object 進行寫入。

static void
w_object(PyObject *v, WFILE *p)
{
    char flag = '\0';

    p->depth++;

    if (p->depth > MAX_MARSHAL_STACK_DEPTH) {
        p->error = WFERR_NESTEDTOODEEP;
    }
    else if (v == NULL) {
        w_byte(TYPE_NULL, p);
    }
    else if (v == Py_None) {
        w_byte(TYPE_NONE, p);
    }
    else if (v == PyExc_StopIteration) {
        w_byte(TYPE_STOPITER, p);
    }
    else if (v == Py_Ellipsis) {
        w_byte(TYPE_ELLIPSIS, p);
    }
    else if (v == Py_False) {
        w_byte(TYPE_FALSE, p);
    }
    else if (v == Py_True) {
        w_byte(TYPE_TRUE, p);
    }
    else if (!w_ref(v, &flag, p))
        w_complex_object(v, flag, p);

    p->depth--;
}

可以看到本質上還是調用了 w_byte,但這僅僅是一些特殊的對象。如果是列表、字典之類的數據,那麼會調用 w_complex_object,也就是代碼中的最後一個 else if 分支。

w_complex_object 這個函數的源代碼很長,我們看一下整體結構,具體邏輯就不貼了,後面會單獨截取一部分進行分析。

static void
w_complex_object(PyObject *v, char flag, WFILE *p)
{
    Py_ssize_t i, n;
    //如果是整數的話,執行整數的寫入邏輯
    if (PyLong_CheckExact(v)) {
        //......
    }
    //如果是浮點數的話,執行浮點數的寫入邏輯
    else if (PyFloat_CheckExact(v)) {
        if (p->version > 1) {
            //......
        }
        else {
            //......
        }
    }
    //如果是複數的話,執行復數的寫入邏輯
    else if (PyComplex_CheckExact(v)) {
        if (p->version > 1) {
            //......
        }
        else {
            //......
        }
    }
    //如果是字節序列的話,執行字節序列的寫入邏輯
    else if (PyBytes_CheckExact(v)) {
        //......
    }
    //如果是字符串的話,執行字符串的寫入邏輯
    else if (PyUnicode_CheckExact(v)) {
        if (p->version >= 4 && PyUnicode_IS_ASCII(v)) {
              //......
            }
            else {
                //......
            }
        }
        else {
            //......
        }
    }
    //如果是元組的話,執行元組的寫入邏輯
    else if (PyTuple_CheckExact(v)) {
       //......
    }
    //如果是列表的話,執行列表的寫入邏輯
    else if (PyList_CheckExact(v)) {
        //......
    }
    //如果是字典的話,執行字典的寫入邏輯
    else if (PyDict_CheckExact(v)) {
        //......
    }
    //如果是集合的話,執行集合的寫入邏輯
    else if (PyAnySet_CheckExact(v)) {
        //......
    }
    //如果是PyCodeObject對象的話
    //執行PyCodeObject對象的寫入邏輯
    else if (PyCode_Check(v)) {
        //......
    }
    //如果是Buffer的話,執行Buffer的寫入邏輯
    else if (PyObject_CheckBuffer(v)) {
        //......
    }
    else {
        W_TYPE(TYPE_UNKNOWN, p);
        p->error = WFERR_UNMARSHALLABLE;
    }
}

源代碼雖然長,但是邏輯非常單純,就是對不同的對象、執行不同的寫動作,然而其最終目的都是通過 w_byte 寫到 pyc 文件中。瞭解完函數的整體結構之後,我們再看一下具體細節,看看它在寫入對象的時候到底寫入了哪些內容?

static void
w_complex_object(PyObject *v, char flag, WFILE *p)
{
    //......
    else if (PyList_CheckExact(v)) {
        W_TYPE(TYPE_LIST, p);
        n = PyList_GET_SIZE(v);
        W_SIZE(n, p);
        for (i = 0; i < n; i++) {
            w_object(PyList_GET_ITEM(v, i), p);
        }
    }
    else if (PyDict_CheckExact(v)) {
        Py_ssize_t pos;
        PyObject *key, *value;
        W_TYPE(TYPE_DICT, p);
        /* This one is NULL object terminated! */
        pos = 0;
        while (PyDict_Next(v, &pos, &key, &value)) {
            w_object(key, p);
            w_object(value, p);
        }
        w_object((PyObject *)NULL, p);
    }    
    //......
}

以列表和字典爲例,它們在寫入的時候實際上寫的是內部的元素,其它對象也是類似的。

def foo():
    lst = [1, 2, 3]

# 把列表內的元素寫進去了
print(
    foo.__code__.co_consts
)  # (None, 1, 2, 3)

但問題來了,如果只是寫入元素的話,那麼 Python 在加載的時候怎麼知道它是一個列表呢?所以在寫入的時候不能光寫數據,類型信息也要寫進去。我們再看一下上面列表和字典的寫入邏輯,裏面都調用了 W_TYPE,它負責將類型信息寫進去。

因此無論對於哪種對象,在寫入具體數據之前,都會先調用 W_TYPE 將類型信息寫進去。如果沒有類型信息,那麼當 Python 加載 pyc 文件的時候,只會得到一坨字節流,而無法解析字節流中隱藏的結構和蘊含的信息。

所以在往 pyc 文件裏寫入數據之前,必須先寫入一個標識,諸如 TYPE_LIST, TYPE_TUPLE, TYPE_DICT 等等,這些標識正是對應的類型信息。

如果解釋器在 pyc 文件中發現了這樣的標識,則預示着上一個對象結束,新的對象開始,並且也知道新對象是什麼樣的對象,從而也知道該執行什麼樣的構建動作。當然,這些標識也是可以看到的,在底層已經定義好了。

到了這裏可以看到,Python 對 PyCodeObject 對象的導出實際上是不復雜的。因爲不管什麼對象,最後都爲歸結爲兩種簡單的形式,一種是數值寫入,一種是字符串寫入。

上面都是對數值的寫入,比較簡單,僅僅需要按照字節依次寫入 pyc 即可。然而在寫入字符串的時候,Python 設計了一種比較複雜的機制,有興趣可以自己閱讀源碼,這裏不再介紹。

字節碼混淆

最後再來說一下字節碼混淆,我們知道 pyc 是可以反編譯的,而且目前也有現成的工具。但這些工具它會將每一個指令都解析出來,所以字節碼混淆的方式就是往裏面插入一些惡意指令(比如加載超出範圍的數據),讓反編譯工具在解析的時候報錯,從而失去作用。

但插入的惡意指令還不能影響解釋器執行,因此還要插入一些跳轉指令,從而讓解釋器跳過惡意指令。

混淆之後多了兩條指令,其中偏移量爲 8 的指令,參數爲 255,但執行的時候會發生越界,因此反編譯的時候毫無疑問會報錯。而解釋器在執行的時候卻沒有問題,因爲在執行到偏移量爲 6 的指令時出現了一個絕對跳轉,直接跳到偏移量爲 10 的指令了。

因此對於解釋器執行來說,混淆前後是沒有區別的。但對於反編譯工具而言就會無法正常工作,因爲它會把每一個指令都解析一遍。

根據這個思路,我們可以插入很多很多的惡意指令,然後再用跳轉指令來跳過這些不合法指令。當然混淆的手段並不止這些,我們還可以添加一下虛假的分支,然後在執行時跳轉到真實的分支當中。

而這一切的目的,都是爲了防止別人根據 pyc 文件反推出源代碼。不過這種做法屬於治標不治本,如果真的想要保護源代碼的話,可以使用 Cython 將其編譯成 pyd ,這是最推薦的做法。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/5bSv33i4QHWoH7NYPvnqcw