news 2026/9/28 5:52:12

Python安全编程实战:从SQL注入到JWT认证的攻防与加固

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python安全编程实战:从SQL注入到JWT认证的攻防与加固

1. 为什么安全编程是 Python 进阶的必修课

1.1 代码能跑只代表完成了 20%

Python 进阶到一定阶段,你会发现真正拉开差距的不是花哨的语法,而是能不能写出扛得住攻击的代码。今天想聊聊我从 SQL 注入到 JWT 认证这一路的实战经验——这俩名字听起来像安全工程师的活,但你只要用 Python 写过一次带登录功能的 Web 接口,就绕不开它们。文中我会把攻击路径、参数化查询、JWT 签名的坑一个个拆开,最后再给你一个可以直接对照改造的代码示例,适合已经能熟练写 Flask/Django、但还没系统补过安全课的开发者。

我常和团队里的小伙伴说,一个功能上线只代表代码完成了 20%,剩下 80% 是它在真实流量下还能不能扛住。SQL 注入的本质,是用户输入被拼进 SQL 语句后改变了查询语义;JWT 认证解决的是“你声称你是谁”的信任问题。这两件事看起来风马牛不相及,但都在回答同一个问题:你的应用有没有给攻击者留下一个“合法入口”。想象你开了一家小卖部,卷帘门关不严,货架上摆满商品,小偷进来拿货只是时间问题。Web 应用也是一样的,输入框、API 参数、请求头、Cookie 都是门,攻击者不一定比你聪明,但一定比你耐心。

1.2 Python 开发者最容易踩的三个安全盲区

第一个盲区是字符串拼接 SQL。Python 写起来太顺手,很多人会把 f-string 直接嵌进 SQL 语句,导致注入。第二个盲区是把密钥、数据库密码写死在代码仓库里,很多从培训课出来的同学根本没有密钥管理的概念。第三个盲区是拿到 token 就相信,不校验签名和算法,尤其在使用 JWT 时,新手以为把一段 base64 串发给前端就万事大吉。这三个盲区正好串联起从注入到认证的完整路线:一个管数据怎么进库,一个管身份怎么确认。

我在面试中经常让候选人聊聊“安全编程”,常见的回答是“用 ORM 就安全了”。但 ORM 不是银弹,真正的安全意识是知道攻击者会怎么打,才能在每一层设防。这也是为什么这篇文章不讲花架子,而是把从注入到认证的关键节点一一说明白。

2. SQL 注入:攻击是怎么发生的,又该如何拦住

2.1 一句话理解注入的本质:数据混进了代码里

SQL 注入不是魔法,它的核心就一句话:用户输入的数据被当成了 SQL 代码的一部分执行。比如登录接口要查用户,正常 SQL 是SELECT * FROM users WHERE username = 'alice'。如果代码用拼接构造语句,攻击者在用户名里输入' OR '1'='1' --,最终查询就变成:

SELECT * FROM users WHERE username = '' OR '1'='1' -- ' AND password = '...'

这个查询有三段逻辑:第一段查空字符串,第二段是一个永远为真的表达式,第三段被注释符--吞掉。数据库执行时,发现条件恒真,于是把表里所有行都返回出来。登录程序只要拿到一行数据就认为认证成功,攻击者不需要任何密码。

用生活化类比:你让门卫查本子上有没有“张三”,攻击者说“我叫张三’或者我没带钥匙并且 1=1”,门卫脑子被绕晕,不仅把张三放进来,还把所有叫李四的人一起放进来。问题不在于门卫不够聪明,而在于你把“查什么名字”和“怎么查”混在了同一句话里。

2.2 万能密码的完整拆解:不要把测试打到真实站点上

很多教程把这类 payload 叫“万能密码”,其实它不是什么神奇秘钥,而是利用闭合字符改变语法结构。我拿一个典型的脆弱登录代码来说明:

username = request.form.get("username") password = request.form.get("password") sql = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'" cursor.execute(sql) if cursor.fetchone(): return "登录成功"

攻击者的输入如果是admin' --,拼出来的 SQL 是:

SELECT * FROM users WHERE username = 'admin' -- ' AND password = '...'

