ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

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 11 --最终查询就变成SELECT * FROM users WHERE username OR 11 -- AND password ...这个查询有三段逻辑第一段查空字符串第二段是一个永远为真的表达式第三段被注释符--吞掉。数据库执行时发现条件恒真于是把表里所有行都返回出来。登录程序只要拿到一行数据就认为认证成功攻击者不需要任何密码。用生活化类比你让门卫查本子上有没有“张三”攻击者说“我叫张三’或者我没带钥匙并且 11”门卫脑子被绕晕不仅把张三放进来还把所有叫李四的人一起放进来。问题不在于门卫不够聪明而在于你把“查什么名字”和“怎么查”混在了同一句话里。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 11 --同理会把整个表的数据带入判断。除了这些还有 UNION 注入、布尔盲注、时间盲注等变体但根因都一样拼接破坏了语法边界。要真正看懂注入最好的办法是写几行代码把自己拼出来的 SQL 打印出来你会发现攻击者的输入早就“架空了”你的逻辑。这里必须说一句想练手一定要搭建本地靶场比如 DVWA、Pikachu、SQLi-Labs或者 CTFhub 技能树里的注入题目。拿没有授权的真实网站去测试是违法行为也是所有安全从业者的红线。哪怕是入行十年的老手在线上用到 sqlmap 之前也要先确认测试授权。2.3 参数化查询Python 侧最有效的防线面对 SQL 注入首选防御不是过滤而是参数化查询。过滤黑名单非常容易绕过大小写混写、注释符、URL 编码、Unicode 变体都能让正则落空。参数化查询的原理是把 SQL 结构和数据分两条通道传给数据库数据只被当作字面量处理不再参与语法解析。这样哪怕输入是 OR 11数据库也只会把它当成一个普通字符串去比较。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_userlocalhost IDENTIFIED BY 强口令; GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO app_userlocalhost; 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(hours1), iat: datetime.datetime.now(datetime.timezone.utc), } token jwt.encode(payload, SECRET_KEY, algorithmHS256)解码时要指定算法白名单data jwt.decode(token, SECRET_KEY, algorithms[HS256])PyJWT 2.x 强制要求algorithms参数这是好事。如果你的依赖还是旧版本尽快升级。3.2 算法混淆攻击不要在解码时相信 algJWT 领域最著名的攻击之一就是算法混淆。攻击者把 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(minutes15), iat: now, jti: uuid.uuid4().hex, }if redis_client.sismember(jwt_blacklist, payload[jti]): return {msg: token revoked}, 401黑名单要设置 TTL避免无限膨胀。另外不要把 token 放在localStorage一旦出现 XSS 漏洞攻击者可以直接读走。更靠谱的做法是放进 httpOnly CookieJavaScript 拿不到跨站脚本的风险会小很多。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() # 漏洞 1SQL 拼接 cur.execute(SELECT * FROM users WHERE username username AND password password ) user cur.fetchone() if not user: return {msg: bad}, 401 # 漏洞 2token 没有 exp算法依赖默认配置密钥硬编码 token jwt.encode({sub: user[0]}, SECRET_KEY, algorithmHS256) 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(minutes15), iat: now, jti: uuid.uuid4().hex, } token jwt.encode(payload, SECRET_KEY, algorithmHS256) 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 wrapper4.3 用脚本验证加固效果安全改造有没有效果不能靠感觉要跑测试。我习惯用 requests 脚本快速验证几个关键场景。注意所有请求都打到本机服务不碰任何线上目标。import requests BASE http://127.0.0.1:5000 # 场景 1SQL 注入被参数化挡下 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数据库权限应用账号权限过高最小权限不授予 DDLJWT 算法信任 Header 中 algdecode 时指定 algorithms 白名单临时密钥硬编码或弱密钥环境变量保存长度足够并定期轮换过期时间token 永久有效设置 exp合理短时配合刷新密码存储明文或弱哈希使用 bcrypt/scrypt 或 werkzeug 哈希6. 写在最后把安全变成习惯我自己带团队时有一条硬规矩所有涉及 SQL 的代码必须走参数化或经过 review 确认所有认证相关代码必须有现成的测试用例钉死。一开始大家都觉得烦直到有人真的用一行注入 payload 打通了登录接口这条规矩才没人再抱怨。安全编程不是额外负担而是把“如果我是攻击者我会从哪里切入”这个念头写进每一次开发里。代码会过期库会迭代但“不信任输入、不硬编码秘密、不盲信 token”这三条原则不会过时。希望能够给你带来帮助。
返回列表