一文让你搞懂 Python 的 pyc 文件

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 一文让你搞懂 Python 的 pyc 文件


pyc 文件的触发


上一篇文章我们介绍了字节码,当时提到,py 文件在执行的时候会先被编译成 PyCodeObject 对象,并且该对象还会被保存到 pyc 文件中。

然而事实并不总是这样,有时当我们运行一个简单的程序时,并没有产生 pyc 文件。因此我们猜测:有些 Python 程序只是临时完成一些琐碎的工作,这样的程序仅仅只会运行一次,然后就不会再使用了,因此也就没有保存至 pyc 文件的必要。

如果我们在代码中加上了一个 import abc 这样的语句,再执行你就会发现解释器为 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 已经生成了。

9ab6a70372934b9c2a42bf998077b338.png

然后 py文件名.cpython-版本号.pyc 便是编译之后的 pyc 文件名。


pyc 文件的导入


如果有一个现成的 pyc 文件,我们要如何导入它呢?

from importlib.machinery import SourcelessFileLoader
tools = SourcelessFileLoader(
    "tools", "__pycache__/tools.cpython-312.pyc"
).load_module()
print(tools.a)  # 1
print(tools.b)  # 你好啊

以上我们就成功手动导入了 pyc 文件。


pyc 文件都包含哪些内容


pyc 文件在创建的时候都会往里面写入哪些内容呢?

1)magic number


这是 Python 定义的一个整数值,不同版本的 Python 会定义不同的 magic number,这个值是为了保证 Python 能够加载正确的 pyc。

比如 Python3.12 不会加载 3.10 版本的 pyc,因为 Python 在加载 pyc 文件的时候会首先检测该 pyc 的 magic number。如果和自身的 magic number 不一致,则拒绝加载。

from importlib.util import MAGIC_NUMBER
print(MAGIC_NUMBER)  # b'\xcb\r\r\n'
with open("__pycache__/tools.cpython-312.pyc", "rb") as f:
    magic_number = f.read(4)
print(magic_number)  # b'\xcb\r\r\n'

pyc 文件的前 4 个字节便是 magic number。

2)pyc 文件的写入时间

这个很好理解,在加载 pyc 之前会先比较源代码的最后修改时间和 pyc 文件的写入时间。如果 pyc 文件的写入时间比源代码的修改时间要早,说明在生成 pyc 之后,源代码被修改了,那么会重新编译并写入 pyc,而反之则会直接加载已存在的 pyc。

3)py 文件的大小


py 文件的大小也会被记录在 pyc 文件中。

4)PyCodeObject 对象

编译之后的 PyCodeObject 对象,这个不用说了,肯定是要存储的,并且是序列化之后再存储。


因此 pyc 文件的结构如下:


74ec65e2a223c7d6f6c90e37735dfd03.jpg

我们实际验证一下:

import struct
from importlib.util import MAGIC_NUMBER
from datetime import datetime
with open("__pycache__/tools.cpython-312.pyc", "rb") as f:
    data = f.read()
# 0 ~ 4 字节是 MAGIC NUMBER
print(data[: 4])  # b'\xcb\r\r\n'
print(MAGIC_NUMBER)  # b'\xcb\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)  # 1726742711
print(
    datetime.fromtimestamp(ts)
)  # 2024-09-19 10:45:11
# 12 ~ 16 字节是 py 文件的大小
print(
    struct.unpack("<I", data[12: 16])[0]
)  # 22

结果和我们分析的一样,前 16 字节是固定的,而 16 个字节往后就是 PyCodeObject 对象,并且是序列化之后的,因为该对象显然无法直接存在文件中。