--后面整段都成了注释,密码条件被无视。输入' OR '1'='1' --同理,会把整个表的数据带入判断。除了这些,还有 UNION 注入、布尔盲注、时间盲注等变体,但根因都一样:拼接破坏了语法边界。要真正看懂注入,最好的办法是写几行代码,把自己拼出来的 SQL 打印出来,你会发现攻击者的输入早就“架空了”你的逻辑。

这里必须说一句:想练手一定要搭建本地靶场,比如 DVWA、Pikachu、SQLi-Labs,或者 CTFhub 技能树里的注入题目。拿没有授权的真实网站去测试,是违法行为,也是所有安全从业者的红线。哪怕是入行十年的老手,在线上用到 sqlmap 之前也要先确认测试授权。

2.3 参数化查询:Python 侧最有效的防线

面对 SQL 注入,首选防御不是过滤,而是参数化查询。过滤黑名单非常容易绕过:大小写混写、注释符、URL 编码、Unicode 变体,都能让正则落空。参数化查询的原理是,把 SQL 结构和数据分两条通道传给数据库,数据只被当作字面量处理,不再参与语法解析。这样哪怕输入是' OR '1'='1,数据库也只会把它当成一个普通字符串去比较。

Python 不同数据库驱动的占位符风格略有差别。sqlite3 用?,psycopg2 和 MySQLdb 用%s。写法如下:

# sqlite3 cursor.execute("SELECT * FROM users WHERE username = ?", (username,)) # psycopg2 cursor.execute("SELECT * FROM users WHERE username = %s", (username,))

不要自己实现“安全转义”函数,边界情况太多,数据库驱动内部已经处理好了。ORM 也不是完全免死金牌:SQLAlchemy 的filter(User.username == username)是安全的,但在使用text()写原生 SQL 时,仍然需要用绑定参数。比如:

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

如果你用 f-string 拼接一个已经包含%s的完整 SQL 再传给 execute,那也不是参数化,因为数据已经混进语句了。

2.4 数据库权限与审计:把损失控制在最小范围

即使 SQL 写得有疏忽,权限收窄也能让你少丢数据。生产库永远不要用 root 账号去连应用,应该为应用单独创建一个账号,只授予它必需的操作权限。以 MySQL 为例:

CREATE USER 'app_user'@'localhost' IDENTIFIED BY '强口令'; GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'localhost'; FLUSH PRIVILEGES;

如果应用只需要读某几张表,连 INSERT 都别给,更不要说 DROP、ALTER 这些高危权限。权限越小,注入被利用后的爆炸半径越小。同时要开启 SQL 审计日志,在出现异常时能回查攻击者的 payload,知道对方摸到了哪一步。输入校验可以当减震器,参数化查询是安全带,数据库权限是最后一道防火墙,三层一起才叫纵深防御。

3. JWT 认证:签名机制、算法混淆与密钥管理

3.1 三段落里到底放了什么

JWT 是一串由两个点号分成三段的字符串,形如xxxxx.yyyyy.zzzzz。第一段是 Header,包含签名算法alg和类型typ;第二段是 Payload,存放声明比如用户 ID、过期时间;第三段是 Signature,用密钥对前两段签名。三段都是 Base64URL 编码,注意这个编码是可逆的,任何人拿到 token 都能解码看到内容,所以密码、手机号这些敏感信息绝对不要放进 Payload。JWT 保证的是完整性,不是机密性。

用 PyJWT 生成一个 token 很简单:

import jwt import datetime SECRET_KEY = "replace-me" payload = { "sub": "user-1001", "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=1), "iat": datetime.datetime.now(datetime.timezone.utc), } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")

解码时要指定算法白名单:

data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

PyJWT 2.x 强制要求algorithms参数,这是好事。如果你的依赖还是旧版本,尽快升级。

3.2 算法混淆攻击:不要在解码时相信 alg

JWT 领域最著名的攻击之一,就是算法混淆。攻击者把 Header 里的alg改成none,删掉签名,如果服务端没有强制指定算法,某些实现会直接放过。另一种更隐蔽:原来服务端用 RS256(非对称加密),攻击者把alg改成 HS256,然后用服务端的公开 RSA 公钥作为 HMAC 密钥去签名新 token。因为公钥是公开的,攻击者可以本地伪造 token 而不需要私钥。

