4.1. 加密过程详解

本文档适用于下列用户

  • 默认选项无法满足加密脚本的性能或者安全方面的需求

  • 加密脚本出现问题不知道错在那里

阅读本文档需要一定的 Python 基础,了解 Shell 脚本,环境变量等相关知识。

一般用户在操作系统中都一个用户目录,默认情况 pyarmor 运行过程的配置和数据文件会存放在这里。在命令行使用下面的命令查看配置目录的位置:

$ echo $HOME/.pyarmor

或者在命令行输入 python 命令,打开 Python 控制台,运行下面的语句:

>>> import os
>>> print(os.path.expanduser("~/.pyarmor"))

备注

在本文档的示例代码中,以 $ 开头的命令都是 Shell 命令,而以 >>> 开头的都是在 Python 控制台运行的脚本语句。

在本文档中,后面会使用 ~/.pyarmor/ 表示这个目录。

我们首先看下面是一个最简单的加密命令:

$ pyarmor gen foo.py

运行完成之后,加密脚本存放在目录 dist/ 下面,输出的文件:

dist/
    foo.py
    pyarmor_runtime.so

在这个过程中,pyarmor 都做了什么呢?

4.1.1. 设置运行环境

pyarmor 首先设置运行环境,主要的步骤如下

  1. 创建运行环境 ctx,根据全局配置文件进行初始化

  2. 如果当前目录存在 .pyarmor/config 文件,使用其中的选项修改 ctx

  3. 读取命令行选项,修改运行环境 ctx

4.1.2. 搜索脚本

接下来 pyarmor 根据命令行传入的文件或者目录,开始搜索需要加密的所有脚本。

配置项 pyexts 列出了需要加密的脚本扩展名,默认是 [.py, .pyw]

首先,命令行输入的脚本,即便扩展名不在 pyexts 之中,也会被增加到需要加密的脚本列表 ctx.resources

其次,命令行输入的目录下面,所有扩展名在 pyexts 之中的会被增加到列表

然后根据下面的两个配置项,决定下一步的搜索范围

  • 选项 --recursive 递归搜索所有的子目录

  • 选项 --find-all 查找所有依赖的模块和包(尚未实现)

4.1.3. 依赖脚本查找

这个功能尚未实现

--find-all 选项设定的时候,会自动查找加密脚本的依赖模块和包。

pyarmor 首先设定搜索路径

  • 继承目前 Python 系统设置的搜索路径

  • 把配置项 pypaths 中指定的路径增加到搜索列表

  • 把当前目录增加到搜索列表

然后使用下面的算法查找脚本 foo 依赖模块:

  1. 把脚本 foo.py 解析生成语法树 tree

  2. 搜索树中所有的 import/from...import 语句

  3. 把其中导入的模块作为依赖项

  4. 根据模块名称和搜索路径,查找对应的模块文件

  5. 把模块文件加入到需要加密的脚本列表

有一些隐含导入的模块无法通过上面的算法发现,这时候会使用脚本的规则文件来辅助查找。脚本的规则文件一般是人工创建,把依赖文件或者包名称添加到组 finder 下面,例如:

[finder]
includes = a.py b/c d.pt

脚本规则文件的命名和存放路径必须符合下面的规则,否则无法起作用:

  • ~/.pyarmor/config/foo.rules 存放在全局配置目录 rules 下面的同名文件

  • .pyarmor/foo.rules 当前配置目录下的扩展名为 .rules 的同名文件

如果规则文件存在,那么这里面指定的模块和文件都会被进行加密处理,并且如果指定了具体的文件名称,即便扩展名不在配置项 .pyexts 也会被增加到加密列表中。

4.1.4. 源代码处理

找到所有需要处理的脚本之后,下一步就是依次对每一个脚本的源代码进行处理

源代码的处理算法主要步骤

  • 读取文件内容,生成资源文件 res

  • 遍历环境配置中所有源代码处理器,依次处理资源文件

  • 处理完成之后把源代码编译成为语法树

文件的编码使用的是系统默认值,如果读取文件出现编码错误,修改配置文件的选项 encoding ,设定正确的值。

4.1.4.1. 内联注释处理器

内置的源代码处理器是内敛注释处理器,默认启用的。

内敛注释处理器只是简单的把源代码中每一行包含的 # pyarmor: 字符串替换为空,这样可以使得原来被注释的代码生效。例如,原来的脚本如下

# pyarmor: print('inline pyarmor code')
def foo():
    i = 2
    # pyarmor: i += 1
    print('i is', i)

处理完之后,代码的内容就变成

print('inline pyarmor code')
def foo():
    i = 2
    i += 1
    print('i is', i)

这个的设计初衷是有时候需要执行一些只能在加密脚本中才能运行的代码,这样在没有加密的时候,调试起来很不方便。所以先把这部分的代码注释掉,在加密之前把注释在去掉。

4.1.5. 语法树处理

源代码处理完成之后,输出的是源代码对应的 ast.Module 语法树。

语法树的处理算法主要步骤

  • 根据环境配置,找到所有的语法树处理器

  • 遍历所有语法树处理器,依次处理资源文件

  • 最后输出的是经过各个处理器修改后的语法树

为了节省内存,源代码处理完成,文件内容会被释放。如果语法树处理器需要从文件的源代码读取信息,需要重新打开文件读取。

每一个语法处理器接受的输入是上一个语法处理器的输出,所以后面的语法处理器处理的语法树可能和最初的源代码并不一致。

