3

Python代码可以加密吗?Python字节码告诉你!

 3 years ago
source link: https://blog.csdn.net/nokiaguy/article/details/113156576
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
Python代码可以加密吗?Python字节码告诉你!_李宁的极客世界bgJBm&nku$q$-CSDN博客

为了让更多的人看到本文,请各位同学动动小手,点击右上角【...】,将本文分享到朋友圈,thanks! 

众所周知,执行Python程序可以直接使用python.exe命令,如下所示:

python abc.py

看到python直接执行了abc.py,可能很多同学认为python是解释执行abc.py的,其实不然。如果要真是解释执行,那效率慢的就没法用了。实际上,Python与Java一样,也是玩字节码出身。Java的字节码叫Java ByteCode,Python的字节码叫Python ByteCode。Python在第一次运行abc.py文件时,会将源代码文件编译成字节码,然后再执行。当然,还可以选择直接生成字节码文件(扩展名是pyc),然后直接执行Python字节码文件。

通常Python是以源代码形式发布的,不过对于一些敏感信息,不希望以源代码形式发布,就可以用字节码形式发布。当然,字节码也可以被反编译。为了让Python源代码更安全,可以制作自己的私有Python环境,这些内容我们后面再说。

相信很多没接触过过Python字节码的同学一定有很多疑问,那么就继续看后面的内容吧!

1.  如何查看Python字节码

我们首先来查看一下Python的字节码,以证明在运行Python脚本时确实是先将Python代码编译成字节码,然后执行的是字节码,而不是直接执行Python源代码。

先看下面的代码:

在这段代码中有一个fun函数,里面使用了全局变量value和局部变量name,并输出了这两个变量的值。最后导入了dis模块。在该模块中有一个disassemble函数,用于输出任何包含__code__属性的Python代码段的字节码形式。

现在执行这段代码,会输出如下内容:

很明显,disassemble输出了类似汇编代码的东西。其实这就是Python字节码的可读形式。每一条指令对应一个字节码。那么为什么要查看字节码呢?其实对于应用开发者来说,最直接的作用就是更好地理解Python源代码。

例如,本例使用了全局变量,也就是global关键字,那么global关键字到底代表什么呢?从Python字节码中就可以很容易看出端倪。

在Python源代码中发生了2次赋值,代码如下:

其中value是全局变量,name是fun函数的局部变量。将这两条赋值操作转换为Python字节码,会得到如下的代码:

从Python字节码可以看出,每一条赋值语句转换成了2条Python字节码。其中都使用了LOAD_CONST指令,这是装载常量的指令。因为value和name都被赋予了一个常量,只是一个是整数,另一个是字符串。不过由于Python在使用变量时不需要指定变量类型(变量有类型,但不需要在定义变量时指定,使用变量时再确定变量的类型),所以不管是装载什么类型的常量给变量赋值,都使用LOAD_CONST指令。

但第2条指令就不同了,对于全局变量value,使用STORE_GLOBAL指令将常量赋给变量,而局部变量name,使用了STORE_FAST指令将常量赋给了变量。这两条指令的区别就是存储的位置不同。由于Python将全局变量和变量放到了不同的位置,所以这两条指令会分别将常量值保存到这些位置。

从这一点判断,global value这条语句其实并没有执行,他只是一个开关,如果加上global value,当为value赋值时就使用STORE_GLOBAL指令,如果没有global value,当为value赋值时就使用STORE_FAST指令。

如果除了global value外,其他的代码都去掉,就看不到global value的身影了。

看下面的Python代码:

执行这段代码,只会得到下面2条Python字节码:

这2条Python字节码实际是让fun函数有一个默认的返回值,也就是如果函数不显式返回一个值,那么默认就会返回None。这里面并没有看到global value的身影。

2. 用Python代码编译Python代码

在使用python命令运行脚本时,尽管将Python源代码编译成了字节码,但并没有将编译结果保存成文件,而一切都是在内存中完成的。如果频繁运行Python的某段程序,运行的实际上是内存中的Python字节码。不过在发布时,我们期望像Java一样,可以发布.class文件,其实Python也有类似的文件,这就是.pyc文件。

用Python代码和命令行都可以将Python源代码编译成.pyc文件,只是在默认情况下,Python做的比较隐蔽,会将.pyc文件生成到一个默认的目录,而且很多IDE(如PyCharm)是不会显示这个目录的。这个目录就是__pycache__。

现在做一个实验,首先创建一个demo.py文件,然后输入下面的代码:

现在执行下面的代码将demo.py文件编译生成.pyc文件。