这个攻击能成立的根源在于服务端盲目信任 token 头里的alg。正确的做法是解码时钉死算法白名单,只允许RS256,并且不接受none。用代码可以这样保护:

payload = jwt.decode(token, public_key, algorithms=["RS256"])

如果你看到有人写jwt.decode(token, key)不带algorithms,基本等于把认证逻辑交给攻击者决定。此外,Header 里的kid参数也可能被利用做路径穿越,有的库会根据kid指定的路径读取密钥文件,若不加校验,攻击者可以指向任意文件。这里不展开多讲,但你要知道:token 的每一个字段都值得怀疑。

3.3 密钥管理:硬编码是最大的窟窿

HMAC 算法的密钥是对称的,服务端持有它,意味着别人拿到密钥就能伪造任意 token,所以它必须像保险箱钥匙一样保管。RS256 算法私钥保密、公钥公开,私钥泄露同样危险。最常见的泄露途径不是安全攻击,而是代码里写死密钥提交到仓库,哪怕仓库是私有的,也迟早出事。

我在实际评审中发现,很多项目把SECRET_KEY = "my-secret"写在 settings.py 里,所有环境共用一套密钥。这等于把保险箱钥匙贴在门框上。正确做法是从环境变量或配置中心读取:

import os SECRET_KEY = os.environ.get("JWT_SECRET", "") if not SECRET_KEY: raise RuntimeError("JWT_SECRET 未配置,拒绝启动")

密钥还要考虑轮换。发布新密钥时,通过kid标识版本,让旧 token 在有效期内还能被识别。轮换时要做好新旧密钥的过渡,避免所有用户被强制踢下线。另外,日志里不要打 token 和密钥,很多事故不是黑客多厉害,而是日志把秘密印在了明处。

3.4 过期、刷新与注销:别把无状态当成万能药

JWT 的无状态特性让它很适合分布式系统,但代价是签发后很难立即吊销。所以你必须设置exp,否则 token 永久有效。一个合理的 access token 有效期通常是 15 分钟到 1 小时,太短体验差,太长风险高。

如果需要主动注销某个 token,可以维护一个黑名单。签发时给 token 加一个jti唯一标识,注销时把jti写进 Redis,鉴权时查一下黑名单。用代码表达:

payload = { "sub": user["id"], "exp": now + datetime.timedelta(minutes=15), "iat": now, "jti": uuid.uuid4().hex, }
if redis_client.sismember("jwt_blacklist", payload["jti"]): return {"msg": "token revoked"}, 401

黑名单要设置 TTL,避免无限膨胀。另外,不要把 token 放在localStorage,一旦出现 XSS 漏洞,攻击者可以直接读走。更靠谱的做法是放进 httpOnly Cookie,JavaScript 拿不到,跨站脚本的风险会小很多。

4. 一个真实接口的安全改造:从 SQL 注入到 JWT 认证

4.1 改造前的漏洞集锦

光讲理论不够,我拿一个典型的 Flask 登录接口来复盘。假设项目一开始长这样:

from flask import Flask, request, jsonify import sqlite3 import jwt import datetime SECRET_KEY = "my-hardcoded-secret" app = Flask(__name__) @app.post("/login") def login(): data = request.get_json() username = data["username"] password = data["password"] conn = sqlite3.connect("app.db") cur = conn.cursor() # 漏洞 1:SQL 拼接 cur.execute("SELECT * FROM users WHERE username='" + username + "' AND password='" + password + "'") user = cur.fetchone() if not user: return {"msg": "bad"}, 401 # 漏洞 2:token 没有 exp,算法依赖默认配置,密钥硬编码 token = jwt.encode({"sub": user[0]}, SECRET_KEY, algorithm="HS256") return {"token": token}

这个接口至少有三个问题:用户名和密码直接拼 SQL,注入随便打;密码明文存储,拖库之后就是批量泄露;token 没有过期时间,也没有算法白名单,拿到就是永久通行证。攻击路径也很清晰,先用admin' --绕过登录拿到合法 token,之后所有需要登录的接口随便访问。

