掘金 后端 ( ) • 2024-04-30 15:56

1. 背景

随着 AI 技术的发展和普及,越来越多的企业开始使用 Python 进行项目开发。Python 以其简洁的语法,强大的库支持,以及优秀的可读性,受到了广大开发者的喜爱。然而,Python作为一种解释型语言,其源代码是公开可读的,这在一定程度上增加了代码被非法篡改或窃取的风险。
当企业将 Python 项目部署到客户服务器后,由于源代码的可读性,客户有可能获取到项目的完整源代码,这对企业的技术秘密保护构成了巨大的挑战。此外,如果客户在没有得到授权的情况下对源代码进行修改,还可能导致项目运行出现问题,影响服务质量。
那么,如何保护 Python 项目的源代码,防止在部署到客户服务器后被客户获取呢?
针对这一问题,本文将对 Python 项目的加密方案进行技术调研,希望通过对现有的技术方案的分析和比较,为企业选择合适的源代码保护方案提供参考。
本文将首先介绍 Python 源代码的保护原理和方法,然后深入探讨各种源代码保护技术的优缺点,最后根据企业的具体需求,提出适合的源代码保护方案。

2. 方案介绍

目前业界存在以下4种常见的软件解决方案:

方案 优点 缺点 编译成字节码 (.pyc文件) - 提供基本的代码保护,防止直接阅读源码。
- Python自身就支持生成.pyc文件,操作简单。 - 字节码可以被反编译,安全性相对较低。
- 不会隐藏程序的逻辑结构和算法。 源代码混淆 - 使代码难以阅读和理解,增加了代码保护的复杂性。
- 可以结合其他方法使用,如编译成.pyc后再混淆。 - 混淆后的代码可能会影响运行效率。
- 完全的安全性仍无法保证,有可能被专业人士破解。 打包为可执行文件 - 用户只能看到一个可执行文件,而不是一系列的Python源文件。
- 适合分发给最终用户使用,使用起来更加简单直接。 - 打包后的应用体积相对较大。
- 有可能被专业工具分析和提取原始字节码。 Cython 或 C 扩展 - 将Python代码转换成C代码,从而编译成机器码执行,提高了保护级别和执行效率。
- 可以与Python无缝集成,部分代码转换成C后,其余代码仍然可以使用Python编写。 - 需要C语言的知识,增加了开发的复杂性。
- 不是所有Python代码都适合或需要转换成C。

由于笔者不是太擅长 C 语言,而且将项目转换成 C 代码,成本较大,感兴趣的朋友可以自行研究,本文主要介绍前面三种方式。 项目 demo 源码地址:https://github.com/yugasun/pyprotect

2.1 编译成字节码(.pyc文件)

克隆项目后,git checkout 分支 pycompile

在项目根目录下添加 compile.py 文件,内容如下:

import compileall
import re

exclude_dir_pattern = re.compile(r"[\\/](venv|build|dist)[\\/]")

compileall.compile_dir("./", force=True, rx=exclude_dir_pattern)

然后执行该文件:

python compile.py

执行成功后,就会在项目根目录下生成文件夹 __pycache__,内容如下:

tree __pycache__ 
__pycache__
├── compile.cpython-310.pyc
└── main.cpython-310.pyc

之后只需要执行命令 PYTHONPATH=__pycache__ python -m main 即可。

2.2 打包为可执行文件 - pyinstaller

官方简介: PyInstaller 读取您编写的 Python 脚本。它分析您的代码,以发现您的脚本执行所需的所有其他模块和库。然后,它会收集所有这些文件的副本,包括活动的 Python 解释器!– 并将它们与您的脚本放在一个文件夹中,或者可以选择放在一个可执行文件中。

通过定义编译脚本,pyinstaller 可以将 python 脚本编译生成可执行文件,使最终用户不需要安装Python环境就可以运行程序。这在一定程度上隐藏了源代码。

这里以生成可执行文件为例:

2.2.1 定义 pyinstaller 打包需要的 spec 文件

pyprotect.spec 内容如下:

# -*- mode: python ; coding: utf-8 -*-
from PyInstaller.utils.hooks import collect_submodules
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--debug", action="store_true")
options = parser.parse_args()

include_libraries = [
    "fastapi",
    "uvicorn",
]

hidden_imports = []

for module in include_libraries:
    hidden_imports += collect_submodules(module)

project_name = "pyprotect"
project_version = "0.1.0"

a = Analysis(
    ["main.py"],
    pathex=[],
    binaries=[],
    datas=[],
    hiddenimports=hidden_imports,
    hookspath=[],
    hooksconfig={},
    runtime_hooks=[],
    excludes=[],
    noarchive=False,
    optimize=0,
)
pyz = PYZ(a.pure)

if options.debug:
    print("####### Debug mode #######")
    exe = EXE(
        pyz,
        a.scripts,
        a.binaries,
        a.zipfiles,
        a.datas,
        name=project_name,
        debug=True,
        strip=False,
        upx=False,
        console=True,
    )
