失效的假设 “这个操作的入口我在前端藏起来了,用户点不到”
所属组 W3 · 访问控制
前置 第 11 篇:横向越权(IDOR)。这一篇是越权的另一个维度:往上够权限
复现环境 Python 3 标准库,无外部依赖

一、藏起按钮,不等于关上门

你的后台页面对普通用户藏起了「删除用户」按钮——非管理员根本看不到它。

可攻击者不用你的页面。他打开开发者工具,看到了那个端点的地址,然后直接发了个请求过去。

上一篇是"平级看平级"。这一篇往上够:普通用户拿到了本该只有管理员有的能力——删用户、改配置、看后台。这叫纵向越权

它最常见的成因简单到有点好笑:鉴权做在了前端。

import threading, urllib.request, urllib.error
from http.server import BaseHTTPRequestHandler, HTTPServer

users = {"alice": "user", "root": "admin"}

class App(BaseHTTPRequestHandler):
    def do_GET(self):                        # 前端:按角色决定渲染哪些按钮
        who = self.headers.get("X-User")
        html = "<button>查看</button>"
        if users.get(who) == "admin":
            html += "<button>删除用户</button>"
        self.send_response(200); self.end_headers(); self.wfile.write(html.encode())
    def do_POST(self):                       # 后端端点
        who = self.headers.get("X-User")
        target = self.path.rsplit("/", 1)[-1]
        if self.path.startswith("/safe/") and users.get(who) != "admin":
            self.send_response(403); self.end_headers(); self.wfile.write(b"denied"); return
        users.pop(target, None)
        self.send_response(200); self.end_headers(); self.wfile.write(b"deleted")
    def log_message(self, *a): pass

srv = HTTPServer(("127.0.0.1", 0), App)
threading.Thread(target=srv.serve_forever, daemon=True).start()
base = f"http://127.0.0.1:{srv.server_address[1]}"

def req(path, method="GET"):
    r = urllib.request.Request(base + path, method=method, headers={"X-User": "alice"})
    try:
        return f"{urllib.request.urlopen(r).status} {urllib.request.urlopen(r).read().decode()[:40]}"
    except urllib.error.HTTPError as e:
        return f"{e.code} {e.read().decode()}"

print("alice 拿到的页面 :", req("/page"))
print("   页面里没有删除按钮 —— 前端确实藏了")
print("但她不用页面,直接打后端端点:")
print("   POST /vuln/root ->", req("/vuln/root", "POST"))
print("   root 还在吗:", "root" in users)
users["root"] = "admin"
print("   POST /safe/root ->", req("/safe/root", "POST"))
print("   root 还在吗:", "root" in users)
srv.shutdown()
alice 拿到的页面 : 200 <button>查看</button>
   页面里没有删除按钮 —— 前端确实藏了
但她不用页面,直接打后端端点:
   POST /vuln/root -> 200 deleted
   root 还在吗: False
   POST /safe/root -> 403 denied
   root 还在吗: True

前端很尽责:alice 不是 admin,所以菜单里没有“删除用户"按钮。开发者看着页面,觉得"删除功能普通用户根本够不着”。

可攻击者不通过你的页面。他直接看网络请求、看接口文档、猜端点,然后手写一个 POST /admin/delete_user 打过去。后端一看——没有任何鉴权,因为写后端的人以为"按钮都藏了,不会有人调这个"。于是 alice 删掉了 root

⭐ 这就是那句要刻在脑子里的话:前端的任何东西都在攻击者的完全控制之下。 按钮、菜单、禁用状态、隐藏字段、JS 里的 if (isAdmin)——这些决定的是用户看到什么,不是服务器允许什么。它们是体验,不是安全

二、为什么前端鉴权是幻觉

想清楚一件事:浏览器里的一切,最终都是用户机器上的数据和代码。用户(攻击者)可以:

· 打开开发者工具,改掉 isAdmin 变量、让隐藏按钮显形
· 完全不用你的前端,直接对 API 发请求(curl / Postman / 脚本)
· 看你的 JS 源码,把所有"隐藏的"端点、参数一览无余

所以前端做的每一个"限制",攻击者都能绕过,因为那台执行限制的机器就是他的

打个比方

前端鉴权,等于在嫌疑人自己家里贴一张"请勿入内"的纸条

那张纸贴在他的墙上,用他的胶水,他想什么时候撕就什么时候撕。你甚至没法确认它还在不在。

后端鉴权是门口站着一个卫兵,每个进来的人都验一次证。这个卫兵站在你的地盘上,归你管。

⚠️ 这个比方还能解释一件常被搞混的事:藏按钮是对的,只是它不是那张纸条的替代品。 不给普通用户看他点了也会失败的操作,这是体验上的礼貌——就像不在菜单上印今天卖完的菜。礼貌不是门锁。 两件事都要做,但只有卫兵那件算安全。

三、鉴权该放在哪一层,怎么放

原则一句话:在每一个能改变状态或读取敏感数据的服务端入口,独立地、默认拒绝地做鉴权。

拆开看三个词:

每一个入口       不是"关键的那几个"。每个端点都要自己验,
               因为攻击者会挨个试你所有的端点
独立地          不依赖"用户是从哪个页面来的""前面那步验过了"。
               每个请求都自带凭证、自己验一遍(呼应第 8 篇:服务器认令牌不认人)
默认拒绝         没有明确"允许",就是"拒绝"。
               别写成"如果是黑名单里的角色就拦"——漏列一个角色就是洞

上面 demo 里 /safe/ 那一支就是这三条:请求一进来先查当前用户是不是 admin,不是就直接 403,在做任何危险动作之前。这和第 6 篇"先验签再反序列化"、第 2 篇"修复点在拼接处"是同一个节奏——检查在危险动作之前,而不是之后或别处。

