API 接口的 SQL 注入与 XSS 防护实战:从原理到绕过的踩坑记录
写这篇文章的起因是前段时间应急一个线上事故:一个看起来"已经用了 ORM"的查询接口被人扫到了盲注,DAST 工具报了三个高危。复盘的时候发现,团队对"参数化查询"和"输入校验/输出转义"的理解普遍停留在"我知道要这么做"的层面,但具体到代码里,几乎每一处都踩了坑。
这篇不讲教科书定义,只讲我在实际代码评审和应急里反复看到的几类问题。代码用 Python(Flask + SQLAlchemy),其他语言思路一致。
一、先说一个反直觉的事:用了 ORM 不等于防住了注入
很多人对 SQL 注入的认知停留在这种代码:
# 经典反面教材
@app.route("/user")
def get_user():
uid = request.args.get("id")
sql = f"SELECT * FROM users WHERE id = {uid}"
rows = db.execute(sql)
return jsonify(rows)
这种拼接当然一眼就能看出来有问题。但真实业务里,绝大多数注入不是这么赤裸裸的,而是藏在那些"看起来还好"的写法里。我归纳成三类,按踩坑频率从高到低排:
1.1 ORDER BY / LIMIT 字段拼接(最高频)
分页接口里这种代码我见过不下二十次:
@app.route("/orders")
def list_orders():
sort = request.args.get("sort", "created_at")
order = request.args.get("order", "asc")
page = request.args.get("page", 1)
# 看起来很正常对吧
sql = text(f"""
SELECT id, order_no, amount, created_at
FROM orders
ORDER BY {sort} {order}
LIMIT :offset, :size
""")
rows = db.session.execute(sql, {
"offset": (int(page) - 1) * 20,
"size": 20,
})
return jsonify([dict(r) for r in rows])
offset 和 size 用了参数绑定,看上去"很规范"。但 sort 和 order 直接拼进了 SQL——ORDER BY 后面是不能用参数绑定的,因为绑定参数会被当成字面值(字符串常量),而不是列名或方向。这是很多人没意识到的坑。
攻击 payload:
GET /orders?sort=(case when (select substring(password,1,1) from users where id=1)='a' then id else order_no end)&order=asc
基于响应里数据排序的不同,盲注就成立了。这种接口在安全扫描里经常漏报,因为排序结果"看起来正常"。
正确做法是白名单:
ALLOWED_SORT_FIELDS = {
"id", "order_no", "amount", "created_at"}
ALLOWED_ORDER = {
"asc", "desc"}
def safe_sort(sort, order):
if sort not in ALLOWED_SORT_FIELDS:
sort = "created_at"
if order.lower() not in ALLOWED_ORDER:
order = "asc"
return sort, order
列名、表名、排序方向这类"标识符"位置,永远不要拼用户输入,只能走白名单。
1.2 ORM 的 raw / text 滥用
SQLAlchemy 的 text() 不是安全护栏,它只是允许你写原生 SQL。参数绑定要你自己手动做:
# 错误:text 里又拼了字符串
db.session.execute(text(f"SELECT * FROM users WHERE name LIKE '%{kw}%'"))
# 正确:用 :name 占位
db.session.execute(
text("SELECT * FROM users WHERE name LIKE :kw"),
{
"kw": f"%{kw}%"}
)
Django ORM 的 extra()、raw(),Peewee 的 SQL(),都是同理——一旦你绕过 ORM 进了原生 SQL 通道,防注入的责任就完全在你手上。
1.3 二次注入
这个最阴险。用户输入第一次入库时做了转义或参数化,看起来安全;但下次从库里读出来再拼到别的 SQL 里,就炸了。
# 注册时参数化,安全
db.session.execute(
text("INSERT INTO users(username) VALUES (:u)"),
{
"u": username}
)
# 修改密码时把 username 直接拼进 SQL(开发觉得"反正是从库里读的,可信")
db.session.execute(
text(f"UPDATE users SET password='{new_pwd}' WHERE username='{user.username}'")
)
如果注册时 username 是 admin'--,入库没问题,但改密码时就变成了:
UPDATE users SET password='xxx' WHERE username='admin'--'
直接改了 admin 的密码。库里的数据不是可信源,这点和很多人的直觉相反。
二、参数化查询到底防住了什么:预编译绑定的本质
讲参数化查询的文章很多,但很少有讲清楚"为什么"的。我以前也只会说"用占位符就行",直到有一次被问"那为什么占位符就行",才发现自己没真懂。
2.1 数据库引擎层的预编译
参数化查询的核心是预编译(prepare)+ 绑定(bind)两步分离:
- 预编译阶段:数据库拿到带占位符的 SQL 字符串(
WHERE id = ?),先做完整的词法、语法分析,生成执行计划。这个阶段,?是一个"未知值的占位",不是字符串的一部分。 - 绑定阶段:把具体参数值传给数据库,数据库直接把值塞进执行计划里的占位位置,不会再做语法解析。
这意味着,无论参数值里有多少个 '、--、union select,数据库都只会把它当成一个普通的字符串字面值去匹配,不会改变 SQL 的语义结构。
换句话说,参数化防注入的本质不是"转义了特殊字符",而是"SQL 的结构和数据从一开始就是分离的两条通道"。
2.2 "假参数化"的几种伪装
理解了本质,就能识别那些"看起来参数化、实际没防住"的写法:
# 假参数化 1:用 f-string 格式化后再传给 text
kw = request.args.get("kw")
sql = text(f"SELECT * FROM goods WHERE name LIKE '%{kw}%'")
db.session.execute(sql) # 没有任何绑定参数,注入成立
# 假参数化 2:自己手动转义单引号(脆弱)
kw = kw.replace("'", "''")
sql = text(f"SELECT * FROM goods WHERE name = '{kw}'")
# 看似防住了,但遇到 GBK/双字节编码场景下的宽字节注入会失效
# 假参数化 3:ORM 的 like 字符串拼接
Goods.query.filter(f"name LIKE '%{kw}%'").all() # 不要这么写
第三个尤其容易漏。SQLAlchemy 推荐写法:
from sqlalchemy import or_
Goods.query.filter(Goods.name.like(f"%{kw}%")).all()
# 或者更明确
Goods.query.filter(Goods.name.ilike(f"%{kw}%")).all()
ORM 的 Column.like() 会自动把参数当作绑定值处理,这才是真正的参数化。
2.3 一个容易被忽略的细节:LIKE 的通配符
参数化防住了引号注入,但 LIKE 的 % 和 _ 通配符不是 SQL 语法字符,参数化管不到:
# 用户输入 "%",会匹配所有记录,可能造成全表扫描
kw = request.args.get("kw")
Goods.query.filter(Goods.name.like(f"%{kw}%")).all()
如果业务不允许用户用通配符,要主动 escape:
def escape_like(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
Goods.query.filter(Goods.name.like(f"%{escape_like(kw)}%")).all()
这不是注入问题,是可用性问题,但实战里经常一起出现。
三、输入校验和输出转义:两件事,别混在一起
这是我在团队里反复强调的一点,也是 CSDN 上多数文章讲得最模糊的地方。输入校验和输出转义是两道独立的防线,目的不同、位置不同、不能互相替代。
3.1 各自的职责
| 维度 | 输入校验 | 输出转义 |
|---|---|---|
| 目的 | 拒绝不合业务规则的输入 | 防止数据在目标上下文里被解析为代码 |
| 位置 | API 入口、模型层 | 渲染层、序列化层 |
| 依据 | 业务规则(类型、长度、格式、白名单) | 输出上下文(HTML / JS / URL / CSS) |
| 失败处理 | 拒绝请求 | 转义后照常输出 |
很多人犯的错是:在输入处做 HTML 转义,然后存进库,再原样返回给前端。这有两个问题:
- 转义后的数据被存进库,下次如果输出到非 HTML 上下文(比如 JSON API、PDF、Excel),就是乱码。
- 转义规则是和输出上下文绑定的,输入时你根本不知道这份数据将来会被渲染到哪里。
正确做法是:输入只校验,不转义;转义发生在输出的最后一刻,根据具体上下文选择转义方式。
3.2 输入校验:白名单优先
Python 里 Pydantic 是目前最顺手的选择。校验的核心思路是白名单而不是黑名单——黑名单永远列不全。
from pydantic import BaseModel, constr, conint, validator
import re
class CreateUserDTO(BaseModel):
username: constr(min_length=3, max_length=20, pattern=r"^[a-zA-Z0-9_]+$")
email: constr(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
age: conint(ge=0, le=150)
bio: constr(max_length=500)
@validator("bio")
def no_control_chars(cls, v):
# 拒绝控制字符(包括 \x00, \r, \n 在不允许的场景)
if re.search(r"[\x00-\x1f\x7f]", v):
raise ValueError("包含非法控制字符")
return v
@app.route("/users", methods=["POST"])
def create_user():
try:
dto = CreateUserDTO(**request.json)
except ValidationError as e:
return jsonify({
"error": e.errors()}), 400
# 入库时用参数化查询,不再做任何转义
...
几点要注意:
pattern用的是白名单正则(只允许字母数字下划线),不是"拒绝出现'<>"这种黑名单。- 长度限制不只是防 DoS,很多注入 payload 都很长,长度卡死能挡掉一部分。
- 拒绝控制字符,尤其是
\x00,它在某些 C 后端的数据库里会被截断字符串(MySQL 5.x 的 sql_mode 默认行为)。
3.3 输出转义:上下文决定一切
XSS 的本质是"数据被当成了代码执行"。防 XSS 的本质是"让数据永远停留在数据语义里,不进入代码语义"。同一个数据,输出到 HTML、JS、URL、CSS 里,需要的转义完全不同。
3.3.1 HTML 上下文
<div>用户名:{
{ username }}</div>
Jinja2 默认开启自动转义,会把 < > & " ' 转成实体。但如果你用了 |safe 或者 Markup(),就关掉了这道防线——这是最常见的自伤。
# 错误:手动 Markup 绕过转义
return render_template("profile.html", bio=Markup(user.bio))
# 正确:让模板自己转义
return render_template("profile.html", bio=user.bio)
3.3.2 JavaScript 上下文(最容易出事)
# 错误:把用户数据直接塞进 JS
return render_template_string("""
<script>
var profile = {
{ user.bio | tojson }};
document.getElementById('bio').innerHTML = profile.bio;
</script>
""", user=user)
tojson 看起来安全(它做了 JSON 编码),但问题出在 innerHTML——如果 profile.bio 里包含 <img src=x onerror=alert(1)>,赋给 innerHTML 时一样会执行。
规则:JS 上下文里能不接收用户数据就不接收,必须接收时用 textContent 而不是 innerHTML。
3.3.3 URL 上下文
from urllib.parse import quote
redirect_url = request.args.get("next", "/")
# 错误:直接拼
return f'<a href="{redirect_url}">跳转</a>'
# 攻击:next=javascript:alert(document.cookie)
# 正确:校验协议白名单 + 转义
ALLOWED_SCHEMES = {
"http", "https", ""}
def safe_url(u):
if u.startswith("/") and not u.startswith("//"):
return u
for scheme in ALLOWED_SCHEMES:
if u.startswith(scheme + ":") or u == "":
return quote(u, safe=":/?#=&")
return "/"
javascript:、data:、vbscript: 这些协议在 href 里都会被执行,白名单只允许 http/https/相对路径。
3.3.4 一个完整的最小化 Flask API 防护模板
把上面的东西拼起来:
from flask import Flask, request, jsonify
from sqlalchemy import text
from pydantic import BaseModel, constr, validator
import re
app = Flask(__name__)
# db = ... 初始化省略
class ListGoodsDTO(BaseModel):
kw: constr(max_length=50) = ""
sort: str = "created_at"
order: str = "asc"
page: conint(ge=1) = 1
@validator("sort")
def check_sort(cls, v):
allowed = {
"id", "name", "price", "created_at"}
return v if v in allowed else "created_at"
@validator("order")
def check_order(cls, v):
return v.lower() if v.lower() in {
"asc", "desc"} else "asc"
def escape_like(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@app.route("/api/goods")
def list_goods():
try:
dto = ListGoodsDTO(**request.args)
except Exception as e:
return jsonify({
"error": "参数错误"}), 400
# 真参数化:列名走白名单,值走绑定
sql = text(f"""
SELECT id, name, price FROM goods
WHERE name LIKE :kw
ORDER BY {dto.sort} {dto.order}
LIMIT :offset, :size
""")
rows = app.db.session.execute(sql, {
"kw": f"%{escape_like(dto.kw)}%",
"offset": (dto.page - 1) * 20,
"size": 20,
})
# 输出阶段:JSON 序列化本身就是一种"转义",
# 但如果前端要把 name 渲染进 HTML,前端那边仍需做 HTML 转义
return jsonify([
{
"id": r.id, "name": r.name, "price": float(r.price)}
for r in rows
])
注意三点:
sort/order经过白名单校验后才拼进 SQL,剩下所有值都走:param绑定。kw在 LIKE 里手动 escape 了通配符。- 返回 JSON 时没有做 HTML 转义——因为这是 API 接口,前端拿到的应该是原始数据,转义由前端渲染层负责。这是输入校验和输出转义分离的典型体现。
四、绕过手段:防护方必须知道的攻防细节
这一节是和大多数"防护文章"拉开差距的地方。不懂绕过,写出来的防护代码多半是纸糊的。
4.1 SQL 注入的常见绕过
4.1.1 大小写与注释绕过
简单 WAF 用关键字黑名单:
UNION SELECT
绕过:
UnIoN SeLeCt
UN/**/ION SE/**/LECT
UNION%0aSELECT -- 用换行符切断关键字
/*!50000UNION*//*!50000SELECT*/ -- MySQL 版本注释
/**/ 内联注释在 MySQL 里是合法语法,能把关键字切成两半还能执行。这就是为什么关键字黑名单不可靠——正确防护只有参数化这一条路。
4.1.2 编码绕过
URL 编码、双重编码、Unicode 编码、Hex 编码:
' -> %27
%27 -> %2527(双重编码,某些中间件会解码两次)
'admin' -> 0x61646d696e(MySQL hex)
SELECT -> %u0053ELECT(IIS Unicode 解析)
实战里我遇到过最阴的:某 WAF 只解码一次 URL,业务层又解码了一次,结果双重编码的 %2527 在 WAF 眼里是无害的 %2527,到了业务层变成 %27 再变成 '。
4.1.3 宽字节注入
这是 MySQL + GBK 编码场景下的经典绕过。PHP 时代的 addslashes() 把 ' 转成 \',攻击者发 %df',addslashes 后变成 %df\',在 GBK 解码下 %df\ 是一个合法汉字,' 就逃逸出来了。
Python 里这种场景相对少(默认 UTF-8),但如果你的库连接 charset 设置成 gbk 又用了字符串拼接,一样会中招:
# 危险配置
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql://user:pwd@host/db?charset=gbk"
4.1.4 二次注入(前面讲过)
入库时安全,出库使用时拼接。防护原则:所有进入 SQL 的值,无论来源(用户、数据库、配置文件),都一律参数化。
4.2 XSS 的常见绕过
4.2.1 黑名单过滤的常见漏网
一个典型但脆弱的过滤:
def xss_filter(s):
for kw in ["<script", "javascript:", "onerror", "onload", "onclick"]:
s = s.replace(kw, "")
return s
绕过方式多到写不完:
<ScRiPt> <!-- 大小写 -->
<scr<script>ipt> <!-- 嵌套,删一次后拼出完整关键字 -->
<img src=x onerror=alert(1)> <!-- 换个标签 -->
<svg/onload=alert(1)> <!-- svg 一样能触发 -->
<img src=x:alert(alt) alt=1> <!-- 不在黑名单里的事件 -->
<body onload=alert(1)>
<iframe src=javascript:alert(1)>
<details/open/ontoggle=alert(1)>
黑名单永远跟不上 HTML 演化。XSS 防护只能靠上下文转义,不能靠关键字过滤。
4.2.2 data: 协议
<a href="data:text/html,<script>alert(1)</script>">click</a>
<iframe src="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg=="></iframe>
很多前端只过滤了 javascript:,漏了 data:。Chrome 在某些版本里对 top-level navigation 的 data: 做了限制,但 iframe、object、低版本浏览器仍然能执行。
4.2.3 JavaScript 上下文的字符逃逸
var name = "用户输入";
如果用户输入 ";alert(1);//,且没做 JSON 编码:
var name = "";alert(1);//";
直接逃逸出字符串。这就是为什么 tojson 是必须的——它会把 " 转成 \"。
4.2.4 模板注入混淆
Jinja2、Jinjaja、Tornado 模板如果直接渲染了用户输入:
# 致命错误
template = f"Hello {user_input}"
render_template_string(template)
用户输入 {
{ ''.__class__.__mro__[1].__subclasses__() }},直接 RCE。这是 SSTI(服务端模板注入),但和 XSS 经常被混在一起讨论。
4.2.5 DOM XSS
document.getElementById('content').innerHTML = location.hash.slice(1);
这种 XSS 不经过服务器,所有服务端防护都管不到。只能靠前端的 textContent、textContent、还是 textContent(重要的事说三遍),以及 CSP。
4.3 防护侧该记住的几条铁律
绕过手段列举不完,但规律是清晰的:
- 黑名单不可靠,无论是 SQL 关键字还是 HTML 标签。
- 参数化是 SQL 注入的唯一正确解,没有替代品。
- 输出转义必须基于上下文,HTML 转义放到 JS 里是无效的。
- 库里的数据不可信,二次注入的根源。
- DOM XSS 服务端管不到,CSP 是最后一道防线。
五、CSP:被低估的纵深防御
最后补一个常被忽略的点。即便前面所有防护都做对了,前端万一有 DOM XSS 漏洞,整条防线就破了。CSP(Content Security Policy)是兜底:
@app.after_request
def set_csp(resp):
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' https://cdn.example.com; "
"object-src 'none'; "
"base-uri 'self'"
)
return resp
CSP 不是用来替代转义的,它是纵深防御的一层。配置时注意:
- 不要用
unsafe-inline,用了等于没设。 - 不要用
unsafe-eval,用了等于没设。 - 上线前先用
Content-Security-Policy-Report-Only观察一段时间,确认没有误伤。
写在最后
做完那次应急复盘,团队改了几个习惯:
- 代码评审里专门有一栏查"原生 SQL",凡是
text()、raw()、f-string拼 SQL 的,必须给出白名单或参数化说明。 - Pydantic DTO 成了所有 API 入口的强制要求,不接受裸
request.args。 - 前端渲染层禁用了
innerHTML,统一用textContent或框架的自动转义。 - 加了 CSP 报告收集,DOM XSS 一旦触发能第一时间看到。
防护从来不是某一处做对了就行,而是一整条链路都不能掉链子。注入和 XSS 这两个老话题,每年 OWASP Top 10 都在,不是因为技术难,而是因为每一处细节都可能被忽略。
把每个"看起来还好"的写法都问一句"这个值来自哪里,会去到哪里",能挡掉大半的事故。