ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python如何保护代码和数据,常见的安全漏洞及应对措施有哪些?

Python如何保护代码和数据,常见的安全漏洞及应对措施有哪些?

前言


这个问题把两件不同的事情捆在了一起:保护代码和保护数据。它们的威胁模型、可用手段和能达到的效果完全不同,混为一谈就会得出错误的结论。


先说最常见的误解:「用 Python 写的代码可以被加密,让别人看不到」。这是做不到的。Python 是解释型语言,源码必须能被解释器读取。有人把.py编译成.pyc就以为安全了,其实.pyc只是字节码,存在成熟的工具可以把它还原成接近原始的源码。商业混淆工具、打包成单文件、甚至编译成原生扩展,都只能提高逆向的成本,不能做到不可逆。真正需要绝对保护的核心逻辑(比如风控规则、计费算法),行业做法是把它放在服务器端,只暴露接口,客户端永远拿不到实现。


而数据保护不一样,它是有确定性答案的工程问题:口令必须用密钥派生函数加盐存储、令牌必须来自密码学安全的随机源、比较必须恒定时间、传输必须加密、数据库查询必须参数化。这些做法有明确的官方依据,不是"尽量做好"的软要求。


本文只写防御侧:告诉你漏洞长什么样、怎么修,不提供任何可用的攻击载荷、webshell 或绕过手法。同时明确一点:Python 2.7 已于 2020 年 1 月 1 日停止维护,继续运行 Python 2 本身就是最大的安全风险之一——即使你的代码"没问题",它也不再收到任何安全补丁。


一、先分清两类保护




维度保护代码保护数据



防范对象有人想抄、想改你的实现有人想读、想改你的数据

能否做到绝对不能,只能提高成本能,有标准做法

主要手段服务端化、混淆、编译加密、哈希、参数化、最小权限

典型错误认知.pyc等于加密口令可以"加密"保存



结论:在代码保护上追求"绝对不可读"是浪费精力,在数据保护上省事则是持续的风险敞口。精力应该按这个比例分配。


二、数据保护的四条硬规矩



  • 口令只存派生值,不存明文也不存可逆密文。口令保存的目标是"能验证"而不是"能还原"。标准库用hashlib.scrypt(3.6 起,依赖 OpenSSL 1.1.0 及以上)或hashlib.pbkdf2_hmac,每个用户一份独立随机盐。MD5、SHA-1、SHA-256 这类通用哈希设计目标是快,正好方便暴力破解。



  • 令牌必须来自secrets。secrets的官方文档写得很直白:random模块的伪随机生成器不应用于安全用途,也不应用于生成安全令牌。生成密码重置链接、会话 ID、API Key 一律用secrets.token_urlsafe()。



  • 比较必须恒定时间。用==比较哈希或令牌会在第一个不同的字节处返回,攻击者可以靠测量响应时间逐字节推断。标准库提供hmac.compare_digest(a, b),两个参数都必须是 bytes-like 或 ASCII 字符串。



  • 敏感配置不能进源码。app_secret、数据库口令、私钥一律走环境变量或专门的密钥管理服务,源码里只留键名。日志里也要避免打印完整令牌,必要时只打印前几位。


三、常见漏洞与对应措施




漏洞危害防御措施



SQL 注入读写/删除任意数据参数化查询execute(sql, params)

反序列化任意代码执行不用pickle处理不可信数据,改用 JSON

代码注入任意代码执行不对不可信输入用eval/exec

命令注入执行系统命令subprocess传列表,不用shell=True

路径穿越读写目录外文件规范化后用is_relative_to校验

归档解压穿越覆盖任意文件tarfile用filter="data",zipfile先检查成员

弱随机数令牌可预测用secrets而非random

依赖投毒供应链入侵锁定版本、校验哈希、只从可信源装



其中pickle需要特别强调:官方文档的原话是「绝不能反序列化来自不可信来源的数据」,因为反序列化过程本身就会导入并调用任意代码。也就是说,攻击者不需要找到你代码里的漏洞,只要让你pickle.loads到他构造的数据,代码就直接执行了。跨进程传数据、存配置、缓存对象,请用 JSON。


eval()和exec()同理——它们的设计目的就是执行任意代码,因此不能用于任何来自外部的输入。需要解析表达式时,用ast.literal_eval()只接受字面量,或者干脆写一个小的解析器。


四、一段可复用的防御代码


# 适用于 Python 3.8+(最后一个 safe_extract_tar 需要 3.12 及以上)
import hashlib
import hmac
import os
from pathlib import Path

SCRYPT_N, SCRYPT_R, SCRYPT_P, DKLEN = 2 ** 14, 8, 1, 32


def hash_password(password):
"""口令 -> 'scrypt$<salt_hex>$<dk_hex>',盐随哈希一起存。"""
salt = os.urandom(16)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=DKLEN)
return "scrypt${}${}".format(salt.hex(), dk.hex())