可扩展的做法,和上一篇一样是"下沉、集中":

· 统一的授权中间件:请求进来先过"这个主体能不能做这个操作"
· 集中式策略(RBAC/ABAC):角色和权限的映射写在一处,不散落在各 handler
· 默认拒绝的路由:新加的端点如果没显式声明所需权限,直接拒绝(fail closed)

四、一个隐蔽变体:功能级 vs 对象级

纵向和横向经常同时缺,别只补一个:

功能级越权(本篇)   普通用户能不能调"删除用户"这个功能?        —— 验角色/权限
对象级越权(第11篇) 能删的话,能不能删【不属于他管辖】的那个?   —— 验归属
一个"部门管理员"可能有权删除用户(功能级通过),
   但只能删【自己部门】的(对象级还要再验)。两道都要过。

⚠️ 这两个维度第一次读容易糊在一起,因为中文里都叫"越权"。分开的办法是各自问一个问题:功能级问"你能不能做这件事",对象级问"你能不能对这一个做"。 一个部门管理员对"删除用户"这个功能是有权的(功能级通过),但对隔壁部门的那个用户是无权的(对象级不通过)。两个问题都要问,问一个不够。

⭐ 真实系统里最常见的漏,是验了功能级、忘了对象级:确认了"你是管理员",却没确认"这个对象归你管"。反过来也有。集中式授权要把两者一起表达。

这一类没有单一的标志性公开事件,它以大量分散的个案存在;有技术细节的会收进事件分析

五、防御

① 鉴权在服务端,每个入口都做       前端隐藏只为体验,后端独立验证才算数
② 默认拒绝(fail closed)          没显式授权就拒绝;新端点自动受保护
③ 集中式策略,别散落 if           RBAC/ABAC 写在一处,功能级 + 对象级一起表达
④ 功能级和对象级都验              "能做这个操作"和"能对这个对象做"是两道
⑤ 写提权测试                     "用普通用户会话调管理员端点,必须 403",进 CI

判断标准:关掉你的前端,直接对每个端点发请求,越权的会被挡吗? 这正是攻击者的视角——他从来不走你家门口那条路,所以那张纸条他压根看不见。

⭐ 上面五条合起来就是那个卫兵:站在你的地盘上、每个人都验、没证就不放、而且新开的门也默认有人站岗(② fail closed)。

六、这些防御的边界

① 默认拒绝要贯彻到"新代码"。 最容易出洞的是新加的端点——如果框架不是"默认受保护、需显式开放",而是"默认开放、需记得加保护",那漏一个只是时间问题。选/配框架时就要让它 fail closed。

② 集中式策略也可能被绕过。 直接查数据库的后台脚本、消息队列消费者、内部 RPC,可能不走你的授权中间件。每一条能触发操作的路径都要过鉴权,不只是 HTTP 入口。

③ 鉴权正确 ≠ 认证正确。 这一篇假设"你知道请求来自谁"(认证,W2)是对的。如果会话令牌被伪造或偷走(第 8/9 篇),再完美的鉴权也建在流沙上。认证和鉴权是两道独立的关,都要立住。

关于时效:纵向越权是纯逻辑漏洞,机制不随技术变。会变的是框架的默认鉴权模型(默认拒绝还是默认允许)——这直接决定你"漏一个端点"的概率,选型时要核实。

七、本篇小结

纵向越权:普通用户够到了管理员的能力。最常见成因 = 鉴权做在了前端。

⭐ 前端的一切都在攻击者控制之下:按钮/菜单/隐藏字段/JS 里的 if
   它们决定"用户看到什么",不决定"服务器允许什么" —— 是体验,不是安全
   攻击者不用你的页面,直接对 API 发请求
⭐ 鉴权必须在服务端:每个入口、独立地、默认拒绝
   (检查在危险动作之前,和"先验签再反序列化"同一个节奏)

功能级(能调这个操作吗) + 对象级(能对这个对象做吗) 两道都要,最常见的漏是只验了一道。

判断标准:关掉前端,直接对每个端点发请求,越权会被挡吗?做成 CI 里的提权测试。

思考题

  1. §一 里前端藏了删除按钮、后端却没鉴权。用一句话说清"藏按钮"防住了谁、没防住谁。为什么"没防住的那个"恰恰是你要防的那个?
  2. 有人说"我们的 API 没有公开文档,端点别人不知道,所以不用每个都鉴权"。用第 11 篇"猜不到不是访问控制"的同一个论证反驳它。
  3. §四 的功能级 vs 对象级:构造一个"部门管理员"的场景,说明只验功能级会漏什么、只验对象级又会漏什么。为什么两道必须同时在?
  4. 为什么"默认拒绝"比"默认允许 + 黑名单拦截"安全得多?把它和第 1 篇"黑名单必输"联系起来——这里漏列的是什么?
  5. §七② 说后台脚本、消息队列可能绕过授权中间件。设计一个场景:HTTP 入口鉴权很严,但一个内部 RPC 让普通用户间接触发了管理员操作。根因是什么?
  6. 认证(W2)和鉴权(W3)是两道关。举一个"认证完美但鉴权失守"和一个"鉴权完美但认证失守"的例子,说明为什么补一道救不了另一道。
  7. 把这一篇的"鉴权在服务端"和第 5 篇的"DOM 型 XSS 只能前端防"放一起:一个说"别信前端",一个说"必须在前端防"。这两句矛盾吗?用"谁是那段代码的执行者、攻击者能不能控制它"来化解。

相关第 11 篇:横向越权(IDOR) · 第 8 篇:会话与令牌 · Web 安全机制篇 · 事件分析