4.2 改造后的登录与鉴权核心代码

改造后的思路是:参数化查询挡住注入,密码哈希存储,JWT 固定算法并加入过期时间和jti,密钥从环境变量读取。密码哈希用 werkzeug 自带的方法,不需要自己造轮子。

import os import uuid import datetime import sqlite3 from flask import Flask, request, jsonify from functools import wraps from werkzeug.security import check_password_hash import jwt app = Flask(__name__) SECRET_KEY = os.environ.get("JWT_SECRET", "") if not SECRET_KEY: raise RuntimeError("JWT_SECRET 未配置,拒绝启动") def get_db(): conn = sqlite3.connect("app.db") conn.row_factory = sqlite3.Row return conn @app.post("/login") def login(): data = request.get_json() or {} username = data.get("username", "") password = data.get("password", "") if not username or not password: return {"msg": "bad request"}, 400 cur = get_db().cursor() cur.execute("SELECT * FROM users WHERE username = ?", (username,)) user = cur.fetchone() if user is None or not check_password_hash(user["password"], password): return {"msg": "invalid username or password"}, 401 now = datetime.datetime.now(datetime.timezone.utc) payload = { "sub": user["id"], "exp": now + datetime.timedelta(minutes=15), "iat": now, "jti": uuid.uuid4().hex, } token = jwt.encode(payload, SECRET_KEY, algorithm="HS256") return {"token": token}

再看鉴权装饰器,重点在于algorithms=["HS256"]必须写死,不能让攻击者决定用什么算法。异常也要分开处理,过期和非法 token 返回不同提示,避免泄露过多信息。

def auth_required(func): @wraps(func) def wrapper(*args, **kwargs): auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): return {"msg": "missing token"}, 401 token = auth_header[7:] try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) except jwt.ExpiredSignatureError: return {"msg": "token expired"}, 401 except jwt.InvalidTokenError: return {"msg": "invalid token"}, 401 request.user = payload return func(*args, **kwargs) return wrapper

4.3 用脚本验证加固效果

安全改造有没有效果,不能靠感觉,要跑测试。我习惯用 requests 脚本快速验证几个关键场景。注意所有请求都打到本机服务,不碰任何线上目标。

import requests BASE = "http://127.0.0.1:5000" # 场景 1:SQL 注入被参数化挡下 r = requests.post(BASE + "/login", json={"username": "admin' --", "password": "whatever"}) print("注入请求状态码:", r.status_code) # 期望 401 # 场景 2:正确账号密码正常登录 r = requests.post(BASE + "/login", json={"username": "alice", "password": "correct-password"}) token = r.json().get("token") print("正常登录拿到 token:", bool(token)) # 场景 3:篡改后的 token 会被拒绝 forged = token[:-2] + "xx" r = requests.get(BASE + "/profile", headers={"Authorization": "Bearer " + forged}) print("篡改 token 状态码:", r.status_code) # 期望 401

这一步能帮你发现改造是否生效。我看到很多项目自信地说“我们用了 JWT”,但生成和校验的代码散落在多个文件,有的地方甚至没有校验过期时间。把测试脚本跑一遍,比自己心里默念“应该没问题”可靠得多。

5. 常见问题与排查技巧实录

5.1 参数化查询不是保险箱

参数化能挡住最常见的注入,但有几个场景它解决不了。第一个是 LIKE 查询,WHERE name LIKE '%' || ? || '%'本身是安全的,但用户输入的%和_会被当成通配符,导致查询范围扩大,需要用转义符处理。第二个是动态排序,ORDER BY ?无法参数化,因为排序字段不是数据,必须用白名单映射,让用户传age、created_at而不是任意字符串。第三个是存储过程内部的动态 SQL,如果存储过程里依然是字符串拼接,那参数化只是把问题搬了个地方。所以每次写 SQL 之前都要问自己:这条语句里面,哪些是结构,哪些是数据?数据参数化,结构白名单化。

5.2 JWT 过期时间相关的奇怪现象

