1. 簡介
query-users
與
view-realm
權限的受限管理員,從標準的使用者清單端點獲得空結果;然而,列出某角色成員的平行端點卻會回傳該角色下所有使用者的完整個人紀錄。此報告重現了有漏洞及正確的程式碼路徑,將請求流程製成序列圖,並將此缺陷定位於 API 安全文獻中記載之更廣泛的物件層級授權失敗類別。
1. 簡介
身分與存取管理(Identity and Access Management, IAM)平台在現代應用程式堆疊中佔有特權地位:每個下游服務都信任它們所產生的 Token,因此 IAM 層內部的授權缺口會向外擴散到其後的所有服務。CVE-2026-17059 說明了即使平台的主要列表端點已正確強化,此類缺口仍可能持續存在。此缺陷是透過對「相鄰」端點(透過不同程式碼路徑回傳相同類別物件的路徑)進行差異化測試所識別,並針對專案主分支上運行 commit
33695405ea
的實際運作實例進行確認。
2. 背景:IAM API 中的物件層級授權
當 API 僅驗證呼叫者是否能到達某個端點,但未檢查呼叫者是否有權存取所請求的特定物件時,就會發生物件層級授權缺陷。此類缺陷在 OWASP API Security Top 10 的連續版本中均被列為最高 API 安全風險,原因正是端點本身通常可供呼叫者合法存取;唯有當回傳的物件與呼叫者的實際權限比對時,違規行為才會發生 [2] 。近期一份針對超過一百個已揭露案例的實證審查進一步發現,此類缺陷有很大一部分正是源於此處所檢視的模式:其中一條程式路徑會強制執行物件層級檢查,而另一條在結構上相似、功能上相鄰的路徑卻省略了該檢查,因此漏洞只有在將兩者並列比較時才會浮現,而不是在單獨審核時能察覺 [3] 。
Keycloak 的管理 API 直接模擬了此風險。支援或客服帳戶通常會被委派
query-users
(用以透過內部工具搜尋或自動完成使用者)和
view-realm
(用以讀取 realm 設定),同時刻意不授予
view-users
,該權限用於管控個別使用者記錄的可見性。此類帳戶在功能上應對使用者目錄無法存取
[1]
。
3. 兩條程式碼路徑
Keycloak 的主要使用者列表端點會在序列化之前,將每個候選使用者路徑通過一個共享的輔助方法。該輔助方法會套用每位使用者的可見性判斷式,因此,查詢該端點的限制管理員會收到一個空陣列而非錯誤,這對於對任何使用者都沒有檢視權限的帳戶而言,是預期行為:
- private Stream<UserRepresentation> toRepresentation(RealmModel realm,
- UserPermissionEvaluator usersEvaluator, Boolean briefRepresentation,
- Stream<UserModel> userModels) {
- // Only applies when the realm still uses the legacy admin permission
- // model; realms on fine-grained admin permissions v2 filter earlier,
- // at the store layer, and are not affected by this defect.
- if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
- usersEvaluator.grantIfNoPermission(...);
- // THE GUARD: every candidate UserModel is tested against the
- // caller's per-user "canView" entitlement before it is ever
- // converted into a representation. A caller with query-users
- // but not view-users fails this predicate for every user,
- // so the stream becomes empty here.
- userModels = userModels.filter(usersEvaluator::canView);
- ...
- }
- // Only objects that survived the filter above reach serialization.
- return userModels.map(user ->
- ModelToRepresentation.toRepresentation(session, user, briefRep) ...);
- }
角色成員端點回傳相同的底層物件型別
UserRepresentation
,但透過一個從未呼叫上述輔助方法的不同方法到達。它執行了兩個粗略的檢查(針對角色,以及呼叫者執行使用者查詢的一般權限),然後直接序列化每個成員:
- public Stream<UserRepresentation> getUsersInRole(...) {
- // Coarse check #1: may the caller view this role at all? For a
- // realm-level role, holding view-realm alone satisfies this.
- auth.roles().requireView(roleContainer);
- // Coarse check #2: may the caller run user queries in general?
- // This is a capability flag, not scoped to any individual user.
- auth.users().requireQuery();
- RoleModel role = roleContainer.getRole(roleName);
- ...
- // THE GAP: members are mapped straight to a full representation.
- // Unlike Snippet 1, there is no .filter(auth.users()::canView)
- // between the store query and the serialization step, so every
- // member of the role is returned regardless of per-user visibility.
- return session.users().getRoleMembersStream(realm, role, firstResult, maxResults)
- .map(u -> ModelToRepresentation.toRepresentation(session, u, briefRep));
- }
這兩個方法最終都呼叫了
ModelToRepresentation.toRepresentation
,因此外洩記錄的形狀與內容(使用者名稱、電子郵件、名字和姓氏、啟用狀態及電子郵件驗證狀態)與擁有完整權限的管理員所見完全相同
[1]
。
3.1 請求流程對比
caller lacks view-users on any of them F-->>U: empty stream U-->>A: 200 OK []
maps every member straight to a representation R-->>A: 200 OK [alice PII, bob PII, ...]
4. 攻擊與前提條件
重現此漏洞需要從單一受限 Token 發出兩個普通請求:一個是針對主要使用者端點,以確認該帳戶被視為無存取權限;另一個是針對該帳戶可見之任何角色的角色成員端點。此缺陷僅在仍運行預設管理權限模型的 realm 上顯現;啟用細粒度管理權限 v2 的 realm 會在資料儲存層更早過濾成員查詢,因此遺漏輔助方法呼叫的影響便不會被觀察到 [1] 。由於遺漏的檢查純粹在物件層級運作,而呼叫者對端點本身的功能層級存取是合法的,此缺陷完全符合物件層級授權類別,而非功能層級存取控制失效,OWASP 框架認為此區別在結構上很重要,儘管在實際案例中兩者經常混合出現 [3] 。在一個多團隊的 realm 中,這會將一個刻意設計為狹窄、單一用途的委派,轉變為一個每次查詢一個角色、即可涵蓋所有其他團隊個人資料的目錄查詢。
5. 修復方式
修復方式模仿了在較早的 commit 中已被修正的相鄰方法:在儲存查詢與序列化步驟之間插入相同的每位使用者可見性判斷式,使角色成員路徑強制執行與主要列表端點相同的恆等式。
- return session.users().getRoleMembersStream(realm, role, firstResult, maxResults)
- // FIX: apply the same per-user visibility check used by the
- // main users endpoint (Snippet 1) before any record is
- // converted into a representation and returned to the caller.
- .filter(auth.users()::canView)
- .map(u -> ModelToRepresentation.toRepresentation(session, u, briefRep));
此變更僅新增了一個 pipeline stage;對於實際上被授權檢視回傳使用者的管理員而言,它並未改變角色成員功能的語義,因為對他們來說
canView
是一個無操作(no-op)。
6. 為何此類缺陷能躲過人工審查
對角色成員方法的單一端點程式碼審查,在孤立檢視下看起來是合理的:它檢查了呼叫者可以檢視角色,且呼叫者可以查詢使用者,這兩者都是合理的、具名權限。唯有將此端點與其相鄰端點並列,並要求滿足相同的不變條件(即主要列表端點對其身分回傳空結果,則任何其他回傳相同物件型別的路徑都不得向其提供使用者記錄)時,此缺口才會變得明顯。針對所有回傳給定物件類別的端點進行差異化、基於不變條件的測試,非常適合用於揭露此類失敗模式,因為它不需要審查者事先懷疑哪個端點遺漏了檢查;它只需要列舉所有回傳該物件的路徑,並對每個路徑套用相同的受限身分即可 [3] 。這呼應了一項普遍發現:物件層級授權缺陷集中於相似的端點在重新套用權限檢查的徹底程度上有所分歧之處,而非端點完全遺漏檢查之處 [2] 。
7. 結論
CVE-2026-17059 並非來自新穎的攻擊技術;它只是一個遺漏的過濾器呼叫,其所在位置距離相同檢查的正確實作只差一個方法。其重要性在於證明,僅強化 IAM 系統中最主要、最常被審審核的端點是不夠的:每個回傳相同類別物件的路徑都必須重新套用相同的物件層級不變條件,而測試此類疏漏最有效的方式,是比較一個刻意受限的身分在全部路徑上的行為,而非單獨檢視任何一個路徑。