def verify_password(password, stored):
try:
algo, salt_hex, dk_hex = stored.split("$")
except ValueError:
return False
if algo != "scrypt":
return False
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(dk_hex)
dk = hashlib.scrypt(password.encode("utf-8"), salt=salt,
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P,
dklen=len(expected))
return hmac.compare_digest(dk, expected)


def find_user(conn, username):
# 参数化查询:值走参数位,绝不拼接进 SQL 文本
return conn.execute(
"SELECT id, password_hash FROM users WHERE username = ?",
(username,),
).fetchone()


def safe_path(base_dir, user_supplied):
"""把用户提供的相对路径限制在 base_dir 之内,挡住路径穿越。"""
base = Path(base_dir).resolve()
target = (base / user_supplied).resolve()
# is_relative_to 需要 Python 3.9 及以上
if not target.is_relative_to(base):
raise ValueError("路径越界: " + str(target))
return target
# Python 3.8 及更早的替代写法:
# if os.path.commonpath([str(base), str(target)]) != str(base):
# raise ValueError("路径越界")


def safe_extract_tar(archive_path, dest_dir):
"""解压 tar:filter='data' 会拒绝绝对路径和带 .. 的成员。
注意:filter 参数需要 Python 3.12 及以上。"""

import tarfile
dest = Path(dest_dir).resolve()
dest.mkdir(parents=True, exist_ok=True)
with tarfile.open(archive_path, "r:*") as tar:
# filter 参数自 Python 3.12 起提供,3.14 起默认即为 'data'
tar.extractall(path=dest, filter="data")

关于归档解压,官方文档的警告是统一的:在没有事先检查的情况下,绝不要解压来自不可信来源的归档文件。原因是归档成员的文件名可以是绝对路径,也可以带..片段,解压时就会写到目标目录之外。tarfile的做法是传filter="data";这个参数在 3.12 引入,3.14 起成为默认值,如果你的运行环境低于 3.12,就必须手工逐个检查成员名,或者改用safe_path的那种校验方式。zipfile的文档也给出了同样的警告,并说明模块会尝试阻止,但不要把"尝试"当成保证。


常见坑点


1. 用通用哈希存口令


❌hashlib.md5(password.encode()).hexdigest()✅hashlib.scrypt(...)或hashlib.pbkdf2_hmac("sha256", ...)通用哈希求快,KDF 求慢,方向正好相反。


2. 用random生成令牌或验证码


❌random.randint(100000, 999999)当短信验证码 ✅secrets.randbelow(1_000_000)或secrets.token_urlsafe(32)官方文档明确写了random不适用于安全用途;它的输出可以被逆推出内部状态。


3. 用==比较敏感值


❌if token == stored_token:✅if hmac.compare_digest(token, stored_token):短路比较会通过响应时间泄露"猜对了几位"。


4. 拼接 SQL


❌conn.execute("SELECT * FROM users WHERE name = '" + name + "'")✅conn.execute("SELECT * FROM users WHERE name = ?", (name,))参数化让值永远只是值,不会被当成 SQL 语法的一部分。


5. 用pickle处理外部数据


❌obj = pickle.loads(request_body)✅obj = json.loads(request_body.decode("utf-8"))pickle反序列化本身就能执行任意代码,官方文档的原话是绝不要反序列化不可信来源的数据。


6. 对输入用eval/exec


❌value = eval(user_input)✅value = ast.literal_eval(user_input)(且仍需try捕获异常) 需要"算个数"的场合也不该用eval,它和exec一样能执行任意代码。


7.subprocess拼字符串并开 shell


❌subprocess.run("ping " + host, shell=True)✅subprocess.run(["ping", "-c", "1", host])开了 shell 就等于把用户输入交给命令行解释器,分号和管道符都会被当成语法。


8. 直接解压不可信归档


❌tarfile.open(p).extractall(dest)✅extractall(dest, filter="data"),或先逐个校验成员路径 成员名里的..和绝对路径会让文件落到目标目录之外。


总结




目标手段标准库



口令不可还原慢哈希 + 独立盐hashlib.scrypt/pbkdf2_hmac

令牌不可预测密码学安全随机secrets

比较不泄露恒定时间比较hmac.compare_digest

查询不可注入参数化占位sqlite3的?

反序列化不执行代码换成数据格式json

文件不越界规范化后校验pathlib的Path.resolve



代码保护的真相是「无法做到绝对,只能提高成本,最有效的办法是把关键逻辑放到服务端」;数据保护的真相是「有一套明确的标准做法,照做即可」。把这两件事分开看待,你就不会再纠结"怎么把 Python 代码加密到别人看不懂",而会把精力放在口令、令牌、查询、反序列化这几处最容易出事的地方——它们才是绝大多数真实事故的入口。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进