import marshal
with open("__pycache__/tools.cpython-312.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 文件的写入


下面通过源码来查看 pyc 文件的写入过程,既然要写入,那么肯定要有文件句柄

// Python/marshal.c
// FILE 是 C 自带的文件句柄
// 可以把 WFILE 看成是 FILE 的包装
typedef struct {
    FILE *fp;
    // 下面的字段在写入数据的时候会看到
    int error; 
    int depth;
    PyObject *str;
    char *ptr;
    const char *end;
    char *buf;
    _Py_hashtable_t *hashtable;
    int version;
} WFILE;

首先是写入 magic number、创建时间和文件大小,它们会调用 PyMarshal_WriteLongToFile 函数进行写入:

// Python/marshal.c
void
PyMarshal_WriteLongToFile(long x, FILE *fp, int version)
{
    // magic number、创建时间和文件大小,只是一个 4 字节整数
    // 因此使用 char[4] 来保存
    char buf[4];
    // 声明一个 WFILE 类型的变量 wf
    WFILE wf;
    // 内存初始化
    memset(&wf, 0, sizeof(wf));
    // 初始化内部字段
    wf.fp = fp;  // 文件句柄
    wf.ptr = wf.buf = buf;  // buf 数组首元素的地址
    wf.end = wf.ptr + sizeof(buf);  // buf 数组尾元素的地址
    wf.error = WFERR_OK;
    wf.version = version;
    // 调用 w_long 将信息写到 wf 里面
    // 写入的信息可以是 magic number、时间和文件大小
    w_long(x, &wf);
    // 刷到磁盘上
    w_flush(&wf);
}

所以该函数只是初始化了一个 WFILE 对象,真正写入则是调用的 w_long。

// Python/marshal.c
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 函数负责

// Python/marshal.c
void
PyMarshal_WriteObjectToFile(PyObject *x, FILE *fp, int version)
{
    char buf[BUFSIZ];
    WFILE wf;
    if (PySys_Audit("marshal.dumps", "Oi", x, version) < 0) {
        return; /* caller must check PyErr_Occurred() */
    }
    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;
    if (w_init_refs(&wf, version)) {
        return; /* caller must check PyErr_Occurred() */
    }
    // 写入头信息由 PyMarshal_WriteLongToFile 负责,它内部会调用 w_long
    // 写入 PyCodeObject 由当前函数负责,它内部会调用 w_object
    w_object(x, &wf);
    w_clear_refs(&wf);
    w_flush(&wf);
}

然后我们看一下 w_object 函数。

// Python/marshal.c
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_object 和 w_long 一样,本质上都是调用了 w_byte。当然 w_byte 只能写入一些简单数据,如果是列表、字典之类的数据,那么会调用 w_complex_object 函数,也就是代码中的最后一个 else if 分支。

w_complex_object 这个函数的源代码很长,我们看一下整体结构,具体逻辑就不贴了,后面会单独截取一部分进行分析。

// Python/marshal.c
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)) {
        // ......
    }
    // 如果是复数的话,执行复数的写入逻辑
    else if (PyComplex_CheckExact(v)) {
        // ......
    }
    // 如果是字节序列的话,执行字节序列的写入逻辑
    else if (PyBytes_CheckExact(v)) {
        // ......
    }
    // 如果是字符串的话,执行字符串的写入逻辑
    else if (PyUnicode_CheckExact(v)) {
        // ......
    }
    // 如果是元组的话,执行元组的写入逻辑
    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 文件中。了解完函数的整体结构之后,我们再看一下具体细节,看看它在写入对象的时候到底写入了哪些内容?

// Python/marshal.c
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))

当然啦,对于 3.12 版本来说,内部的元素会以元组的形式被收集起来。

但很明显,如果只是将元素收集起来显然是不够的,否则 Python 在加载的时候怎么知道它是一个列表呢?所以在写入的时候不能光写数据,还要将类型信息也写进去。我们再看一下上面列表和字典的写入逻辑,里面都调用了 W_TYPE,它负责写入类型信息。

因此无论对于哪种对象,在写入具体数据之前,都会先调用 W_TYPE 将类型信息写进去。如果没有类型信息,那么当解释器加载 pyc 文件的时候,只会得到一坨字节流,而无法解析字节流中隐藏的结构和蕴含的信息。

所以在往 pyc 文件里写入数据之前,必须先写入一个标识,诸如 TYPE_LIST, TYPE_TUPLE, TYPE_DICT 等等,这些标识正是对应的类型信息。

如果解释器在 pyc 文件中发现了这样的标识,则预示着上一个对象结束,新的对象开始,并且也知道新对象是什么样的对象,从而也知道该执行什么样的构建动作。当然,这些标识也是可以看到的,在底层已经定义好了。


a2dfc45741ea16650a3d083310558778.png

到了这里可以看到,Python 对 PyCodeObject 对象的导出实际上是不复杂的。因为不管什么对象,最后都会归结为两种简单的形式,一种是数值写入,一种是字符串写入。

上面都是对数值的写入,比较简单,仅仅需要按照字节依次写入 pyc 即可。然而在写入字符串的时候,Python 设计了一种比较复杂的机制,有兴趣可以自己阅读源码,这里不再介绍。


字节码混淆


最后再来说一下字节码混淆,我们知道 pyc 是可以反编译的,而且目前也有现成的工具。但这些工具它会将每一个指令都解析出来,所以字节码混淆的方式就是往里面插入一些恶意指令(比如加载超出范围的数据),让反编译工具在解析的时候报错,从而失去作用。

但插入的恶意指令还不能影响解释器执行,因此还要插入一些跳转指令,从而让解释器跳过恶意指令。

9bf51785074da0f024de9d82ded4f695.png