so easy,只需要两行代码(还有一行是import语句),就可以编译demo.py,运行程序后,如果在IDE中,什么都不会发生,别急,切换到demo.py文件所在的目录,会看到多了一个__pycache__目录,打开一看,目录里有一个名为demo.cpython-38.pyc的文件。在读者的机器上文件名可能不同,差异就在最后的数字上,这里的38表示我用的Python版本是3.8,这里不会显示小版本号。如果读者使用的事3.7,那么生成的.pyc文件就是demo.cpython-37.pyc。

现在进入控制台,进入demo.cpython-38.pyc文件所在的目录,执行python demo.cpython-38.pyc命令,同样可以输出结果,与python demo.py执行的结果完全相同。所以在发布Python应用时,可以直接发布pyc文件。

compile函数在编译Python文件时,可以指定第2个参数值,表示要生成的.pyc文件名,这样就可以指定将pyc文件放到特定的目录,代码如下:

执行这段代码,可以在当前目录生成一个名为demo.pyc的文件,执行python demo.pyc命令,同样会得到我们期望的结果。

如果需要编译的Python脚本太多,可以多次调用compile函数,也可以使用compileall模块中的compile_dir函数递归编译指定目录中的所有Python脚本文件。

现在做一个实验,在当前目录创建3层子目录:aa/bb/cc,并在每一层目录创建一个或多个Python脚本文件,可以不写任何代码(空文件即可),如图1所示。

现在执行下面的代码编译aa目录中所有的Python脚本文件。

执行这段代码,首先会递归扫描所有的目录,然后会编译所有发现的Python脚本文件,如图2所示。

查看这几个目录,每一个目录都有一个名为__pycache__目录,里面是对应的pyc文件。

如果不想递归编译所有目录中的Python脚本文件,可以使用compile_dir函数的第2个参数指定递归层次,0表示当前目录(不递归),1表示递归一层目录,以此类推。例如,下面的代码只编译当前目录中所有的Python脚本文件。

3. 在命令行中编译Python脚本

python命令同样可以将.py文件编译成.pyc文件,例如,如果要编译demo.py文件,可以使用下面的命令:

python -m demo.py

这里的-m命令行参数表示编译demo.py,执行这行命令后,会在当前目录的__pycache__目录生成demo.cpython-38.pyc文件,然后可以使用python直接执行这个文件。

如果想递归编译目录中所有的Python文件,可以使用下面的命令:

python  -m compileall aa

这行命令可以递归编译aa目录中的所有Python文件。如果还想对编译结果进行优化,可以加-O或-OO,那么这两个优化参数有什么区别呢?

如果不加优化参数,只加-m,那么就不会进行优化,也就是优化层次(Level)为0,当不优化时,Python的内部变量__debug__为True,读者可以在Python Shell中输出这个变量值。如果设置了-O参数,那么优化层次是1,在这一优化层次,会将__debug__变量的值设为False。如果使用-OO参数,优化层次是2,不仅将__debug__变量的值设为False,而且将Python中的docstrings也去除了。docstrings就是Python中的文档注释,可以用来为API自动产生文档。也就是3对单引号或双引号括起来的部分。

其中上一部分讲的compile函数和compile_dir函数也有设置优化level的参数,就拿compile函数来说,该函数的第4个参数用于设置优化层次,默认值是-1,相当于-O参数。还可以设置为0(不优化)、1(与默认值相同)和2(相当于-OO参数)。下面的代码用level = 2的层次优化编译demo.py。

py_compile.compile('demo.py', 'demo.pyc', False, 2)

其实这里的优化,并不是指优化Python Byte Code,而是去掉不同的调试信息和文档。这里的调试信息主要是指为了在Console或日志中输出的一些用于展示程序执行状态的信息。如果这些随着程序发布,会让程序运行效率大打折扣。因为执行在Console或日志中输出信息的代码是很慢的(相对于直接在内存中执行的代码)。

如果使用命令行方式优化编译.py文件,如果使用的是-O参数,生成的目标文件是:demo.cpython-38.opt-1.pyc,如果使用的是-OO参数,生成的目标文件是:demo.cpython-38.opt-2.pyc。

4. 如何对Python代码加密

尽管可以将.py文件编译生成.pyc文件,但.pyc文件和Java的.class文件一样,很容易被反编译。更稳妥的方式是制作一个私有的Python编译和运行环境,说白了,就是修改Python编译器的源代码。听着很高大上,其实并不复杂,只需要修改其中的常量即可。

首先下载Python源代码,然后找到如下两个文件:

大家可以打开这两个文件看看,opcode.py文件中的代码片段是这样的:

opcode.h文件中的代码片段是这样的:

我们可以看到,在opcode.h文件中定义了一堆宏(相当于常量),而opcode.py文件中同样定义了与opcode.h同名的值,对应的整数值也相等。做过编译器的同学应该能猜出来这是什么东西,其实就是Python Byte Code对应的指令编码。编译出来的.pyc文件都是由这些指令组成的。例如,for指令定义如下:

