首页 > 大数据 > 数据挖掘 > API接口的SQL注入与XSS防护实战
写这篇文章的起因是前段时间应急一个线上事故:一个看起来"已经用了 ORM"的查询接口被人扫到了盲注,DAST 工具报了三个高危。复盘的时候发现,团队对"参数化查询"和"输入校验/输出转义"的理解普遍停留在"我知道要这么做"的层面,但具体到代码里,几乎每一处都踩了坑。
这篇不讲教科书定义,只讲我在实际代码评审和应急里反复看到的几类问题。代码用 Python(Flask + SQLAlchemy),其他语言思路一致。
很多人对 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)这种拼接当然一眼就能看出来有问题。但真实业务里,绝大多数注入不是这么赤裸裸的,而是藏在那些"看起来还好"的写法里。我归纳成三类,按踩坑频率从高到低排:
分页接口里这种代码我见过不下二十次:
@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列名、表名、排序方向这类"标识符"位置,永远不要拼用户输入,只能走白名单。
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 通道,防注入的责任就完全在你手上。
这个最阴险。用户输入第一次入库时做了转义或参数化,看起来安全;但下次从库里读出来再拼到别的 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 的密码。 库里的数据不是可信源,这点和很多人的直觉相反。
讲参数化查询的文章很多,但很少有讲清楚"为什么"的。我以前也只会说"用占位符就行",直到有一次被问"那为什么占位符就行",才发现自己没真懂。
参数化查询的核心是**预编译(prepare)+ 绑定(bind)**两步分离:
WHERE id = ?),先做完整的词法、语法分析,生成执行计划。这个阶段,
? 是一个"未知值的占位",不是字符串的一部分。这意味着,无论参数值里有多少个
'、
--、
union select,数据库都只会把它当成一个普通的字符串字面值去匹配,不会改变 SQL 的语义结构。
换句话说,参数化防注入的本质不是"转义了特殊字符",而是" SQL 的结构和数据从一开始就是分离的两条通道"。
理解了本质,就能识别那些"看起来参数化、实际没防住"的写法:
# 假参数化 1:用 f-string 格式化后再传给 textkw = 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() 会自动把参数当作绑定值处理,这才是真正的参数化。
参数化防住了引号注入,但 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 上多数文章讲得最模糊的地方。 输入校验和输出转义是两道独立的防线,目的不同、位置不同、不能互相替代。
| 维度 | 输入校验 | 输出转义 |
|---|---|---|
| 目的 | 拒绝不合业务规则的输入 | 防止数据在目标上下文里被解析为代码 |
| 位置 | API 入口、模型层 | 渲染层、序列化层 |
| 依据 | 业务规则(类型、长度、格式、白名单) | 输出上下文(HTML / JS / URL / CSS) |
| 失败处理 | 拒绝请求 | 转义后照常输出 |
很多人犯的错是: 在输入处做 HTML 转义,然后存进库,再原样返回给前端。这有两个问题:
正确做法是: 输入只校验,不转义;转义发生在输出的最后一刻,根据具体上下文选择转义方式。
Python 里 Pydantic 是目前最顺手的选择。校验的核心思路是 白名单而不是黑名单——黑名单永远列不全。
from pydantic import BaseModel, constr, conint, validatorimport reclass 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 用的是
白名单正则(只允许字母数字下划线),不是"拒绝出现
'
<
>"这种黑名单。\x00,它在某些 C 后端的数据库里会被截断字符串(MySQL 5.x 的 sql_mode 默认行为)。XSS 的本质是"数据被当成了代码执行"。防 XSS 的本质是"让数据永远停留在数据语义里,不进入代码语义"。 同一个数据,输出到 HTML、JS、URL、CSS 里,需要的转义完全不同。
<div>用户名:{{ username }}</div>Jinja2 默认开启自动转义,会把
<
>
&
"
' 转成实体。但如果你用了
|safe 或者
Markup(),就关掉了这道防线——这是最常见的自伤。
# 错误:手动 Markup 绕过转义return render_template("profile.html", bio=Markup(user.bio))# 正确:让模板自己转义return render_template("profile.html", bio=user.bio)# 错误:把用户数据直接塞进 JSreturn 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 >,赋给 innerHTML 时一样会执行。
规则:JS 上下文里能不接收用户数据就不接收,必须接收时用
textContent 而不是
innerHTML。
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/相对路径。
把上面的东西拼起来:
from flask import Flask, request, jsonifyfrom sqlalchemy import textfrom pydantic import BaseModel, constr, validatorimport 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 了通配符。这一节是和大多数"防护文章"拉开差距的地方。不懂绕过,写出来的防护代码多半是纸糊的。
简单 WAF 用关键字黑名单:
UNION SELECT
绕过:
UnIoN SeLeCtUN/**/ION SE/**/LECTUNION%0aSELECT -- 用换行符切断关键字/*!50000UNION*//*!50000SELECT*/ -- MySQL 版本注释
/**/ 内联注释在 MySQL 里是合法语法,能把关键字切成两半还能执行。这就是为什么关键字黑名单不可靠——
正确防护只有参数化这一条路。
URL 编码、双重编码、Unicode 编码、Hex 编码:
' -> %27 %27 -> %2527(双重编码,某些中间件会解码两次) 'admin' -> 0x61646d696e(MySQL hex) SELECT -> %u0053ELECT(IIS Unicode 解析)
实战里我遇到过最阴的:某 WAF 只解码一次 URL,业务层又解码了一次,结果双重编码的
%2527 在 WAF 眼里是无害的
%2527,到了业务层变成
%27 再变成
'。
这是 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"
入库时安全,出库使用时拼接。防护原则: 所有进入 SQL 的值,无论来源(用户、数据库、配置文件),都一律参数化。
一个典型但脆弱的过滤:
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 >
黑名单永远跟不上 HTML 演化。 XSS 防护只能靠上下文转义,不能靠关键字过滤。
<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、低版本浏览器仍然能执行。
var name = "用户输入";
如果用户输入
";alert(1);//,且没做 JSON 编码:
var name = "";alert(1);//";
直接逃逸出字符串。这就是为什么
tojson 是必须的——它会把
" 转成
\"。
Jinja2、Jinjaja、Tornado 模板如果直接渲染了用户输入:
# 致命错误template = f"Hello {user_input}"render_template_string(template)用户输入
{{ ''.__class__.__mro__[1].__subclasses__() }},直接 RCE。这是 SSTI(服务端模板注入),但和 XSS 经常被混在一起讨论。
document.getElementById('content').innerHTML = location.hash.slice(1);这种 XSS 不经过服务器,所有服务端防护都管不到。只能靠前端的
textContent、
textContent、还是
textContent(重要的事说三遍),以及 CSP。
绕过手段列举不完,但规律是清晰的:
最后补一个常被忽略的点。即便前面所有防护都做对了,前端万一有 DOM XSS 漏洞,整条防线就破了。CSP(Content Security Policy)是兜底:
@app.after_requestdef 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 观察一段时间,确认没有误伤。做完那次应急复盘,团队改了几个习惯:
text()、
raw()、
f-string 拼 SQL 的,必须给出白名单或参数化说明。request.args。innerHTML,统一用
textContent 或框架的自动转义。防护从来不是某一处做对了就行,而是一整条链路都不能掉链子。注入和 XSS 这两个老话题,每年 OWASP Top 10 都在,不是因为技术难,而是因为每一处细节都可能被忽略。
把每个"看起来还好"的写法都问一句"这个值来自哪里,会去到哪里",能挡掉大半的事故。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050389/viewspace-3177965/ ,如需转载,请注明出处,否则将追究法律责任。