ITPub博客

首页 > 大数据 > 数据挖掘 > API接口的SQL注入与XSS防护实战

API接口的SQL注入与XSS防护实战

原创 数据挖掘 作者:ob_Alan 时间:2026-07-31 17:01:08 0 删除 编辑

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)**两步分离:

  1. 预编译阶段:数据库拿到带占位符的 SQL 字符串( WHERE id = ?),先做完整的词法、语法分析,生成执行计划。这个阶段, ? 是一个"未知值的占位",不是字符串的一部分。
  2. 绑定阶段:把具体参数值传给数据库,数据库直接把值塞进执行计划里的占位位置, 不会再做语法解析。

这意味着,无论参数值里有多少个 '、 --、 union select,数据库都只会把它当成一个普通的字符串字面值去匹配,不会改变 SQL 的语义结构。

换句话说,参数化防注入的本质不是"转义了特殊字符",而是" SQL 的结构和数据从一开始就是分离的两条通道"。

2.2 "假参数化"的几种伪装

理解了本质,就能识别那些"看起来参数化、实际没防住"的写法:

# 假参数化 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() 会自动把参数当作绑定值处理,这才是真正的参数化。

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 转义,然后存进库,再原样返回给前端。这有两个问题:

  1. 转义后的数据被存进库,下次如果输出到非 HTML 上下文(比如 JSON API、PDF、Excel),就是乱码。
  2. 转义规则是和输出上下文绑定的,输入时你根本不知道这份数据将来会被渲染到哪里。

正确做法是: 输入只校验,不转义;转义发生在输出的最后一刻,根据具体上下文选择转义方式。

3.2 输入校验:白名单优先

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 用的是 白名单正则(只允许字母数字下划线),不是"拒绝出现 ' < >"这种黑名单。
  • 长度限制不只是防 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 上下文(最容易出事)

# 错误:把用户数据直接塞进 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。

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, 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
    ])

注意三点:

  1. sort / order 经过白名单校验后才拼进 SQL,剩下所有值都走 :param 绑定。
  2. kw 在 LIKE 里手动 escape 了通配符。
  3. 返回 JSON 时没有做 HTML 转义——因为这是 API 接口,前端拿到的应该是原始数据,转义由前端渲染层负责。 这是输入校验和输出转义分离的典型体现。

四、绕过手段:防护方必须知道的攻防细节

这一节是和大多数"防护文章"拉开差距的地方。不懂绕过,写出来的防护代码多半是纸糊的。

4.1 SQL 注入的常见绕过

4.1.1 大小写与注释绕过

简单 WAF 用关键字黑名单:

UNION SELECT

绕过:

UnIoN SeLeCtUN/**/ION SE/**/LECTUNION%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 >

黑名单永远跟不上 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 防护侧该记住的几条铁律

绕过手段列举不完,但规律是清晰的:

  1. 黑名单不可靠,无论是 SQL 关键字还是 HTML 标签。
  2. 参数化是 SQL 注入的唯一正确解,没有替代品。
  3. 输出转义必须基于上下文,HTML 转义放到 JS 里是无效的。
  4. 库里的数据不可信,二次注入的根源。
  5. DOM XSS 服务端管不到,CSP 是最后一道防线。

五、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 观察一段时间,确认没有误伤。

写在最后

做完那次应急复盘,团队改了几个习惯:

  • 代码评审里专门有一栏查"原生 SQL",凡是 text()、 raw()、 f-string 拼 SQL 的,必须给出白名单或参数化说明。
  • Pydantic DTO 成了所有 API 入口的强制要求,不接受裸 request.args。
  • 前端渲染层禁用了 innerHTML,统一用 textContent 或框架的自动转义。
  • 加了 CSP 报告收集,DOM XSS 一旦触发能第一时间看到。

防护从来不是某一处做对了就行,而是一整条链路都不能掉链子。注入和 XSS 这两个老话题,每年 OWASP Top 10 都在,不是因为技术难,而是因为每一处细节都可能被忽略。

把每个"看起来还好"的写法都问一句"这个值来自哪里,会去到哪里",能挡掉大半的事故。


来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050389/viewspace-3177965/ ,如需转载,请注明出处,否则将追究法律责任。

下一篇: 没有了~
请登录后发表评论 登录
全部评论