拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python Web安全实战:SQL注入与JWT认证的攻防要点

Python Web安全实战:SQL注入与JWT认证的攻防要点 1. 安全编程的底层逻辑为什么每个 Python 开发都得懂点攻防很多写 Python 的朋友最初接触这门语言是被爬虫、数据分析、自动化脚本吸引过来的。到了进阶阶段开始用 Flask、FastAPI、Django 写 Web 服务这时候安全问题就绕不过去了。我见过太多团队业务代码写得飞起一问安全配置全是裸奔。数据库直连不带参数校验、登录态全靠前端传个 user_id、Token 签名密钥硬编码在代码里…… 说实话这些问题不一定要被黑客盯上才叫问题——等被扫出来再补成本至少翻五倍。这一讲聚焦两个实战高频点SQL 注入和 JWT 认证。前者是 Web 安全里最经典、杀伤力最大的漏洞类型后者是现在前后端分离架构下最常见的认证方案。两个点单独拎出来都能讲一天这一讲把它们串起来从原理到底层实现到防护手段走一遍完整的安全开发闭环。先说清楚这篇文章适合谁有一定 Python 基础会用 Flask 或 FastAPI 写过接口想系统了解 Web 安全实战的开发者。如果你连 Python 环境都还没装好先去把环境配好装好依赖库再来读这篇实操部分需要能跑代码。需要提醒的是本文所有注入演示都在本地环境完成用的都是自己搭的靶场或本地数据库千万不要对真实网站做任何测试。搞安全的都知道这个底线但每次都得强调一遍。2. 内容整体设计与思路拆解2.1 SQL 注入到底是怎么发生的SQL 注入的本质一句话就能说清用户的输入被拼进了 SQL 语句且被当成了 SQL 代码执行。举个例子现在有个登录接口代码大概是这样的import sqlite3 def login(username, password): conn sqlite3.connect(user.db) cursor conn.cursor() sql SELECT * FROM users WHERE username username AND password password cursor.execute(sql) result cursor.fetchone() return result表面看起来没毛病用户传用户名、密码查表查到就登录成功。问题在于——如果用户传的用户名是admin --呢拼出来的 SQL 就变成了SELECT * FROM users WHERE username admin -- AND password xxx在 SQL 里--是注释符。后半段 AND password xxx全被注释掉了。这条语句实际执行的是SELECT * FROM users WHERE username admin密码校验被完全绕过了。这就是最早也是最经典的“万能密码”原理——根本不是什么魔法就是利用注释符把你精心设置的校验条件给废掉。更狠一点的注入还能用 UNION 查询把别的表的数据带出来。比如 UNION SELECT username, password FROM users --直接拖库。列数不对就猜列数报错就靠报错信息反推结构网站响应慢就搞时间盲注——这些在我后面讲实操时会具体演示。2.2 为什么 JWT 是现在最流行的认证方案Session 认证的时代服务端要存 Session ID还得管过期时间、并发登录、分布式同步一套搞下来很麻烦。JWTJSON Web Token的思路不一样把用户信息加密成一个字符串发给客户端客户端每次请求带回来服务端验签通过就算认证成功。好处非常明显服务端无状态不需要存 Session水平扩展的时候不用考虑 Session 同步问题。这也是为什么现在前后端分离的项目里 JWT 几乎成了标配。但 JWT 用不好就是灾难。我在项目评审里见过的高频错误包括签名密钥太短且硬编码、没设置过期时间、算法写成none、把密码等敏感信息塞进 Payload。这些后面都会展开讲。要理解 JWT 的坑得先知道它的结构。下面用一张表格直观展示 JWT 的三段式结构分段名称内容编码方式第一段Header算法类型如 HS256、Token 类型Base64Url第二段Payload用户 ID、角色、过期时间等声明Base64Url第三段Signature签名防止篡改HMAC 或 RSA 签名三个部分用点号连接整体长这样eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoxMjMsImV4cCI6MTcwMDAwMDAwMH0.sQ0pYb2vK2p0XQmY4Q8VqVZ1G4kxL3RzxHzYxcbWZME第一段和第二段只是 Base64Url 编码不是加密任何人拿到都能解码出内容。真正防篡改的是第三段签名——如果内容被改了签名校验不过。2.3 攻防视角先知道攻击者怎么想才能写好防御代码我做安全相关项目的习惯是先站在攻击者的角度想清楚“怎么打”再回来写防御代码。这样写出来的代码才有针对性而不是按文档模板堆 middleware。SQL 注入的攻击面主要在登录框、搜索框、任何把用户输入直接拼进 SQL 的地方。攻击者会先试单引号看报不报错再试注释符看能不能闭合再试 UNION 查询看能不能拖数据。JWT 的攻击面主要在Token 的获取能不能伪造、Token 的存储有没有放在 localStorage 被 XSS 偷走、Token 的校验有没有校验签名、有没有校验过期时间。下面两张图是我脑子里这套攻防体系的简化模型用文字画出来攻击者视角SQL 注入: 用户输入 → 注入语句 → 拼接进 SQL → 数据库执行 → 数据泄露/绕过认证 关键环节每一步都可能设防 防御者视角SQL 注入: 参数化查询 → SQL 语句结构固定 → 用户输入只当数据 → 注入失效攻击者视角JWT: 获取 Token → 解密 Payload因为没加密→ 篡改 Payload → 伪造签名 → 请求接口 关键环节签名密钥、算法选择、过期校验 防御者视角JWT: 强密钥 安全算法 → 签名校验 → 过期校验 → 敏感信息不放 Payload这套攻防模型是贯穿全文的核心思考方式列攻击路径、找防御点位、逐个击破。3. SQL 注入核心细节解析与实操要点3.1 注入类型与判断方法SQL 注入按响应方式分三类每种判断方法不一样。我实测下来的经验是先看网站报不报错再看返回内容变没变最后再考虑盲注。由易到难效率最高。第一类报错注入。你输入一个单引号页面直接抛出 SQL 语法错误把部分 SQL 语句也跟着显示出来了。这种情况最容易利用因为报错信息本身就是信息泄露。如果配置得当错误信息里还有数据库版本、表结构信息。第二类联合查询注入。页面正常返回但你通过改变查询条件能把数据“带”到页面上来。判断方法很简单id1有结果、id9999没结果那试试id9999 UNION SELECT 1,2,3 --页面显示几列后面就能拖几列的数据。第三类盲注。页面既不报错也不会显示查询内容只有“是”或“不是”两种状态。这种最麻烦需要靠substr()函数配合条件判断逐个字符去猜。速度慢但很有效。三类注入的判断逻辑用 max 函数来做只能反映当前响应状态对初学者来说直接经验判断更快——实践里我通常直接用 sqlmap 这类工具辅助但对学习来说手工判断一定要会。3.2 真实漏洞代码演示本地靶场环境为了演示效果我在本地搭了一个极简的 Flask 靶场。完整代码如下仅供本地学习使用from flask import Flask, request import sqlite3 app Flask(__name__) def init_db(): conn sqlite3.connect(poc.db) cursor conn.cursor() cursor.execute(CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, username TEXT, password TEXT)) cursor.execute(CREATE TABLE IF NOT EXISTS secrets (id INTEGER PRIMARY KEY, secret TEXT)) cursor.execute(INSERT OR IGNORE INTO users (id, username, password) VALUES (1, admin, admin123)) cursor.execute(INSERT OR IGNORE INTO secrets (id, secret) VALUES (1, flag{this_is_a_local_poc})) conn.commit() conn.close() app.route(/login) def login(): username request.args.get(username, ) password request.args.get(password, ) conn sqlite3.connect(poc.db) cursor conn.cursor() sql SELECT * FROM users WHERE username username AND password password print(执行 SQL:, sql) cursor.execute(sql) result cursor.fetchone() if result: return 登录成功欢迎 str(result[1]) return 登录失败 app.route(/search) def search(): keyword request.args.get(q, ) conn sqlite3.connect(poc.db) cursor conn.cursor() sql SELECT * FROM secrets WHERE secret LIKE % keyword % print(执行 SQL:, sql) cursor.execute(sql) result cursor.fetchall() if result: return str(result) return 无结果 if __name__ __main__: init_db() app.run(debugTrue, port5000)跑起来之后访问http://localhost:5000/login?usernameadmin --password任意值控制台会打印出实际执行的 SQLSELECT * FROM users WHERE username admin -- AND password 任意值登录直接被绕过。注意我这个演示为了便于观察特意加了 print 打印真实攻击场景里你根本看不到执行了什么语句只能靠返回结果判断。接下来访问http://localhost:5000/search?q UNION SELECT id, secret FROM secrets --数据全出来了。字符串就是被拼进了 LIKE 的条件里UNION 把 secrets 表的内容带了出来。3.3 防护手段参数化查询是底线修复这个漏洞核心就一句话把 SQL 语句的结构和用户输入的数据彻底分开。使用参数化查询之后代码变成这样app.route(/login) def login_safe(): username request.args.get(username, ) password request.args.get(password, ) conn sqlite3.connect(poc.db) cursor conn.cursor() sql SELECT * FROM users WHERE username ? AND password ? cursor.execute(sql, (username, password)) result cursor.fetchone() if result: return 登录成功欢迎 str(result[1]) return 登录失败注意那个?它是占位符。传参的时候用户输入的值只会被当作纯数据传给数据库永远不会被解析成 SQL 代码。我再怎么传admin --它也只是一个普通的字符串admin --想闭合前面的引号没门——因为 SQL 结构在执行计划生成的时候就已经固定了后面传进去的只是绑定变量。这个机制怎么理解类比一下面试官手里有一张固定问题清单你回答的内容再天花乱坠也只是“回答”不会变成“清单里的一道题”。参数化查询就是这个思路。实际项目中ORM 框架比如 SQLAlchemy默认就走参数化查询所以用 ORM 不等于绝对安全但如果你写的是原生 SQL?占位符就是底线。3.4 输入校验作为第二道防线参数化查询能搞定几乎所有注入场景但工程上我建议再加一道输入校验。为什么因为参数化不是万能的——数据库的存储过程、动态 SQL 拼接、部分 ORM 的高级用法里仍然可能存在注入点。常见校验规则登录用户名只允许字母、数字、下划线长度限制搜索关键词过滤特殊字符例如单引号、分号、注释符数字类型的参数强制转换成 int 类型用 Python 实现一个简单的校验函数import re def sanitize_input(value, pattern, max_length50): if not value or len(value) max_length: return None if not re.match(pattern, value): return None return value username sanitize_input(username, r^[a-zA-Z0-9_]$)这种做法叫白名单校验——只允许合法格式而不是尝试过滤非法内容。过滤黑名单的问题在于攻击变种太多了你永远堵不完白名单则是一刀切不符合格式的一律拒绝省心得多。4. JWT 认证核心细节解析与实操要点4.1 手写一个 JWT 的生成与校验理解 JWT 最好的方式是自己写一遍不会依赖任何现成库也能搞清楚内部机制。import base64 import hashlib import hmac import json import time SECRET_KEY my-super-secret-key-change-me def base64url_encode(data: bytes) - str: return base64.urlsafe_b64encode(data).rstrip(b).decode() def base64url_decode(data: str) - bytes: padding * (4 - len(data) % 4) return base64.urlsafe_b64decode(data padding) def create_token(user_id: int, expires_in: int 3600) - str: header {alg: HS256, typ: JWT} payload {user_id: user_id, exp: int(time.time()) expires_in} header_segment base64url_encode(json.dumps(header).encode()) payload_segment base64url_encode(json.dumps(payload).encode()) signing_input f{header_segment}.{payload_segment} signature hmac.new(SECRET_KEY.encode(), signing_input.encode(), hashlib.sha256).digest() signature_segment base64url_encode(signature) return f{signing_input}.{signature_segment} def verify_token(token: str): try: header_segment, payload_segment, signature_segment token.split(.) signing_input f{header_segment}.{payload_segment} expected_signature hmac.new(SECRET_KEY.encode(), signing_input.encode(), hashlib.sha256).digest() actual_signature base64url_decode(signature_segment) if not hmac.compare_digest(expected_signature, actual_signature): return None payload json.loads(base64url_decode(payload_segment)) if payload.get(exp, 0) time.time(): return None return payload except Exception: return None关键点逐一说明base64url_encode用了 urlsafe 编码因为 JWT 要在 URL 里传递而标准 Base64 里的/在 URL 里有特殊含义必须换掉。签名算法是hmac.new(...)——密钥和签名输入一起做 SHA256 哈希任何一方改动都会导致签名对不上。hmac.compare_digest是常量时间比较普通字符串比较在某些攻击下会有时序侧信道这个用常数时间比较更稳。校验expToken 过期了直接拒绝。4.2 JWT 常见漏洞算法混淆与密钥泄露JWT 最大的坑有两个几乎没有例外。第一个坑是算法混淆。服务端原本用 HS256对称加密如果攻击者把 Header 里的alg改成none某些处理不当的服务端会直接跳过签名校验——Token 随便编。还有一些场景是服务端用 RSA非对称验签但攻击者把算法改成 HS256这时候服务端可能用公钥当作 HMAC 的密钥来验签因为公钥是公开的攻击者就能自己伪造 Token。防御手段代码里明确指定允许的算法列表而不是信任 Header 里写的算法。用PyJWT的话这样做import jwt try: payload jwt.decode(token, SECRET_KEY, algorithms[HS256]) except jwt.InvalidAlgorithmError: return None第二个坑是密钥泄露。很多人图省事把密钥直接写在代码里或者设置成secret、123456这种。JWT 的签名密钥一但泄露攻击者可以伪造任意身份——这等于认证系统整个报废。实际渗透测试中jwt_tool这类工具跑弱密钥字典有时候几秒钟就出结果。密钥管理的底线建议密钥长度至少 256 位32 字节以上开发、测试、生产环境用不同密钥生产密钥通过环境变量或密钥管理服务注入不进代码仓库定期轮换至少半年一次不要在日志里打印密钥或完整 Token4.3 安全 JWT 认证流程实战Flask 完整示例下面是一个相对完整的 Flask JWT 认证实战代码包含登录、鉴权、退出三个部分。退出用黑名单方案实现。import jwt import redis import datetime from flask import Flask, request, jsonify from functools import wraps app Flask(__name__) app.config[SECRET_KEY] prod-env-secret-key-overwrite-me app.config[JWT_ALGORITHM] HS256 app.config[JWT_EXPIRE_MINUTES] 30 # 生产环境用 Redis 存 Token 黑名单 r redis.Redis(hostlocalhost, port6379, db0, decode_responsesTrue) # 模拟用户表 USERS {admin: pbkdf2:sha256:260000$abcdef$1234567890abcdef1234567890abcdef} def generate_token(user_id: str) - str: payload { sub: user_id, iat: datetime.datetime.utcnow(), exp: datetime.datetime.utcnow() datetime.timedelta(minutesapp.config[JWT_EXPIRE_MINUTES]), jti: uuid.uuid4().hex } token jwt.encode(payload, app.config[SECRET_KEY], algorithmapp.config[JWT_ALGORITHM]) return token def token_required(f): wraps(f) def decorated(*args, **kwargs): auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): return jsonify({error: Missing token}), 401 token auth_header.split( )[1] # 先查黑名单 if r.get(fblacklist:{token}): return jsonify({error: Token revoked}), 401 try: payload jwt.decode(token, app.config[SECRET_KEY], algorithms[app.config[JWT_ALGORITHM]]) except jwt.ExpiredSignatureError: return jsonify({error: Token expired}), 401 except jwt.InvalidTokenError: return jsonify({error: Invalid token}), 401 request.user_id payload.get(sub) return f(*args, **kwargs) return decorated app.route(/login, methods[POST]) def login(): data request.get_json() username data.get(username, ) password data.get(password, ) # 校验密码走安全哈希比较不能拿明文比对 if username in USERS: expected_hash USERS[username] if verify_password_hash(expected_hash, password): token generate_token(username) return jsonify({token: token}), 200 return jsonify({error: Invalid credentials}), 401 app.route(/logout, methods[POST]) token_required def logout(): auth_header request.headers.get(Authorization, ) token auth_header.split( )[1] # 把 Token 加入黑名单过期时间设为 30 分钟与 Token 有效期一致 r.setex(fblacklist:{token}, app.config[JWT_EXPIRE_MINUTES] * 60, 1) return jsonify({message: Logged out}), 200 app.route(/profile, methods[GET]) token_required def profile(): return jsonify({user: request.user_id}), 200这个方案里有几个设计决策值得重点关注黑名单方案JWT 本身是无状态的服务端不能主动让 Token 失效。要在“退出登录”场景让 Token 失效最简单的方法就是黑名单。我这里用了 Redis加了和 Token 有效期一致的自动过期时间避免黑名单无限膨胀。jti字段Token 唯一 ID这是审计和撤销的抓手。如果后面要实现“踢人下线”或者“同一用户仅允许一个有效 Token”全靠jti来标识。密码哈希登录时比对密码用的是安全哈希函数生成的密文绝不能用明文比对。4.4 前端存储Token 别放 localStorage这一步很多团队完全没意识。前后端分离项目Token 存储位置有讲究。放 localStorage 是最常见的做法但存在巨大的安全风险——只要站点上有任何一个 XSS 漏洞攻击者就能执行localStorage.getItem(token)把 Token 偷走。XSS 漏洞在真实项目里太常见了评论区的富文本没过滤、URL 参数直接渲染到页面这些都可能成为 XSS 攻击点。更稳妥的方案是用 HttpOnly Cookie。Cookie 设置HttpOnly之后JavaScript 无法读取XSS 偷不到。配合Secure和SameSite属性还能防中间人劫持和跨站请求伪造。两种方案对比下来方案防 XSS 窃取防 CSRF适用场景localStorage Authorization 头不安全较好原生 App 或纯 API 服务HttpOnly Cookie安全需要额外 CSRF 防护浏览器 Web 应用两种方案没有绝对优劣但根据我多年的项目总结来看浏览器端应用优先选 HttpOnly Cookie 更安全。唯一需要注意的是用 Cookie 方案时要防 CSRF——常见做法是加自定义请求头或 CSRF Token。5. 常见问题与排查技巧实录5.1 SQL 注入排查速查表真实项目里怎么判断自己写的代码有没有 SQL 注入风险按照下面这个表格过一遍就清楚了检查点安全做法危险做法数据库操作方式参数化查询或 ORMf-string 拼 SQL、字符串拼接动态排序字段白名单映射如[id, name]直接把字段名拼进 SQLLIKE 查询LIKE ?配合%拼接参数LIKE % keyword %IN 子句生成占位符列表直接把列表拼成字符串数字型参数int()强转直接放入 SQL排查的时候代码搜索关键词execute(里面有没有出现或者fcursor.execute的第二个参数是空还是真的有参数传入。Python 里最容易出事的是cursor.execute(fSELECT ... WHERE x {value})这种写法遇到一个改一个。5.2 JWT 认证排查速查表检查点安全做法危险做法签名算法服务端白名单指定 HS256/RS256信任 Header 中 alg 字段密钥强度32 字节以上随机值secret、123456过期时间30 分钟以内动态计算不设 exp 或设一年Payload 内容只放 user_id/jti/角色放密码、身份证号Token 存储HttpOnly CookielocalStorage退出登录黑名单机制前端删 Token无效5.3 实际踩过的坑讲几个我在实际项目里真实的踩坑经历都是文档上不太会写的细节。坑一PyJWT 版本差异。旧版 PyJWT 的jwt.decode()如果你不传algorithms会用 header 里声明的算法去校验。在 2.x 版本中algorithms参数是强制必填的。所以如果你升级了依赖老的解密代码可能直接抛异常报错也可能暴露。最稳妥的做法从第一天开始就显式传algorithms[HS256]靠别人懦弱不如靠自己靠谱——这句对编程开发同样适用。坑二时间戳时区问题。之前有个项目Token 明明没过期后端一直报Signature has expired。查了半天发现是服务器时区是 UTC客户端生成的iat是8时区。JWT 的exp和iat规范上统一用 UTC 时间戳生成和校验都统一用datetime.now(timezone.utc)或者直接用int(time.time())不要自己换算。坑三SQLite 的?占位符和 MySQL 的%s不一样。写习惯了 MySQL 的%s切到 SQLite 时用%s会直接报错。这不是 SQL 注入问题是语法适配问题但确实容易踩。用数据库适配层如SQLAlchemycore 或 ORM能避免这类低级错误。坑四黑名单的过期时间要和 Token 有效期匹配。如果 Token 有效期 30 分钟黑名单只存 5 分钟那退出登录后 5 分钟 Token 又能用了等于退出白退了。我见过线上事故就是因为这个参数没对齐。建议在代码里直接引用同一个配置常量。5.4 开发提效快速定位与自动化检测实际项目里不能只靠人工排查。我推荐在开发流程里加入自动化检测代码层面用bandit做静态扫描能扫出常见的拼接式 SQL、硬编码密钥等风险。用法很简单bandit -r 你的项目目录。依赖层面用pip-audit检查有已知漏洞的依赖包。数据库层面用 sqlmap 对测试环境做周期扫描它能把注入点完整检测出来——但注意只能在你有权限的靶场或测试环境跑生产环境不要碰。写代码的时候我还建议做一个统一的 ORM 基类强制所有数据库操作走 ORM 或参数化查询。团队里如果有人在代码评审中看到了字符串拼接的 SQL直接打回不留情面。安全问题上宽松一次后面就可能变成事故。6. 安全开发习惯进化为独立部分经过了上面这些环节最后想聊一个大多数人会忽略的问题安全不是写代码时候的一个阶段而是持续运转的长期体系。6.1 最小权限原则在认证体系内的延伸不管是数据库账号还是 API 密钥权限都要最小化。数据库账号不要用 root单独为每个服务建账号只授予该服务需要的增删改查权限。API 密钥同理只给必须的那几个权限不要图省事一把梭。6.2 代码评审中的安全 Checklist我再强调一遍代码评审节点安全问题是必需项不是可选项。每次评审至少过一遍这个清单可以在团队内部直接用SQL 语句是否为参数化写法密钥和密码是否有硬编码JWT 校验是否指定了算法白名单Token 是否放进了 localStorage日志中是否有敏感信息密码存储是否为安全哈希依赖版本是否有已知漏洞7. 从代码到防线这堂课的最终沉淀SQL 注入和 JWT 认证一个是输入侧的安全底线一个是认证侧的安全根基。把这两块做好Web 应用的安全水位能提升一大截。我个人在实际项目里最深的体会是安全的本质是习惯。参数化查询、显式算法白名单、强密钥管理——这些不是需要的时候再去查文档而是写每一行代码时自然带出来的肌肉记忆。等到出事了再补成本高出十倍不止。最后分享一个实用小技巧写一个新的 Flask/FastAPI 项目时把安全相关的模板直接做成项目脚手架的一部分。数据库操作统一封装、JWT 中间件直接内置、密钥从环境变量读取——新项目一创建就站在安全基础上而不是写完业务再去补安全。这个习惯坚持两年下来团队代码的安全问题数量会显著降低。这一讲的内容到这里就完整了。下一讲如果继续进阶路线方向大概率是文件上传漏洞与反序列化安全这两个点同样是 Python Web 开发里高频出现的硬骨头。到时候再会。
返回列表