#define FOR_ITER                 93

也就是说,如果Python代码中有for循环,就一定会有这个指令。我们可以做个试验,下面有一段包含1个for循环的Python代码:

输出这段这段代码的Python字节码,如下:

我们可以看到,第4行就是FOR_ITER指令,每一条指令由2个字节组成,第1个字节表示指令本身,第2个字节表示操作数。而在第11行的JUMP_ABSOLUTE指令是跳转指令,FOR_ITER与JUMP_ABSOLUTE配合才能形成循环。JUMP_ABSOLUTE直接跳到了6,也就是FOR_ITER指令所在的位置。

由于FOR_ITER指令对应的数值是93,这是十进制,转换为十六进制是5d,如果考虑后面的操作数12(十六进程是0C,至于为什么操作数是12,这是FOR_ITER指令的特性,读者可以查阅Python字节码的相关文档,这个问题与本文无关,这里先不做阐述),那么完整的指令应该是5d0c。所以编译demo.py,生成对应的.pyc文件,然后打开.pyc文件(用可以查看二进制数据的软件打开),会看到如图1所示的十六进制形式的代码,在第6行可以找到5d0c,这就是for循环的起始指令。

读者可以再加一个for循环,代码如下:

查看pyc文件的代码,会看到如图2的形式。很明显,第6行和第7行都有5d0c指令,这就表明这段代码中包含2条for语句。

Python字节码的反编译器都是根据这些规则实现的,但问题是,如果5d不表示for循环,而表示if语句,那么原有的反编译器岂不是不好使了。

如果在代码中有if语句,那么根据不同的场景,会使用POP_JUMP_IF_FALSE指令或POP_JUMP_IF_TRUE指令,这两条指令在opcode.h的定义如下:

如果有下面的Python代码:

那么会使用POP_JUMP_IF_FALSE指令,这时pyc代码中就会包含72(114的十六进制表示),但如果将FOR_ITER的93和POP_JUMP_IF_FALSE的114调换一下,变成如下形式,那么按Python的标准指令会将for当成if,if当成for,这样反编译出来的代码就乱套了。而反编译器是无法知道你是如何互换指令值的。这就像直接用标准的base64编码是无法加密的,但如果将标准的base64编码随机打乱,用这个打乱的base64编码规则进行编码,是无法用标准的base64编码表解码的。除非拿到了变化后的base64编码表,如果要测试每一种排列,会有64的阶乘这么多种可能,在有限的时间内是根本不可能破解的。而这种修改Python源代码的方式,就相当于打乱标准base64编码表的顺序,增加了破解的难度和时间。

另外,光修改前面介绍的两个文件还不行,还需要修改另外一个文件,路径如下:

<Python源代码根目录>/Python/opcode_targets.h

读者可以打开这个文件,看看为什么要修改这个文件,文件的代码片段如下:

很明显,这段代码用来定义Python字节码的指令,而在opcode.h文件中定义的每一个宏对应的值,就是opcode_targets数组的索引。我们知道,C语言数组索引从0开始,所以opcode_targets数组的第1个元素是一个占位符(&&_unknown_opcode),而POP_TOP指令在opcode.h文件中值正是1,所以正好与opcode_targets数组的第2个元素对应。

我们可以继续查看opcode_targets数组的代码,看到下面的代码形式:找到TARGET_INPLACE_TRUE_DIVIDE,对应的是INPLACE_TRUE_DIVIDE指令,如图3所示。

然后在opcode.h文件中找到INPLACE_TRUE_DIVIDE指令,正好值是29,正好对应opcode_targets中索引为29的元素值。而TARGET_INPLACE_TRUE_DIVIDE下面是一堆&&_unknown_opcode占位符,这也说明INPLACE_TRUE_DIVIDE后面有很多空闲的值,再看看opcode.h文件中的定义,如图4所示。

很显然,INPLACE_TRUE_DIVIDE指令后面的RERAISE指令就直接从48开始了,所以要用多个&&_unknown_opcode作为占位符,否则就无法找到对应的指令了。

所以修改Python源代码要遵循下面的规则:

(1)修改opcode.py文件和opcode.h文件中代码,要统一互换,不能只互换一个;

(2)然后将opcode_targets.h中opcode_targets数组的相对位置也换过来,否则就无法找到对应的指令了;

都改完了,然后就可以编译Python代码了,执行下面的命令即可:

最后在发布程序时,需要带上自己编译的Python环境,标准的Python环境已经无法运行我们自己生成的pyc文件了。

当然,包含Python代码的方式有好很多种,例如,对Python代码混淆、将Python代码转换成C代码等等,这些内容我后面会专门写文章讲解。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK