上一篇文章我们介绍了字节码,当时提到,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=1b="你好啊"
如何将其编译成 pyc 呢?
importpy_compilepy_compile.compile("tools.py")
查看当前目录的 __pycache__ 目录,会发现 pyc 已经生成了。
然后py文件名.cpython-版本号.pyc为编译之后的 pyc 文件名。
pyc 文件的导入
如果有一个现成的 pyc 文件,我们要如何导入它呢?
fromimportlib.machineryimportSourcelessFileLoadertools=SourcelessFileLoader("tools","__pycache__/tools.cpython-38.pyc").load_module()print(tools.a)#1print(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。我们实际验证一下:
importstructfromimportlib.utilimportMAGIC_NUMBERfromdatetimeimportdatetimewithopen("__pycache__/tools.cpython-38.pyc","rb")asf:data=f.read()#0~4字节是MAGICNUMBERprint(data[:4])#b'U\r\r\n'print(MAGIC_NUMBER)#b'U\r\r\n'#4~8字节是4个\x00print(data[4:8])#b'\x00\x00\x00\x00'#8~12字节是pyc的写入时间(小端存储),一个时间戳ts=struct.unpack("<I",data[8:12])[0]print(ts)#1671001724print(datetime.fromtimestamp(ts))#2022-12-1420:32:23#12~16字节是py文件的大小print(struct.unpack("<I",data[12:16])[0])#21
结果和我们分析的一样,因此对于任何一个 pyc 文件来说,前 16 字节是固定的(如果 Python 低于 3.7,那么前 12 个字节是固定的)。
16 个字节往后就是 PyCodeObject 对象,并且是序列化之后的,因为该对象显然无法直接存在文件中。
importmarshalwithopen("__pycache__/tools.cpython-38.pyc","rb")asf:data=f.read()#通过marshal.loads可以反序列化#marshal.dumps则表示序列化code=marshal.loads(data[16:])#此时就拿到了py文件编译之后的PyCodeObjectprint(code)"""<codeobjectat0x...,file"tools.py",line1>"""#查看常量池print(code.co_consts)#(1,'你好啊',None)#符号表print(code.co_names)#('a','b')
问题来了,既然我们可以根据 pyc 文件反推出 PyCodeObject,那么能否手动构建PyCodeObject 然后生成 pyc 呢?来试一下。
a=1b=2c=3
上述代码编译之后的结果,就是我们要构建的 PyCodeObject。
fromimportlib.utilimportMAGIC_NUMBERimportstructimporttimefromtypesimportCodeTypeimportmarshalfromopcodeimportopmapHEADER=MAGIC_NUMBER+b"\x00"*4#时间随便写HEADER+=struct.pack("<I",int(time.time()))#大小随便写HEADER+=struct.pack("<I",30)#构建PyCodeObjectcode=CodeType(0,#co_argcount0,#co_posonlyargcount0,#co_kwonlyargcount3,#co_nlocals1,#co_stacksize0,#co_flagsbytes([#a=1分为两步#第一步:先通过 LOAD_CONST 将常量加载进来#因此指令是LOAD_CONST,然后参数是0#表示加载常量池中索引为0的常量opmap["LOAD_CONST"],0,#第二步:通过 STORE_NAME 将常量和符号绑定起来#参数是0,表示和符号表中索引为0的符号进行绑定opmap["STORE_NAME"],0,#b=2opmap["LOAD_CONST"],1,opmap["STORE_NAME"],1,#c=3opmap["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"",#co_name1,#co_firstlinenob"",#co_lnotab(),#freevars()#cellvars)#pyc文件内容pyc_content=HEADER+marshal.dumps(code)#生成pyc文件withopen("build_pyc.pyc","wb")asf:f.write(pyc_content)#然后加载生成的pyc文件fromimportlib.machineryimportSourcelessFileLoadermod=SourcelessFileLoader("build_pyc","build_pyc.pyc").load_module()print(mod)#print(mod.a)#1print(mod.b)#2print(mod.c)#3
怎么样,是不是很有趣呢?
pyc 文件的写入
下面通过源码来查看pyc文件的写入过程,既然要写入,那么肯定要有文件句柄。
//位置:Python/marshal.c//FILE是C自带的文件句柄//可以把WFILE看成是FILE的包装typedefstruct{FILE*fp;//文件句柄//下面的字段在写入信息的时候会看到interror;intdepth;PyObject*str;char*ptr;char*end;char*buf;_Py_hashtable_t*hashtable;intversion;}WFILE;
首先是写入 magic number、创建时间和文件大小,它们会调用PyMarshal_WriteLongToFile函数进行写入:
voidPyMarshal_WriteLongToFile(longx,FILE*fp,intversion){//magicnumber、创建时间和文件大小,只是一个整数//在写入的时候,使用char[4]来保存charbuf[4];//声明一个WFILE类型变量wfWFILEwf;//内存初始化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。
staticvoidw_long(longx,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 进行写入。
staticvoidw_object(PyObject*v,WFILE*p){charflag='\0';p->depth++;if(p->depth>MAX_MARSHAL_STACK_DEPTH){p->error=WFERR_NESTEDTOODEEP;}elseif(v==NULL){w_byte(TYPE_NULL,p);}elseif(v==Py_None){w_byte(TYPE_NONE,p);}elseif(v==PyExc_StopIteration){w_byte(TYPE_STOPITER,p);}elseif(v==Py_Ellipsis){w_byte(TYPE_ELLIPSIS,p);}elseif(v==Py_False){w_byte(TYPE_FALSE,p);}elseif(v==Py_True){w_byte(TYPE_TRUE,p);}elseif(!w_ref(v,&flag,p))w_complex_object(v,flag,p);p->depth--;}
可以看到本质上还是调用了 w_byte,但这仅仅是一些特殊的对象。如果是列表、字典之类的数据,那么会调用 w_complex_object,也就是代码中的最后一个else if分支。
w_complex_object 这个函数的源代码很长,我们看一下整体结构,具体逻辑就不贴了,后面会单独截取一部分进行分析。
staticvoidw_complex_object(PyObject*v,charflag,WFILE*p){Py_ssize_ti,n;//如果是整数的话,执行整数的写入逻辑if(PyLong_CheckExact(v)){//......}//如果是浮点数的话,执行浮点数的写入逻辑elseif(PyFloat_CheckExact(v)){if(p->version>1){//......}else{//......}}//如果是复数的话,执行复数的写入逻辑elseif(PyComplex_CheckExact(v)){if(p->version>1){//......}else{//......}}//如果是字节序列的话,执行字节序列的写入逻辑elseif(PyBytes_CheckExact(v)){//......}//如果是字符串的话,执行字符串的写入逻辑elseif(PyUnicode_CheckExact(v)){if(p->version>=4&&PyUnicode_IS_ASCII(v)){//......}else{//......}}else{//......}}//如果是元组的话,执行元组的写入逻辑elseif(PyTuple_CheckExact(v)){//......}//如果是列表的话,执行列表的写入逻辑elseif(PyList_CheckExact(v)){//......}//如果是字典的话,执行字典的写入逻辑elseif(PyDict_CheckExact(v)){//......}//如果是集合的话,执行集合的写入逻辑elseif(PyAnySet_CheckExact(v)){//......}//如果是PyCodeObject对象的话//执行PyCodeObject对象的写入逻辑elseif(PyCode_Check(v)){//......}//如果是Buffer的话,执行Buffer的写入逻辑elseif(PyObject_CheckBuffer(v)){//......}else{W_TYPE(TYPE_UNKNOWN,p);p->error=WFERR_UNMARSHALLABLE;}}
源代码虽然长,但是逻辑非常单纯,就是对不同的对象、执行不同的写动作,然而其最终目的都是通过 w_byte 写到 pyc 文件中。了解完函数的整体结构之后,我们再看一下具体细节,看看它在写入对象的时候到底写入了哪些内容?
staticvoidw_complex_object(PyObject*v,charflag,WFILE*p){//......elseif(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);}}elseif(PyDict_CheckExact(v)){Py_ssize_tpos;PyObject*key,*value;W_TYPE(TYPE_DICT,p);/*ThisoneisNULLobjectterminated!*/pos=0;while(PyDict_Next(v,&pos,&key,&value)){w_object(key,p);w_object(value,p);}w_object((PyObject*)NULL,p);}//......}
以列表和字典为例,它们在写入的时候实际上写的是内部的元素,其它对象也是类似的。
deffoo():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 ,这是最推荐的做法。