混淆之后多了两条指令,其中偏移量为 8 的指令,参数为 255,表示加载常量池中索引为 255 的元素。如果常量池没有这么多元素,那么显然会发生索引越界,导致反编译的时候报错。

但对于解释器来说,是可以正常执行的,因为在执行到偏移量为 6 的指令时出现了一个相对跳转,直接跳到偏移量为 10(6 + 4)的指令了。

因此对于解释器执行来说,混淆前后是没有区别的。但对于反编译工具而言则无法正常工作,因为它会把每一个指令都解析一遍。根据这个思路,我们可以插入很多很多的恶意指令,然后再利用跳转指令来跳过这些不合法指令。当然混淆的手段并不止这些,我们还可以添加一下虚假的分支,然后在执行时跳转到真实的分支当中。

而这一切的目的,都是为了防止别人根据 pyc 文件反推出源代码。不过这种做法属于治标不治本,如果真的想要保护源代码的话,可以使用 Cython 将其编译成 pyd ,这是最推荐的做法。

相关文章
|
1月前
|
自然语言处理 数据处理 Python
python操作和解析ppt文件 | python小知识
本文将带你从零开始,了解PPT解析的工具、工作原理以及常用的基本操作,并提供具体的代码示例和必要的说明【10月更文挑战第4天】
304 60
|
29天前
|
安全 Linux 数据安全/隐私保护
python知识点100篇系列(15)-加密python源代码为pyd文件
【10月更文挑战第5天】为了保护Python源码不被查看,可将其编译成二进制文件(Windows下为.pyd,Linux下为.so)。以Python3.8为例,通过Cython工具,先写好Python代码并加入`# cython: language_level=3`指令,安装easycython库后,使用`easycython *.py`命令编译源文件,最终生成.pyd文件供直接导入使用。
python知识点100篇系列(15)-加密python源代码为pyd文件
|
11天前
|
开发者 Python
Python中__init__.py文件的作用
`__init__.py`文件在Python包管理中扮演着重要角色,通过标识目录为包、初始化包、控制导入行为、支持递归包结构以及定义包的命名空间,`__init__.py`文件为组织和管理Python代码提供了强大支持。理解并正确使用 `__init__.py`文件,可以帮助开发者更好地组织代码,提高代码的可维护性和可读性。
15 2
|
1月前
|
Linux 区块链 Python
Python实用记录(十三):python脚本打包exe文件并运行
这篇文章介绍了如何使用PyInstaller将Python脚本打包成可执行文件(exe),并提供了详细的步骤和注意事项。
51 1
Python实用记录(十三):python脚本打包exe文件并运行
|
27天前
|
Java Python
> python知识点100篇系列(19)-使用python下载文件的几种方式
【10月更文挑战第7天】本文介绍了使用Python下载文件的五种方法,包括使用requests、wget、线程池、urllib3和asyncio模块。每种方法适用于不同的场景,如单文件下载、多文件并发下载等,提供了丰富的选择。
|
28天前
|
数据安全/隐私保护 流计算 开发者
python知识点100篇系列(18)-解析m3u8文件的下载视频
【10月更文挑战第6天】m3u8是苹果公司推出的一种视频播放标准,采用UTF-8编码,主要用于记录视频的网络地址。HLS(Http Live Streaming)是苹果公司提出的一种基于HTTP的流媒体传输协议,通过m3u8索引文件按序访问ts文件,实现音视频播放。本文介绍了如何通过浏览器找到m3u8文件,解析m3u8文件获取ts文件地址,下载ts文件并解密(如有必要),最后使用ffmpeg合并ts文件为mp4文件。
|
1月前
|
JSON 数据格式 Python
Python实用记录(十四):python统计某个单词在TXT/JSON文件中出现的次数
这篇文章介绍了一个Python脚本,用于统计TXT或JSON文件中特定单词的出现次数。它包含两个函数,分别处理文本和JSON文件,并通过命令行参数接收文件路径、目标单词和文件格式。文章还提供了代码逻辑的解释和示例用法。
41 0
Python实用记录(十四):python统计某个单词在TXT/JSON文件中出现的次数
|
1月前
|
计算机视觉 Python
Python操作PDF文件
Python操作PDF文件
|
1月前
|
Python
Python实用记录(十二):文件夹下所有文件重命名以及根据图片路径保存到新路径下保存
这篇文章介绍了如何使用Python脚本对TTK100_VOC数据集中的JPEGImages文件夹下的图片文件进行批量重命名,并将它们保存到指定的新路径。
32 0
|
1月前
|
Python
如何利用Python快捷地操作文件和文件夹
关注B站用户“肆十二-”,观看更多实战教学视频。本文介绍Python的shutil库,涵盖文件和文件夹的复制、移动、删除及归档等高级操作,提供实用代码示例。
24 0