| 失效的假设 | “请求体里会有哪些字段,是我的表单决定的” |
| 所属组 | W3 · 访问控制(补) |
| 前置 | 第 12 篇:纵向越权。这一篇是它的一个特别安静的变体 |
| 复现环境 | Python 3 标准库,无外部依赖 |
一、一个只该改昵称的接口
你在做"修改个人资料"。前端表单上有两个输入框:昵称、邮箱。用户填完点保存,前端把它们发过来:
{"nickname": "alice2", "email": "[email protected]"}
后端你写得很省事——反正字段以后还会加,干脆遍历请求体,有什么就更新什么。
有人给你发了这个:
{"nickname": "alice2", "is_admin": 1}
他现在是管理员了。
打个比方
这像一张你自己印的申请表。表上只有两栏:姓名、联系方式。你觉得申请人最多也就填这两栏——因为表就是这么印的。
可对方根本没用你的表。他自己拿了张白纸,抄上"姓名"“联系方式”,然后在下面又加了一行:“职务:总经理”。
而你的办事员的工作方式是"看到一栏就录一栏"。他不核对这张纸上该有哪些栏,他只是照着录。
⭐ 失效的假设就在这里:你以为请求体的字段集合由你的表单决定。它由发请求的人决定。 表单只是前端的一张图,攻击者用 curl 就把它绕过去了——这和第 12 篇"前端的一切都在攻击者控制之下"是同一条,只是这次被控制的不是按钮,是字段名。
二、跑一遍:SQL 全参数化,照样出事
这一节的 demo 值得单看,因为它同时满足两件通常被认为矛盾的事:代码完全符合第 2 篇的要求(每个值都参数化,一处拼接都没有),而漏洞成立。
import sqlite3, json
db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE users(id INTEGER PRIMARY KEY, nickname TEXT, email TEXT, is_admin INT DEFAULT 0);
INSERT INTO users VALUES (1,'alice','[email protected]',0);
""")
def update_profile_vuln(uid, body):
"""把请求体里的字段更新到用户上 —— 注意:每个【值】都走了参数化"""
fields = list(body)
sql = f"UPDATE users SET {', '.join(f'{k}=?' for k in fields)} WHERE id=?"
db.execute(sql, [body[k] for k in fields] + [uid]); return sql
ALLOWED = {"nickname", "email"} # 只允许用户改这两个
def update_profile_safe(uid, body):
fields = [k for k in body if k in ALLOWED]
sql = f"UPDATE users SET {', '.join(f'{k}=?' for k in fields)} WHERE id=?"
db.execute(sql, [body[k] for k in fields] + [uid]); return sql
adm = lambda: db.execute("SELECT is_admin FROM users WHERE id=1").fetchone()[0]
body = json.loads('{"nickname":"alice2","is_admin":1}') # 攻击者多加了一个字段
print("改之前 is_admin :", adm())
print("有洞版拼出的 SQL:", update_profile_vuln(1, body))
print("改之后 is_admin :", adm())
db.execute("UPDATE users SET is_admin=0 WHERE id=1")
print()
print("修复版拼出的 SQL:", update_profile_safe(1, body))
print("改之后 is_admin :", adm())
改之前 is_admin : 0
有洞版拼出的 SQL: UPDATE users SET nickname=?, is_admin=? WHERE id=?
改之后 is_admin : 1
修复版拼出的 SQL: UPDATE users SET nickname=? WHERE id=?
改之后 is_admin : 0
盯住有洞版那条 SQL。 它长这样:
UPDATE users SET nickname=?, is_admin=? WHERE id=?
一个占位符都不少,每个值都是绑定进去的,sqlite3 全程把它们当数据。第 2 篇的功课一分没丢。 可 is_admin 从 0 变成了 1。
⚠️ 这里第一次读会有点转不过来,值得停一下:这一次攻击者控制的不是值,是字段名。
SQL 注入 攻击者控制【值】,想让值改变语句的结构 -> 参数化把这条路堵死了
批量赋值 攻击者控制【哪些列出现在语句里】 -> 参数化对此毫无意见
参数化保护的是"值不要变成代码"。它从来没有承诺过"这条语句该动哪几列"——那是业务规则,不是语法问题。所以这两件事是正交的:你可以两个都做对、也可以只做对一个。
修复版只多了一步:先过一遍白名单,再拼字段名。 于是那条 SQL 里根本不出现 is_admin。
⭐ 注意修复的位置:不在"检查这个值安不安全",在"这个字段允不允许出现"。 这和第 1 篇 ③ 的白名单映射、第 2 篇的动态列名是同一招——动态的是标识符时,只能白名单。
三、危险字段远不止 is_admin
is_admin 是最好讲的例子,但真实系统里更常见的是这些:
归属类字段。 user_id、owner_id、tenant_id——改掉它,就把一条记录过继给了别人,或者把自己的记录塞进别人的租户里。这一步之后,第 11 篇的归属校验会忠实地放行,因为归属确实被改成他了。
状态类字段。 order.status、payment.paid、refund.approved——直接把订单标成已支付、把退款标成已批准,跳过了整条业务流程。这类往往比提权更值钱,也更少被想到。
金额与配额。 price、discount、credit_limit、quota。经典场景是下单接口把整个商品对象都收下来,价格也在里面。
时间与审计字段。 created_at、updated_by——改掉它们不直接得利,但能污染审计线索,让事后追查失效。
⚠️ 这些字段有个共同点:它们都不在前端表单上。所以按"表单上有什么"去审代码,一个都发现不了;必须按"这张表有哪些列“去审。
一个更隐蔽的入口:嵌套对象
框架的绑定往往是递归的:
{"nickname": "x", "profile": {"avatar": "..."}, "company": {"id": 7}}
↑ 顺着关联对象改到了另一张表
顶层字段你可能白名单了,可嵌套下去的那一层呢?白名单要覆盖整棵树,不只是第一层。
⭐ 这句话应该很眼熟——第 6 篇 §六③ 说过同样的话(类型白名单要覆盖整条对象图),第 3 篇的"对象图导航"也是它。只要一个机制会沿着对象往下走,你的检查就必须跟着走到同样深。
四、为什么这个坑填不平
因为造成它的那个特性,是框架卖点里最受欢迎的一个。
“一行代码把请求体绑到模型上”——它省掉的是最枯燥的样板代码,谁都喜欢。而它的危险是沉默的:
你写的 实际发生的
────────────────────────────────────────────────
把用户提交的资料更新上去 把用户【提交的每一个键】更新上去
新建一条记录 用请求体里的所有字段初始化它,包括你没在表单里放的
⚠️ 更麻烦的是,这个洞会随着时间自己长出来:接口写的时候,表里只有 nickname 和 email,绑定全部字段完全无害。半年后有人加了一列 is_admin——那个接口一行没改,就变成了提权接口。 加列的人不会想到去看那个改资料的接口,写接口的人早就不在这个项目了。
⭐ 这是这一篇最该带走的一条:批量赋值的漏洞常常不是"写出来"的,是"长出来"的。 所以防御必须是"默认拒绝”(新加的列自动不在白名单里),而不是"记得排除危险字段"(新加的列自动就被绑上了)。
五、它出现过的地方
展开在事件分析里:
2012.03 GitHub / Rails 批量赋值
研究者在"更新我的公钥"这个表单里,多提交了一个 public_key[user_id],
值填成 rails 账号的 id —— 于是他自己那把公钥的【归属】被改到了 rails 名下,
他因此能向 rails/rails 推送提交。GitHub 一小时内修复。
⭐ 两点值得记住。
第一,他改的是归属字段,不是 is_admin。 public_key[user_id] 正是 §三 第一类——把一条属于自己的记录过继出去。这比"把自己设成管理员"隐蔽得多,也是现实中更常见的形态。
第二,这件事的结局是改默认值。 Rails 生态此后把"默认绑定请求体里的全部字段"改成了"必须显式声明哪些字段可绑"。修复不是"教育开发者记得排除危险字段",是把默认反过来——从"默认全绑、你去排除"改成"默认不绑、你去声明"。这正是 §四 那条:只有默认拒绝,才治得住"自己长出来"的洞。
六、防御
① 白名单允许绑定的字段 默认拒绝:没显式声明的字段一律不绑(首选)
② 用专门的入参类型 定义一个只含可改字段的 DTO/schema,
而不是把请求体直接绑到数据库模型上
③ 敏感字段走独立接口 改角色、改归属、改状态要有自己的端点和自己的鉴权
④ 白名单覆盖整棵对象树 嵌套对象和关联同样要声明(§三)
⑤ 加列时有检查 新增列要能被"哪些接口会绑到它"这个问题拦一下
判断标准:你的更新接口,能不能列出它允许被改的字段清单? 列不出来,说明它绑的是"用户发来的所有键"。
⭐ ② 是工程上最省心的一条:别让请求体直接碰数据库模型。 中间隔一个只有几个字段的入参类型,is_admin 连被赋值的机会都没有——这和第 1 篇"让数据永远不进入语法位置"是同一种思路:不是拦住它,是不给它路。
七、这些防御的边界
① 白名单写在哪里很重要。 写在每个 handler 里,就会漏;写在模型/schema 上一处声明、全局生效,才跟得上表结构的变化。
② “只读字段"的判断随角色变。 管理员确实需要能改 is_admin。所以白名单往往不是一张表,而是”这个角色能改哪些字段"——它是第 12 篇功能级鉴权的延伸,别做成一个全局常量了事。
③ 它和 IDOR 是两回事,容易混。 第 11 篇是"改 id 去访问别人的对象";这一篇是"改字段去动自己对象上不该动的那一列"。一个是选错了行,一个是动错了列。两道检查互不替代。
④ 创建接口比更新接口更容易漏。 大家习惯性地审"更新",但"创建"同样会用请求体初始化对象,而且新建时字段更全、审得更少。
关于时效:机制不随技术变。会变的是框架的默认绑定策略——主流框架在 2012 年之后陆续从"默认全绑"改成了"必须声明",但不同框架、不同版本、不同用法(有的方法安全、有的方法不安全)差别很大。用之前必须查你这个版本的默认,这正是本篇不给"某框架默认安全"结论的原因。
八、本篇小结
批量赋值:你以为请求体的字段集合由你的表单决定,其实由发请求的人决定。
和第 12 篇同源 —— 被攻击者控制的这次不是按钮,是【字段名】。
⭐ demo 里那条 SQL 完全参数化、一处拼接都没有,照样把普通用户变成了管理员
因为 SQL 注入控制的是【值】,批量赋值控制的是【哪些列进了语句】
=> 两件事正交:参数化对"该动哪几列"毫无意见,那是业务规则不是语法问题
⭐ 危险字段远不止 is_admin:归属(user_id/tenant_id) · 状态(paid/approved) ·
金额配额 · 审计字段 —— 它们的共同点是【都不在前端表单上】
⭐ 这个洞常常是"长出来"的:接口没改,半年后有人加了一列,它就变成了提权接口
=> 所以只能靠默认拒绝,靠不了"记得排除危险字段"
防御:字段白名单(默认拒绝) · 别让请求体直接碰数据库模型 · 敏感字段走独立接口 ·
白名单覆盖整棵对象树
边界:和 IDOR 是两回事(选错行 vs 动错列) · 创建接口比更新接口更容易漏
思考题
- §二 那条有洞的 SQL 是完全参数化的。用第 2 篇"参数化保护的是拼接的那一个点"这句话,解释为什么它在这里帮不上忙。
- 把"批量赋值"和"IDOR"各写一句失效的假设,然后说明为什么修好一个不影响另一个。
- §四 说这个洞会"自己长出来"。设计一条 CI 检查或一个流程动作,让"新增一列"这件事能被"哪些接口会绑到它"拦一下。
- §三 说危险字段的共同点是"都不在前端表单上"。据此说明:为什么"照着前端表单审接口"这种审计方式对这一类漏洞完全无效?
- 一个后台管理接口确实需要允许管理员修改
is_admin。你会怎么设计,才能既满足它、又不让普通用户的资料接口也具备这个能力?(对照 §七②) - 嵌套对象绑定(§三末)和第 6 篇的"类型白名单要覆盖整条对象图"、第 3 篇的"对象图导航"是同一个陷阱吗?用一句话概括这个陷阱。
- 2012 年那起之后,Rails 把默认从"全绑"改成了"必须声明"。这个改动对存量代码意味着什么?为什么说"改默认值"比"写一份最佳实践文档"有效得多?
相关:第 12 篇:纵向越权 · 第 11 篇:横向越权(IDOR) · 第 2 篇:SQL 注入 · 第 6 篇:不安全反序列化 · Web 安全机制篇 · 事件分析