
CVE-2026-49268 — LDAP 인젝션 인증 우회 취약점 분석 및 완화
| 브랜치 | 1.13-CVE-2026-49268 |
| 작성자 | Jinwoo Hwang (https://JinwooHwang.com) |
이 브랜치(1.13-CVE-2026-49268)는 Apache Shiro 1.13 릴리스의 LDAP 인젝션 인증 우회 취약점에 대한 포괄적인 보안 해결책을 포함하고 있습니다.
공식 NVD 설명
원격 공격자가 DefaultLdapRealm 클래스의 Distinguished Name(DN) 구성에 LDAP 특수 문자를 주입할 수 있습니다. 사용자가 제공한 사용자 이름 입력이 RFC 2253 특수 문자에 대한 이스케이프 처리 없이 LDAP DN 템플릿에 직접 연결됩니다. 이로 인해 공격자가 LDAP 바인드 인증에 사용되는 DN 구조를 조작할 수 있으며, 잠재적으로 인증을 우회하거나 다른 사용자를 사칭할 수 있습니다. 이 문제는 DefaultLdapRealm을 사용할 때 모든 Apache Shiro 버전(2.2.0까지)과 3.0.0-alpha-1에 영향을 미칩니다.
| 필드 | 값 |
|---|---|
| CVE | CVE-2026-49268 — Apache Shiro: DefaultLdapRealm의 LDAP DN 인젝션 |
| CWE ID | CWE-90 — LDAP 쿼리에 사용되는 특수 요소의 부적절한 중화('LDAP 인젝션') |
| CVSS v4.0 | 8.8 HIGH — CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N/S:P/AU:Y/R:A/RE:L/U:Red |
| CVSS v3.1 | 9.1 CRITICAL — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 영향받는 버전 | org.apache.shiro:shiro-core 0부터 2.2.0까지(포함); 3.0.0-alpha-0부터 3.0.0-alpha-1까지(포함) |
| 업스트림에서 수정된 버전 | 2.2.1 및 3.0.0-alpha-2 |
| 참조 | https://lists.apache.org/thread/svszql3od8td7hn6conyj2oq70v53b5s |
Apache Shiro는 LDAP 디렉터리에 대해 사용자를 인증할 수 있습니다. 이를 위해 제출된
사용자 이름을 Distinguished Name(해당 사용자 항목에 대한 디렉터리의 주소)으로 변환하는데,
사용자 이름을 구성된 템플릿에 그대로 넣습니다. 예를 들어
uid={0},ou=users,dc=mycompany,dc=com과 같습니다.
영향받는 버전에서는 사용자 이름이 원시 텍스트로 해당 템플릿에 붙여넣어집니다. 몇 가지 구두점 문자 — 가장 중요한 것은 쉼표 — 는 디렉터리 서버에게 일반 텍스트가 아니라, 주소의 한 부분을 다음 부분과 구분하는 구문입니다. 따라서 이러한 문자를 포함한 사용자 이름은 주소 내부의 값처럼 동작하지 않고 주소 자체의 일부처럼 동작하기 시작합니다.
실질적인 결과는 로그인하는 사람이 자신의 이름을 제공하는 것뿐만 아니라 Shiro가 어느 디렉터리 항목에 대해 인증을 시도할지에 영향을 줄 수 있다는 것입니다. 배포에서 의도한 컨테이너에서 조회되는 대신, 조회가 디렉터리의 다른 곳으로 리디렉션될 수 있습니다. 디렉터리의 구성 방식과 허용 범위에 따라, 이는 잘못된 신원으로 인증되거나, 인증이 성공해서는 안 되는 상황에서 성공하는 결과로 이어질 수 있습니다.
위험 프로필. 위험을 높이는 요소: 사전 접근 권한이나 자격 증명이 필요하지 않습니다 — 입력이
로그인 경계에 도달하며, 이는 애플리케이션에 접근할 수 있는 누구나 도달할 수 있습니다; 그리고
영향받는 클래스는 Shiro를 LDAP에 연결하는 표준적이고 문서화된 방식이므로, 이는 특이한 구성이 아닙니다.
위험을 낮추는 요소: 배포에서 실제로 DefaultLdapRealm(또는 JndiLdapRealm)을 구성된 DN 템플릿과 함께
사용해야 합니다; 다른 방식으로 인증하거나, 전체 DN 또는 인증서와 같은 비텍스트 자격 증명을 전달하는
배포는 영향을 받지 않습니다. 리디렉션된 조회가 사용 가능한 인증을 산출하는지 여부는
대상 디렉터리 자체의 구성과 접근 규칙에 따라 달라지며, 이는 사이트마다 다릅니다.
릴리스 계획을 위해 주목할 만한 두 번째의 비보안적 결과가 있습니다: 정당한 사용자 이름에
백슬래시가 포함되거나 #으로 시작하는 사용자는 현재 전혀 로그인할 수 없습니다.
그들을 위해 생성된 주소가 올바른 형식의 이름이 아니어서 즉시 거부되기 때문입니다.
다른 구두점은 즉시 실패하지 않습니다 — 주소가 참조하는 항목을 조용히 변경하며, 이것이 위의 보안 문제입니다.
동일한 수정이 두 가지 모두를 해결합니다.
core/src/main/java/org/apache/shiro/realm/ldap/DefaultLdapRealm.java, getUserDn(String),
수정되지 않은 기준 버전의 227–250행:```java
protected String getUserDn(String principal) throws IllegalArgumentException, IllegalStateException {
if (!StringUtils.hasText(principal)) {
throw new IllegalArgumentException("User principal cannot be null or empty for User DN construction.");
}
String prefix = getUserDnPrefix();
String suffix = getUserDnSuffix();
if (prefix == null && suffix == null) {
log.debug("userDnTemplate property has not been configured, indicating the submitted "
+ "AuthenticationToken's principal is the same as the User DN. Returning the method argument "
+ "as is.");
return principal;
}
int prefixLength = prefix != null ? prefix.length() : 0;
int suffixLength = suffix != null ? suffix.length() : 0;
StringBuilder sb = new StringBuilder(prefixLength + principal.length() + suffixLength);
if (prefixLength > 0) {
sb.append(prefix);
}
sb.append(principal); // <-- inserted verbatim
if (suffixLength > 0) {
sb.append(suffix);
}
return sb.toString();
}
템플릿은 구성 시점에 `{0}` 토큰을 기준으로 한 번 분할되어
(`setUserDnTemplate`, 181–200행) `prefix`와 `suffix`로 나뉜다. 인증 시점에는
principal이 그 사이에 연결된다. **어느 시점에도 인코딩은 적용되지 않는다.** `org/apache/shiro/realm/ldap/` 어디에도
이스케이프 헬퍼는 존재하지 않는다.
### 3.2 가정되었지만 결코 강제되지 않은 불변식
주변 코드는 `getUserDn`의 결과를 잘 구성된 Distinguished Name으로 취급한다 —
그것은 곧바로 `LdapContextFactory.getLdapContext(...)`에 전달되고, 거기서부터 JNDI에
바인드 DN으로 전달된다. 이는 대체된 principal이 *단일 속성 값*일 때만 타당하다.
문자열 연결로는 그것을 강제할 수 없다. RFC 2253(그리고 RFC 4514)은
`,` `+` `"` `\` `<` `>` `;` `=`, 선행 `#`, 그리고 선행/후행 공백을 DN 내의 구조적
구문으로 규정한다. 이들 중 어느 것이든 principal에 나타나면 파서는 그것을 내용이 아니라
구조로 읽는다. *"principal은 정확히 하나의 RDN 값을 차지한다"* 라는 불변식은
모든 하위 소비자에 의해 가정되었으나 어느 곳에서도 강제되지 않았다.
### 3.3 실행 흐름, 진입점에서 결함까지```
submitted credentials (username, password)
│
▼
DefaultLdapRealm.doGetAuthenticationInfo(AuthenticationToken) [line 292]
│
▼
DefaultLdapRealm.getLdapPrincipal(AuthenticationToken) [line 338]
│ principal instanceof String ?
│ ├── no ──► return principal unchanged ── NOT AFFECTED (e.g. X.509)
│ └── yes ──┐
▼ │
DefaultLdapRealm.getUserDn(String) [line 227]
│ prefix == null && suffix == null ?
│ ├── yes ──► return principal unchanged ── NOT AFFECTED ("principal IS the DN")
│ └── no ──┐
▼ │
prefix + principal + suffix ◄── DEFECT: unencoded concatenation [line 246]
│
▼
LdapContextFactory.getLdapContext(userDn, credentials)
│
▼
JNDI bind against the directory using the constructed DN
템플릿 uid={0},ou=users,dc=mycompany,dc=com, principal jsmith,ou=admins:
| 생성된 DN | 파싱된 RDN | |
|---|---|---|
| 의도된 결과 | uid=jsmith\,ou\=admins,ou=users,dc=mycompany,dc=com | 4 |
| 기준선 (수정 전) | uid=jsmith,ou=admins,ou=users,dc=mycompany,dc=com | 5 |
이름에 구성 요소가 추가된다. ou=users는 더 이상 대상이 되는 컨테이너가 아니며 —
ou=admins가 그 사이에 삽입된다. 이는 어떤 이스케이프 구현과도 독립적인
javax.naming.ldap.LdapName으로 파싱하여 검증되었다.
예약 문자 집합에 대한 기준선의 측정된 동작 (JDK 8, LdapName 파싱):
| Principal | 기준선 결과 | 효과 |
|---|---|---|
jsmith,ou=admins | 파싱됨, 5 RDN | 구조 변경됨 — 컨테이너가 삽입됨 |
jsmith;ou=admins | 파싱됨, 5 RDN | 구조 변경됨 — RFC 1779에서 ;는 RDN 구분자임 |
jsmith+uid=admin | 파싱됨, 4 RDN | 다중값 RDN이 됨; uid가 두 번째 값을 얻음 |
jsmith=admin | 파싱됨, 4 RDN | 값 손상됨 |
quo"te, angle<br>ackets, leadingSpace, trailingSpace | 파싱됨, 4 RDN | 값 손상됨 |
back\slash | IllegalArgumentException | 형식 오류 — 바인드를 시도할 수 없음 |
#leadingNumberSign | IllegalArgumentException | 형식 오류 — 바인드를 시도할 수 없음 |
두 문자가 이름의 내용뿐 아니라 구조를 변경한다: 쉼표, 그리고 RFC 1779가 대체 RDN
구분자로 허용하는 세미콜론이다. +는 동일한 RDN에 두 번째 속성 값을 도입한다.
\와 선행 #만이 이름을 파싱 불가능하게 만든다.
userDnTemplate 미설정. getUserDn은 principal을 그대로 반환한다. 이는 문서화된
"제출된 principal이 곧 DN" 모드이며, 호출자가 완전한 DN을 제공하고 그 정확성에
대한 책임을 진다. 영향을 받지 않음.String principal. getLdapPrincipal은 String principal만 getUserDn으로
라우팅하며, 그 외의 것(X.509 인증서, 사용자 정의 토큰)은 그대로 통과한다. 영향을 받지 않음.setUserDnTemplate 검증 (181–200행)은 null, 공백, 또는 {0}이 없는 템플릿을
올바르게 거부한다. 이는 운영자가 제공한 템플릿을 검증하는데, 그것은 결코 신뢰할 수
없는 입력이 아니었다 — 따라서 이는 올바른 코드이며 단지 이 결함을 다루지 않을 뿐이다.JndiLdapRealm**은 DefaultLdapRealm을 확장하며 getUserDn을 재정의하지 않으므로
결함을 상속한다. 테스트로 확인됨(§6.1). 범위 내에 있으며, 동일한 변경으로 수정된다.AbstractLdapRealm.searchFilter (89행)는 두 번째 {0} 치환을 가지며, 기본값은
(&(objectClass=*)(userPrincipalName={0}))이고 인가 경로에서 사용된다. 이는 영향을 받지 않는다.