我见过最典型的问题是“token 刚签发就过期”。绝大多数原因是时区。PyJWT 的exp要求是 UTC 时间戳,如果你用datetime.now()算本地时间,而服务器跑在 UTC 时区,一对比自然就过期了。解决方法是都使用datetime.datetime.now(datetime.timezone.utc),或者显式转换。

另一个常见现象是“我明明改了密钥,旧 token 还能用”。这通常是因为部署了多个实例,不同实例读取的环境变量不一致,或者存在新旧密钥过渡期。用kid标识密钥版本,并检查配置中心的下发是否覆盖了所有节点。还有的人注销 token 后马上访问接口还是 200,多半是黑名单查询没有接入鉴权中间件,或者 Redis 里没有设置 TTL,黑名单只加不查等于没加。

5.3 合规练手环境与工具箱

想深入练习,本地靶场是最好的选择。DVWA 和 Pikachu 都自带漏洞页面,适合理解注入和越权。SQLi-Labs 是专门练 SQL 注入的关卡平台,从基础到绕过都有。CTFhub 技能树里的注入题目也值得刷,做完之后再把攻击手法迁移到自己的项目里去防御。工具方面,Burp Suite 用来拦截和修改请求,sqlmap可以自动化检测 SQL 注入,jwt_tool专门用来调试和伪造 JWT,但这些工具只能用于授权测试,打自己靶场没问题,打未授权系统就是给自己找麻烦。

5.4 安全自检速查表

检查项常见风险正确做法
SQL 查询字符串拼接导致注入参数化查询,禁止 f-string 拼 SQL
数据库权限应用账号权限过高最小权限,不授予 DDL
JWT 算法信任 Header 中 algdecode 时指定 algorithms 白名单
临时密钥硬编码或弱密钥环境变量保存,长度足够并定期轮换
过期时间token 永久有效设置 exp,合理短时,配合刷新
密码存储明文或弱哈希使用 bcrypt/scrypt 或 werkzeug 哈希

6. 写在最后:把安全变成习惯

我自己带团队时有一条硬规矩:所有涉及 SQL 的代码必须走参数化或经过 review 确认,所有认证相关代码必须有现成的测试用例钉死。一开始大家都觉得烦,直到有人真的用一行注入 payload 打通了登录接口,这条规矩才没人再抱怨。安全编程不是额外负担,而是把“如果我是攻击者,我会从哪里切入”这个念头写进每一次开发里。代码会过期,库会迭代,但“不信任输入、不硬编码秘密、不盲信 token”这三条原则不会过时。希望能够给你带来帮助。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 5:52:09

Hadoop与物联网:海量传感器数据存储与分析实战

做物联网这块的兄弟应该都有过这种体验:传感器数量一上来,数据量根本不是“涨”的,是“炸”的。一个智能大棚项目,我接了300多个环境监测节点,每5秒回传一次温度、湿度、光照、CO₂浓度,一天下来就是500多万…

作者头像 李华
网站建设 2026/9/28 5:51:36

STM32部署轻量级神经网络:从PyTorch到INT8量化实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 5:51:02

Ninja构建报错:multiple outputs aren‘t supported 的成因与解法

第一次撞上这个报错是在一个周五下午。项目用的是 CMake Ninja,配置阶段一切正常,cmake --build .刚跑起来不到两秒就崩了,终端里孤零零甩了一行:ninja: error: build.ninja:1180: multiple outputs arent (yet?) supported说真…

作者头像 李华
网站建设 2026/9/28 5:50:47

立创EDA正则表达式批量修改PCB丝印大小实战指南

画完PCB直接下单打样的朋友,应该都经历过这一幕:板子寄回来,电阻电容的位号丝印小到要拿放大镜才能勉强看清,贴着板边甚至要斜着看。我最早在嘉立创EDA里画板时,也在这上面栽过跟头。板上一堆R1、R2、C1、C2&#xff0…

作者头像 李华
网站建设 2026/9/28 5:50:42

Maven手动安装jar依赖:install-file命令详解与排错指南

先说个挺常见的场景:你从某个渠道拿到一个第三方功能包的jar文件,往项目lib目录里一扔,然后在pom.xml里加了对应依赖,IDEA 一刷新,理论上应该能用了吧?结果编译报错,依赖依然爆红,控…

作者头像 李华