内置的语法树处理器主要有二个

  • 函数调用处理器,通过修改 ast.Call,确保调用的函数是经过加密的

  • 导入模块处理器,通过修改 ast.Import,确保导入的模块是经过加密的

4.1.5.1. 函数调用处理器

函数调用处理器主要功能是通过修改 ast.Call,在运行时刻检查被调用的函数是否是加密后的函数,主要目的是为了防止运行过程中加密函数被替换。

函数调用处理器的基本算法如下

  • 遍历当前脚本的语法树,找到所有 ast.Import 中的模块

  • 读取当前脚本的规则表中那些隐藏的模块名称

  • 遍历当前脚本的语法树的 ast.Call 结点

  • 判断 ast.Call 是否调用的是加密模块

  • 如果是的话,修改当前结点,对调用进行保护

通过修改 ast.Call ,在调用之前检查被调用的函数是否加密,从而保护加密函数不能被黑客使用注入脚本等方式替换

模块中被调用的这些函数有的没有被加密,例如扩展模块中函数,系统库的函数。完全自动识别所有脚本中被调用的函数是加密的,还是没有被加密的,基本是不可能实现的。所以必须通过人工的规则配置,来达到最大限度的自动识别

函数调用如果不是使用名称,而是使用表达式的话,算法无法正确判断。这时候就需要人工配置模块的检查规则,来处理这些调用。

模块的规则文件说明参考上面的模块规则 ,调用检查器的规则添加到节 assert.call 下面:

[assert.call]
includes =
excludes =

脚本调用保护的规则定义在组 assert.call ,其基本的实现算法如下

  • 如果命令行选项 --assert-call 没有指定,那么不进行任何调用保护

  • 对于任何一个 ast.Call ,使用 includesexcludes 规则进行检查。只有所有 includes 规则返回真,并且所有 excludes 都返回假的 ast.Call 会进行保护

对于 includes 规则

  • 空表示自动确定那些调用需要保护,

    首先是保护本模块定义的函数名称的调用 这样可能会存在导入其它模块的同名函数,这种情况可以使用 excludes 进行排除

    其次检查导入的模块是否被加密,如果加密的会,也会保护那些可以确定是从加密模块导入的方法 对于这些例外,可以增加额外的规则到 includes 中

  • includes 包含多个规则时候,规则的运算是 or ,即任何一个为真,则返回真

  • includes 可以使用 True 表示任何调用都需要保护

对于 excludes 规则

  • 空表示返回 False

  • excludes 包含多个规则的时候,规则的运算是 or ,即任何一个为真,则返回 True

规则的格式如下:

[scope:]pattern

名称匹配,第一个字符表示匹配方式
    '+' 前缀
    '-' 后缀
    '*' 包含
    '/' 正则表达式,正则表达式不能包含 ":"
    '=' 相等

其它任意字符开始则表示 pattern 是空格分开的名称列表

由于函数检查是在运行时刻进行,对加密脚本性能有一定影响,默认是没有启用的。使用选项 --assert-call 可以启用被调用函数的保护检查:

$ pyarmor gen --assert-call foo.py

4.1.5.2. 导入模块处理器

导入模块处理器主要功能是通过修改 ast.Import,在运行时刻检查被导入的模块是否被加 密,主要目的是为了防止运行过程中加密模块被替换。

导入调用处理器的基本算法如下

  • 遍历当前脚本的语法树,找到所有 ast.Import 中的模块

  • 读取当前脚本的规则表中那些隐藏的模块名称

  • 遍历当前脚本的语法树的 ast.Import 结点

  • 判断 ast.Import 是否导入的是加密模块

  • 如果是的话,修改当前结点,对导入的模块进行检查

对于算法无法正确判断的模块导入,这时候就需要人工配置模块的检查规则,来处理这些调 用。导入模块处理器的规则添加到节 [assert.import] 下面:

[assert.import]
enables =
includes = [scope:]name[@lineno]
excludes =

该选项默认没有启用,使用选项 --assert-import 可以启用调用导入模块处理器:

pyarmor gen --assert-import foo.py

4.1.6. 模块代码处理

所有的语法树处理器完成之后,输出的修改后的 ast.Module 语法树。接下来就是对模块代码进行处理:

* 根据环境配置,找到所有的模块代码处理器
* 编译语法树为模块代码对象 mco
* 遍历所有模块代码处理器,依次处理资源文件的模块代码对象 mco
* 最后输出的是经过各个处理器修改后的模块代码对象

每一个模块代码处理器接受的输入是上一个模块代码处理器的输出,所以除了第一个代码处理器之外,后面的处理器输入的 mco 可能和语法树 mtree 不一致。

内置的模块代码处理器有二个

  • 重构代码处理器,主要实现自动重命名模块中类,函数,属性和变量的名称

  • 加密代码处理器,主要是对代码对象进行加密处理

4.1.7. 加密脚本生成

下列相关选项影响加密脚本中导入 运行辅助包 的方式

4.1.7.1. 同名脚本的输出路径

如果存在同名脚本,例如:

pyarmor gen a/foo.py b/foo.py

默认输出分别是:

  • a/foo.py -> dist/foo.py

  • b/foo.py -> dist-1/foo.py

4.1.7.2. 运行辅助文件生成

选项 --enable-themida--platform 决定了运行辅助文件中需要那些动态库。