else:
    exe = EXE(
        pyz,
        a.scripts,
        a.binaries,
        a.datas,
        [],
        name=project_name + "_" + project_version,
        debug=False,
        bootloader_ignore_signals=False,
        strip=False,
        upx=True,
        upx_exclude=[],
        runtime_tmpdir=None,
        console=True,
        disable_windowed_traceback=False,
        argv_emulation=False,
        target_arch=None,
        codesign_identity=None,
        entitlements_file=None,
        icon=["ico\\example.ico"],
    )

2.2.2 执行构建命令

项目根目录下添加 gen.sh 文件,内容如下:

#!/bin/sh

rm -rf ./dist

# get --debug option
if [ "$1" = "--debug" ]; then
  DEBUG="--debug"
else
  DEBUG=""
fi

# Pyinstaller options
PYINSTALLER="pyinstaller -y"
SPEC_FILE="pyprotect.spec"

${PYINSTALLER} ${SPEC_FILE} -- ${DEBUG}

上面的 --debug 参数并不是 pyinstaller 命令参数,我这里只是为了调试,而自定义的。

通过定义项目入口文件 main.py ,执行 pyinstaller 命令后,就可以在项目根目录下生成可执行文件 dist/pyprotect_0.1.0,执行 ./dist/pyprotect_0.1.0 就可以启动项目了。

当然以上脚本还定义了一些额外参数,这里就不一一介绍,可以参考官方文档:https://pyinstaller.org/en/stable/usage.html

2.3 代码混淆 - pyarmor

克隆项目后,git checkout 分支 pyarmor

注意:请在具有相同 Python 版本和相同平台的机器中运行这个混淆,否则它不起作用。因为pyarmor_runtime_000000 有一个扩展模块(用C或C++编写的一个模块,使用 Python 的 CAPI 与核心和用户代码进行交互),它依赖于平台并绑定到Python版本。

安装 pyarmor:

pip install pyarmor

2.3.1 混淆单一文件

pyarmor gen demo.py

此命令生成一个混淆脚本 dist/demo.py,这是一个有效的Python脚本,通过Python解释器可以运行它:

python dist/demo.py

检查默认输出路径中的所有生成文件:

ls dist/
...    demo.py...    pyarmor_runtime_000000

有一个额外的Python包 pyarmor_runtime_000000,这是运行混淆脚本所必需的。

2.3.2 混淆包/文件夹内

它还可以将整个路径 dist 复制到另一台机器上。但这并不方便,更好的方法是使用-i在包路径中生成所有必需的文件:

pyarmor gen -O dist app

检查输出:

tree dist
dist
├── app
│   ├── __init__.py
│   ├── libs
│   │   ├── __init__.py
│   │   └── moduleA.py
│   └── main.py
├── demo.py
└── pyarmor_runtime_000000
    ├── __init__.py
    ├── __pycache__
    │   └── __init__.cpython-311.pyc
    └── pyarmor_runtime.so

现在所有内容都在包路径 dist 中,只需将整个路径复制到任何目标机器即可。

2.3.3 支持过期时间的混淆

可以很容易地通过 -e 来设置混淆脚本的到期日期。例如,生成过期日期为 10 天的混淆脚本:

pyarmor gen -O dist -e 10 demo.py

运行混淆脚本 dist/demo.py 以验证它:

python dist/demo.py

让我们使用另一个表单来设置过去的日期 2023-05-30

pyarmor gen -O dist -e 2023-05-30 demo.py

现在 dist/demo.py 应该不起作用:

python dist/demo.py

通过给混淆后的代码设置过期时间,可以有效控制目标机器的使用权限,可以用作基于时间的 license 机制。

2.3.4 将混淆脚本绑定到设备

从 Pyarmor 8.4.6 开始,通过 python -m pyarmor.cli.hdinfo 获取目标机器硬件信息:

python -m pyarmor.cli.hdinfo

Machine ID: 'abcedkldjkldjdkldjkd'
Default Mac address: '9c:3e:53:7f:44:5a'
Default IPv4 address: '192.168.0.46'

然后可以用 -b 将硬件信息绑定到混淆脚本。例如,绑定到 Mac 地址:

pyarmor gen -O dist -b 9c:3e:53:7f:44:5a demo.py

所以 dist/demo.py 只能在目标机器中运行。

3. 总结

在对比了本文提到的几种方案之后,我个人更倾向于推荐使用 打包为可执行文件 的方法来有效防止源代码泄露。采用这种方式,当应用部署后,用户将只能接触到一个可执行文件,而不是众多散落的 Python 源文件。这不仅能在一定程度上保护代码不被轻易获取,还因其直接性和易用性,特别适合向最终用户分发。而且也可以根据实际需求,定制化 spec 文件,来优化打包后的可执行文件。

当然,实际情况因人而异,不同的项目可能会有不同的需求,比如需要更高的安全性,或者需要更好的运行效率。在选择源代码保护方案时,需要根据项目的具体情况,综合考虑各种因素,选择最适合的方案。

4. 项目源码

https://github.com/yugasun/pyprotect

5. 参考文档

  1. python compileall
  2. pyinstaller
  3. pyarmor